☰
RAG标配未必是向量数据库:Elasticsearch混合检索方案详解
2026/10/16 8:10:30 网站建设 项目流程

上个月团队做 RAG 检索方案选型,还没等我把需求场景讲完,就有同学拍板定调:既然要做 RAG,肯定得配一个向量数据库,这不是标准答案吗。我当时没有急着反驳,但心里很清楚,这个判断大概率拍早了。后来我用一套已有的 Elasticsearch 集群跑通了整个流程,效果不输专用向量库,还少维护了一套组件。今天这篇就聊聊,为什么 RAG 技术栈里始终被忽略的 Elasticsearch,反而像扫地僧一样稳。

先说结论:如果你的场景需要“语义相似度召回 + 关键词精确匹配 + 结构化过滤”混着来,Elasticsearch 几乎是唯一能用一个集群全部搞定还不出戏的方案。而这也正是大多数 RAG 落地项目的真实形态,不是学术 demo 里那种“给一段 query 就返回最像的三段文本”那么单纯。

1. 为什么 RAG 一上来就想到向量数据库

1.1 惯性思维从哪来

这两年大模型应用铺天盖地,技术社区里讲 RAG 的教程十篇有八篇默认要引入一个向量数据库。截图里永远是一个文档切片工具、一个 embedding 接口、一个向量库、一个大模型。看多了之后,很多人形成了一种路径依赖:做 RAG,等于要上向量数据库。甚至有些技术评审会上,你不提向量数据库,别人会认为你的方案不够“前沿”。

这种惯性思维的根源,其实是教程把流程简化了。为了把原理讲清楚,教程通常只保留“文本转向量、向量检索、交给大模型”这三个环节,至于业务里常见的过滤条件、精确匹配、权限隔离、热更新,统统被抽象掉了。于是新手学到的不是一套检索系统,而是一条单线流水线。

1.2 你对 ES 的了解可能还停留在全文检索

我问过身边不少人,为什么不用 Elasticsearch。得到的回答高度一致:ES 是搞全文搜索的,做不了语义检索。这个说法放在 ES 6.x 时代基本成立,但在 8.x 之后已经站不住脚了。ES 从 7.x 开始引入向量字段,到 8.x 已经具备比较成熟的原生 kNN 检索能力,支持 HNSW 图索引,能够按余弦相似度、点积等方式直接查。

更关键的是,ES 从未丢掉自己的老本行:BM25 全文检索、分词器、过滤器、聚合、权限控制,这些能力在纯向量数据库里要么没有,要么做得非常弱。你花大力气把文本切成向量存进向量库,然后遇到一个精确编码需求,比如用户输入“iPhone 15 256G 蓝色”,向量库只能给你返回一堆语义相近的废话,而 ES 可以在同一个索引里把 token 级别的匹配玩得明明白白。

这让我想起一个很常见的工程误区:为了“语义”两个字,把基础但极其可靠的关键词检索给丢了。真实业务里,用户可能就用产品型号、工单编号、错误码来提问,这种场景下关键词精确匹配的命中率几乎百分之百,语义向量反而会因为训练数据里没见过而出现偏差。

2. ES 凭什么配得上“扫地僧”:被忽略的原生向量检索

2.1 原生向量检索能力:dense_vector + kNN

ES 8.x 的向量能力,核心是新增了 dense_vector 字段类型,并围绕它实现了图索引和近邻检索。你可以在 mapping 里声明一个字段专门存浮点数组,维度通常和 embedding 模型的输出维度一致,常见的有 384 维、768 维、1024 维。底层索引可以选择 HNSW 算法,查询的时候直接发一个 knn 请求,ES 会返回与 query_vector 最相近的 top K 文档。

这跟专用向量数据库的核心原理是一回事,只是在 ES 里它和文本检索、filter 聚合共存在同一个 Lucene 索引中。对研发团队来说,这意味着不再需要单独搭一套 Milvus、Qdrant 或者 FAISS 服务,也不用处理向量库和业务库之间的数据同步问题。你只需要一个 ES 集群,数据写一份,什么检索模式都能覆盖。

有些人会说,ES 的向量检索性能比不上专用 vector db。这话得分场景。实测在小规模到中等规模数据量下,比如几百万条以内,HNSW 参数调一调,ES 的召回延迟完全可以控制在几十毫秒级别,根本不会成为 RAG 链路的瓶颈。RAG 的耗时大头向来是 embedding 生成和 LLM 推理,一个常用 embedding 模型调 API 可能就要几百毫秒,相比之下检索那十几毫秒完全无感。

