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~32efConstruction200默认通常够用不改efSearch40延迟可接受时调到 80~120我踩过的坑是一开始为了延迟好看把efSearch压到 20结果 Top 5 召回率掉了将近 10 个百分点。后来把这几个参数做成配置项压测时分别跑一轮而不是凭感觉定死。2.2 增量更新与向量一致性向量库不等于文档库。业务数据更新时不能只改原始文档还要同步更新向量库里的向量。否则会出现“文档已经改了检索到的还是旧内容”的尴尬情况。我的做法是业务写入先落业务库成功后发一条消息到队列由消费者负责把文本重新切分、重新生成向量、更新向量库。更新动作分两步先删掉旧的向量记录再插入新的。如果只插不删同一个文档会产生多个版本的片段检索时老版本反复占着 TopK 席位新内容反而进不去。全量重建时也不要直接清空线上索引先把新索引建好再切换读取流量这样回滚也容易。3. Spring AI 里的检索链路从 Embedding 到 VectorStoreSpring 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 ListDocument 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 位回答忠实度指生成内容是否只基于检索片段。指标含义建议目标Hit5期望片段是否在前 5 条召回内越高越好至少 80%检索延迟单次召回的 P95 耗时根据业务要求定无效召回率返回片段中与问题无关的比例越低越好忠实度回答是否严格基于召回片段人工抽检这个评测集不需要很大但必须有代表性。把线上真实问题、常见错别字问法、同义改写问法都放进去比单纯堆数据量有用得多。5.3 效果不佳时的排查顺序如果换了很多模型、调了很多参数还是不行我建议按顺序查一遍。先看数据再查检索最后才调 Prompt。数据环节包括切分是否合理、元数据是否完整、增量更新是否正常。检索环节包括召回通道是否单一、排序是否有效、阈值是否合适。Prompt 环节是最后一步因为你不能指望模型弥补检索结果的空白。这个顺序我踩了多次才总结出来。早期一效果差就换大模型成本上去了效果反而更差。后来改成先查检索结果再改 Prompt解决了大多数问题。实际上只要召回的前五条里有正确答案后面怎么调 Prompt 都相对容易。6. 再聊几句个人经验如果非要给搜索扩展这条线找一个核心我觉得不是向量数据库也不是某个模型而是“你有多了解自己的数据”。向量数据库只是工具RAG 也只是方法真正决定体验的是文档怎么切、元数据怎么标、检索结果怎么评估。我现在的习惯是每次调整检索链路前先跑一遍第一百道题的评测集再挑三个真实线上问题人工看召回结果。事实证明人工看完十次比调一百次参数都有用。很多问题只要把原始检索结果打印出来一眼就能看出是阈值、切分还是过滤的问题。这个项目后续还有不少可做的地方比如多轮对话时的 query 改写、常见问题的结果缓存、检索结果的在线反馈收集。但基于这几轮经验我最想提醒的还是那句话别急着换大模型先把检索链路里的搜索基本功补齐。搜索扩展扩展的不只是功能更是你对数据质量和线上问题的敏感度。