首页 / 资讯中心 / 文章详情

ES+RAG混合检索实战:从选型到落地全记录

ES+RAG混合检索实战:从选型到落地全记录 ★ FEATURED ARTICLE
做搜索的同学对 ElasticSearch 肯定不陌生搞大模型应用的基本都在聊 RAG。把这两个词拼在一起是我最近大半个月一直在折腾的事情。这篇就当作一份初使用报告把我从选型到落地的完整过程、踩过的坑、最后跑通的链路一次性讲清楚。先说结论ES 8.x 自带的向量检索能力配合开源 embedding 模型和一套好用的混合检索策略完全可以支撑中小规模知识库的 RAG 场景。你不需要一上来就搭一整套向量数据库也不需要在 ES 和向量库之间做数据同步少维护一套系统就是少一堆麻烦。这篇内容适合两类人看一类是手里已经有 ES、想低成本接 RAG 的另一类是刚开始接触 RAG、对整套链路还没啥概念的。我会把关键配置、代码片段、参数选择逻辑都贴出来照着抄基本能跑。1. 为什么是“ESRAG”不是纯向量数据库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. 从零搭一套 ESRAG选型和环境怎么定2.1 版本和部署形态确认你的 ES 能不能跑向量这一步容易被人忽略但非常关键。不是所有 ES 版本都能直接跑向量检索。我当时查了一下ES 7.x 想用向量检索需要额外安装插件而且官方维护力度一般。到了 8.xdense_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 B0.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_len500, overlap50): 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 做语义匹配最后用 RRFReciprocal Rank Fusion把两路结果融合排序。先说为什么不能只用一路。纯 BM25 的短处前面提过语义相近但用词不同的 query 会漏召回。纯 kNN 的问题是精确匹配失效比如产品型号、订单编号、人名这类信息语义检索基本抓瞎。两路互补效果才稳定。kNN 查询的写法{ knn: { field: content_vector, query_vector: [...], k: 10, num_candidates: 100 } }k是每个分片返回的最近邻数量num_candidates是每个分片考虑的候选数量。这个参数不是越大越好num_candidates太大会拖慢查询速度太小会损失召回精度。我测试下来5 万数据规模下k10, num_candidates100比较均衡。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 初使用结论这套组合对多数知识库场景够用我在实际使用中最深的体会是ESRAG 的组合远没有想象中复杂真正的难点在于把检索做扎实。只要分块合理、混合检索调通、大模型组装正确一个可用度相当高的知识库问答系统就搭起来了。很多 RAG 翻车案例追根溯源不是大模型不行而是过去检索那一环太糙。这套方案目前能直接支撑的场景包括内部技术文档问答、产品客服知识库、政策制度查询、教学资料答疑。如果后续数据量涨到几千万级、或者出现多轮对话的记忆需求再考虑引入更专业的向量检索服务或者对话管理模块也不迟。但至少现阶段ES 这个老伙计依然能打。最后再分享一个我自己的小习惯每次调整完分块策略或检索参数都顺手把改动记录在评测结果旁边。几次迭代下来回看哪些改动真正提升了指标心里就有数了。工具链可以演进但这个方法论什么时候都用得上。
阅读完成 · 觉得有帮助?
咨询建站