
上篇发出去之后收到最多的提问不是“向量数据库怎么部署”而是“我已经按文档把相似度检索跑通了为什么真实查询的效果这么差”。这个问题特别典型因为Spring AI把VectorStore的接入做得太顺滑了顺滑到让人误以为RAG的关键就是“引一个依赖、配一个Key、调一次search”。等到业务同学拿着具体问题来验收才发现向量检索拍拍脑袋选个TopK根本扛不住。这篇是搜索扩展的下篇我打算把上篇没展开的部分一次性补齐从单路向量检索的失效场景到多路召回、查询改写、重排融合、权限隔离再到Agentic RAG和Ontology增强。这里每一个环节都不是“锦上添花”而是从Demo走向生产必须趟过去的坎。1. 为什么“相似度搜索”在真实项目里撑不住从TopK到多路召回先看一个我最近帮朋友排查的案例。他们内部做了一个Spring Boot的合同问答系统文档都是上传到MinIO再切块进Milvus。上线第一周业务提了一个问题“2024年Q3跟XX公司的采购合同付款方式是什么”这个问题的embedding和库里几十份采购合同都很接近向量检索返回的前5条里有3条是2023年甚至2022年的合同。最离谱的是有一条2025年刚签的框架协议因为条款措辞相似也被排到了前面。这不是调参能解决的问题。问题根源在于向量检索衡量的是“语义相似”不是“事实匹配”。用户问的是“某个特定主体在某段时间内的特定约定”这里面既有语义条件也有结构化条件还有时效条件。只靠一个向量去编码所有这些信息embedding模型再强也办不到。1.1 单路检索的三大失效场景我把日常见到的失败案例归成三类方便对号入座。第一类是同义词和领域术语的漂移。通用embedding模型能理解“工资”和“薪酬”接近但不一定理解企业内部常说的“薪酬包”“Offer总包”“HC数”之间的等价关系。法律、医疗、专利这种强术语场景更严重很多专业短语在通用模型的向量空间里根本不在邻近区域。第二类是专有名词和复合实体的断裂。比如“RAG”和“检索增强生成”在向量空间里可能距离很远再比如“XX-2024-078号文件”这种编号tokenize之后几乎不携带语义信息。用户按编号检索的时候向量检索表现极差但关键词精确匹配一下子就能命中。第三类是结构化条件混在自然语言里。比如“排除掉已经归档的项目”“只看上海团队产出的文档”“时间是上个月”。这些条件如果只靠向量去“悟”结果就是召回了一堆语义相近但根本不满足条件的内容。1.2 多路召回怎么拆语义、关键词与结构化过滤既然单路不行那就分开走。我在生产环境里的做法是把检索拆成三条管道每条管道解决一类问题召回管道解决什么问题典型技术选型返回结果向量召回语义相近、说法不同Embedding Milvus / Redis VSS按相似度排序的文档片段关键词召回编号、精确短语、术语Elasticsearch BM25 / Redisearch按相关度排序的文档片段结构化过滤时间、部门、文档类型、权限范围元数据字段精确匹配满足过滤条件的候选子集注意这三路不是三选一而是并行执行、结果合并。项目里我一般用CompletableFuture把三路查询丢出去等全部返回后再做融合。融合策略后面第3章会详细讲。无论是Spring AI的VectorStore还是我自己封装的SearchService我都建议在一开始就定义好一个统一检索接口内部再聚合多路来源。因为一旦业务增长大概率还会接入第四路、第五路召回比如基于图谱的实体召回。接口设计得好的话新增一路只是加一个实现类的事。1.3 Spring AI里做并行召回的落地姿态在Spring AI 1.0的API体系里VectorStore接口本身就支持通过SearchRequest传filterExpression做元数据过滤。但要把关键词召回纳进来还是得自己拼装。我的典型做法是写一个HybridSearchService里面注入VectorStore和RestClient指向内部的Elasticsearch然后public SearchResult hybridSearch(SearchQuery query) { CompletableFutureListDocument semanticFuture CompletableFuture.supplyAsync(() - vectorStore.similaritySearch( SearchRequest.builder() .query(query.text()) .topK(50) .filterExpression(query.filter()) .build() )); CompletableFutureListDocument keywordFuture CompletableFuture.supplyAsync(() - esKeywordSearch(query.text(), query.filter())); ListDocument merged new ArrayList(); merged.addAll(semanticFuture.join()); merged.addAll(keywordFuture.join()); return fuseAndRerank(merged, query); }关键点在于向量路召回TopK我一般给到50关键词路也召回50而不是只取5条。因为后面还要做重排如果这阶段就把候选砍到5条重排就没有意义了。2. 检索之前的“前处理”决定成败Query改写与Embedding管理很多团队把大量精力花在调模型上却忽略了最便宜的优化在数据进向量库之前以及在用户查询进向量库之前先把文本整理成人话。这两步我称为“前处理”。前处理做得不好后面所有环节都在一个歪地基上盖楼。2.1 Query改写把用户的口语翻译成数据库的语言用户的原始提问通常不适合直接embedding。典型情况有三种。第一种是指代消解。多轮对话里用户问“那它的有效期呢”这里的“它”指的是上一轮讨论的某份合同。如果你把这句话单独拿去embedding检索结果大概率是乱的。Spring AI里可以在对话上下文中做改写把“它”替换成上一轮识别出的实体。第二种是口语到书面语的转换。用户问“XX产品多少钱一年”数据库里存的是“XX产品年度订阅价格为”。直接embedding可能也能召回但召回质量不稳定。通过LLM把问题改写成“XX产品年度订阅价格是多少”再去做向量检索效果会稳定得多。第三种是HyDEHypothetical Document Embeddings。这是目前我实测提升检索准确率最明显的手段先让LLM根据用户问题生成一段虚拟的、接近标准文档风格的回答然后用这段虚拟回答去向量库里检索。原理其实很好理解用户问题的文本风格和知识库文档风格通常差异很大embedding模型在跨风格匹配时容易失真而HyDE先把查询“翻译”成文档风格再做相似度匹配就顺了。String rewrittenQuery chatClient.prompt() .system(你是一个检索辅助引擎。请把用户的问题改写为适合知识库检索的书面查询并补充同义词。) .user(原始问题 originalQuery) .call() .content(); SearchRequest request SearchRequest.builder() .query(rewrittenQuery) .topK(50) .build();这里要提醒一句HyDE需要一次额外LLM调用延迟和成本都是真实存在的。我的经验是只在原始查询的向量匹配度不高的时候才启用HyDE而不是每个请求都走。判断方法很简单先用原始查询做一次检索如果最高相似度分都低于阈值再走HyDE。2.2 Embedding模型版本与切块参数最容易被忽略的生产事故先说一个我踩过的坑。上线第二周我把Embedding模型从text-embedding-3-small换成了bge-m3想着模型更强了直接切换就行。结果当天线上问啥啥不准。排查了半天才反应过来新旧模型生成的向量空间完全不一样旧文档的向量还是旧模型算的新查询却用新模型算两边根本不在一个坐标系里。所以Embedding模型升级必须伴随全量数据重新向量化。这件事没有捷径。我在项目里会把embeddingModelVersion字段写进每条Document的metadata检索时也把这个版本号作为过滤条件确保查询和文档始终使用同一模型。切块参数同样值得较真。Spring AI自带的TokenTextSplitter适合快速起步但固定token数切块经常把一句完整的话拦腰截断。我的经验是普通制度文档按Markdown标题或段落边界切单块控制在200到400 token。表格密集的文档整表保留不要切开否则LLM看到的都是残缺行列。同一文档的相邻块保留20到50 token重叠避免切在关键词中间。这里建议每个团队准备一套属于自己领域的“黄金切块样本”拿二三十篇典型文档反复调整参数而不是抄别人文章的默认值。2.3 向量库选型Milvus、Redis还是pgvector很多人在Spring AI里配向量库时习惯性选择某个大厂托管服务其实选型应该由数据量和查询模式决定。我遇到过不少团队数据量只有几十万条却上了分布式集群运维成本比业务成本还高。选型适合场景需要关注的点Redis VSS数据量百万以内已有Redis集群对响应延迟毫秒级要求内存成本高复杂过滤能力弱于专用库Milvus千万级以上支持复杂标量过滤和时间线过滤组件多最小部署也要几台机器pgvector数据量不大想和业务库统一运维查询性能在千万级后会明显下滑Spring AI对这几类都有现成的VectorStore实现切换成本被大大降低了。我的建议很实在先问自己有多少数据、有多少并发、有没有专业运维再决定用哪个。不是越重型越好。3. 召回之后融合排序与重排才敢把结果交给LLM多路召回之后手上有三批来源不同、分数含义不同的候选片段。这时候不能直接拼接起来丢给LLM因为向量相似度分数、BM25相关度分数不在一个量纲上没法直接比大小不同批次里很可能有内容重复的片段这些候选是按“相关性”排序的不是按“对回答当前问题的价值”排序的。3.1 RRF多路结果分数不可比的具体解法分数不可比时最简单可靠的融合方法不是学一个排序模型而是用Reciprocal Rank FusionRRF。RRF不看具体分数只看“这条文档在第几个位置上被召回”。公式长这样score(d) Σ 1 / (k rank_i(d))其中rank_i(d)是文档d在第i路召回中的排名k一般取60。每一路召回的“投票权重”通过排名的倒数体现排名越靠前贡献越大。举个例子文档A在向量路排第2在关键词路排第10在过滤路排第5。那它的RRF分数就是1/(602) 1/(6010) 1/(605)。另一篇文档B在向量路排第1但在关键词路排到第80那它的RRF分数反而不如A。这个特性很好用多路都认可的内容会被显著放大权重。实践中我会把融合后的候选取前30条进入下一步重排。3.2 Rerank模型怎么选怎么用融合后的30条还是太多了而且排序依据依然是“相似度”不是“有用性”。所以我通常再接一个Rerank模型把30条压缩到5条。Rerank和Embedding的差别在于Embedding是一次性把文档编码成向量检索时只算向量距离Rerank则是把“用户问题 文档片段”这对组合一起送进模型让模型直接打分输出“这条内容对回答当前问题有没有用”。所以Rerank更准但计算成本也更高。选型方面有条件部署GPU服务BGE-Reranker-v2-m3这类开源模型部署成HTTP服务Spring AI侧用RestClient调用。没有GPU但有预算走云端Rerank API。什么都没有可以用LLM做“穷人版Rerank”让模型输出[0,10]的打分。但成本高、延迟高只适合低并发内部工具。我的生产参数大致是粗召回TopK50RRF融合后取Top30Rerank后取Top5最后把这5条拼进Prompt。见过不少团队跳过Rerank直接用TopK5的结果去生成效果差距在专业领域里非常明显。3.3 硬过滤与跨时空约束时效性、权限过滤顺序重排解决“内容相关性”但还有一类条件必须在重排之前用“硬过滤”处理不能靠模型“悟”。典型就是权限和时效。比如检索企业内部制度用户所在部门是研发部那含有“财务部内部流程”的文档片段无论语义多相关都不能进候选集。这一步要放在多路召回阶段通过filterExpression把不可见内容直接挡在门外。如果等重排完再过滤一旦Top结果全是不可见内容后面可选的余地就很小了。时效性也一样。新闻资讯类场景我会给文档打上发布时间召回阶段就用时间范围过滤或者召回后对分数做时间衰减处理finalScore 0.8 * semanticScore 0.2 * timeDecayScoretimeDecayScore可以简单设置为exp(-daysAgo / halfLife)半衰期按业务自己定。这个公式不复杂但能非常有效地解决“旧文档因为语义太近而常年霸榜”的问题。4. 把知识库做成“可运营”的系统多租户隔离与效果评估从Demo到生产一个显著差异是Demo里面只有一个全量的知识库谁查都是那一个索引生产环境里不同部门、不同角色、不同项目看到的数据边界完全不一样。开始做RAG之前一定要先把这道边界划清楚。4.1 企业知识库真的适合“一股脑”放进向量库吗我见过不止一个团队的做法是给AD域建一个文档同步任务所有文件解析完就往向量库里写结果用户问“A项目预算”的时候系统把B项目甚至C客户的信息也检索出来了。这不是模型的问题是数据治理的问题。写文档的metadata时至少要有这几个字段tenantId租户或组织IDdepartmentId归属部门docType文档类型合同、制度、周报、SOP等visibility可见范围公开、指定部门、指定人员createdAt/expireAt时间边界后面所有检索请求都必须带上当前用户的这些上下文。Spring AI的SearchRequest支持表达式SearchRequest.builder() .query(rewrittenQuery) .filterExpression(tenantId acme visibility public departmentId in [rd, ops]) .topK(50) .build();注意filterExpression的语法在Spring AI各个版本里略有调整遇到语法解析报错的时候优先去看当前版本的FilterExpressionBuilder文档不要硬套旧版本写法。4.2 权限过滤链路上传、注入、检索三步走权限过滤不是检索那一刻临时想起来的而是从文档进库的第一天就要设计。我在团队里推行三步走上传阶段解析原始文档时把来源系统的权限元数据一并读出来。比如从Confluence同步文档时顺便把空间的可见权限读出来写入Document的metadata。切块阶段切块后每一片继承父文档的权限字段。这个看似自然但很多切块工具默认只保留文本把metadata丢了结果后续所有权限字段都是空的。检索阶段从当前登录用户的上下文组装filter加入SearchRequest。这里一定要在“召回阶段”过滤不是拿到结果后再筛。有一个实践中的细节不要把完整权限字段暴露给前端。检索接口入参只接受用户身份内部根据用户身份查权限模型再组装filter。否则用户改一个请求参数就能看到不属于自己的数据这是重大事故。4.3 效果评估和可观测性没有指标就别谈优化团队经常在“感觉效果变好了”和“感觉效果变差了”之间来回拉扯因为没有量化指标。我从一开始就要求每次检索都埋日志至少记录原始查询文本改写后查询文本各路召回返回数量Rerank后Top5的文档ID和分数LLM最终采用的文档ID生成耗时这些日志是排查一切问题的底牌。有的用户问“为什么答案不对”一看日志要么是改写改偏了要么是召回阶段就把相关文档过滤掉了要么是Rerank把正确答案排到第6位。问题出在哪个环节一目了然。再往上一步可以引入RAG评估框架比如RAGAS用“忠实度Faithfulness”“答案相关度Answer Relevance”“上下文相关度Context Relevance”三个维度做自动评估。但我建议先做一个轻量的“黄金集”挑100条典型业务问题标注每道题对应的标准文档ID每次发版后跑一遍看命中率有没有退步。有了这个基线你才敢安心升级Embedding模型、调切块参数、换Rerank模型。5. 从“一次检索”到“自主获取”Agentic RAG与Ontology增强等到多路召回、重排、权限隔离都稳定了可以再往前走一步。最近的趋势是从“一次查询一次检索”升级到“让Agent自己决定怎么检索”。这一步做得好体验提升明显做得不好就是给自己制造新的故障点。5.1 Agent工具化让模型自己决定何时检索普通的RAG流程是固定的拿到用户问题检索生成。Agentic RAG则把“检索”变成Agent可以调用的一个工具。模型先判断需不需要检索、需要检索几次、先检索哪个库根据检索结果决定是直接回答还是继续追问。Spring AI 1.0里通过Tool注解可以把一个方法暴露给模型作为工具Service public class KnowledgeBaseTools { Tool(description 在知识库中检索与给定问题最相关的文档片段入参为检索问题) public String searchKnowledgeBase(String query) { SearchRequest request SearchRequest.builder() .query(query) .topK(5) .build(); ListDocument docs vectorStore.similaritySearch(request); return docs.stream() .map(Document::getContent) .collect(Collectors.joining(\n---\n)); } }然后ChatClient里通过.tools(new KnowledgeBaseTools())注入模型就会在合适的时机自动调用这个工具。5.2 给Agent立边界不能裸奔向量库这里想重点说一个反模式有人直接把VectorStore作为Tool暴露给Agent模型想怎么查就怎么查。这样做的风险很大模型可能一次调用什么都不带或带一个含糊的query导致召回质量失控模型可能绕过业务层面的权限上下文直接按内容检索没有查询频率限制内部知识库可能被打爆。我的做法是包一层KnowledgeBaseSearchService里面强制注入当前用户上下文限制检索库范围限制每次返回的片段数量并且在调用时记录审计日志。Tool暴露给模型的是“业务语义”而不是“技术能力”。5.3 Ontology增强补上实体关系这张“暗网”最后聊一下Ontology RAG。这个词听起来玄乎实际解决的问题很具体文本相似度只能关联“说法相近”的内容关联不了“实体关系相近”的内容。举个例子。用户问“负责XX项目的产品经理最近晋级了吗”。如果你只有文本向量你需要同时检索到“XX项目团队页”和“XX员工晋级公告”两者在文本上可能毫无共同词全量知识库里相似度分数都排不进去。但如果你有一张简单的实体关系表项目A - 成员B - 最近事件“晋级”一次图谱查询就能定位答案。Ontology RAG的落地不一定要引入图数据库。我见过不少团队用一张关系表就能解决80%的问题实体类型实体ID关联实体关系类型ProjectP001张三memberEmployee张三晋级公告related_event检索时先做一次实体解析把用户问题中的实体拎出来在图谱表中查出关联文档再作为一路“实体召回”加入RRF融合。效果立竿见影实现成本也不高。我在实际项目中踩过几次坑之后最大的体会是RAG系统效果不好时不要总怀疑大模型不行。先检查切块是否合理、权限是否在召回阶段过滤、多路召回是否真正并行、重排模型有没有接、日志能不能定位问题。把这些基础环节一个个夯实比盲目换更强的模型有用得多。最后再分享一个小技巧接入Agentic RAG之前先把普通RAG链路的“黄金测试集”跑通留好基线分。否则Agent自己发挥的时候效果忽好忽坏你根本说不清是模型变笨了还是召回链路被Agent绕开了。