know-engine

自定义基于ElasticSearch的向量检索器

ES的语义相似度检索,采用的是KNN的方式,在ES的官网中(https://www.elastic.co/docs/solutions/search/vector/knn ) 提到,ES支持两种 kNN 搜索方法 分别是: 近似 k…

TL;DR

ES的语义相似度检索,采用的是KNN的方式,在ES的官网中(https://www.elastic.co/docs/solutions/search/vector/knn ) 提到,ES支持两种 kNN 搜索方法 分别是: 近似 k…

ES的语义相似度检索,采用的是KNN的方式,在ES的官网中(https://www.elastic.co/docs/solutions/search/vector/knn ) 提到,ES支持两种 kNN 搜索方法 分别是: 近似 kNN(Approximate kNN)——推荐用于生产环境 - 使用 knn 查询选项或 knn 查询 DSL。 - 底层索引结构:默认使用 HNSW(Hierarchical Navigable Small World)图算法 - 优点:低延迟、高扩展性,适合大规模数据。 - 要求:dense_vector 字段需启用 index: true(Elasticsearch 8.0+ 默认开启)。 精确 kNN(Exact kNN)——适用于小数据集 - 使用 script_score + 向量函数(如 cosineSimilarity)。 - 暴力搜索:遍历所有文档计算相似度。 - 缺点:性能差,不适用于大数据。 - 适用场景:数据量小、需 100% 精确结果,或配合强过滤条件缩小范围。 还记得我们在 ✅接入ElasticSearch做向量存储 中讲解的时候,提到ES会自动创建一个索引,索引中包含的vector字段,他的定义如下:

"vector"


}
  • type: dense_vector:表示存储的是稠密向量数据。
  • dims: 1536:向量的维度是 1536。
  • index :true是开启高性能向量搜索(kNN)的“开关”。告诉 Elasticsearch “不要只把向量当作文本存起来,请为它建立 HNSW 导航图,以便我能以毫秒级的速度进行近似搜索。”
  • similarity: cosine:使用余弦相似度来计算向量之间的距离。
  • index_options (索引算法):
  • type: int8_hnsw:int8 是数据的存储格式(还支持float、byte等),将每个数字转换为 8 位整数。每个数字只占用 1 字节 内存。hnsw是数据的索引结构,这是一种近似最近邻搜索算法。是kNN的一种优化手段。 为什么推荐 int8_hnsw? 在 Elasticsearch 8.x 版本中,int8_hnsw 经常被作为默认推荐或首选配置,因为它解决了向量数据库最大的痛点——内存成本。 | 特性 | 普通 | int8_hnsw | | --- | --- | --- | | 内存占用 | 高 (100%) | 低 (约 25%) | | 搜索速度 | 快 | 极快 | | 搜索精度 | 100% (基准) | ~99%+ | | 适用场景 | 对精度有极端要求的科研场景 | 绝大多数生产环境、推荐系统、语义搜索 |

index_options (索引算法): - m: 控制HNSW 图中每个节点的邻居数量。 m 表示一个节点的邻居数量,默认值为16。邻居数量越多,召回率会相应提高,但这将对性能产生较大影响,并增加内存占用。如果对召回率有严格要求,可以将其设置为64或更大的值。 index_options (索引算法): - ef_construction: 在插入新向量、建立索引时,算法不会盲目地随便找几个邻居,而是会先在一个“候选池”里进行一轮搜索。ef_construction 就定义了这个候选池的大小。 ef_construction的值越大,构建出的图结构更合理、连接质量更高。这意味着在未来的向量搜索中,算法更容易通过导航图找到真正的最近邻,从而获得更高的召回率(Recall)和搜索精度。但是在建立索引时,算法需要计算和比较更多的节点,导致索引构建的时间显著变长。如果对召回率有严格的要求,可以将其设置为512或更大的值。 那么也就是说,默认情况下,ES会使用 HNSW 算法进行近似 knn 搜索。 在检索的介绍时,我们提到过ES的混合检索:

✅混合检索如何实现?

因为我们采用了ElasticSearch来做知识的存储,而且ES同时支持向量检索和关键词检索,我们只需要依赖一个扩展包langchain4j-elasticsearch的话,就能使用他的检索器做检索了。 为什么要做混合检索? 只所以要用,是 LLMentor 这里面会用到ElasticsearchConfigurationKnn来初始化一个Retriever,除了这个ElasticsearchConfigurationKnn,还有一个实现是ElasticsearchConfigurationScript,这两个就是分别做近似kNN(HNSW)和精确kNN(script_score + 向量函数)实现的。 所以,我们在构造ElasticsearchContentRetriever时,如果更看重精确度,则可以采用ElasticsearchConfigurationScript,但是他的效率会差一些。

embeddingRetriever = ElasticsearchContentRetriever
.builder()
        .restClient(restClient)
        .embeddingModel(openAiEmbeddingModel)
        .configuration(ElasticsearchConfigurationScript.builder().build())
        .maxResults(MAX_RESULT)
        .indexName(INDEX_NAME)
        .minScore(MIN_SCORE)
        .build();

那如果更看重速度,可以适当牺牲一点精确度,或者是精确度影响也不大的话,可以使用ElasticsearchConfigurationKnn。

embeddingRetriever = ElasticsearchContentRetriever
.builder()
        .restClient(restClient)
        .embeddingModel(openAiEmbeddingModel)
        .configuration(ElasticsearchConfigurationKnn.builder().build())
        .maxResults(MAX_RESULT)
        .indexName(INDEX_NAME)
        .minScore(MIN_SCORE)
        .build();

实际的工程实践是: 使用ES做RAG的检索召回时,先采用近似KNN,及HNSW来检索,如果发现召回率比较差,则可以考虑先优化其他的,比如提示词、比如分段、比如做查询重写等等,以及多路召回,混合检索等。然后再考虑优化m和ef_construction这两个参数,最后再考虑使用精确knn,因为他会影响性能。即: - 先易后难:从最简单的提示词开始。 - 先外后内:先优化检索层之外的环节。 - 性能优先:将影响性能的“重操作”放在最后。 那么,我们借助ElasticsearchConfigurationKnn实现一个我们自己的ContentRetriever,之所以要自定义,是因为在这里除了检索,还需要完善父子分块的检索处理。所以需要做定制。 代码实现如下:

package cn.hollis.llm.mentor.know.engine.rag.modules;

import cn.hollis.llm.mentor.know.engine.document.service.KnowledgeSegmentService;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.rag.content.Content;
import dev.langchain4j.rag.content.ContentMetadata;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.rag.content.retriever.elasticsearch.ElasticsearchContentRetriever;
import dev.langchain4j.rag.query.Query;
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
import dev.langchain4j.store.embedding.EmbeddingSearchResult;
import dev.langchain4j.store.embedding.elasticsearch.*;
import dev.langchain4j.store.embedding.filter.Filter;
import org.elasticsearch.client.RestClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.*;

import static cn.hollis.llm.mentor.know.engine.rag.constant.MetadataKeyConstant.BROTHER_CHUNK_ID;
import static cn.hollis.llm.mentor.know.engine.rag.constant.MetadataKeyConstant.PARENT_CHUNK_ID;
import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey;

/**
 * KnowEngine Elasticsearch 内容检索器
 * <p>
 * 基于 Elasticsearch 的向量检索实现,支持以下特性:
 * <ul>
 *   <li><b>向量检索 (KNN)</b>:使用 Embedding 模型将查询文本向量化,进行相似度搜索</li>
 *   <li><b>全文检索</b>:支持 Elasticsearch 全文搜索</li>
 *   <li><b>混合检索</b>:结合向量检索和全文检索(需 Elasticsearch 相应许可证)</li>
 *   <li><b>关联内容扩展</b>:自动检索兄弟分段 (brother chunk) 和父分段 (parent chunk) 内容</li>
 * </ul>
 * <p>
 * <b>关联内容扩展机制:</b>
 * <ul>
 *   <li>兄弟分段:具有相同父分段的其他子分段,用于获取完整上下文</li>
 *   <li>父分段:从 Redis 中读取父分段的完整文本,替换子分段以获得更完整的语义</li>
 * </ul>
 * <p>
 *
 * @see ElasticsearchContentRetriever
 * @see ContentRetriever
 */
public class KnowEngineElasticsearchContentRetriever extends AbstractElasticsearchEmbeddingStore implements ContentRetriever {

    private static final Logger log = LoggerFactory.getLogger(ElasticsearchContentRetriever.class);
    private final EmbeddingModel embeddingModel;
    private final int maxResults;
    private final double minScore;
    private final Filter filter;
    private final KnowledgeSegmentService knowledgeSegmentService;

    /**
     * Creates an instance of ElasticsearchContentRetriever using a RestClient.
     *
     * @param configuration  Elasticsearch retriever configuration to use (knn, script, full text, hybrid, hybrid with reranker)
     * @param restClient     Elasticsearch Rest Client (mandatory)
     * @param indexName      Elasticsearch index name (optional). Default value: "default".
     *                       Index will be created automatically if not exists.
     * @param embeddingModel Embedding model to be used by the retriever
     * @param maxResults     Maximum number of results to retrieve
     * @param minScore       Minimum score threshold for retrieved results
     * @param filter         Filter to apply during retrieval
     */
    public KnowEngineElasticsearchContentRetriever(
            ElasticsearchConfiguration configuration,
            RestClient restClient,
            String indexName,
            EmbeddingModel embeddingModel,
            final int maxResults,
            final double minScore,
            final Filter filter,
            KnowledgeSegmentService knowledgeSegmentService) {
        this.embeddingModel = embeddingModel;
        this.maxResults = maxResults;
        this.minScore = minScore;
        this.filter = filter;
        this.knowledgeSegmentService = knowledgeSegmentService;
        this.initialize(configuration, restClient, indexName);
    }

    /**
     * 根据查询条件检索相关内容
     * <p>
     * 根据当前配置的检索模式执行内容检索,支持以下三种模式:
     * <ul>
     *   <li><b>全文检索</b>:当配置为 {@link ElasticsearchConfigurationFullText} 时,直接执行全文搜索</li>
     *   <li><b>混合检索</b>:当配置为 {@link ElasticsearchConfigurationHybrid} 时,结合向量检索和全文检索</li>
     *   <li><b>向量检索(默认)</b>:将查询文本向量化后进行 KNN 相似度搜索</li>
     * </ul>
     * <p>
     * 在向量检索模式下,检索结果还会进行关联内容扩展:
     * <ul>
     *   <li>兄弟分段扩展:根据 brotherChunkId 检索同一父分段下的其他兄弟分段,补全上下文</li>
     *   <li>父分段替换:根据 parentChunkId 从 Redis 中读取父分段的完整文本,替换子分段以获得更完整的语义</li>
     * </ul>
     *
     * @param query 查询对象,包含待检索的文本内容
     * @return 检索到的内容列表,包含原始检索结果及扩展的关联内容
     */
    @Override
    public List<Content> retrieve(final Query query) {
        // 全文检索模式:直接执行全文搜索并返回结果
        if (configuration instanceof ElasticsearchConfigurationFullText) {
            log.debug("Using a full text search query");
            return this.fullTextSearch(query.text()).stream()
                    .map(t -> Content.from(
                            t,
                            Map.of(
                                    ContentMetadata.SCORE, t.metadata().getDouble(ContentMetadata.SCORE.name()),
                                    ContentMetadata.EMBEDDING_ID,
                                    t.metadata().getString(ContentMetadata.EMBEDDING_ID.name()))))
                    .toList();
        }
        // 将查询文本转换为向量
        Embedding referenceEmbedding = embeddingModel.embed(query.text()).content();
        // 构建向量搜索请求,设置查询向量、最大返回数量、最低相似度分数和过滤条件
        EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
                .queryEmbedding(referenceEmbedding)
                .maxResults(maxResults)
                .minScore(minScore)
                .filter(filter)
                .build();

        // 混合检索模式:结合向量检索和全文检索
        if (configuration instanceof ElasticsearchConfigurationHybrid) {
            return mapResultsToContentList(this.hybridSearch(request, query.text()));
        }

        // 向量检索模式(默认):执行 KNN 向量相似度搜索
        List<Content> searchContents = mapResultsToContentList(this.search(request));
        // 去重并按文本内容排序
        searchContents = searchContents.stream().distinct().sorted(Comparator.comparing(content -> content.textSegment().text())).toList();
        List<Content> finalContents = new ArrayList<>(searchContents);

        // 兄弟分段缓存和父分段缓存,避免重复查询
        Map<String, List<Content>> brotherDocMap = new HashMap<>();
        Map<String, List<Content>> parentDocMap = new HashMap<>();

        Iterator<Content> iterator = searchContents.iterator();

        for (; iterator.hasNext(); ) {
            Content content = iterator.next();
            // 兄弟分段扩展:检索具有相同 brotherChunkId 的其他兄弟分段
            String brotherChunkId = content.textSegment().metadata().getString(BROTHER_CHUNK_ID);
            if (brotherChunkId != null) {
                List<Content> cachedBrotherDocs = brotherDocMap.get(brotherChunkId);
                if (cachedBrotherDocs != null) {
                    // 命中缓存,直接使用已检索的兄弟分段
                    finalContents.addAll(cachedBrotherDocs);
                } else {
                    // 未命中缓存,按 brotherChunkId 过滤检索兄弟分段
                    Filter brotherFilter = metadataKey(BROTHER_CHUNK_ID).isEqualTo(brotherChunkId);
                    request = EmbeddingSearchRequest.builder()
                            .filter(brotherFilter)
                            .build();
                    List<Content> brotherDocs = mapResultsToContentList(this.search(request));
                    brotherDocMap.put(brotherChunkId, brotherDocs);
                    finalContents.addAll(brotherDocs);
                }
            }

            // 父分段替换:用父分段的完整文本替换子分段,获取更完整的语义
            String parentChunkId = content.textSegment().metadata().getString(PARENT_CHUNK_ID);
            if (parentChunkId != null) {
                List<Content> cachedParentDocs = parentDocMap.get(parentChunkId);
                if (cachedParentDocs != null) {
                    // 如果已经缓存中有过这个父分段了,说明已经用过了,这里就不用再加了,避免重复
                    finalContents.remove(content);
                } else if (knowledgeSegmentService != null) {
                    // 读取 parentChunk 的文本内容
                    String segmentText = knowledgeSegmentService.getTextByChunkId(parentChunkId);
                    if (segmentText != null) {
                        // 用父分段文本构造新的 Content,替换当前的子分段内容
                        TextSegment parentSegment = TextSegment.from(segmentText, content.textSegment().metadata());
                        Content parentContent = Content.from(parentSegment, content.metadata());
                        List<Content> parentDocs = List.of(parentContent);
                        parentDocMap.put(parentChunkId, parentDocs);
                        finalContents.remove(content);
                        finalContents.addAll(parentDocs);
                    } else {
                        log.warn("parentChunk not found in Redis, chunkId: {}", parentChunkId);
                        finalContents.remove(content);
                    }
                }
            }
        }
        return finalContents;
    }

    private List<Content> mapResultsToContentList(EmbeddingSearchResult<TextSegment> searchResult) {
        List<Content> result = searchResult.matches().stream()
                .filter(f -> f.score() > minScore)
                .map(m -> Content.from(
                        m.embedded(),
                        Map.of(
                                ContentMetadata.SCORE, m.score(),
                                ContentMetadata.EMBEDDING_ID, m.embeddingId())))
                .toList();
        log.debug("Found [{}] relevant documents in Elasticsearch index [{}].", result.size(), indexName);
        return result;
    }

    public static KnowEngineElasticsearchContentRetriever.Builder builder() {
        return new KnowEngineElasticsearchContentRetriever.Builder();
    }

    public static class Builder {

        private RestClient restClient;
        private String indexName = "default";
        private ElasticsearchConfiguration configuration =
                ElasticsearchConfigurationKnn.builder().build();
        private EmbeddingModel embeddingModel;
        private int maxResults;
        private double minScore;
        private Filter filter;
        private KnowledgeSegmentService knowledgeSegmentService;

        /**
         * @param restClient Elasticsearch RestClient.
         * @return builder
         */
        public KnowEngineElasticsearchContentRetriever.Builder restClient(RestClient restClient) {
            this.restClient = restClient;
            return this;
        }

        /**
         * @param indexName Elasticsearch index name (optional). Default value: "default".
         * @return builder
         */
        public KnowEngineElasticsearchContentRetriever.Builder indexName(String indexName) {
            this.indexName = indexName;
            return this;
        }

        /**
         * @param configuration the configuration to use
         * @return builder
         */
        public KnowEngineElasticsearchContentRetriever.Builder configuration(ElasticsearchConfiguration configuration) {
            this.configuration = configuration;
            return this;
        }

        public KnowEngineElasticsearchContentRetriever.Builder embeddingModel(EmbeddingModel embeddingModel) {
            this.embeddingModel = embeddingModel;
            return this;
        }

        public KnowEngineElasticsearchContentRetriever.Builder maxResults(int maxResults) {
            this.maxResults = maxResults;
            return this;
        }

        public KnowEngineElasticsearchContentRetriever.Builder minScore(double minScore) {
            this.minScore = minScore;
            return this;
        }

        public KnowEngineElasticsearchContentRetriever.Builder filter(Filter filter) {
            this.filter = filter;
            return this;
        }


        public KnowEngineElasticsearchContentRetriever.Builder stringRedisTemplate(KnowledgeSegmentService knowledgeSegmentService) {
            this.knowledgeSegmentService = knowledgeSegmentService;
            return this;
        }

        public KnowEngineElasticsearchContentRetriever build() {
            return new KnowEngineElasticsearchContentRetriever(
                    configuration, restClient, indexName, embeddingModel, maxResults, minScore, filter, knowledgeSegmentService);
        }
    }
}
版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。