做 Java 后端的朋友,最近是不是也被“给系统加个智能搜索/知识库问答”这类需求缠上了?我上个月刚把一个业务模块从纯关键词检索升级成混合搜索,技术栈就是标题里这套:Java + LangChain4j + Elasticsearch。简单说,就是用 LangChain4j 把 Elasticsearch 当作向量存储,在同一个索引里同时支持 BM25 关键词检索和向量语义检索,再用 RRF(Reciprocal Rank Fusion,倒数排名融合)把两路结果合并排序。这篇博文就把整个集成过程、核心代码、环境坑点全部摊开来讲,想抄作业的直接往下看。
先说清楚这篇内容适合谁:你是一个 Java / Spring Boot 开发者,公司已经有 Elasticsearch,想在系统里做语义检索或者给 LLM 应用做知识库召回,但又不想为了一个向量检索单独引入 Milvus、FAISS 之类的重型组件。那这套方案对你来说就是性价比最高的选择。文章里会覆盖选型原因、环境安装、依赖引入、写入向量、混合检索、RRF 融合,还有我实际踩过的 license 和健康检查的坑。
1. 为什么要在 Java 生态里做混合搜索
1.1 关键词搜索的边界,语义搜索的上限
用 Elasticsearch 做过站内搜索的兄弟应该都有体会:BM25 这套关键词匹配机制,对精确词、ID、型号、人名这种结构化特征非常友好,但你让它处理口语化 query 就很容易翻车。举个例子,用户搜“怎么退货”,文档里写的是“退款流程说明”,两个句子没有任何一个词是重合的,BM25 直接给零分。这是关键词搜索的物理边界——它不懂语义。
那换成纯向量搜索行不行?向量检索是把文本映射成高维向量,用余弦相似度衡量语义距离,确实能解决“说人话”的问题。但向量模型对专有名词、订单号、版本号这类精确信息并不敏感,你给它一个 PO-20240001 去向量化,最后召回的结果往往不是你想要的。所以真实生产环境里,最优解不是二选一,而是让两条路都走一遍再融合,这就是混合搜索(Hybrid Search)存在的意义。
我在项目里总结下来,混合搜索其实是在解决一个很朴素的问题:关键词管精确,向量管模糊,两边取长补短。 不做混合的时候,要么精确查询漏掉口语化表达,要么语义查询漏掉精确标识。两条召回通道合并之后,覆盖率明显上来了。
1.2 选型:为什么偏偏是 LangChain4j + Elasticsearch
很多做 AI 应用的人第一反应是用 Python 的 LangChain,但咱们是 Java 团队,一个功能就要引一条 Python 服务链路,后续维护成本太高了。LangChain4j 就是专门给 JVM 生态准备的 LLM 编排框架,它把 Embedding 模型接入、向量存储、对话记忆、RAG 这些能力都做成了 Java 接口。Spring Boot 项目里引一个 starter 就能用,这对 Java 后端来说太友好了。
再聊 Elasticsearch。我知道很多团队在做向量检索的时候会下意识引入 Milvus、FAISS、Qdrant 之类的专业向量库。但如果公司里已经有 ES 集群在跑业务日志或者商品搜索,再单独维护一套向量库,等于多养了一个中间件。ES 从 8.0 开始内置了 dense_vector 字段类型和近似 KNN 检索能力,8.8 之后还支持了原生 RRF,完全能hold住中小规模的语义检索场景。用一套基础设施同时承载关键词检索和向量检索,这是我在选型时最看中的点。
对比一下当时考虑的几条路线:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 单独引入 Milvus | 向量检索性能强 | 额外运维一套集群,与现有 ES 数据割裂 |
| 只用 ES 做关键词,不用向量 | 实现简单 | 口语化、同义查询效果差 |
| ES + LangChain4j 混合检索 | 复用 ES,支持关键词与语义,集成成本低 | 大规模高并发向量检索能力相对专业向量库弱 |
中小团队、业务知识库、内部工单检索这类场景,选 LangChain4j + ES 是很务实的决定。
1.3 混合检索到底在解决什么问题
把混合检索再往深挖一层,它本质上是在解决召回(Recall)和精度(Precision)之间的平衡。纯向量检索召回高但会有噪声,特别是语义相近但实际不同主题的文档会被误召回;纯 BM25 精确但召回不足,换个说法就找不到。
混合检索的思路是:先分别用关键词和向量各召回一批候选,再通过融合算法把两边的排名合并。这样既保证了口语化表达能被语义通道捞回来,也保证了精确词能通过关键词通道强匹配。这个思路在 RAG 应用里尤其重要——给大模型提供的上下文如果召回的文档不对,后面生成质量再高也没用。 我这次做的功能就是给内部的工单知识库做问答底座,第一步就是先把召回做好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与依赖搭建
2.1 本地 Elasticsearch 环境快速就绪
我是用 Docker 在本地起的 ES,这种方式最省心,不污染系统环境。建议用 8.x 系列版本,我用的是 8.11,Elasticsearch Java Client 和 LangChain4j 的兼容性都很好。
yaml复制# docker-compose.yml
version: '3'
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
container_name: es-hybrid
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms1g -Xmx1g
ports:
- "9200:9200"
这里有两个关键点值得说。
第一,discovery.type=single-node 是单机开发的必配项,不加这个 ES 会因为找不到集群节点而启动失败。单节点本身就可以满足测试需求,等真正上生产再考虑多节点集群。
第二,xpack.security.enabled=false 是关闭安全认证。ES 8.x 默认开启 HTTPS 和账号密码,本地开发如果不想处理证书和 token 的问题,可以先关掉。当然生产环境一定要开,后面我在排查章节还会细说这个坑。
启动之后验证一下:
bash复制curl http://localhost:9200
看到带 cluster_name 和 tagline 的 JSON 返回,环境就绪了。
2.2 Maven 依赖与 Spring Boot 配置
我这边是 Spring Boot 3.2 的项目,JDK 17。核心依赖如下:
xml复制<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
<version>0.31.0</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-elasticsearch</artifactId>
<version>0.31.0</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>0.31.0</version>
</dependency>
langchain4j-elasticsearch 这个模块就是对 ES Java Client 的封装,内部提供了 ElasticsearchEmbeddingStore,它帮你处理索引创建、向量写入、KNN 搜索这些操作。对业务开发来说,我们不需要关心底层 DSL 怎么拼,只需要面向 EmbeddingStore 接口编程就行。
然后在 application.yml 里配置连接信息和 Embedding 模型:
yaml复制langchain4j:
elasticsearch:
host: 127.0.0.1
port: 9200
index-name: knowledge_base
langchain4j.open-ai.embedding-model:
base-url: ${EMBEDDING_BASE_URL}
api-key: ${EMBEDDING_API_KEY}
model-name: text-embedding-3-small
如果你用的是 OpenAI 兼容接口,直接配 base-url 和 api-key;如果公司内网有自建的 Embedding 服务,或者想用 Ollama 跑本地模型,LangChain4j 也有对应的 OllamaEmbeddingModel 可以切换。这个灵活度是我比较满意的地方。
2.3 版本兼容性:LangChain4j 0.31.0 与 ai4j 的来龙去脉
这里要专门说一个版本相关的知识点,因为我发现不少人在群里问。LangChain4j 从 0.31.0 开始把底层的核心 SPI 下沉到了 ai4j 模块,语焉不详的版本变化导致很多人升级之后发现原来 API 找不到了。
我自己排查后的理解是这样的:ai4j 相当于 LangChain4j 的“内核”,像 EmbeddingModel、EmbeddingStore、ContentRetriever 这些接口被抽象到了 ai4j 包下,而 LangChain4j 在上层提供了更方便的集成能力。所以你在 0.31.0 之后写代码,import 路径可能从 dev.langchain4j... 变成了 dev.ai4j... 开头。
这不是什么大不了的变化,只要按照官方文档调整 import 即可。但如果你用的是更早的 0.30 或更低的版本,升级时要留意这一点。我的建议是,新项目直接用 0.31.0 或更新版本,老项目升级时先把编译错误列出来,逐个调整 import,不要盲目更换核心 API。
3. 把文档写入向量索引的完整实现
3.1 知识文档的建模与切块
写入向量之前,先要解决一个前置问题:文档怎么切块(Chunking)。 如果你把一个 2000 字的文档整篇塞给 Embedding 模型,结果往往是灾难——既超出了模型的 token 上限,向量也被稀释得没有重点。
我的做法是按段落和固定长度组合切分。先按自然段落分开,如果某个段落仍然超过 500 字,再按句子边界继续切,确保每块长度在 200~500 字之间。LangChain4j 也提供了 DocumentSplitter 工具类,可以直接用:
java复制Document document = Document.from(content, metadata);
DocumentSplitter splitter = new DocumentByParagraphSplitter(500, 50);
List<TextSegment> segments = splitter.split(document);
这里 DocumentByParagraphSplitter 的第一个参数是每段最大字符数,第二个是重叠字符数。重叠是为了避免切块把完整的语义切碎,比如一句话被拦腰截断,两边的向量都表达不清。经验值设 10% 左右的重叠比较合适。
注意 Metadata 的保留,我一般会把文档标题、分类、来源链接、更新时间都塞进 Metadata,后面检索结果展示的时候直接取用,非常方便。
3.2 Embedding 模型接入与维度选择
Embedding 模型有很多选择,但有一个问题必须提前想清楚:写入索引时的向量维度,必须和检索时的维度完全一致。 如果你写入用的是 1024 维的模型,检索时换成了 768 维的,ES 直接报映射冲突。
我这次用的是 OpenAI 兼容接口的 text-embedding-3-small,默认输出 1536 维。如果你用本地模型比如 BGE-M3,维度可能是 1024。选定一个模型之后,尽量在项目里固定下来,不要频繁切换。换模型的代价不仅仅是重新生成向量,还要重建索引,这个成本在生产环境是肉眼可见的。
LangChain4j 里创建 EmbeddingModel 很简单:
java复制EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
.baseUrl("http://your-embedding-service:8080/v1")
.apiKey("sk-xxx")
.modelName("text-embedding-3-small")
.build();
如果你有 Ollama,本地跑个 qwen2.5:7b 或者 bge-m3 也没问题,代码结构几乎一样,换一个 Builder 就行了。
3.3 写入 Elasticsearch 的代码示例
接下来是核心动作:构建 ElasticsearchEmbeddingStore,把切好的文本段向量化,写入 ES。
java复制ElasticsearchConfiguration config = ElasticsearchConfiguration.builder()
.hostName("127.0.0.1")
.port(9200)
.indexName("knowledge_base")
.dimension(1536)
.build();
EmbeddingStore<TextSegment> embeddingStore = new ElasticsearchEmbeddingStore(config);
// 遍历每个文本段
for (TextSegment segment : allSegments) {
Embedding embedding = embeddingModel.embed(segment.text()).content();
embeddingStore.add(embedding, segment);
}
索引字段里会自动创建一个 dense_vector 字段来存向量,文本内容放在原文和 Metadata 里。这个封装用起来很顺手,你不需要手动写 PUT mapping。
不过我要提醒一个细节:dimension 参数必须写对,它对应你 Embedding 模型的输出维度。如果写错了,创建索引之后想改就很麻烦。最稳妥的做法是先在代码里打印一下 embedding.dimension(),再决定传入值。
如果数据量比较大,建议分批提交,我习惯每 200 条 flush 一次,避免一次性写入太多把 ES 内存打满。实测下来单机 2G 堆内存在这个量级下是扛得住的。
4. 混合检索与 RRF 融合实战
4.1 分别召回:关键词搜索 + 向量搜索
混合检索的第一步,是让两条检索通道各自工作。LangChain4j 的 ContentRetriever 体系里,两类检索都有对应的 API。
先看关键词检索。这个就等价于 ES 里最传统的 match 查询,我封装了一个方法:
java复制public List<RankedResult> searchByKeyword(String query, int limit) {
KeywordSearchRequest request = KeywordSearchRequest.builder()
.query(query)
.maxResults(limit)
.build();
KeywordSearchResult response = keywordSearchEngine.search(request);
return response.results();
}
再看语义检索。这里用的是向量相似度搜索,先给 query 生成向量,再在向量索引里找最相近的 TopK:
java复制public List<RankedResult> searchByEmbedding(String query, int limit) {
Embedding queryEmbedding = embeddingModel.embed(query).content();
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(limit)
.build();
EmbeddingSearchResult response = embeddingStore.search(request);
return response.results();
}
两条通道的 maxResults 我建议设置成最终需要数量的 2 到 3 倍。比如你希望最终返回 10 条,那每条通道先召回 20~30 条。原因是融合阶段会丢弃一部分低排名结果,如果一开始召回太少,融合后的结果集会显得很单薄。
4.2 RRF 为什么比加权求和靠谱
最初我也以为“混合”就是把两边的分数加起来排序,结果一实践就发现问题:BM25 的分数和向量余弦相似度根本不在一个量级。 BM25 分数可能从 0 到十几,余弦相似度在 0 到 1 之间,你直接加权求和,向量那边的分数对排序几乎没有贡献,等于没混合。
归一化能解决一部分问题,min-max 或者 z-score 都行,但归一化对离群值很敏感,而且你需要维护两套分数的统计信息,挺麻烦的。
后来我改用 RRF(Reciprocal Rank Fusion),核心公式特别简单:
code复制score(d) = Σ 1 / (k + rank_i(d))
其中 rank_i(d) 是文档 d 在第 i 条检索结果里的排名。取倒数再累加,它只看排名不看原始分数,天然避开了“分数不可比”的问题。k 是平滑参数,经验上取 60 左右效果不错。
用生活化的比喻来解释:这不是把两边的“考试分数”加在一起算总分,而是看“两场比赛里的名次”——一个文档在关键词搜索里排第 1、在语义搜索里排第 5,那它大概率比两边都排第 8 的文档更相关。名次天然具有可比性,这也是 RRF 最优雅的地方。
4.3 应用层实现 RRF 融合排序
RRF 实现起来非常直接,我只用了短短几十行代码:
java复制public List<ScoredResult> hybridSearch(String query, int topN) {
int recall = topN * 3;
List<RankedResult> keywordResults = searchByKeyword(query, recall);
List<RankedResult> semanticResults = searchByEmbedding(query, recall);
Map<String, Double> scoreMap = new HashMap<>();
Map<String, RankedResult> detailMap = new HashMap<>();
addRrfScores(scoreMap, detailMap, keywordResults, 0);
addRrfScores(scoreMap, detailMap, semanticResults, 1);
return scoreMap.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topN)
.map(entry -> {
ScoredResult result = new ScoredResult();
result.setChunkId(entry.getKey());
result.setScore(entry.getValue());
result.setContent(detailMap.get(entry.getKey()).content());
return result;
})
.collect(Collectors.toList());
}
private void addRrfScores(Map<String, Double> scoreMap,
Map<String, RankedResult> detailMap,
List<RankedResult> results,
int offset) {
int k = 60;
for (int i = 0; i < results.size(); i++) {
RankedResult result = results.get(i);
String chunkId = result.id();
double score = 1.0 / (k + i + 1);
scoreMap.merge(chunkId, score, Double::sum);
if (!detailMap.containsKey(chunkId)) {
detailMap.put(chunkId, result);
}
}
}
这里有几个工程上的细节值得展开。
第一,scoreMap.merge(chunkId, score, Double::sum) 是 RRF 的核心累加操作,同一个文档如果在两条通道都被召回,它的两部分贡献会自动求和。
第二,我用 detailMap 缓存了每个 chunk 的详情。为什么?因为融合之后你可能需要展示命中内容、来源标题、得分明细,提前把详情对象挂上去,后面组装响应就非常快,不用再回 ES 查一次。
第三,offset 参数看起来没用上,其实是为了标识通道来源——如果后续要调试或者分析各通道贡献,可以顺手打印一下。我在实际项目里加了一个日志开关,统计每条结果分别来自哪一路召回,这在调优召回阈值的时候很有用。
4.4 返回结果的组织与后处理
融合排序之后,后处理也不能马虎。我总结了三件事必须做:
第一是去重。两条检索通道很容易召回同一份文档,RRF 合并时虽然用 Map 去重了,但如果两个文档块内容几乎一样(比如切块重叠过多),需要再做一次相似内容去重。我基于文本的 SimHash 做了一个简单过滤,效果还行。
第二是分页与深度查询。混合检索不太好做深分页,因为融合发生在应用层,你不可能把整个索引的所有结果都拉回内存。生产环境我限制 topN 最大 50,业务上够用就行。想要游标式翻页的话,建议用 search_after 结合最终排序结果里的稳定字段实现,别用 from + size。
第三是可解释性。返回结果里带上“关键词命中要点”和“语义匹配度”的信息,这样前端可以展示“为什么给你推这条”,对用户信任度很有帮助。
5. 踩坑实录与问题排查
5.1 原生 RRF 功能用不了?License 的现实问题
网上很多教程会告诉你,Elasticsearch 8.8+ 已经支持原生 RRF 了,在 DSL 里写 "rank": {"rrf": {...}} 就行。这个说法本身没错,但有个大坑:原生 RRF 检索功能是白金版/企业版才提供的,基础版 License 用不了。 我当时兴冲冲地把 rank 参数加进查询,结果返回错误,查了文档才发现许可证限制。
我看到热搜词里有人问“elasticsearch 9 版本 rrf 是企业版的怎么办”,思路和我当时一样:别在服务端纠结这个功能,直接在应用层自己实现 RRF。 前文第 4 节的代码就是一套完整的应用层方案,不依赖 ES 的铂金功能,免费版本地跑毫无压力。
再提醒一句:生产环境尽量用 Basic License,不要随便开 Trial,试用期一到功能受限,全链路出问题的时候很被动。我们的经验是,企业内网知识库这个体量,用自研 RRF 完全没问题,没必要为这个功能给 ES 买更高阶的许可证。
5.2 Elasticsearch Health Check Failed 排查
日志里看到 e.elasticsearchrestclienthealthindicator: elasticsearch health check failed 是很典型的报错。第一次遇到时我还以为是 ES 挂了,排查一圈发现不是。这个报错可能的原因和排法我整理了表格:
| 原因 | 排查方式 | 解决办法 |
|---|---|---|
| ES 服务没启动 | curl http://localhost:9200 看是否有响应 | 检查 Docker 容器状态 |
| 端口不通 | telnet 127.0.0.1 9200 | 检查宿主机防火墙和 Docker 端口映射 |
| 认证信息错误 | 看申请配置里的账号密码和 ES 实际是否一致 | 重置密码或在 ES 配置里关闭安全模块 |
| 客户端版本与服务端版本不兼容 | 打印客户端版本和服务端版本对比 | 统一升级到兼容的大版本 |
尤其是 ES 8.x 默认开了安全认证,如果你本地用 xpack.security.enabled=false 启动,但 application.yml 里还保留着用户名密码,也会导致健康检查失败。配置里的安全和实际环境对不上,是本地开发最常见的坑。
5.3 BM25 和向量分数不可直接比较
这个问题我在 4.2 节已经讲了很多,但值得再单独拿出来强调一次:两路分数的分布差异太大,千万不要直接相加。
我做个直观对比。BM25 的分值受词频、文档长度、逆文档频率影响,长短文档的得分差异极大;向量检索的相似度则受 Embedding 模型影响,基本落在 0~1 区间。你用 0.8 的余弦相似度加上 8.0 的 BM25 分数,语义通道直接失去话语权。
应用层 RRF 之所以好用,就是因为把排名作为统一量纲。如果你实在想用加权分数,记得先做归一化,而且权重要靠线下评估集去调,不能拍脑袋。我们团队后来测评发现:RRF 方案在 Top-10 命中率上和加权方案持平,但在参数调节的成本上低了一个数量级。
5.4 本地 ES 8.x 安全配置适配
最后再说一个刚上手的人几乎必踩的坑:ES 8.x 默认启动是 https + 账号密码认证,而且初始密码是自动生成的,在启动日志里找。如果你用默认配置,Java Client 那边链接需要配证书和登录信息,第一次接入很容易卡住。
开发环境的最佳实践就是我在 2.1 节给的方案——用 Docker 启动时把安全关掉:
yaml复制environment:
- xpack.security.enabled=false
这样 http://localhost:9200 直接可访问,Java Client 里不需要额外配 SSL 和认证。这个方案只适合本地环境,生产环境请务必开启安全认证,并且在客户端配置正确的证书和凭证。
我自己实际踩坑后总结出的体会是:本地开发环境越简单越好,把安全相关的东西全部留给生产环境去配置。开发阶段就被证书、HTTPS、密码重置这些事打断,写业务代码的节奏感全没了。
最后再分享几点实战体会
这套混合搜索方案上线之后,我最满意的不是某项技术指标提升了多少,而是整个接入过程的“顺滑感”。LangChain4j 把 ES 的向量存储细节封装得很干净,业务代码基本没碰过原生 ES DSL,Java 团队接手起来毫无心理负担。
如果要在结尾给点建议,我会说三件事:第一,先拿小规模数据集跑通整个链路,再谈优化;确认搜索结果的召回质量确实比纯关键词好,再考虑上生产;第二,RRF 里的 k 值不用纠结,60 是一个经过了大量实践检验的默认值,想试其他值就从 30 到 100 之间扫一遍;第三,这个方案后续扩展空间很大,比如可以接入 rerank 模型、加多路召回、配合大模型做 RAG。先把这个混合检索底座打好,后面这些都是水到渠成的事。
