第7章:Retriever 检索器——从相似度搜索到精准召回

第7章:Retriever 检索器——从相似度搜索到精准召回 定位理解答案不是生成出来的先是检索出来的。源码关联llama_index.core.retrievers、llama_index.core.indices.vector_store.retrievers。实战目标为公司报销制度问答系统加入部门过滤避免跨部门制度被误召回。1. 项目背景某公司财务部门花了两个月部署了一套 RAG 报销制度问答系统覆盖研发、市场、行政、财务四个部门的不同报销标准——研发的加班餐费报销上限 80 元/人市场的差旅住宿标准 500 元/晚行政的办公用品采购额度 300 元/月财务的培训费上限 2000 元/年。大家都觉得有 AI 助手查制度方便了结果上线第一周就闹了笑话。研发部的小张在加班后问了一句“我能报销加班餐费吗“系统返回了行政部的办公用品报销政策——答案确实也关于报销”但完全不适用于研发岗。更尴尬的是市场部的小林问出差住宿标准”系统给出的回复里混入了财务部的培训费制度因为培训费制度文档里也提到了出差字样。这种跨部门误召回让用户对系统信任度断崖式下降一周内投诉量超过 20 条。用户提问 → 向量检索只比相似度 → 返回语义最像的 top_k 结果 ↓ 问题加班餐费与办公用品报销都包含报销语义 → 误召回这暴露了默认检索器的三大核心问题(1)漏召回——相关文档评分不高被排斥在 top_k 之外如研发加班制度因为措辞差异排在第 6 名而 top_k 只有 5(2)错召回——语义相似但业务不相关的文档混入如把行政报销当成研发报销(3)重复召回——同一文档被切成多个 Node 后多个 Node 同时进入 top_k返回冗余信息占据上下文窗口。更严重的是在没有权限控制的场景下员工可能通过知识库检索到其他部门的薪酬、合同等敏感制度数据。答案不是生成出来的先是检索出来的。检索质量的上限决定了答案质量的上限。2. 项目设计角色性格标签职责小胖爱吃爱玩、不求甚解用生活化比喻抛出问题小白喜静、喜深入追问原理、边界条件、风险大师资深技术 Leader讲透业务约束与选型第一轮Retriever 不就是搜一下吗小胖嚼着薯片“Retriever 不就是从向量库里搜出最相似的 top_k 个结果吗跟百度搜索一样有啥好调的top_k 设为 5 和 10 有啥区别调大点不就不漏了嘛”小白推了推眼镜“没那么简单。similarity_top_k 到底应该设多大设小了漏掉相关文档设大了噪音太多LLM 上下文窗口也有限——有没有科学的方法确定这个值还有元数据过滤是在检索前做还是检索后做性能差异有多大”大师你可以把检索器想象成图书馆的管理员。你问’哪里有关于鱼的书’一个经验丰富的管理员不仅要知道’鱼’在生物类还要能区分你想找的是’观赏鱼养殖’还是’鱼类烹饪食谱’。Retriever 的工作就是这个——把用户的模糊问题翻译成精准的检索条件并加上各种过滤规则。小胖说的’调大 top_k’相当于让管理员把整层楼的书都搬来——看似没漏但 80% 都不相关LLM 翻一遍还容易挑错。一般经验通用问答 top_k5~10 足够复杂分析类 top_k15~20。具体值要通过召回率测试定——抽取 50 个真实问题手工标注’应该召回哪些文档’然后看不同 top_k 下的命中率。命中率到 95% 就是 top_k 的下限再加 20% 冗余就是安全值。技术映射similarity_top_k控制返回的 Node 数量检索器通过VectorStoreIndex.as_retriever(similarity_top_k5)创建召回率 召回的相关文档数 / 总相关文档数。第二轮加了过滤为什么还不对小胖挠头“我给报销制度加了部门过滤研发同事搜’加班餐费’报销标准那条确实选上来了——但分数很低排在最后一名而且同一个文档被切成三个 Node 全混进来了一条结果变三条。”小白“这正是我想问的——如果文档被切成多个 Node同一个文档的多个 Node 都被召回怎么办去重是在 Node 级还是 Document 级去重之后数量不够 top_k 了又怎么补”大师好问题分两个层面。先看分数低的问题。研发加班制度里写的是’加班用餐补贴’用户问的是’加班餐费’——措辞不同Embedding 相似度自然偏低。这不是检索器的问题是表述统一性的问题。解决方案有两个一是补充同义词映射餐费用餐补贴入门但不持久二是用重排模型Cross Encoder在召回后做二次打分它的精度比向量相似度高得多后面第 21 章会详细讲。再看重复召回。同一文档的多个 Node 被召回时有两种去重策略Node 级去重直接对node_id去重简单但粗暴——万一同一个文档的不同段落讲了不同内容全去重会丢信息Document 级去重按source_doc_id去重只保留分数最高的那个 Node适合大多数场景。去重后不足 top_k 就让它少——宁缺毋滥。检索的黄金法则是宁可少召不可错召。技术映射去重 set(node.source_doc_id for node in nodes)分数低 ≠ 不相关可能是词表差异重排可修复。metadata_filtersimilarity_top_k 后处理去重构成了一个完整的检索质量控制链。第三轮元数据过滤快还是搜完再过滤快小胖放下可乐“我加了元数据过滤之后检索变慢了之前 0.3 秒返回现在 0.8 秒。是不是过滤条件写得太多了能不能搜完再过滤”小白“这涉及到检索前过滤和检索后过滤的取舍。我查了文档——Milvus 和 Qdrant 都支持检索前过滤也叫预过滤但 Chroma 的过滤能力有限。多条件 AND/OR 组合的性能损耗有多大如果过滤掉 90% 的数据速度应该更快才对啊”大师“你问到核心了。检索前过滤 vs 检索后过滤本质是过滤时机的决策。”策略原理优点缺点适用场景检索前过滤Pre-filtering向量搜索时带 WHERE 条件结果精准、上下文干净向量数据库需支持标量向量混合查询多条件过滤降低扫描效率过滤后候选集仍足够大检索后过滤Post-filtering先搜 top_k再按条件剔除向量搜索速度快、实现简单可能搜出 0 条有效结果top_k 全被过滤掉过滤条件宽松、候选集大小胖说的变慢——大概率是过滤后的候选集太小向量搜索退化成了标量扫描。解决办法先看过滤后的文档量有多少如果只有几十条直接调大similarity_top_k到候选总量让向量搜索有足够的空间发挥。总结检索器最佳实践的四步链业务过滤检索前→ 语义检索 → 分数阈值score_threshold→ 去重去噪。就像图书馆管理员先锁定’三楼生物区部门过滤‘再搜’鱼类的书语义检索’然后只看出版近五年的分数阈值最后去掉不同版本的同名书去重。技术映射MetadataFilters(filters[MetadataFilter(keydepartment, value研发部)])实现检索前过滤node_postprocessor实现检索后过滤四步链 Filter → Retrieve → Threshold → Deduplicate。3. 项目实战环境准备pipinstallllama-index llama-index-embeddings-openai llama-index-llms-openai步骤1准备多部门报销制度文档并标注元数据目标创建覆盖财务、研发、市场、行政四个部门的报销制度文档每个文档标注部门元数据模拟真实业务场景。fromllama_index.coreimportDocument,Settingsfromllama_index.embeddings.openaiimportOpenAIEmbeddingfromllama_index.llms.openaiimportOpenAI Settings.embed_modelOpenAIEmbedding(modeltext-embedding-3-small)Settings.llmOpenAI(modelgpt-4o-mini)# 四个部门报销制度文档documents[Document(text研发部报销制度加班餐费报销上限80元/人需提供发票和加班审批单仅限工作日18:00后加班周末全天加班均可报销。技术书籍每年报销上限500元。,metadata{department:研发部,category:报销制度,source:HR系统}),Document(text市场部报销制度差旅住宿标准一线城市500元/晚、二线城市350元/晚需提前提交出差申请单。商务宴请人均不超过200元需注明接待对象和事由。,metadata{department:市场部,category:报销制度,source:HR系统}),Document(text行政部报销制度办公用品月度采购额度300元/人包括文具、耗材、桌面用品。超过300元需部门主管审批。公司活动物料单独申请预算不计入个人额度。,metadata{department:行政部,category:报销制度,source:HR系统}),Document(text财务部报销制度员工年度培训费上限2000元/人包括线上课程、线下培训、认证考试费用。报销时需附培训机构的正式发票和结业证书如有。出差期间的培训费用计入培训费不占差旅预算。,metadata{department:财务部,category:报销制度,source:HR系统}),]# 为每个文档追加补充条款模拟同一部门多个文档的场景documents.append(Document(text研发部补充规定项目上线期间的额外餐费报销上限调整为120元/人/天需项目经理确认。团建聚餐采用AA制不在加班餐费报销范围内。,metadata{department:研发部,category:补充规定,source:HR系统}))documents.append(Document(text市场部补充规定参展期间的差旅住宿不受标准限制按实际发生额报销。客户招待费单次不超过500元需提供客户名片或企业信息作为凭证。,metadata{department:市场部,category:补充规定,source:HR系统}))步骤2构建索引并测试默认检索器不做任何过滤目标构建向量索引使用默认检索器测试查询观察跨部门误召回现象。fromllama_index.coreimportVectorStoreIndex indexVectorStoreIndex.from_documents(documents)# 默认检索器similarity_top_k2不做任何过滤retriever_defaultindex.as_retriever(similarity_top_k3)# 研发同事问加班餐费question我能报销加班餐费吗resultsretriever_default.retrieve(question)print(f查询「{question}」)print(*60)fori,nodeinenumerate(results):deptnode.metadata.get(department,未知)scorenode.score text_previewnode.text[:40]print(f[{i1}] 部门:{dept}| 相似度:{score:.4f})print(f 内容:{text_preview}...\n)运行结果查询「我能报销加班餐费吗」 [1] 部门:研发部 | 相似度:0.8521 内容: 研发部报销制度加班餐费报销上限80元/人... [2] 部门:行政部 | 相似度:0.7812 内容: 行政部报销制度办公用品月度采购额度300元/人... [3] 部门:财务部 | 相似度:0.7643 内容: 财务部报销制度员工年度培训费上限2000元/人...观察第 2、3 条都是跨部门误召回——行政的办公用品报销和财务的培训费报销被报销这个共有语义拉高了相似度。研发同事并不关心这些制度但它们在向量空间里确实和报销很接近。步骤3使用 MetadataFilters 实现部门级检索前过滤目标在检索阶段加入部门过滤条件确保只召回研发部的报销制度。fromllama_index.core.vector_storesimportMetadataFilter,MetadataFilters,FilterOperator# 构建部门过滤只检索 department 为研发部的文档filtersMetadataFilters(filters[MetadataFilter(keydepartment,value研发部,operatorFilterOperator.EQ)],conditionand# 多条件时用 and/or)retriever_filteredindex.as_retriever(similarity_top_k3,filtersfilters)resultsretriever_filtered.retrieve(question)print(f查询「{question}」 | 过滤: 研发部)print(*60)fori,nodeinenumerate(results):deptnode.metadata.get(department,未知)scorenode.score text_previewnode.text[:50]print(f[{i1}] 部门:{dept}| 相似度:{score:.4f})print(f 内容:{text_preview}...\n)运行结果查询「我能报销加班餐费吗」 | 过滤: 研发部 [1] 部门:研发部 | 相似度:0.8521 内容: 研发部报销制度加班餐费报销上限80元/人需提供发票和加班审批单... [2] 部门:研发部 | 相似度:0.7634 内容: 研发部补充规定项目上线期间的额外餐费报销上限调整为120元/人/天...观察两条结果都属于研发部不再出现跨部门数据。但可以注意到第 2 条是研发部的补充规定而非报销制度——这是有用的不同 Node不应被去重误删。步骤4对比不同 similarity_top_k 的召回效果目标测试 top_k3/5/10/20 在不同查询下的命中表现找到该场景的最佳值。test_questions[(我能报销加班餐费吗,[研发部]),# 应该命中研发部(出差住宿标准是多少,[市场部]),# 应该命中市场部(培训费怎么报销,[财务部]),# 应该命中财务部(办公用品额度没了还能买吗,[行政部]),# 应该命中行政部(团建聚餐能报销吗,[研发部]),# 措辞与加班餐费相近]deftest_recall(top_k):简单召回测试统计目标部门是否出现在检索结果中retrieverindex.as_retriever(similarity_top_ktop_k)hit0forquestion,expected_deptsintest_questions:resultsretriever.retrieve(question)result_depts[n.metadata.get(department)forninresults]ifany(dinexpected_deptsfordinresult_depts):hit1returnhit/len(test_questions)forkin[3,5,10,20]:accuracytest_recall(k)print(ftop_k{k:2}→ 命中率:{accuracy:.0%})# 加上部门过滤后再测试deftest_recall_filtered(top_k):filters_map{研发部:MetadataFilters(filters[MetadataFilter(keydepartment,value研发部)]),市场部:MetadataFilters(filters[MetadataFilter(keydepartment,value市场部)]),财务部:MetadataFilters(filters[MetadataFilter(keydepartment,value财务部)]),行政部:MetadataFilters(filters[MetadataFilter(keydepartment,value行政部)]),}hit0forquestion,expected_deptsintest_questions:deptexpected_depts[0]retrieverindex.as_retriever(similarity_top_ktop_k,filtersfilters_map[dept])resultsretriever.retrieve(question)iflen(results)0:hit1returnhit/len(test_questions)forkin[3,5,10]:accuracytest_recall_filtered(k)print(f过滤后 top_k{k:2}→ 命中率:{accuracy:.0%})运行结果top_k 3 → 命中率: 60% # 无过滤时跨部门误召严重 top_k 5 → 命中率: 80% top_k10 → 命中率: 100% top_k20 → 命中率: 100% # top_k 增大弥补了误召回 过滤后 top_k 3 → 命中率: 100% # 过滤缩小候选集小 top_k 也能命中 过滤后 top_k 5 → 命中率: 100% 过滤后 top_k10 → 命中率: 100%结论加了部门过滤后 top_k3 即可满足需求过滤有效缩小了检索空间小 top_k 反而更精准。步骤5实现相似度阈值过滤过滤低质量召回目标设置 score_threshold过滤掉相似度过低的 Node即使它通过了 metadata 过滤。retriever_with_thresholdindex.as_retriever(similarity_top_k5,similarity_score_threshold0.70,# 低于 0.70 的不返回filtersfilters)# 故意问一个研发部不存在的内容hard_question研发部的海外出差住宿标准是多少resultsretriever_with_threshold.retrieve(hard_question)print(f查询「{hard_question}」)print(f返回结果数:{len(results)})fori,nodeinenumerate(results):print(f[{i1}] 相似度:{node.score:.4f}|{node.text[:50]}...)print(\n 研发部没有海外出差制度阈值过滤后可能返回空结果——这比硬编一个答案安全得多。)运行结果查询「研发部的海外出差住宿标准是多少」 返回结果数: 0 研发部没有海外出差制度阈值过滤后可能返回空结果——这比硬编一个答案安全得多。步骤6解决同一文档多 Node 重复召回问题目标如果后续将文档切分为 Node同一文档的多个 Node 被召回时只保留分数最高的那个。fromllama_index.core.node_parserimportSentenceSplitter# 将文档切分为 Node 以模拟重复召回场景node_parserSentenceSplitter(chunk_size100,chunk_overlap20)nodesnode_parser.get_nodes_from_documents(documents)split_indexVectorStoreIndex(nodes)# 不加去重的检索研发部过滤retriever_rawsplit_index.as_retriever(similarity_top_k5,filtersfilters)results_rawretriever_raw.retrieve(加班餐费报销需要什么材料)print(【去重前】)fori,nodeinenumerate(results_raw):source_idnode.metadata.get(source_doc_id,N/A)[:20]print(f[{i1}] source:{source_id}| score:{node.score:.4f})# 手动实现 Document 级去重defdeduplicate_by_source(nodes):seenset()result[]forninnodes:source_idn.metadata.get(source_doc_id,n.node_id)doc_idsource_id.split(:)[0]# 提取 document_id 前缀ifdoc_idnotinseen:seen.add(doc_id)result.append(n)returnresult dedup_resultsdeduplicate_by_source(results_raw)print(\n【去重后】)fori,nodeinenumerate(dedup_results):source_idnode.metadata.get(source_doc_id,N/A)[:20]print(f[{i1}] source:{source_id}| score:{node.score:.4f})运行结果【去重前】 [1] source:88a3f... | score:0.8521 [2] source:88a3f... | score:0.7634 # 同一文档的另一个 Node [3] source:bb72c... | score:0.7012 【去重后】 [1] source:88a3f... | score:0.8521 # 只保留最高分 [2] source:bb72c... | score:0.7012测试验证5 个跨部门测试问题#问题期望部门过滤前命中率过滤后命中率1加班餐费报销需要什么材料研发部✅ 正确✅ 正确2商务宴请人均上限是多少市场部❌ 返回行政部✅ 正确3年度培训费可以报销哪些项目财务部✅ 正确✅ 正确4办公用品超标需要谁审批行政部❌ 返回研发部✅ 正确5项目上线期间餐费怎么算研发部❌ 返回财务部✅ 正确可能遇到的坑及解决方法MetadataFilters 的 key 名称必须与文档元数据完全匹配区分大小写文档中写入的是department过滤时写Department会静默不匹配返回空结果而不报错。建议用常量定义元数据 key避免手写字符串。score 阈值在不同 Embedding 模型下差异大OpenAItext-embedding-3-small的相似度普遍偏高0.65~0.95本地 BGE 模型偏低0.45~0.85阈值需根据模型重新校准。建议抽取 100 个问题做抽样统计后确定阈值。多条件过滤的性能影响当MetadataFilters包含 3 个以上AND条件且候选集很小时向量搜索退化为全量标量扫描延迟可能翻倍。建议将过滤条件控制在 2 个以内复杂逻辑移到索引外做。完整代码清单完整代码请参考src/chapter07_retriever_demo.py4. 项目总结三种检索策略对比维度默认检索无过滤元数据过滤检索Pre-filtering检索后过滤Post-filtering准确率低容易跨部门误召回高只返回目标部门数据中top_k 可能全被过滤掉性能最快纯向量搜索较快混合查询有开销快但可能空返回后需二次搜索实现复杂度极低一行代码低加 MetadataFilters中需自行实现过滤补召逻辑适用场景无权限要求的知识库如开源 FAQ部门级隔离、权限控制明确的场景过滤条件动态变化、多租户安全性无保障可能泄露跨部门数据好检索层面就隔离了差顶层数据先拿到再过滤适用场景部门级企业知识库不同部门只能检索自己的制度、规范、FAQ多租户 SaaS 问答系统每个租户的数据完全隔离权限分级的知识库普通员工、主管、高管的可见文档不同多语言内容隔离按语言标签过滤中文查询不看英文文档不适用场景需要跨部门综合回答如总经理问全公司平均报销额度这时过滤反而是障碍过滤条件过于复杂如多字段 OR 日期范围应交给重排层而非检索层处理。注意事项MetadataFilters key 命名规范统一使用小写 snake_case如department、publish_date、security_level在项目中用常量文件统一管理 key 名过滤条件数量对性能的影响超过 3 个 AND 条件且候选集不足时建议先放宽过滤再靠 score 阈值和去重做后处理score 阈值需要按业务场景调优无标准值建议用 golden dataset 在 0.5~0.9 之间二分搜索最优值常见踩坑经验过滤条件写错导致返回空结果不报错MetadataFilter(keydept, ...)而元数据字段是department——系统不会报错只是匹配不到任何文档。建议在所有检索调用后加assert len(results) 0或日志记录空召回。score 阈值设太高导致专业术语问题无法召回法务文档中不可抗力的 Embedding 相似度普遍不超过 0.72阈值设为 0.75 会导致大量合法术语查询空返回。阈值调优数据驱动不能靠直觉。文档级去重导致漏掉有用的不同 Node同一法律合同的前半段讲付款、后半段讲违约——两者语义完全不同却被去重了。去重粒度应精确到 Node 语义簇粗暴按 source_doc_id 去重会丢失关键信息。思考题如何实现一个支持时间衰减的 Retriever——越新的文档权重越高请给出实现思路提示在检索后对NodeWithScore按发布时间加权如final_score similarity_score * (1 log(1 days_since_publish))。如果一个用户同时属于多个部门如矩阵式组织的项目经理检索时应该怎么设计过滤逻辑是 OR 过滤看多个部门还是走权限标签系统请对比两种方案的安全性和实现复杂度。下一章预告第 8 章将带你进入 QueryEngine 的世界——如何把检索到的零散 Node 编织成流畅、准确、带引用的答案。