☰
RAG搜索扩展实战:从向量检索到混合检索与重排序
2026/10/17 3:29:26 网站建设 项目流程

1. 搜索扩展到底要扩展什么

RAG 上线之后,我收到最多的反馈不是“回答不通顺”,而是“该找到的答案找不到”。这篇接在 Spring AI 系列上篇后面,专门聊搜索扩展,也就是把向量数据库和 RAG 里的检索层单独拎出来,讲清楚从能跑到能用的差距。适合已经跑通基础 Demo、正准备把知识库问答推到真实业务里的朋友。

很多团队做 RAG 时,第一步就是把文档切成块,灌进向量库,然后开始调 Prompt。跑 Demo 确实很顺,因为数据量小、问题简单、答案基本就在原段落里。一旦换成真实业务文档,用户问法五花八门,甚至提问里根本没有和文档重合的关键词,向量检索的结果就会迅速变差。这个时候很多人会怪生成模型,其实问题往往在检索层。

搜索扩展这个说法,听起来像“多加几个搜索入口”,但真正要扩展的是三件事。一是召回层,不能再只靠向量距离,得把精确匹配、词频、倒排、元数据过滤全部纳入。二是排序层,向量初筛后还需要一个更细的排序模型,把真正有用的片段提到最前面。三是数据层,文档怎么切、元数据怎么标注、增量怎么更新,这些决定了检索效果的上限。你后续看到的所有调优动作,基本都落在这三件事上。

1.1 一次提问暴露的问题:向量检索的单腿走路

我印象很深的一个案例是某内部知识库系统。用户问:“采购审批流程调整后,预算在哪个环节校验?”向量库返回的前几条都是“采购审批流程”“预算调整”“审批环节”这些相关片段,但真正描述“校验”步骤的那个段落排到了第九名。把前五条拼给生成模型,模型自然答不出准确答案,只能给一句模棱两可的话。

当时第一反应是换更强的 Embedding 模型,换成更贵的接口之后,Top 5 命中的情况确实好了一些,但问题仍然存在。用户问“哪个环节校验”,文档里写的是“预算管控节点”,语义相似度模型能感觉到两个句子有关,却很难精确判断哪个片段才是用户要的答案。这种场景靠单条向量链路解决不了,必须让检索层具备“搜索系统”的能力:既能语义扩展,也能精确命中,还能用规则过滤掉明显不相关的片段。

1.2 搜索扩展的三个层面:召回、排序、数据

先说召回层。向量检索只是召回的一种方式,负责“意思接近”。关键词检索负责“字面精确”,文档里的产品型号、报错码、专有名词,往往只有倒排索引能接住。元数据过滤负责“条件约束”,比如只看某个部门、某个版本、某类文档。这三者不是互斥关系,而是并行召回后合并结果。

再看排序层。用向量库自带的相似度排序直接取 TopK,在数据量小的时候够用,数据量一大就容易被相关但不够精确的片段挤占。重排序模型会把候选片段和用户问题放在一起做更细的匹配度打分,再把分数融合成最终排序。这一步对回答质量提升明显,尤其是“资料里有相似段落但都不完全是答案”的情况。

最后是数据层。同一个文档,按固定长度切出来的块,和按语义结构切出来的块,检索效果完全不一样。元数据是否完整,决定了过滤能不能做。增量更新是否及时,决定了用户拿到的是不是新知识。搜索扩展到最后,拼的往往不是模型,而是数据工程。

2. 向量数据库选型:不是所有向量存储都能扛住线上检索

上篇里我提过,Spring AI 对向量存储的抽象比较统一,接口层面的替换成本没有想象中高。但这不代表选型可以随便拍脑袋。很多人在本地用内存型向量库跑 Demo,一切正常;上了生产,数据量到几十万条、并发查询十几路,问题就全出来了。

选型时不要只看“能存向量、能算余弦相似度”的演示指标。真正要考量的我觉得有四个:写入吞吐、查询延迟、过滤能力、运维成本。写入吞吐决定你全量灌数据要多久;查询延迟决定用户问一句要等多久;过滤能力决定能否按租户、权限、文档类型缩小范围;运维成本决定这个系统到底能不能长期有人维护。

考量维度嵌入式/扩展方案独立向量库全托管托管服务
部署成本低,跟随现有数据存储部署中,需要单独维护集群最低,开箱即用
适合规模百万级以内够用千万级以上更合适看服务商配额
过滤能力依赖底层存储的表达式能力独立实现,强弱不一通常较弱,需靠接口拼
运维成本低高中,但费用上涨明显