2.2 从生产角度算三笔账

第一笔是数据一致性账。很多团队做 RAG,业务数据在 MySQL,然后写定时任务同步到向量库,同步过程中经常出现字段对不上、删除不同步的问题。如果直接用 ES,项目里原本就可能有日志搜索、商品搜索已经在用 ES,数据链路是现成的,只需往 mapping 里加一个向量字段,再补一个 embedding 写入逻辑,同步成本大幅降低。

第二笔是运维账。每增加一个中间件,就要多考虑一台机器、监控面板、告警规则、备份方案、版本升级。向量数据库不像 Kafka 或 Redis 那么简单,索引策略、分片设计、内存规划都得重新学一遍。而 ES 的运维经验在行业内已经非常成熟,随便一个后端工程师都能处理常见问题。对于十几个人的小团队,这个优势尤其明显。

第三笔是业务灵活度账。RAG 很少只做“查相似文本”这一件事。通常用户会带条件:只看某个知识库分类下的内容、只要 30 天内的公告、不要某个状态的数据。纯向量库做这类过滤有的能力很弱,有的需要在召回后二次筛选,还有的可能就根本不好实现。ES 的 term、range、wildcard、bool filter 可以把这些条件直接推到检索阶段,效率完全不在一个量级。

2.3 能力和定位速览对比

能力Elasticsearch 8.x常见专用向量数据库
向量检索支持 dense_vector + HNSW kNN原生支持,性能激进
关键词全文检索BM25,分词、同义词、模糊匹配支持完善弱,通常不支持或很简陋
结构化过滤强,filter 与评分过程解耦部分支持,能力参差
数据同步与业务共用一套同步链路需要额外维护管道
聚合分析完整聚合框架弱
运维生态成熟稳定,社区资料多较新,问题排查资料少

这张表不需要看得太细,关键是最后两行,运维生态和延伸能力。对一个要长期维护的 RAG 系统来说,这两点的价值比某些 benchmark 里的 recall 多 0.5% 要实在得多。

3. 纯向量检索不够用,混合检索才是 RAG 的真实形态

3.1 一个案例看清语义检索的短板

我之前接手过一个客服知识库项目,里面存了大量设备故障的处理文档。每篇文档都带有设备型号和错误码。用户提问很典型,比如“TS-300 连不上 WiFi 怎么办”。我第一版方案只用了向量检索,返回结果惨不忍睹:语义上最接近的文档确实出来了,但有的完全没提到“TS-300”这个型号。因为 embedding 模型对长尾的型号字符串不敏感,很容易被“连不上”“WiFi”“怎么办”这些高频词带着走。

后来我用了一个土办法,把型号单独提取出来,用 keyword 字段做 term 匹配,再和向量检索结果做融合,召回质量立刻上去了。这件事给我印象很深:真实场景里,用户的问题往往同时包含实体信息和自然语言描述。实体信息需要精确匹配,自然语言描述需要语义匹配,两者缺一不可。

3.2 BM25 和向量检索是互补的

BM25 召回的是“字面命中”的文档,它对拼写、专有名词、代码片段有极高的精确度。向量召回处理的是“语义相似”,对同义改写、口语化表达很友好。两种方式各有强项,也各有盲区。BM25 搜不到同义表述,比如“这个设备开不了机”和“设备无法启动”字面上差异很大。向量检索搜不到精确数字和型号,因为 embedding 模型在数字维度的表现普遍不理想。

大多数人做 RAG 的时候,检索链路只用了其中一种。要么上向量数据库做纯语义检索,要么沿用老的 ES 只做关键词搜索。前一种丢了精确,后一种丢了泛化。混合检索就是一种取长补短的策略,把 BM25 和向量召回的候选集合并起来,用 RRF 或者加权求和做重排,让两种信号互相补充。

3.3 RRF 融合策略

RRF 是一种简单有效的融合算法,核心思想就是不看分数看排名。每个检索方式各自返回一份文档排名列表,RRF 给每篇文档打分时,用的公式是多个排名倒数的累加。如果一个文档在两种检索里都排在前面,它的 RRF 分数就会显著高于只被一种方式命中的文档。这样做的好处是不用纠结 BM25 分数和向量相似度分数之间的尺度差异,因为这两个分数一个取决于词频,一个取决于余弦距离,数值范围完全不对齐,强行加权求和往往需要调一堆系数。

