做搜索的同学对 ElasticSearch 肯定不陌生,搞大模型应用的基本都在聊 RAG。把这两个词拼在一起,是我最近大半个月一直在折腾的事情。这篇就当作一份初使用报告,把我从选型到落地的完整过程、踩过的坑、最后跑通的链路,一次性讲清楚。
先说结论:ES 8.x 自带的向量检索能力,配合开源 embedding 模型和一套好用的混合检索策略,完全可以支撑中小规模知识库的 RAG 场景。你不需要一上来就搭一整套向量数据库,也不需要在 ES 和向量库之间做数据同步,少维护一套系统就是少一堆麻烦。这篇内容适合两类人看:一类是手里已经有 ES、想低成本接 RAG 的,另一类是刚开始接触 RAG、对整套链路还没啥概念的。我会把关键配置、代码片段、参数选择逻辑都贴出来,照着抄基本能跑。
1. 为什么是“ES+RAG”,不是纯向量数据库
1.1 先搞清楚 RAG 解决什么问题
大模型再强,也逃不过三个尴尬问题:知识停留在训练时刻、编起答案来理直气壮、碰不到企业内部数据。RAG 的思路说白了就一句话——不逼模型硬答,而是先让检索系统把相关资料找出来,塞进提示词,让模型“带着资料答题”。这个过程很像开卷考试:模型不需要背下所有知识点,只要会读材料、会归纳就行。
这个领域做得久了你会发现,决定 RAG 效果上限的往往不是大模型本身,而是前面那一段检索。检索到的资料质量高,答案就准;检索到的资料乱七八糟,模型再聪明也只能跟着乱。而检索这件事,恰恰是 ElasticSearch 的老本行。ES 做全文检索已经积累了十几年,有成熟的分词、倒排索引、相关性打分,现在又有了向量检索能力,两边一结合,正好对上 RAG 的需求。
1.2 已有的 ES 不能浪费,向量库不是银弹
当时我面临的第一个选择就是:要不要单独部署一个向量数据库?查了一圈资料,也试了几天,最后放弃了。原因其实很现实。
第一,ES 8.x 之后已经原生支持dense_vector字段类型和 kNN 检索,功能上能满足大部分向量召回场景。我测试的体量是几十万条文档分块,这个规模对 ES 来说完全不是问题。再叠加 BM25 关键词检索做混合召回,效果比单纯用向量库还好一些。
第二,公司里已有的数据管道、权限控制、索引生命周期管理,全是基于 ES 的。如果换一套向量库,数据同步、权限对接、运维监控全都得重搞一遍。为一个不那么必要的“专业向量库”引入两套系统之间的数据一致性负担,不划算。
第三,纯向量检索有一些天然短板。举个例子,用户问的如果是“订单号 O20230115 的状态”,这种精确匹配场景向量模型很容易栽跟头,但 ES 的 term 查询一下就能命中。实际知识库场景里,关键词精确匹配和语义模糊匹配往往是交替出现的,混合检索明显更稳。
所以我的判断是:不折腾新系统,把 ES 用透,把“BM25 + 向量检索 + 融合排序”做扎实,对绝大多数知识库问答场景已经够用了。后面所有方案都围绕这个思路展开。
2. 从零搭一套 ES+RAG,选型和环境怎么定
2.1 版本和部署形态:确认你的 ES 能不能跑向量
这一步容易被人忽略,但非常关键。不是所有 ES 版本都能直接跑向量检索。
我当时查了一下,ES 7.x 想用向量检索需要额外安装插件,而且官方维护力度一般。到了 8.x,dense_vector字段类型才算真正内置,kNN 搜索也变得可用。所以如果你手头还是 7.x,别犹豫,能升就升到 8.x 以上,省掉一堆插件兼容性问题。
我用的是单节点部署,数据量不大,先不考虑集群。Java 版本用的是内置的 JDK,没有额外配置。有两点需要提醒:一是堆内存大小要设好,我给了 2GB,测试阶段够用;二是别把堆内存无脑调大,要给 Lucene 的底层文件缓存留空间,否则大索引场景下性能反而下降。
# 设置堆内存,修改 config/jvm.options -Xms2g -Xmx2g # 确认版本 curl -X GET "http://localhost:9200"跑起来之后,我先建了个测试索引,插了几条带向量的数据,验证knn查询能通,才开始正式做数据准备。这个“先验证、再铺开”的习惯帮我省了不少事。
2.2 向量模型选型:中文场景别只看维度
选 embedding 模型的时候,我一开始也觉得“维度越大越厉害,直接用商用 API 最简单”。后来实际对比下来,发现问题远没那么简单。我做了三组对比测试:开源中文 embedding 模型 A、商用 API 的 embedding 服务 B、还有不用向量只用关键词检索的对照组。
测试集用的是企业内部问答数据,大概 200 多组问题,人工判定了每个问题的标准答案出处。结果很有意思:
| 方案 | 平均召回准确率(Top 5) | 单条平均耗时 | 成本 |
|---|---|---|---|
| 开源中文模型 A(本地部署) | 0.87 | 约 15ms | 仅占机器资源 |
| 商用 API B | 0.85 | 约 80ms(含网络) | 按调用量计费 |
| 仅 BM25 关键词检索 | 0.63 | 约 5ms | 无额外成本 |
开源模型 A 的维度是 768,商用 API B 的维度有 1536。但实际效果上 A 反而略好。原因也不难理解:A 是专门针对中文语料优化的,对企业内部的行业术语理解得更好;B 虽然通用能力更强、维度更高,但在我们这个垂直领域并不占优。
这里有个常被忽视的细节:向量维度直接决定索引占用空间和查询耗时。768 维的向量,100 万条数据内存占用已经不小了。HNSW 图索引在构建和查询时都要大量访问内存,维度越高,内存和 CPU 消耗越大。所以选模型的时候不要只看“效果最强”,要看“够用且能扛住”。
我的结论是:中文场景优先选针对性优化的开源模型,自己部署,一劳永逸;商用 API 适合起步阶段验证效果,长期跑不划算。
2.3 LLM 接入方式:API 还是私有化
retrieval 部分搞定了,生成部分还得有个大模型。这里的选择也很直接:一种是用商用大模型 API,另一种是自己部署开源模型。
我测试的时候两种都试了。商用 API 的好处是省事,效果也稳定,适合做 PoC。但要注意延迟和限流,单次问答如果还要做多轮检索,整体耗时会叠加。我自己部署的开源模型在效果上略逊,但胜在可控,数据不出内网,对内部知识库场景是个很重要的考量。
如果你只是初用,我建议先接 API 把链路跑通,别一上来就在部署模型上花时间。链路验证没问题之后,再根据实际情况决定要不要换私有化部署。
3. 完整落地链路:索引创建、写入、混合检索、生成答案
3.1 数据准备:清洗和分块策略
这步我愿称之为“RAG 效果的第一决定因素”,比选哪个大模型重要得多。
原始文档要做的第一件事是清洗。我处理的是一批内部技术文档和客服知识库,格式五花八门,有 PDF、Word、还有直接从网页存的 HTML。清洗的目标是去掉页眉页脚、导航链接、图片标签等噪声,尽量提取出纯文本内容。这一步用的脚本很简单:PDF 用文本解析库提取,Word 转成纯文本,HTML 直接用爬虫常用的解析工具取正文。
清洗完就是分块(chunking)。很多人低估了分块的重要性,随手按固定字数一刀切。我一开始也是这么干的,结果检索效果惨不忍睹:一个问题查出来的几个分块,要么内容重叠太多,要么把两个完全不相关的段落切在一个块里。
后来我改成按“段落优先 + 窗口补充”的策略:先按文档的自然段落切分,然后把过短的段落和相邻段落合并,每个分块控制在 300~500 字左右,块与块之间保留少量字符重叠(我用了 50 字),避免句子被硬生生切断。
# 伪代码示意,实际脚本略 def chunk_text(text, max_len=500, overlap=50): paragraphs = split_by_paragraph(text) chunks = [] current = "" for para in paragraphs: if len(current) + len(para) <= max_len: current += para else: if current: chunks.append(current) current = para # 处理重叠 ...经验是:分块大小不能一概而论,要根据文档类型灵活调整。技术文档段落逻辑性强,按段落切效果就好;常见问题类条目短,可以把几条合并成一个块;说明书类的长文本就要小心,别把操作步骤的上下文切断了。
3.2 索引 mapping 和 pipeline 配置
数据准备好了,下一步是把分块写入 ES。这里要设计索引结构,核心是一个文本字段加一个向量字段。
我用的是 ES 8.x 标准做法:source 字段存原文,content_type 字段存内容类型,content_vector 字段存向量。向量字段的类型是dense_vector,维度跟 embedding 模型输出保持一致,相似度度量用的余弦相似度。
PUT /knowledge_base { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "doc_id": { "type": "keyword" }, "title": { "type": "text", "analyzer": "ik_max_word" }, "content": { "type": "text", "analyzer": "ik_max_word" }, "content_vector": { "type": "dense_vector", "dims": 768, "index": true, "similarity": "cosine" }, "created_at": { "type": "date" } } } }文本字段我用的是ik_max_word分词器,中文场景下比默认的 standard 分词器好太多——standard 分词会把中文句子切成一整个长词,检索时基本失去作用。
向量的生成我走的是 ingest pipeline,这样在写入文档的时候自动调用 embedding 模型生成向量,省得在业务代码里手动调模型。pipeline 配置大概长这样:
PUT /_ingest/pipeline/embedding_pipeline { "description": "generate embedding for content", "processors": [ { "script": { "source": "ctx.content_vector = [/* 调用本地模型接口,将ctx.content转为向量 */]" } } ] }实际场景里,我用的是一个本地 HTTP 服务封装了 embedding 模型的推理,pipeline 里的脚本通过 HTTP 请求拿到向量再写回去。ES 的 script processor 能调用外部接口,这个有不少人不知道。
3.3 批量写入与增量更新的实测要点
写入用 Bulk API,批量打包,性能比一条条 insert 快了一个数量级。我 5 万条分块数据,批次大小 500,跑完大概用了两三分钟。
有个细节我吃过大亏:向量必须做归一化。如果你用的 embedding 模型返回的不是归一化向量,相似度计算会出偏差。我的做法是在 pipeline 里对向量做 L2 归一化,处理完再写入。如果模型本身已经归一化了,跳过也行,但最好验证一下。
增量更新方面,我用doc_id作为文档的幂等标识,同一个doc_id多次写入会覆盖,避免重复数据。删除逻辑也简单:按doc_id删就行。整体上,ES 的原生写入删除能力完全够用,不需要额外维护同步任务。
3.4 混合检索:BM25 与 kNN 怎么配
到了重头戏:检索。我实际用的是混合检索策略,同时查两路——一路用 BM25 做关键词匹配,一路用 kNN 做语义匹配,最后用 RRF(Reciprocal Rank Fusion)把两路结果融合排序。
先说为什么不能只用一路。纯 BM25 的短处前面提过,语义相近但用词不同的 query 会漏召回。纯 kNN 的问题是精确匹配失效,比如产品型号、订单编号、人名这类信息,语义检索基本抓瞎。两路互补,效果才稳定。
kNN 查询的写法:
{ "knn": { "field": "content_vector", "query_vector": [...], "k": 10, "num_candidates": 100 } }k是每个分片返回的最近邻数量,num_candidates是每个分片考虑的候选数量。这个参数不是越大越好,num_candidates太大会拖慢查询速度,太小会损失召回精度。我测试下来,5 万数据规模下k=10, num_candidates=100比较均衡。
BM25 查询也简单:
{ "query": { "match": { "content": { "query": "用户问题文本", "operator": "or" } } } }两路结果出来后,我用 RRF 融合。核心思想是:不直接按分数相加,而是按排名倒数加权,这样能弱化不同检索方式分数尺度不一致的问题。公式很朴素:每个文档的融合得分等于两路排名的倒数之和。由于是初使用阶段,我直接在应用层写了这个融合逻辑,没有用 ES 8.8 之后内置的rank语法,逻辑更透明,也便于调整权重。
RRF 的 k 值默认取 60,实践下来这个值确实比较稳。如果你用的是新版 ES,内置 RRF 是:"rrf": { "rank_window_size": 10, "rank_constant": 60 },效果一样,能省一次应用层计算。
3.5 Prompt 组装与答案生成
检索完拿到 Top-K 分块,接下来就是拼 Prompt 送给大模型。
这一步的原则是:上下文要给够,但不能给太多。给太多不仅浪费 token,还会稀释重点,模型容易被无关信息带偏。我取 Top-6 个分块,每个分块前加上[来源N]标记,然后拼成一段“参考资料”。
我用的 prompt 模板是这样的:
你是知识库助手。请只根据下面的参考资料回答用户问题。 如果参考资料不足以回答问题,请明确说“资料中没有找到相关内容”。 回答时尽量引用资料原文,并在句末用[来源N]标注。 参考资料: [1] {{分块1}} [2] {{分块2}} [3] {{分块3}} [4] {{分块4}} [5] {{分块5}} [6] {{分块6}} 用户问题:{{用户问题}}这个模板有两个设计意图。第一句“只根据参考资料回答”是为了约束模型不要脑补。中途加一句“资料不足就直说”是为了避免模型硬凑答案。引用标记[来源N]则方便前端展示依据,用户能看到答案出自哪篇文档,信任感会强很多。
上下文长度控制用 token 数而不是字符数更准确。中文场景下我粗略按一个字约 1 到 1.5 个 token 估算,Top-6 分块加上 prompt 本身,总量控制在 3000 token 左右,主流模型基本都能容纳。
4. 初用阶段我踩过的坑和排查方法
4.1 分数高不代表答案对
这是个很反直觉的坑。ES 返回的_score很高,很容易给人“这结果肯定靠谱”的错觉。但实测下来,kNN 的余弦相似度分数和“答案正确与否”之间不是严格正相关。
有一次我查一个技术问题,返回的第一个分块相似度 0.89,但内容讲的是另一个相似概念,答案完全偏了。反而排第三的分块相似度只有 0.72,命中了正确答案。
后来我调整策略:别用分数绝对值当硬阈值,看排名位置。把 Top-K 从 3 扩到 6,让后续的融合排序和模型自己有更多上下文可选。ES 的分数是用作排序依据的,不是用来当置信度判断的。如果一定要设阈值做过滤,也要基于你自己的测试集去标定,别拿网上别人说的数值直接套。
4.2 分块太随意,查得到文档却查不到答案
这个坑我在 3.1 提过,但值得一提再提,因为太典型了。我最初按固定 200 字分块,一个常见问题条目被切成两半,提问“怎么重置密码”,命中的那块只包含背景介绍,压根没有操作步骤。模型拿到不完整的资料,自然答不出完整答案。
解决方式就是改成段落优先的切分策略,同时保证一个完整操作流程尽量在一个块里。块长度宁可稍长一点,也别把逻辑切断。这里没有银弹公式,得根据业务文档的特性慢慢调。
4.3 HNSW 参数和堆内存
ES 8.x 的向量索引默认基于 HNSW 算法。这个算法有三个关键参数:m控制每个节点的最大连接数,ef_construction控制建索引时的搜索宽度,ef_search控制查询时的搜索宽度。我的经验是:m默认 16 足够,ef_construction默认 100 在数据量不大时够用,查询时的ef_search才是真正影响召回精度的,需要调高一些。
这些参数在 mapping 里配:
"content_vector": { "type": "dense_vector", "dims": 768, "index": true, "similarity": "cosine", "index_options": { "type": "hnsw", "m": 16, "ef_construction": 100, "ef_search": 100 } }内存方面,向量索引构建时会消耗比预期更多的堆内存。我遇到过集群黄色健康状态,排查半天才发现是堆内存使用率飙到 90%,频繁 GC。解决办法是分批次写入、降低并发、调大堆内存。给向量索引留够内存这个事情,越早意识到越好。
4.4 中文检索的细节
中文场景比英文多一层分词的坑。ES 默认的 standard 分词器对中文的支持约等于没有,它会把一整句话当成一个词。后来我换成了ik_max_word分词器,中文字典匹配能力比默认好太多。
要注意的是,ik_max_word 是最细粒度切分,词条多、索引大;ik_smart 是粗粒度切分,词条少、更精准。我最终用的是ik_max_word做索引、ik_smart做搜索,这样索引覆盖全、搜索更精准。这个组合在不少项目里被验证过,可以照搬。
5. 效果怎么评估,后续往哪优化
5.1 一套能快速操作的回归评测方法
搞了半天,最怕自我感觉良好。我建议准备一个简单的评测集,不用太复杂,但至少要有。做法是:人工整理 50 到 100 组“问题 → 正确答案所在文档”的映射,然后跑一遍检索流程,看 Top-5 里有没有命中正确答案。
记录的指标有两个:Top-1 准确率和 Top-5 召回率。如果 Top-5 召回率已经很高,但 Top-1 不行,说明融合排序还有优化空间。如果 Top-5 都不行,那就是检索源头的问题,得回头调分块和模型。这个“先分级、再定位”的思路能帮你快速判断瓶颈在哪,不会瞎调。
5.2 调优优先级和可执行清单
根据这几周的实测,我总结了一个调优优先级,按性价比排序:
- 数据清洗和分块策略。改动效果立竿见影,而且基本不费算力。
- 混合检索的权重与融合方式。BM25 和向量的权重、RRF 的 k 值,值得反复试。
- 向量模型本身的选型。到了这个阶段再考虑换模型,效果差异基本稳定。
- 大模型的 prompt 设计。前三个到位之后,prompt 的效果才能真正体现出来。
参考清单长这样:
- 垃圾内容没清理干净时,别急着调参数。
- Top-5 召回率低于 0.8,优先看分块和模型。
- Top-1 准确率低于 Top-5 召回率 20 个百分点以上,优化融合排序。
- 一切效果问题先回归评测集,别靠感觉。
5.3 初使用结论:这套组合对多数知识库场景够用
我在实际使用中最深的体会是:ES+RAG 的组合远没有想象中复杂,真正的难点在于把检索做扎实。只要分块合理、混合检索调通、大模型组装正确,一个可用度相当高的知识库问答系统就搭起来了。很多 RAG 翻车案例,追根溯源不是大模型不行,而是过去检索那一环太糙。
这套方案目前能直接支撑的场景包括:内部技术文档问答、产品客服知识库、政策制度查询、教学资料答疑。如果后续数据量涨到几千万级、或者出现多轮对话的记忆需求,再考虑引入更专业的向量检索服务或者对话管理模块也不迟。但至少现阶段,ES 这个老伙计依然能打。
最后再分享一个我自己的小习惯:每次调整完分块策略或检索参数,都顺手把改动记录在评测结果旁边。几次迭代下来,回看哪些改动真正提升了指标,心里就有数了。工具链可以演进,但这个方法论什么时候都用得上。