我自己在多数业务项目里优先选“跟着关系数据库一起走”的扩展方案,原因很简单:团队里已经有人会运维数据库,不需要再引入一套分布式系统。数据量到了千万级、检索延迟明显变高之后,再考虑搬到独立向量库也不迟。

2.1 索引参数:HNSW 不是只填一个向量维度

大多数向量库默认走 HNSW 这类图索引。它有两个常见参数,分别是M和efConstruction,查询时还有一个efSearch。这几个参数直接决定了召回质量和检索延迟之间的平衡。

M是图里每个点的最大连接数。数值越大,图越稠密,检索时路径越多样,理论上召回更好,但内存占用和索引构建时间也会上升。efConstruction是建索引时的候选集大小,只管构建速度和质量,不管查询。efSearch是查询时扫描的候选点数量,这个值越大,召回一般越好,但单次查询延迟也会成比例上升。

参数起步值调优方向
M16内存充足时再提高到 24~32
efConstruction200默认通常够用,不改
efSearch40延迟可接受时调到 80~120

我踩过的坑是:一开始为了延迟好看,把efSearch压到 20,结果 Top 5 召回率掉了将近 10 个百分点。后来把这几个参数做成配置项,压测时分别跑一轮,而不是凭感觉定死。

2.2 增量更新与向量一致性

向量库不等于文档库。业务数据更新时,不能只改原始文档,还要同步更新向量库里的向量。否则会出现“文档已经改了,检索到的还是旧内容”的尴尬情况。

我的做法是:业务写入先落业务库,成功后发一条消息到队列,由消费者负责把文本重新切分、重新生成向量、更新向量库。更新动作分两步,先删掉旧的向量记录,再插入新的。如果只插不删,同一个文档会产生多个版本的片段,检索时老版本反复占着 TopK 席位,新内容反而进不去。全量重建时也不要直接清空线上索引,先把新索引建好,再切换读取流量,这样回滚也容易。

3. Spring AI 里的检索链路:从 Embedding 到 VectorStore

Spring AI 本身不替你做检索算法,它做的是把模型调用、向量存储、文档加载这些动作封装成一致的接口。你的业务代码只需要面向接口编程,底层换向量库时,上层逻辑可以少改甚至不改。

但这层抽象有一个前提:Embedding 模型必须一致。索引用 A 模型生成向量,查询时换 B 模型生成向量,两个模型输出的向量空间都可能不同,相似度计算就没意义了。这个问题不是 Spring AI 能帮你兜底的,配置层面必须钉死。

3.1 Embedding 模型先选型,再定维度

生产项目里最好不要频繁更换 Embedding 模型。每次换模型,向量维度可能变,相似度分布也可能变,已经灌进向量库的数据几乎都要重新生成向量。所以在项目初期就要确认模型选型,然后一直沿用。

如果使用 Spring Boot 的自动配置,通常会有一个EmbeddingModel的 Bean 注入到容器里。给 VectorStore 和后续的检索服务用同一个 Bean 就好。配置上,我习惯把模型名称、向量维度都写进配置中心,方便后续追踪。这个维度不只是给向量库建表用的,你的切分策略、检索阈值都要围绕这个模型的实际表现来定。

3.2 检索动作的代码落点:VectorStore 统一抽象

Spring AI 里最常见的检索接口是VectorStore.similaritySearch。传入查询文本和SearchRequest,返回一组Document。Document里除了正文内容,还可以带metadata,这正好用来放来源、部门、文档类型等信息。

@Service public class KnowledgeSearchService { private final VectorStore vectorStore; public KnowledgeSearchService(VectorStore vectorStore) { this.vectorStore = vectorStore; } public List<Document> search(String query, int topK) { SearchRequest request = SearchRequest.builder() .query(query) .topK(topK) .similarityThreshold(0.3) .build(); return vectorStore.similaritySearch(request); } }

similarityThreshold这个值要格外小心。不同 Embedding 模型产出的相似度分数分布差异很大,有些模型 0.7 以上才算相关,有些模型 0.3 已经可用。我建议先跑一批真实问题,把检索结果的分数分布打印出来,再决定阈值。不要照抄别人的配置。

依赖层面,如果你用的是关系库扩展方案,通常只需要引入对应 Spring Boot 依赖,然后声明一个PgVectorStore之类的实现类即可。接口保持VectorStore,业务代码就不需要关心底层实现。

3.3 检索结果进 Prompt:不是拼接,是约束