ES 8.8 之后原生支持了 retriever 和 RRF,可以在一个请求里同时执行文本查询和 knn 查询,再由 ES 内部融合打分排序。这意味着混合检索不再是某种实验室特性,而是可以用 DSL 直接在线上用的能力。如果你还在用 ES 8.8 以下的版本,也可以自己取两路结果在业务侧做融合,但整体体验会差一些,有条件建议升级。

4. 实操落地:一套 ES 索引完成语义+关键词混合召回

4.1 前置准备:embedding 模型与维度确定

要跑通这套方案,需要一个 embedding 模型把文本转化成向量。可以用开源的 BGE 系列、text2vec 系列,也可以调用云厂商的 embedding 接口。维度方面,常见的 384 维、768 维、1024 维都支持,但维度越高,内存占用越大,写入越慢,所以不必盲目追求最高维度,按模型输出为准即可。

有一点要注意,切分文档做向量化时,一个知识条目的文本块不能太大。向量检索本质是拿整个文本块去算相似度,如果每个块 5000 字,内容主题不聚焦,query 的向量会被“平均”掉,召回效果就模糊。实践中我倾向于将知识文档按段落或者按语义边界切成 300 到 800 字的小块,再为每个块生成向量。元信息比如标题、分类、来源、ID,用一个 text 或 keyword 字段单独保存,不参与向量化,但可以用来做过滤。

4.2 索引 mapping:注意向量字段参数

下面是一份常见 mapping 配置。索引名称随意,字段设计按业务需要调整。核心部分是 content_vector 字段的类型声明和 HNSW 参数。

PUT /rag_docs { "mappings": { "properties": { "doc_id": { "type": "keyword" }, "title": { "type": "text", "fields": { "keyword": { "type": "keyword" } } }, "content": { "type": "text" }, "category": { "type": "keyword" }, "status": { "type": "keyword" }, "publish_date": { "type": "date" }, "content_vector": { "type": "dense_vector", "dims": 768, "index": true, "similarity": "cosine", "index_options": { "type": "hnsw", "m": 16, "ef_construction": 100 } } } } }

几个关键参数解释一下。similarity选 cosine 适合绝大多数文本匹配场景,如果 embedding 模型本身已经对向量做了归一化,选 dot_product 也可以,结果等价。m和ef_construction是 HNSW 图索引的核心参数。m控制每个节点的最大连接数,值越大图越密,召回精度越高,但内存占用也越高。ef_construction控制建索引时的搜索范围,值越大建图越慢,索引质量越好。默认值 16 和 100 在大多数场景够用,如果数据量到了一千万以上,建议先用小规模数据压测。

还有一点,doc_id我建议单独用 keyword 存一份原始 ID。ES 的_id虽然也能用,但如果有业务侧更新和删除,维护起来不如显式字段方便。

4.3 写入与查询侧代码示例

写入文档的流程分两步。第一步,切好文本块,调用 embedding 模型生成向量;第二步,连同文本和向量一起写进 ES。用 Python 客户端示例的话大概是下面这样,注意把向量转成 list 格式。

from elasticsearch import Elasticsearch es = Elasticsearch("http://localhost:9200") def add_doc(doc_id, title, content, category, publish_date, vector): doc = { "doc_id": doc_id, "title": title, "content": content, "category": category, "publish_date": publish_date, "content_vector": vector } resp = es.index(index="rag_docs", id=doc_id, document=doc) return resp

查询侧的核心是一个 knn 检索请求。下面这段 DSL 表示:在 content_vector 字段上做近邻搜索,返回最相似的 10 条,候选集取 100 条。

{ "knn": { "field": "content_vector", "query_vector": [0.01, 0.02, 0.03], "k": 10, "num_candidates": 100 } }

num_candidates值得多说一句。它不是最终的返回条数,而是 HNSW 图搜索时访问的候选节点数量。这个值越大,越有可能找到真正的近邻,但延迟会升高。当数据量大、精度要求高的时候,num_candidates建议至少是k的 10 倍。如果你的k是 10,num_candidates用 100 起步比较合理,极限场景可以拉到 200 到 500,但一定要压测延迟。

4.4 业务查询如何组合 filter、BM25 和 kNN

