1. 先说结论:搜索引擎选型,藏着一半的答案
上篇我们把 RAG 的整体链路跑通了:文档加载、切分、Embedding、存储、召回、生成。很多人在这一步就停下来了,觉得能回答几个问题就算完事。但真正把系统推到线上、面对千万级文档和真实用户查询的时候,你会发现自己面对的不是一个"能跑"的问题,而是一个"跑得好不好"的问题。搜索扩展这件事,本质上就是把向量检索从"能用"打磨到"好用"的过程。
这篇下部,我会聚焦在向量数据库的选型与使用、索引参数调优、以及混合检索、重排序、缓存和评估这几个扩展方向上。目标是给你一份可以直接照着做的决策清单和踩坑手册。适合的人群是:已经搭过一版简单的 RAG Demo、正在纠结向量库选哪个、或者发现检索效果忽好忽坏、不知道该从哪入手优化的开发者。
先说我对搜索扩展这件事的整体判断:绝大多数 RAG 效果不好,问题都出在召回这一层,而不是生成这一层。 模型能力不够可以换更大的模型,但检索扩不回来、噪音排不掉,模型再强也没办法凭空变出正确答案。所以下面所有内容,本质上都是在回答一个问题——如何让召回层更精准、更快、更便宜。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 选库十年功:主流向量数据库的取舍逻辑
2.1 业界三件套,我直接说结论
目前市面上你能接触到的向量存储方案大致可以分成三类:一类是专门的向量数据库,比如 Milvus、Qdrant、Weaviate;一类是在传统数据库上加向量扩展,比如 PostgreSQL 的 pgvector、Elasticsearch 的 dense_vector、Redis 的向量索引;还有一类是直接基于文件或对象存储自研的暴力检索方案,这个只适合极小数据量或离线场景。
这三类怎么选?我的经验是一个比较粗但有效的判断标准:看你的主数据形态。
如果你的项目已经重度使用 PostgreSQL,并且数据量在百万级以内,pgvector 是最省事的方案。你不用引入新的中间件、不用处理数据同步、不需要额外维护一套权限体系,直接在现有表结构上加一列 embedding,建一个 IVFFlat 或 HNSW 索引就能用。我见过不少团队把整个 RAG 系统塞进 pgvector 里,运营了大半年也没出什么问题。
如果数据量过了千万级,或者你的查询 QPS 很高、需要独立的扩容能力,那就得上专门的向量数据库。Milvus 的生态最全,分布式能力强,但部署和运维复杂度也是最高的;Qdrant 在单机性能、过滤能力和 API 易用性上非常突出,我个人对 Qdrant 的印象很好,它的 payload 过滤机制做得非常顺手;Weaviate 的好处是原生支持多种模型和混合检索,开箱即用程度最高。
Elasticsearch 的向量能力则适合一种特殊场景:你原本就有 ES 在做关键字搜索,不想再搭一套系统,那直接在已有索引里加向量字段,做"ES 原生召回 + 向量召回"的融合,整个架构成本是最低的。坏处是它的向量检索性能和专门的向量库比还是有差距,同样的硬件条件下,延迟和吞吐都会逊色一些。
2.2 不需要在数据量上过度焦虑
很多人一上来就担心"我的数据量会不会太大"。实际上,绝大多数企业内部知识库的文档量级在几万到几十万之间,切分后产生的 chunk 数量在几十万到几百万之间。这个量级,pgvector 和单机 Qdrant 都完全扛得住,完全不需要上分布式架构。
反过来,真正需要重点考虑的是查询延迟和索引构建时间。百万级向量、HNSW 索引、单机 Qdrant 构建索引的时间大概在几分钟到十几分钟,查询 P99 延迟在几十毫秒到上百毫秒之间。这个性能对 RAG 系统来说是完全够用的,因为一次完整问答的总耗时往往在 2 到 5 秒,检索部分占的比例并不高。
所以不要把时间花在纠结"要不要上分布式"上。真正值得花时间的,是下面的索引调参和检索策略。数据量到千万级以上再考虑扩容,到那时你已经有足够多的线上数据来判断瓶颈在哪了。
2.3 选型中常见的三个认知误区
第一个误区:以为向量数据库会自动处理文本匹配。 实际上,向量数据库只负责存向量、算距离、返回 TopK,它不理解文本含义。文本变成向量这件事发生在 Embedding 模型环节,向量库不关心你的向量是怎么来的。所以换 Embedding 模型后必须重建所有向量,这个后面专门讲。
第二个误区:把相似度分数当置信度。 不同向量库返回的分数范围不一致,Cosine 相似度在 -1 到 1 之间,L2 距离则没有上界。我见过有人直接拿分数做硬性过滤,结果换了索引类型之后分数分布全变了,一批原本该通过的文档全被卡掉。分数只能用来排序,不要直接设阈值,阈值应该在评测后确定。
第三个误区:觉得元数据过滤和向量检索是两件事。 好的向量库能把这两者融合到索引层面。比如 Qdrant 的 Filter,可以让你在召回过程中就限定"只查某个部门的数据""排除某个分类",而不只是查完再 filter。这个能力对业务级 RAG 系统来说非常重要,直接决定了你要不要在后处理阶段写一堆恼人的过滤逻辑。
3. HNSW 索引,不是用默认参数就完事
3.1 先搞懂你在调什么:M 和 efConstruction
HNSW 是目前主流向量库默认使用的索引算法,它的核心思路是构建一张多层图,在图上做贪心搜索。两个关键参数,一个叫 M,控制每个节点最多连多少条边,M 越大,图越稠密,召回率越高,但内存占用和构建时间也越大;另一个叫 efConstruction,控制构建时的搜索范围,它只影响构建时的质量,和查询无关。
Qdrant 和 Milvus 里通常会有一个独立的小参数叫 ef,或者叫 search_ef,这个才是查询时可以动态调的。它和 efConstruction 不是一回事,很多人把这两个参数混在一起调,调了半天没效果。
我的建议是:构建参数要按数据特性定,不要迷信默认值。 如果你的 chunk 数量在 10 万以下,M=16 默认值完全够用;如果上百万,考虑 M=32。关键是 efConstruction 不要设太大,否则构建时间会急剧膨胀。100 万向量下 M=16、efConstruction=200 的构建时间大概是默认值的两到三倍,召回率提升却不到一个百分点,性价比很低。
3.2 构建耗时、内存、召回率的三角权衡
HNSW 本质上是用空间换时间。M 越大,每个节点的邻接表越长,占用的内存就越多。Qdrant 里默认的 M=16 对 100 万维的 768 维向量,内存占用大约在 1 到 2 个 GB,这个是正常水平。
如果你发现内存吃紧,优先检查是不是在向量之外还开了太多的 payload 索引。比如你对每个 chunk 的标题、文件名、作者、权限都建了全文索引或关键字索引,内存暴涨很快。一个很常见的做法是:只对需要过滤的字段建索引,其他字段让它们普通存储即可。
召回率的提升依赖 M 是一条平滑曲线,不是线性的。M 从 4 提到 16,召回率提升非常明显;从 16 提到 32,提升放缓;从 32 提到 64,基本就饱和了。所以不要一上来就追求 M=64,性价比太差。
3.3 参数扫描实测:我用一组数据给你参考
我在一个模拟项目里做了测试,数据集是 50 万条 768 维向量,用 Qdrant 的 HNSW 索引跑了一批参数组合。直接给你有价值的结果:
- M=16、efConstruction=100、ef=64:构建时间约 4 分钟,P99 查询延迟约 35ms,召回率(Recall@10)约 0.93
- M=32、efConstruction=200、ef=128:构建时间约 9 分钟,P99 查询延迟约 60ms,召回率约 0.97
- M=64、efConstruction=400、ef=256:构建时间约 22 分钟,P99 查询延迟约 110ms,召回率约 0.98
差别最大的其实是 ef 这个查询参数。同一个索引下,ef 从 64 提到 256,查询延迟翻倍,召回率只提升不到 2 个百分点。所以我的经验是:优先调构建参数让索引本身质量够好,查询 ef 保持在一个适中水平,否则每次查询都在替低质量索引买单。
提示:如果你的场景允许离线批量构建索引,强烈建议构建时把 efConstruction 拉高,查询时保持较低的 ef。这样内存不涨,构建时间多花一点,查询性能和召回率都兼顾。
4. 把搜索效果打上去的四种扩展手段
4.1 混合检索不是堆砌,是互补
只用向量检索,最大的问题是它天然擅长语义匹配,但不擅长精确匹配。用户搜"Bug 修复记录",向量检索会返回一堆关于"故障处理、缺陷解决"的内容,却可能漏掉标题里明明白白写着"Bug 修复"但语义和查询不那么贴近的文档。反过来,BM25 关键字检索对精确匹配很准,但搜同义词、口语化表达时经常无功而返。
混合检索的思路是:两条召回路径都跑一遍,然后把结果合并去重。业界常见的实现是 RRF(Reciprocal Rank Fusion),原理非常简单:对每条路径返回的结果,按排名给一个加权分,排名越靠前权重越高,然后合并排序。
我在实际项目里,混合检索对最终 Answer 质量的提升是立竿见影的。原来纯向量检索时候,用户搜一些专业缩写、型号编码,答案经常答非所问;加上 BM25 的分路召回后,这一类查询基本都能命中正确答案。实现方式也简单,只需在应用层写一个加权合并函数,或者用 ES 的 RRFScript 在查询层直接完成融合。
4.2 重排序:向量粗排之后,需要一层精排
向量检索本质上是一种"粗排",它基于向量间的几何距离判断相关性。但这个相关性很粗糙,不能区分同一主题下的细微差异。如果知识库里存在大量相似文档,TopK 里容易出现同质化的内容,正确答案排在第 6 位,前面全是冗长的"前言介绍"。
重排序的思路是用一个专门的 Cross-Encoder 模型,把查询和每个候选文档拼成一个序列,让模型同时看到两边内容,输出一个相关性分数。这个方案效果很好,因为 Cross-Encoder 能建模细粒度的语义交互,比双塔式的向量相似度精准得多。代价是速度慢,一次查询如果做 50 个候选的精排,大概要多花几百毫秒。
工程上的实践是:向量检索先召回 50 到 100 个候选,交给重排序模型精排后取 TopK=5,再送进大模型生成。这一步通常能把最终回答质量的提升 10 到 20 个百分点。如果你用的框架是 Spring with AI,可以直接在SearchClient的返回结果上自己接一层重排序的逻辑,不需要改框架。
4.3 查询改写:简单但经常被忽略
用户搜索"上个月的数据报表怎么导出",直接拿这个完整句子去向量检索,效果其实一般。因为 Embedding 模型在训练时面对的大多是短语、短句,完整的长句会让语义重心分散。查询改写的本质是从用户的模糊表达中提取出更容易被检索系统命中的查询形式。
两个最实用的改写策略:第一,用大模型对用户查询进行意图识别和关键词抽取,把长句改写成"数据报表 导出 上个月"这样的短查询;第二,对查询中的实体、专有名词进行扩展,比如用户搜"CT",可以扩展出"CT、计算机断层扫描",这样能弥补 Embedding 模型的领域盲区。
我做过对比测试,同样的文档库,加入查询改写之后 TopK 命中率提升了大概 12 个百分点。而且行数不多,也就是一段 Prompt 的事。但要注意,改写不能做得太激进,否则把用户的查询从"怎么退款"改成"退款流程 条件 时效",反而可能丢掉原来的口语特征,影响语义匹配。
4.4 元数据过滤:业务约束进索引
在实际生产中,知识库往往不是单一来源。即便在部门内部,也会有公开资料、内部资料、机密资料之分。如果全部混在一个向量库里,一个普通员工搜到一个机密文档,检索层没拦住,生成层又不知道该不该说,就是很危险的漏洞。
解决方案就是元数据过滤。在文档入库时,把部门、权限级别、文档类型、更新时间这些字段作为 payload 存进向量库;检索时在查询条件里带上过滤条件,让向量库在索引层面直接忽略不符合条件的文档。这样既避免了生成层误用敏感信息,也大幅减少了候选集的大小,检索性能反而更快。
注意:权限过滤千万不要放在召回之后做后置过滤。向量库的 TopK 是全局排序后的结果,如果后置过滤砍掉一半,实际有效候选只剩一半,召回质量会明显下降。权限过滤条件必须进入向量库查询本身。
5. 从 Demo 到可用的关键:缓存、评估与观测
5.1 语义缓存:命中率怎么算,命中之后怎么用
RAG 系统里,大模型生成的部分成本最高,延迟也最高。而实际用户查询里,重复和相似的查询比例非常高。对完全相同的查询,直接命中缓存返回上一次的结果,是最简单的优化。但对相似的查询,就需要语义缓存——通过向量相似度判断两个查询是否表达同一个意图。
实现方式不复杂:用一个小的向量集合存历史查询,每次新查询来的时候,先拿它的向量去这个集合里做一次近邻查询,如果最高相似度超过阈值,就直接返回缓存结果。阈值的确定需要谨慎,设得太低会误命中,设得太高则命中率低。我的经验是先在离线数据上画出相似度分布,找一个能把"意图相似"和"只是字面相似"分开的分界点。
缓存命中之后还有个细节:如果缓存里存的是"问题 + 答案"的完整对,那直接返回答案没问题;但如果你想让结果更个性化,比如根据当前用户身份做定制,缓存就变得不可用。所以缓存更适合通用问题的解答,对强个性化场景帮助有限。
5.2 一套可落地的离线评测流程
很多团队在优化 RAG 系统时靠的是"随手试几个问题看效果",这个做法在开发期可以,但真正要判断改动有没有效,必须有一套可量化的评测流程。
我建议先做检索层的离线评测。构造一个包含 50 到 100 条查询的数据集,每条查询标注出理想的相关文档 ID。每次改完索引参数或检索策略,跑一遍数据集,统计 Recall@K 和 MRR。这一层评测跑得快、结果稳定,能快速暴露检索策略是不是退化。
然后做端到端评测。用一套真实的问答对,让系统跑完整链路后把结果给评审人员打分,主要看答案的相关性、完整性和幻觉程度。端到端评测很昂贵,所以频率不用太高,一周跑一次即可。我的经验是:先确保检索层指标稳定,再上端到端评测,否则你根本分不清是检索的问题还是生成的问题。
5.3 观测面板上,哪些指标真正有意义
线上运行之后,观测是少不了的。至少要有几类指标:检索性能(P99 延迟)、命中率(有多少查询命中了缓存、多少没命中)、召回质量(间接观测,比如用户有没有对答案点"没用")、以及系统资源(向量库的 CPU、内存、索引构建状态)。
其中召回质量用间接观测是一个务实的做法,因为线上你无法实时知道正确答案是什么。比较常见的替代方案是让用户对回答做反馈,然后定期用反馈数据校准离线评测集。这样离线评测集逐渐贴近真实分布,评测结果也更可信。
另外一个很容易被忽略的指标是向量库索引的构建和刷新状态。数据持续更新时,索引在不停重建,如果 HNSW 索引构建失败或处于 degraded 状态,检索质量会大幅下降,但直接看延迟可能完全看不出异常。我建议把索引状态、最大 segment ID、待索引数据量这些指标一起纳入监控。
6. 我在这个方案里踩过的雷,替你排一下
6.1 Embedding 不一致:换了模型,没重建索引
这是我见过最多人踩的坑。开发时先用一个开源 Embedding 模型,后来觉得效果不好换成了另一个模型,或者调整了向量维度,但存量数据里的向量没有重新生成。结果就是:查询向量的分布和库里的向量分布对不上,相似度分数乱七八糟,检索结果毫无意义。
解决方案只有一个:换 Embedding 模型、改向量维度、改切分粒度,任何一个变更,都必须触发全量数据重新向量化和索引重建。 这是一个流程性的约束,强烈建议在数据入库模块里加一个 embedding_model_version 字段,每次查询时校验版本,不一致就提示重建。
6.2 数据更新与删除延迟
向量数据库对删除和更新的支持,不同产品差异很大。Qdrant 对单点删除支持比较好,Milvus 则更适合批量操作。如果你的 RAG 系统需要频繁更新文档,比如产品手册每周更新一次,删除旧版本、加入新版本,这个操作如果不到位,库里会长期堆积"过期文档",导致检索噪音越来越大。
我的建议是:为每个 chunk 保存文档版本号和更新时间,查询时通过元数据过滤排除过期版本;定期做全量或增量的索引清理。不要把"删除"这件事交给向量库自己处理,业务层面的版本管理必须同步配合。
6.3 内存爆炸与索引预热
HNSW 索引用的是内存索引,如果数据量过百万、维度又是 1536,内存占用可能到几个 GB。如果向量库和业务服务部署在同一台机器上,很容易触发 OOM。更隐蔽的问题是索引冷启动:服务重启后,索引需要从磁盘加载,加载期间查询延迟会被拉得很高。
解决方案是:第一,给向量库独立分配内存资源,不要和业务进程混在一起;第二,配置索引预加载,保证服务重启后索引在内存中驻留,而不是等第一个查询到来才从磁盘拉数据。
6.4 相似度阈值不是拍脑袋定的
很多人喜欢给相似度设一个 0.7 的阈值,"低于 0.7 就不返回结果"。这个数值没有任何理论依据,而且不同 Embedding 模型、不同索引类型下,相似度分布完全不同。0.7 在一个系统里可能已经是非常好的匹配,在另一个系统里可能只是泛泛相关。
正确做法是:拿一批真实查询,统计返回结果的相似度分布,找一个能区分"相关"和"不相关"的边界点。而且这个边界要定期维护,因为数据分布会随着文档更新而变化。更稳妥的方案是——不设硬阈值,直接取 TopN,让重排序模型来把关。把相似度分数留给排序用,而不是当作一票否决的门槛。
回到最开始那个判断:搜索扩展这件事,功夫花在检索层,收益会体现在整个系统上。向量数据库的选型和索引调参是地基,混合检索、重排序、查询改写是楼层的布局,缓存和评估则是物业标准。每一步都不复杂,但每一步都需要你沉下心去测、去看、去迭代。至少我在项目里做完这些之后,最直观的感受是:用户不再问"为什么这个搜不到"了,而是开始问"这个回答能不能再快一点"——这大概就是搜索扩展做到位了的信号。