很多人的第一步是把所有检索结果用换行拼起来塞进 Prompt。这种做法在小样本里看着有效,但很快会遇到两个问题:一是上下文窗口被无关片段占满,二是生成模型分不清哪些片段才是可靠依据。

我比较推荐在 Prompt 模板里明确限定依据范围,并要求模型在找不到答案时直接承认。我们用的是大模型,不是搜索引擎,它会把“语气很肯定但内容错误”的拼接片段当成事实依据,所以你要在 Prompt 里做约束。

String template = """ 你是企业内部知识库助手。请只依据下面的资料回答问题。 如果资料中没有明确答案,请直接回答“资料中未找到”,不要自行推断。 资料: {context} 问题: {question} """;

在拼接上下文时,我会把每个片段的元数据也拼进去,比如来源文件名、章节标题、更新日期。这样模型既能引用来源,生成内容也更克制。后面做线上问题排查时,你也能根据回答反推召回的是哪几个片段。

4. 文档切分、元数据过滤与混合检索:效果差先怪这里

把检索链路的代码跑通之后,下一步才是真正调效果。我发现一个规律:只要回答质量不满意,九成问题出在数据准备阶段,而不是模型或者 Prompt。换句话说,生成层能做的提升很有限,检索层的输入质量才是天花板。

4.1 切分策略:先理解用户会问什么,再决定粒度

固定长度切分是最容易上手的方案,比如每 512 个字符切一块,相邻块重叠 64 个字符。好处是实现简单、排错省事;坏处是会割裂语义,比如把“采购审批流程调整后”和“预算校验环节”分到两个块里,检索时那条真正完整的逻辑链就断了。

更好的做法是按文档结构走。如果原始文档有 Markdown 标题或 Word 标题,就按标题层级切;如果是一段段完整短文,就按段落切,再对过长段落做二次分割。代码片段、表格这类特殊内容我习惯单独处理,避免整块拆碎。

切分策略适合场景注意点
固定长度 + 重叠快速上线容易割裂语义,重叠率要调
按标题/章节结构清晰的说明文档标题层级要规范
按段落问答对、短文案段落过长时需要二次拆分
按表格/代码定义技术文档需要单独保留格式语义

切分粒度还和用户提问粒度有关。用户问的是“某个功能怎么配置”,切块最好是完整步骤;用户问的是“某个概念是什么”,切块可以更小。上线后我会把用户真实问题按主题归类,再回头检查切分策略,而不是一次性定死。

4.2 元数据过滤:检索前的第一道闸门

向量检索的问题在于它只算语义相似度,完全不知道文档归属、权限、时效这些业务约束。如果整个知识库里混着多个部门、多个租户、多个版本的文档,就必须用元数据过滤把范围先缩小。

我通常在切分时给每个Document打上这些字段:来源文件名、文档类型、所属部门、适用版本、更新日期、可见范围。检索时不直接搜全库,而是先过滤出当前用户能看到的那批文档。

SearchRequest request = SearchRequest.builder() .query(query) .topK(20) .filterExpression("tenantId == 'tenant-01' && docType in ['faq', 'manual']") .build();

这段示意代码里的filterExpression写法,具体语法取决于向量库实现,但思路是一致的:先用条件缩小候选集,再做语义检索。权限过滤尤其重要,否则用户很可能问出另一个部门的内部流程,而模型根本意识不到信息不该出现。

4.3 混合检索与重排序:向量不是唯一入口

只靠向量检索的另一个问题是“精确匹配能力弱”。用户输入产品型号“A-2024-B”,文档里恰好也出现了这个型号,但周围措辞完全不同,向量相似度可能不够高。这个时候,关键词检索的倒排索引反而能直接命中。

所以我的检索链路是双路召回:向量检索负责语义相似,关键词检索负责字面命中,两部分结果合并后做重排序。合并方式我常用 RRF,公式简单,效果也比较稳:

score(d) = sum(1 / (k + rank_i(d)))

其中rank_i(d)是文档 d 在某一检索通道里的排名,k通常取 60。RRF 不依赖具体相似度分数,只依赖排名,正好能融合向量和关键词两种不太可比的分数。

重排序阶段可以引入一个更精细的匹配模型,把“问题 + 候选段落”成对输入,输出相关性分数。这一步的代价是增加延迟,但它能有效把最相关片段顶到最前面,避免前五个位置被“相关但不够精确”的片段霸占。

5. 上线前我踩过的坑:从召回不到料到回答胡说

这一部分想分享一个完整的排查链路。与其给你一堆“最佳实践”,不如把一次真实故障的定位过程写出来。当时的问题是:用户提问后,模型总是回答“资料中未找到”,但库里明明有对应文档。