单一 knn 查询只能解决“给我相似的文档”,但业务需求往往是“给我这个分类下、最近一个月发布的、状态为启用的相似文档”。这时候需要把 filter 加上去。ES 的 filter 不会参与评分,只会缩小候选范围,性能比 must 子句里的条件好很多。

下面这段 DSL 同时做了三件事:用 term 过滤分类、用 range 过滤时间、用 knn 召回相似文本。

{ "knn": { "field": "content_vector", "query_vector": [0.01, 0.02, 0.03], "k": 10, "num_candidates": 100, "filter": { "bool": { "filter": [ { "term": { "category": "troubleshooting" } }, { "range": { "publish_date": { "gte": "now-30d/d" } } } ] } } } }

如果还要叠加关键词匹配,比如标题里必须包含“WiFi”字样,就需要混合检索。ES 8.8 及以上版本可以直接用 retriever 语法做 RRF 融合,示例如下。

{ "retriever": { "rrf": { "retrievers": [ { "standard": { "query": { "bool": { "must": [ { "match": { "title": "WiFi" } } ], "filter": [ { "term": { "category": "troubleshooting" } } ] } } } }, { "knn": { "field": "content_vector", "query_vector": [0.01, 0.02, 0.03], "k": 10, "num_candidates": 100, "filter": { "term": { "category": "troubleshooting" } } } } ], "rank_window_size": 50, "rank_constant": 60 } } }

rank_window_size是每个检索器各自返回的候选数量,也就是进入 RRF 融合的文档总数上限,默认值是 50,如果两个检索器要融合更多文档可以调大。rank_constant对应 RRF 公式里的常数项,默认 60,用于平滑排名倒数,避免排位过分极端。这两项一般不需要频繁调,保持默认即可。

如果你的 ES 版本不支持 retriever,退路是分别在业务侧调两个查询,拿到两份文档 ID 列表后再做 RRF。顺序:先执行 BM25 查询取前 N 个 ID,再执行 knn 查询取前 N 个 ID,最后按 RRF 公式对两路 ID 重排。效果上和原生 RRF 几乎一致,只是多一次网络往返,略显繁琐。

5. 我在真实项目里踩过的坑

5.1 常见报错和异常

做向量检索时遇到最多的问题是维度不一致。embedding 模型换过一版,输出维度从 768 变成 1024,但新建索引时没改 mapping,写入文档直接报错。ES 的 dense_vector 字段维度在创建索引时就固定了,后期无法修改,只能新建索引再 reindex。所以规划索引的时候,先把 embedding 模型锁定,不要在生产环境里随意切换。如果确实要换模型,提前设计好双索引迁移方案。

第二个容易踩的坑是,向量字段声明了index: false。这种情况下字段内容可以存,但不能做 kNN 查询,只能通过 script_score 暴力计算相似度。小数据量还好,数据量大了之后速度会非常慢。检查 mapping 时注意确保index为true,且index_options中配置了 HNSW 参数。

第三个问题比较隐蔽:ES 的 kNN 查询结果不会像普通 query 那样稳定复现。因为 HNSW 是近似近邻算法,每次搜索找到的可能不是全局绝对最优,只是大概率最优。在离线评测的时候,如果你拿同一批 query 反复跑,召回结果可能有一小部分浮动,这属于正常现象,不代表系统故障。和团队对齐预期时,要说明这一点,避免被当成 bug 来查。

5.2 性能相关经验

向量索引是纯内存开销大户。每一个 dense_vector 字段都要为 HNSW 图预留空间,维度越高、m 值越大,内存占用越明显。一条 768 维的向量占用的内存大约在几 KB 到几十 KB 区间,一千万条数据就能吃掉几个 GB 的内存。这还没算 ES 的其他开销。所以数据量大的时候,不要做全量向量化存到一个索引,按业务域拆成多个索引,或者用 filter 把向量搜索限制到必要的子集,都能显著降低内存压力。

写入性能方面,HNSW 索引的构建需要时间,批量灌数据时建议把 refresh_interval 调大,比如设置成 30 秒或者 -1,等数据全部写完之后再恢复。否则每次 refresh 都会触发向量索引的更新,大批量写入会非常慢。这个技巧对常规文档索引同样适用,对向量字段效果尤其明显。

还有一个容易被忽视的点:分片数不要拍脑袋定。向量索引属于重内存索引,分片数过多会导致每个分片的 HNSW 图都被复制多份,内存被白白浪费。按单分片容量预估,一条向量按 4 字节乘以维度再加图结构开销估算,单分片控制在 50GB 以内比较稳妥。当然最终还是要靠压测决定。

