最近在搞 RAG 相关的东西,遇到了一个实际需求:既要根据关键词把文档捞出来,又得能按语义找到内容相近的段落。单独用 Elasticsearch 的关键词查询,常碰到同义改写之后完全找不到的尴尬;单独做向量检索,精确数字、代码片段、人名又经常召回不准。后来我选了 LangChain4j 的 Elasticsearch 集成来做混合检索,把 BM25 和向量召回在结果层用 RRF 做融合,整套流程跑通之后,检索质量确实上了一个台阶。
这篇东西写给谁?写给用 Java 做后端、想在项目里接 RAG 或语义搜索,但又不想重新造轮子的同学。你不需要已经精通 LangChain4j,只需要熟悉 Spring Boot、Maven,以及最基础的 Elasticsearch 概念。我会把整个集成的选型思路、环境搭建、索引设计、查询代码、常见坑都过一遍,最后还会聊几个我实际踩过的坑。
1. 混合检索到底解决了什么问题
1.1 从“找出包含关键词”到“理解用户想找什么”
传统搜索的经典代表就是 ES 的 BM25 全文检索。它的思路很朴素:把文档拆成词条,统计 term frequency 和 inverse document frequency,相关性就是“这个词在哪些文档里出现得越多、在整个索引里越罕见,文档就越靠前”。这套东西在“精确命中”场景下非常可靠,比如用户搜“phone number: 12345”,你把它倒排索引里的精确词条拿出来,匹配得非常干脆。
但它的硬伤在于词汇鸿沟。用户查询和文档可能语义相同但用词完全不同,比如“怎么退订流量包”和“关闭数据套餐的步骤”,在词面上基本不重叠,BM25 就很难召回。这也是 RAG 应用里最常见的检索失败场景。
向量检索的思路完全不同。它先通过 embedding 模型把文本映射成稠密向量,然后拿查询向量去索引里做近邻搜索,比如余弦相似度或点积。语义相近的句子即使没有一个共同词,向量距离也会很近。缺点则是它比较擅长“模糊”,不擅长“精确”。虽然现在不少 embedding 模型对数字、标识符也有一定的表达能力,但真到“版本号 v2.3.1”这种级别,召回依旧容易漂移。
混合检索就是两边都做:先用关键词锁定精确信息,再用向量补上语义泛化,最后在结果集上做融合排序。LangChain4j 做这件事的妙处在于,它帮你把“写两套查询再合并”的琐碎过程封装进了统一的 API 里,而且底层 ES 客户端不用你手工维护连接和序列化。
1.2 LangChain4j 在 Java 生态里扮演什么角色
LangChain4j 是 Java 世界里的 LLM 应用框架,对标的是 Python 生态里很火的 LangChain。它提供了一整套抽象:ChatLanguageModel、EmbeddingModel、EmbeddingStore、ContentRetriever、AiServices 等等。你用它来做 RAG,流程大概是:先切分文档,调用 EmbeddingModel 把每个 chunk 转成向量,存入 EmbeddingStore;查询时再把问题转成向量,从 store 里检索出 top-k 文本片段,拼进 prompt 后交给 LLM 生成答案。
它和 Python 生态不太一样的地方在于:Java 项目里很多人其实已经有一套基于 Spring Boot 的现有服务,LangChain4j 的设计目标就是让你能低门槛地嵌进这种传统 Java 后端里,而不是另起炉灶。官方还提供了 langchain4j-spring-boot-starter,只要在 application.yml 里写配置就能启动。
而我选择它的另一个原因是版本迭代很有节奏感。早期版本分发包名是 com.langchain4j,后来按不同集成拆成了多个模块,比如 langchain4j-elasticsearch、langchain4j-open-ai、langchain4j-ollama。我最早在 0.31.0 上跑通过一个 demo,后来升级到 1.x,核心 API 变化不大,但模块名和配置项有调整,升级时留意 changelog 就好。
1.3 为什么向量库选了 Elasticsearch
可选方案很多:专用向量数据库如 Milvus、Qdrant、Pinecone,也有关系型数据库插件比如 pgvector,还有 Redis 的向量模块。如果你是新项目、纯做向量检索,这些都很合适。但如果是存量项目,ES 已经在作为核心检索引擎,那么再引入一个独立向量库意味着要处理两套存储、两套运维、两份数据同步。而在 ES 8.x 里,dense_vector 字段类型已经相当成熟,kNN 查询性能在中小数据量上表现可以接受,再加上 ES 本来就支持 BM25,天然就是做混合检索的底子。
另外,ES 8.8 版本以后,官方已经在查询 DSL 里直接支持了 rank: rrf 语法,可以把布尔查询和 kNN 查询结果在 ES 内部做融合,避免把 top-k 结果拉回应用层再手写融合逻辑。虽然这个能力在不同许可证下可能有版本差异,但在 8.x 某个较新的开源许可版本里已经可用;如果你手里是企业版,那就更不用担心。这个点我们后面实操部分单独说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前置准备:环境搭建和选型
2.1 版本怎么搭配才不折腾
我先说结论,这套组合我在本地和测试环境都验证过:
- Java 17 或 21,我推荐 21,后续 Spring Boot 3.2+ 对 21 支持已经很好。
- Spring Boot 3.2.x 以上,配合 langchain4j-spring-boot-starter 使用。
- Elasticsearch 8.11 以上,用 Docker 起单节点,开启安全认证但用简单密码,或者直接关闭安全,本地开发不折腾。
- LangChain4j 使用 1.x 稳定版,比如 1.1.0 左右;如果你还在用 0.31.0,注意坐标的变化。
如果你完全不用 Spring Boot,也可以只在 pom 里引入 langchain4j-elasticsearch,它不强制依赖 Spring。LangChain4j 的 spring-boot-starter 只是把一些 Bean 自动装配,核心逻辑在 langchain4j-core 和各个集成模块里。
2.2 Docker 快速起一个 Elasticsearch 实例
本地开发最简单的方式就是 Docker。先确认 Docker 可用,然后执行:
bash复制docker network create langchain4j-es-network
docker run -d \
--name langchain4j-es \
--net langchain4j-es-network \
-p 9200:9200 \
-p 9300:9300 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
-e "ES_JAVA_OPTS=-Xms1g -Xmx1g" \
docker.elastic.co/elasticsearch/elasticsearch:8.11.1
这里把安全关掉,是因为本地 demo 不想搞证书和密码;如果要在测试环境用,建议至少开 basic auth。关闭安全之后,访问 http://localhost:9200 应该能看到类似这样的响应:
json复制{
"name" : "langchain4j-es",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "xxxx",
"version" : {
"number" : "8.11.1",
"build_flavor" : "default"
}
}
注意 xpack.security.enabled=false 在 8.x 中如果不做额外配置,会禁用 HTTPS 和认证;如果你之前起过带认证的容器,记得清掉数据卷再重启。
2.3 Maven 坐标与 Spring Boot 配置
我用的依赖大概是这样的,你可以根据实际版本微调:
xml复制<properties>
<langchain4j.version>1.1.0</langchain4j.version>
</properties>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-elasticsearch</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-easy-rag</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
<version>${langchain4j.version}</version>
</dependency>
</dependencies>
如果你准备接入大模型 API,比如 OpenAI、DashScope、Ollama,再引入对应模块即可。这里我先用 Ollama 跑本地 embedding 模型,不走外部 API,数据不出内网。
Spring Boot 配置文件里,我一般这样填:
yaml复制langchain4j:
elasticsearch:
url: http://localhost:9200
ollama:
chat-model:
base-url: http://localhost:11434
model-name: qwen2.5:7b
embedding-model:
base-url: http://localhost:11434
model-name: nomic-embed-text
如果你用的不是 Spring Boot,而是纯 Java,那么自己创建 ElasticsearchConfiguration 和 EmbeddingStore 也完全可以,后面代码里会看到。
3. 混合检索核心流程:从索引到查询
3.1 定义一个可注入的 EmbeddingModel
在 LangChain4j 里,EmbeddingModel 是个接口,负责把文本转成向量。不同的模型实现返回的向量维度差别很大,比如 nomic-embed-text 是 768 维,all-MiniLM-L6-v2 是 384 维。维度必须和 ES 索引里的 dense_vector 映射保持一致,否则写入和查询都会报错。
我通常先封装一个配置类:
java复制import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.ollama.OllamaEmbeddingModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
@Configuration
public class EmbeddingModelConfig {
@Bean
public EmbeddingModel embeddingModel() {
return OllamaEmbeddingModel.builder()
.baseUrl("http://localhost:11434")
.modelName("nomic-embed-text")
.timeout(Duration.ofSeconds(60))
.build();
}
}
这里有个小细节:Ollama 的 embedding 模型和 chat 模型可能不在同一台机器,如果分开部署,baseUrl 各配各的。如果你用 OpenAI 的 text-embedding-3-small,维度是 1536,同样要注意。
3.2 索引映射怎么设计
ElasticsearchIndexConfiguration 是 LangChain4j 里负责初始化索引的工具类。如果不想手工写 REST 请求建索引,可以直接用它:
java复制import dev.langchain4j.store.embedding.elasticsearch.ElasticsearchIndexConfiguration;
import org.elasticsearch.client.RestClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
@Configuration
public class ElasticsearchStoreConfig {
@Bean
public ElasticsearchClient elasticsearchClient() {
RestClient restClient = RestClient.builder(new HttpHost("localhost", 9200, "http")).build();
RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
return new ElasticsearchClient(transport);
}
@Bean
public ElasticsearchIndexConfiguration indexConfiguration() {
return ElasticsearchIndexConfiguration.builder()
.indexName("hybrid_docs")
.dimension(768)
.build();
}
}
它内部会自动创建名为 hybrid_docs 的索引,并生成 mapping。我建议你在起业务代码前,先用 curl 或 Kibana 看一下实际 mapping:
bash复制curl http://localhost:9200/hybrid_docs/_mapping?pretty
正常情况下,LangChain4j 会写入这些字段:
- text:原始文本
- text_vector:dense_vector,维度对应模型输出
- metadata:各种业务属性
- embedding_model_name:写入向量时用的模型名字,方便不同模型的数据隔离
如果你需要做更加精细的分词、指定 IK 分词器、或者给某个字段加 keyword 子字段做聚合排序,可以不用 LangChain4j 自动建索引,自己先在 ES 里建好索引,再让 LangChain4j 复用现有索引。这往往更符合生产环境需求。
3.3 把文档拆块并写入向量索引
先看一段最朴素的写入代码,把一篇文章切分成若干 chunk,然后逐条写入:
java复制import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.data.embedding.Embedding;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class DocumentIngestionService {
private final EmbeddingStore<TextSegment> embeddingStore;
private final EmbeddingModel embeddingModel;
public DocumentIngestionService(EmbeddingStore<TextSegment> embeddingStore,
EmbeddingModel embeddingModel) {
this.embeddingStore = embeddingStore;
this.embeddingModel = embeddingModel;
}
public void ingest(String content, String docId) {
Document document = Document.from(content);
List<TextSegment> segments = DocumentSplitters.recursive(300, 30).split(document);
for (TextSegment segment : segments) {
segment.metadata().put("docId", docId);
Embedding embedding = embeddingModel.embed(segment.text()).content();
embeddingStore.add(embedding, segment);
}
}
}
这里 chunk 大小是 300 个字符、重叠 30 个字符。为什么这样设?如果 chunk 太大,向量平均池化之后语义容易被稀释,而且超出模型最大输入长度会被截断;如果太小,上下文不完整,尤其对需要整段理解的问题会吃亏。300 个字符是我在英文文档和中文文档混合场景里的折中值,中文可以适当降到 200,英文可以升到 500,具体看内容结构。
我踩过的一个坑是:不要在一个循环里逐条 embed 再逐条 add。那意味着每个 chunk 都发起一次 HTTP 请求到 Ollama,速度惨不忍睹,1000 个 chunk 能跑十几分钟。正确的做法是先批量 embed,再批量 add。LangChain4j 给的 EmbeddingStore 接口支持批量 add,代码改成:
java复制List<TextSegment> segments = DocumentSplitters.recursive(300, 30).split(document);
List<Embedding> embeddings = embeddingModel.embedAll(segments.stream().map(TextSegment::text).toList()).content();
embeddingStore.addAll(embeddings, segments);
实测下来,吞吐量能提升一个量级。
3.4 第一路召回:BM25 关键词搜索
LangChain4j 的 ElasticsearchEmbeddingStore 屏蔽了底层 Query DSL,但我们仍然可以拿到 ES 的 RestClient 或 ElasticsearchClient 做原生查询。做混合检索时,我自己一般直接走原生 ES 查询,因为 LangChain4j 默认的 query 走的是向量检索路径,关键词检索需要额外拼查询。
一个标准的 BM25 查询长这样:
java复制var searchResponse = esClient.search(s -> s
.index("hybrid_docs")
.query(q -> q
.bool(b -> b
.should(sh -> sh
.match(m -> m
.field("text")
.query(queryText)
)
)
.should(sh -> sh
.match(m -> m
.field("text")
.query(queryText)
.operator(Operator.And)
)
)
)
)
.size(20),
Doc.class
);
你可能会问,为什么 should 里面写两个 match,一个 OR 一个 AND?这是为了兼顾召回率和排序精准度。纯 OR 能保证搜索词的分词结果有一个命中就能召回,但排序可能把弱相关文档顶上来;加上 AND 分值,再用 boost 调权重,可以让完全命中的文档更靠前。生产环境里我还会再加一个 rank_feature 字段,比如文档点击率、更新时间,让老数据不会长期霸榜。
ES 默认的 BM25 参数是 k1=1.2、b=0.75,一般不需要动。如果发现短查询对长文档不友好,可以适当调 b 到 0.3~0.5,让文档长度惩罚变小。
3.5 第二路召回:kNN 向量检索
在 LangChain4j 内部跑向量检索其实很简单:
java复制Embedding queryEmbedding = embeddingModel.embed(question).content();
List<EmbeddingMatch<TextSegment>> matches = embeddingStore.findRelevant(queryEmbedding, 10);
它默认返回 top 10 条。但为了在混合检索里对齐各路的 topK,我建议直接使用原生 kNN 查询:
java复制var knnResponse = esClient.search(s -> s
.index("hybrid_docs")
.knn(k -> k
.field("text_vector")
.queryVector(queryVector)
.k(20)
.numCandidates(100)
)
.size(20),
Doc.class
);
k 是最终返回多少近邻,numCandidates 是在每个分片上评估的候选数量。在单节点本地环境里,numCandidates 设成 10 倍 k 是常见做法;多分片时,要注意 numCandidates 是分片级别的候选数,如果分片多,最终合并时可能已经足够;如果分片少,可以适当调大。这个参数直接关系到召回准度和查询延迟的平衡。
默认情况下,ES 对 dense_vector 用的 kNN 算法是 HNSW。索引写入时会构建一个图结构,因此写入性能会比普通倒排索引慢一些。如果你的数据量很大,可以调整 mapping 里的 index_options,比如 m 和 ef_construction。本地 demo 用默认值就行,生产环境再针对数据量调优。
还有一点:查询向量必须是 float 数组,维度必须和索引里的 dense_vector 维度一致。我遇到过维度不一致报错,原因是 embeddingModel 配置里写错了模型名,结果写入时用了 768 维,查询时却因为 Ollama 返回了别的模型维度而报错。
3.6 结果融合:RRF 怎么做
混合检索的最后一步,是把 BM25 召回集合和 kNN 召回集合合并成一个排序结果。最简单粗暴的方法是 score 相加,但两路分数范围完全不同,BM25 的分数和向量相似度不在一个量纲,直接加权重会导致某一路主导。RRF(Reciprocal Rank Fusion)就是专门解决这个问题的。
RRF 的公式我记成:
code复制score(d) = Σ 1 / (k + rank_i(d))
其中 k 是一个常数,通常取 60。rank_i(d) 表示文档 d 在第 i 路结果里的排名。也就是说,它只看名次不看原始分数,这样就绕开了分数不可比的问题。比如 BM25 里排第 1 的文档,贡献 1/61;向量里排第 5 的文档,贡献 1/65;两路都排进前 20 的文档,分数自然更高,这就是“两边都认可”的文档优先浮出来。
如果你用的是 ES 8.8 以上版本,并且打开了相应功能,可以直接用原生 RRF 查询,让 ES 在内部完成融合。一个最简化的 HTTP 查询是这样的:
json复制POST /hybrid_docs/_search
{
"size": 20,
"query": {
"match": {
"text": "关闭数据套餐的步骤"
}
},
"knn": {
"field": "text_vector",
"query_vector": [/* 768 个 float */],
"k": 20,
"num_candidates": 100
},
"rank": {
"rrf": {
"window_size": 20,
"rank_constant": 60
}
}
}
看到这里你会发现,ES 原生的 RRF 把 query 和 knn 两路结果融合得很好,不需要我们在应用层手工合并。代码里,我用 ElasticsearchClient 构造 Query 和 KnnQuery,然后设置 rank:
java复制SearchRequest searchRequest = new SearchRequest.Builder()
.index("hybrid_docs")
.query(q -> q.match(m -> m.field("text").query(queryText)))
.knn(k -> k.field("text_vector").queryVector(queryVector).k(20).numCandidates(100))
.rank(r -> r.rrf(rrf -> rrf.windowSize(20).rankConstant(60)))
.size(20)
.build();
SearchResponse<Doc> response = esClient.search(searchRequest, Doc.class);
如果你用的 ES 版本或者许可证限制导致 rank: rrf 不可用,那就只能手写应用层融合。我自己也写过一版,思路是:先分别拿到 BM25 top20 和 kNN top20,给每条结果一个名次,然后对每个 docId 累加 1/(k+rank),最后按累计分排序。只要数据量不夸张,这个方案性能完全够。
4. 实测过程中最常见的几个坑
4.1 Elasticsearch 连接半天调不通
新手最常见的报错是 ElasticsearchStatusException 或连接拒绝。先说排查思路:
- 先检查 Docker 容器是否活着:
docker ps,如果没在,用docker logs langchain4j-es看日志。 - 再看 9200 端口是否能通:
curl http://localhost:9200,不能通说明端口映射没起来。 - 如果安全没关干净,ES 8.x 默认会启用 HTTPS,普通 http 客户端连不上,要么在启动命令里明确
-e xpack.security.enabled=false,要么配置 SSL。 - Spring Boot 配置里如果用
localhost连不上,可以试试127.0.0.1。有些环境 DNS 解析 IPv6 会出问题。
还有一个隐蔽问题:LangChain4j 的 ES 模块如果和 Spring Boot 的 ES 客户端版本冲突,会出现序列化报错,比如 class java.util.LinkedHashMap cannot be cast to class ...。这通常是因为 ES 服务端和客户端大版本不一致,ES 8.11 服务端搭配 8.11.x 客户端,或者至少大版本同为 8.x。
4.2 dense_vector 字段映射和维度报错
我在第一次跑通之前,被 Exception: [text_vector] is not a vector field 这类错误卡了很久。出现这类问题的原因基本都是索引 mapping 里 dense_vector 维度不对,或者是索引早就存在,但当时用的是另一个模型的维度。
解决方法和预防办法:
- 删除旧索引重建,让 LangChain4j 的自动建索引逻辑生成正确 mapping。
- 自己建索引时,明确固定维度,并在代码里写个启动校验,对比 EmbeddingModel 的向量维度和 ES mapping 的维度,不一致就 fail-fast。
- 如果在同一个索引里切换了 embedding 模型,最好新建索引而不是覆盖写。混合编码模型产生的向量互相计算相似度,结果没有参考意义。
4.3 RRF 在企业版或旧版本上的替代方案
正如热词里提到的,ES 9 版本关于 RRF 许可证的问题让我一度很头痛。如果你用的是 ES 9 并遇到 RRF 不可用的情况,建议先确认你手里的许可证类型。对于企业版用户,通常直接咨询供应商开通对应权限;如果你不想被许可证卡住,最稳妥的做法是放弃 ES 原生的 rank: rrf,在应用层做融合。
应用层融合的写法也不复杂,我把样例贴出来:
java复制public List<String> hybridSearch(String queryText, List<Float> queryVector, int topN) {
List<ScoredDoc> bm25Docs = searchByBM25(queryText, 20);
List<ScoredDoc> knnDocs = searchByKnn(queryVector, 20);
Map<String, Double> rrfScore = new HashMap<>();
double k = 60.0;
for (int i = 0; i < bm25Docs.size(); i++) {
rrfScore.merge(bm25Docs.get(i).docId(), 1.0 / (k + i + 1), Double::sum);
}
for (int i = 0; i < knnDocs.size(); i++) {
rrfScore.merge(knnDocs.get(i).docId(), 1.0 / (k + i + 1), Double::sum);
}
return rrfScore.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topN)
.map(Map.Entry::getKey)
.toList();
}
实际效果和 ES 原生 RRF 在 topN 不大时几乎一样。唯一的差别是它多了一次网络往返,但对绝大多数 Java 后端服务来说,这点开销可以接受。
4.4 中文分词不干净的典型表现
如果你把 ES 的默认 standard analyzer 用在中文上,分词结果会变成单字。比如“关闭数据套餐的步骤”会被切成一堆单字,虽然 BM25 在词频统计上也能跑,但召回质量和相关性排序都远不如用 ik_max_word。混合检索里,向量那一路不受分词影响,但 BM25 这一路完全依赖分词质量。所以我在生产配置里给 text 字段加 IK 分词器:
json复制{
"settings": {
"analysis": {
"analyzer": {
"default": {
"type": "ik_max_word"
}
}
}
}
}
如果你用 LangChain4j 的自动建索引能力,就得先手工建索引再来调它。另外,IK 分词器不是 ES 自带插件,需要提前安装到 plugins 目录。本地 Docker 里我一般这样启动:
bash复制docker run ... \
-v ./plugins:/usr/share/elasticsearch/plugins \
...
记得在容器里装完插件后重启容器,否则分词器不生效。
4.5 性能和内存问题别忽视
ES 8.x 默认分配 1GB 堆内存,如果索引数据量和向量维度上来了,写入会变得很慢。测试环境我踩过写入时内存溢出的问题:批量写入 10 万条文档,还没跑完就 OOM。后来我把堆内存调到 4GB,并关闭了不必要的副本,写入吞吐才正常。
查询侧也要注意 size 和 window_size。混合检索时,如果你把 window_size 设置得过大,ES 性能会明显下降。我通常让 window_size 等于 20,顶多 50,不用贪大。毕竟 RRF 融合只需要考虑“每路召回前几十名”就足够,再靠后的文档即使补进去,对最终 top10 的影响也非常有限。
5. 在 LangChain4j 里怎么串成完整的 RAG 链路
如果只是做搜索Demo,上面已经够了。但实际项目里,我们通常是把混合检索结果喂给大模型做问答。在 LangChain4j 里,可以用 ContentRetriever 把上面的混合检索逻辑封装起来,再通过 AiServices 绑定到 ChatModel。这样,每来一个问题,会自动触发 retriever,然后把检索结果作为上下文拼进 prompt。
一个简单的自定义 Retriever 可以这样写:
java复制public class HybridContentRetriever implements ContentRetriever {
private final HybridSearchService hybridSearchService;
@Override
public List<Content> retrieve(String userMessage) {
List<Content> contents = new ArrayList<>();
for (String docId : hybridSearchService.hybridSearch(userMessage, ...)) {
contents.add(Content.from(loadTextById(docId)));
}
return contents;
}
}
然后构建 Assistant:
java复制Assistant assistant = AiServices.builder(Assistant.class)
.chatLanguageModel(chatModel)
.contentRetriever(new HybridContentRetriever(...))
.build();
这样在业务代码里你只需要调用 assistant.chat(userMessage),内部就自动完成混合检索和生成。如果你不想自定义 Retriever,也可以用 LangChain4j 自带的 EmbeddingStoreContentRetriever,但它默认只走向量检索,不走 BM25。
我自己实测的感受是,纯向量检索在通用问答上表现尚可,但只要问题里包含了精确数字、型号、操作命令,混合检索的胜出是一目了然的。比如用户问“在 application.yml 里怎么配置 Elasticsearch 地址”,BM25 这一路能精确命中 “application.yml” 和 “Elasticsearch”,向量这一路又能把“怎么配置”的语义泛化到其他相似表述,两路结合之后,答案稳定很多。
6. 我的几点实战体会
最后分享几个实操中沉淀下来的原则,不一定每条都适用于所有场景,但能帮你少走弯路。
第一,索引的生命周期管理要早做。ES 一旦写入了数据,mapping 基本就不能改了,尤其是 dense_vector 的维度。我现在的习惯是先在一个临时索引里跑通全流程,确认 embedding 模型、切分参数、检索参数都满意之后,再正式建生产索引,然后才灌数据。
第二,不要迷信 RRF 是银弹。它虽然解决了分数不可比的问题,但它把“名次”当成了唯一信号,如果某一路检索本身质量太差,RRF 只能尽量不让它带偏结果,不能逆天改命。所以真正花时间的地方,还是在于把每一路召回的质量分别调到合格线以上。比如中文分词、同义词扩展、向量模型的选型,这些才决定天花板。
第三,混合检索的评估一定不能只看一两个例子。我建议提前准备一个小的评测集,至少 20 个问题,每个问题标注期望命中的文档。每次改参数就批量跑一遍,对比召回率、命中位置、最终答案质量。没有评测集,你很难判断到底是融合策略的问题还是某一路检索的问题。
把 LangChain4j 和 Elasticsearch 用起来之后,我在 Java 工程里做语义搜索就舒服多了。既没有引入额外的向量数据库,又保住了精确检索能力。这篇文章里的代码,你拿回去改改索引名、模型名,基本就能跑出一套最小可用的混合检索服务。后续如果业务量变大,再考虑引入更专业的向量库、增加更多的 rerank 模型,整个架构也有清晰的升级路径。