排查的第一步是复现检索接口本身。我把用户原话直接传给KnowledgeSearchService.search,打印返回的Document列表和相似度分数。发现列表是空的。这说明问题出在召回层,不关生成模型的事。

第二步查similarityThreshold。当时阈值写的是 0.6,但实际模型产出的相关片段最高分只有 0.45。也就是说,检索把候选片段都算出来了,只是被阈值拦住了。把阈值降到 0.3 之后,列表有内容了,但分数普遍偏低,需要继续查。

第三步看 Embedding 模型是否一致。索引当时的模型和查询侧用的模型不是同一个,我本来以为“都是向量模型应该差不多”,结果向量空间完全不兼容。统一模型并重建索引之后,同样问题的相似度明显上升。这个坑最隐蔽,因为代码不报错、接口不报错,只有分数不对。

5.1 一套可复用的检索问题定位流程

从那之后,我开始用固定流程排查检索质量问题,不再靠猜。先打印实际检索结果,看有没有返回;再打印相似度分数分布,判断阈值是否合理;然后核对 Embedding 模型版本和向量维度;接着检查元数据过滤条件是否过严;最后单独用关键词搜一下,看是不是完全无法命中。

现象排查方向常见修复
检索结果为空阈值太高、过滤条件太严降低阈值、放宽过滤
结果有但相关度低Embedding 模型不一致统一模型并重建索引
精准词搜不到缺少关键词检索通道接入倒排索引
老内容反复出现未删旧向量更新时先删后插
回答总编造Prompt 约束不够、上下文超长限制依据范围、控制片段数量

这五类是我遇到最多的。前两类靠日志和指标就能定位,第三类需要做混合检索,第四类要改数据更新流程,第五类更多是 Prompt 和上下文管理问题。

5.2 评测不能只看“最后回答对不对”

很多人上线后只看“回答对不对”,把大模型生成的答案当成唯一验收标准。这是最危险的做法,因为生成模型有很强的补全能力,即使检索结果不相关,它也可能编出听起来正确的答案。

我建议准备一个小型评测集,比如一百对“问题 + 期望命中的文档片段”。每次改动切分、检索或排序逻辑时,都先跑一遍评测集,记录两个数:命中率和回答忠实度。命中率指期望片段是否出现在召回结果的前 N 位;回答忠实度指生成内容是否只基于检索片段。

指标含义建议目标
Hit@5期望片段是否在前 5 条召回内越高越好,至少 80%
检索延迟单次召回的 P95 耗时根据业务要求定
无效召回率返回片段中与问题无关的比例越低越好
忠实度回答是否严格基于召回片段人工抽检

这个评测集不需要很大,但必须有代表性。把线上真实问题、常见错别字问法、同义改写问法都放进去,比单纯堆数据量有用得多。

5.3 效果不佳时的排查顺序

如果换了很多模型、调了很多参数还是不行,我建议按顺序查一遍。先看数据,再查检索,最后才调 Prompt。数据环节包括切分是否合理、元数据是否完整、增量更新是否正常。检索环节包括召回通道是否单一、排序是否有效、阈值是否合适。Prompt 环节是最后一步,因为你不能指望模型弥补检索结果的空白。

这个顺序我踩了多次才总结出来。早期一效果差就换大模型,成本上去了,效果反而更差。后来改成先查检索结果再改 Prompt,解决了大多数问题。实际上,只要召回的前五条里有正确答案,后面怎么调 Prompt 都相对容易。

6. 再聊几句个人经验

如果非要给搜索扩展这条线找一个核心,我觉得不是向量数据库,也不是某个模型,而是“你有多了解自己的数据”。向量数据库只是工具,RAG 也只是方法,真正决定体验的,是文档怎么切、元数据怎么标、检索结果怎么评估。

我现在的习惯是每次调整检索链路前,先跑一遍第一百道题的评测集,再挑三个真实线上问题人工看召回结果。事实证明,人工看完十次,比调一百次参数都有用。很多问题只要把原始检索结果打印出来,一眼就能看出是阈值、切分还是过滤的问题。

这个项目后续还有不少可做的地方,比如多轮对话时的 query 改写、常见问题的结果缓存、检索结果的在线反馈收集。但基于这几轮经验,我最想提醒的还是那句话:别急着换大模型,先把检索链路里的搜索基本功补齐。搜索扩展扩展的不只是功能,更是你对数据质量和线上问题的敏感度。

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

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

立即咨询