5.3 召回质量验证的一种土办法

之前我收到过不少“效果不好”的反馈,但问具体哪里不好,对方只能说出“感觉不对”。为了避免凭感觉调参,我养成了一个习惯:维护一个固定的评测 query 集,每一条 query 都手工标出应该召回哪几篇文档。模型和参数改版之后,拿这几十条 query 跑一遍,统计命中率。这个做法很土,但非常有效。很多所谓的效果差,最后发现是文档切分太粗,或者是 embedding 模型选得不对,压根还没到调参阶段。

另一个更轻量的办法是,在检索引擎前面加一个 debug 开关,把用户真实 query 的检索候选打印出来。这样当大模型回答质量差时,可以先看召回结果对不对,再判断是检索问题还是生成问题。这个思路能帮你快速定位 RAG 链路的故障分层,不至于一有问题就无头苍蝇一样重调 prompt。

6. 选型判断:什么情况下继续用向量库,什么情况下用 ES

6.1 什么时候别硬上 ES

ES 不是万能的。如果你的数据量已经到了几十亿条,对向量检索延迟有极高要求,同时有专职的中间件团队在维护,那么专用向量数据库的极致性能可能更合适。ES 的 kNN 能力虽然强,但在超大规模型数据集上的表现,确实未必拼得过专门为向量检索优化的存储引擎。再一个场景是,你的业务已经完全建立在一套向量库之上,检索逻辑、数据管道、运维工具都围绕它转,这时候仅仅因为“ES 也能做”就迁移,ROI 不高。

还要考虑版本因素。如果你的 ES 还停留在 7.x 早期,向量能力需要通过插件或者 script_score 迂回实现,体验会差不少。这时候要么规划升级,要么先继续用已有的向量库。总之,选型看的是当前团队实际条件和约束,不是看谁的名气大。

6.2 什么时候别多此一举上向量库

反过来看,如果你的团队已经有 ES 集群,数据量在千万级以内,业务涉及文本搜索和向量召回并存,我认为新增一个向量数据库完全是多此一举。把这个结论放在评审会上,最直接的论据就是:ES 一个请求能完成混合检索,向量库加 ES 要两跳才能完成类似逻辑,还要处理两份数据的同步和一致性,复杂度成倍增加。用一句行业里的老话,不是组件越多系统越先进,而是能够用最少组件满足需求的系统才最容易维护。

还有一个很容易被忽略的场景:知识库类 RAG 应用的文档数量其实没有想象中那么大。一个中型企业的内部知识库,几万到几十万篇文档已经是常态,膨胀到千万级的都不多见。这种量级对 ES 来说连热身都算不上,上专用向量库是杀鸡用牛刀,除了向上汇报时多几个关键词,对线上实际体验没有本质提升。

6.3 最终拍板清单

做决策时,我建议你按下面这套问题过一遍。

  • 现有技术栈里是不是已经有 ES?如果有,先试用向量检索能力,跑一个 POC,用真实 query 验证效果和延迟,再决定要不要引入新组件。
  • 业务是否需要过滤、聚合、权限隔离?如果答案是肯定的,ES 的综合成本远低于向量库加 ES 的“双子星”架构。
  • 数据量级是否到了千万/亿级,并且对召回精度和延迟有极端要求?如果是,再考虑专用向量数据库。
  • 团队是否愿意把 embedding 模型、切分策略、参数调优这些真正决定 RAG 效果的问题花时间打磨?选型只是开始,真正拉开差距的是后面这些细节。

从这个角度回看标题,Elasticsearch 所谓的“扫地僧”,其实不是说它的向量检索性能碾压专用库,而是说它在一个已经到处都在用、看起来平平无奇的组件里,悄悄补齐了向量能力,并且把混合检索这条路走通了。真正懂 RAG 的人,早就开始算这笔工程账了。

我个人的体会是,做技术选型最忌讳被名词带着跑。先看清楚自己的数据长什么样、用户怎么提问、系统里已经有什么组件,再决定要不要加新东西。很多时候,你缺的不是一个向量数据库,而是把已有工具的潜力用足。下一次别人再跟你提“RAG 标配向量库”,你不妨先回一句:那 ES 呢?先把混合检索跑出来再说。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询