第 15 篇我们把 RAG 流水线从加载一路串到生成,拿自己的笔记库已经能跑了。但换成一组真实问题压测,朴素流水线很快会暴露四个症状:型号、错误码这类精确词检索不到;答案明明在库里,却排在 top-k 边缘进不了上下文;用户问得口语化,命中率忽高忽低;检索回的内容过期甚至越权。四个症状对应四味药——混合检索、重排、查询改写、元数据过滤,外加一味偏方 HyDE。
本篇逐一拆解,每个技巧按"解决什么问题 → 原理 → 代码 → 效果"展开,代码基于 langchain-core 1.6(2026-10)。全文按"混合检索 → 重排 → 查询改写 → HyDE → 元数据过滤 → 组合决策"组织。依赖在第 15 篇项目的基础上补齐:uv add langchain-classic langchain-community rank-bm25 jieba sentence-transformers lark——LangChain 1.x 重构之后,本篇用到的这批经典检索器大多搬进了 langchain-classic 包。
1、混合检索
向量检索的软肋是精确词。问题里出现产品型号 TX-8200、错误码 ERR_CONN_RESET 时,embedding 会把它们揉进整句的语义平均里——问连接报错,召回的却是一堆"语义相近"的连接池调优讨论。BM25 恰好是这类词的克星:稀疏关键词检索,按词项精确匹配打分,错误码这种几乎不出现的罕见词权重极高,一票定音。两种检索的互补一张表看全:
| 维度 | 向量检索 | BM25 |
|---|---|---|
| 强项 | 语义泛化,换个说法也能召回 | 精确词、型号、错误码、函数名 |
| 弱项 | 罕见专有词被语义平均稀释 | 换个同义词就失手 |
| 召回依据 | 稠密向量相似度 | 词项精确匹配与词频权重 |
融合用 EnsembleRetriever 加权完成,向量侧挂第 15 篇建好的库,BM25 侧用切分时的 chunks 列表现场建索引:
import jieba
from langchain.embeddings import init_embeddings
from langchain_chroma import Chroma
from langchain_community.retrievers import BM25Retriever
from langchain_classic.retrievers import EnsembleRetriever
embeddings = init_embeddings("openai:text-embedding-3-small")
vectorstore = Chroma(collection_name="my_notes",
persist_directory="./kb", embedding=embeddings)
bm25 = BM25Retriever.from_documents(chunks, preprocess_func=jieba.lcut)
bm25.k = 10 # BM25 侧取 10 条参与融合
vector = vectorstore.as_retriever(search_kwargs={"k": 10})
hybrid = EnsembleRetriever(
retrievers=[bm25, vector],
weights=[0.4, 0.6], # 偏关键词的问题调向 BM25
)
hits = hybrid.invoke("启动报 ERR_CONN_RESET 怎么排查?")
解读四个点。其一,融合算法是加权 RRF(Reciprocal Rank Fusion):只看每个检索器给出的排名,按名次倒数加权求和,两边分数量纲完全不同也不影响结果,细节见 EnsembleRetriever 参考。其二,weights 从 0.5/0.5 起步,语料偏术语、偏编号(内部系统名、错误码)就往 BM25 调。其三,rank_bm25 默认按空白切词,中文整句会被当成一个 token,必须传 preprocess_func=jieba.lcut——这是中文知识库最容易踩的坑。其四,BM25 索引纯内存、不落盘,每次启动要用 chunk 列表重建,建库时记得把切分结果单独存一份。效果上,错误码问题里含该错误码的排障记录会从十名开外回到前排:向量靠语义泛化,BM25 靠精确命中,各补各的盲区——一路失手,另一路顶上。
2、两阶段重排
第二个症状:答案在库里,但排在 top-4 的第 3 位,前两位"语义相近但不答案"。病根在粗排——向量检索是 bi-encoder,问题和文档各自独立编码,两边根本没"见过面",快是快(库内向量预先算好,查询时做一次近邻搜索),精细区分度却不够。cross-encoder 反过来:把(问题,文档)拼成一对一起过模型,逐对精细打分,准得多,但每对都要一次完整前向——全库上万块直接精排,成本不可接受。
工程答案是两阶段:粗排负责"别漏",精排负责"排对"。粗排放宽到 20 条候选,cross-encoder 只给这 20 对打分,取前 5 进生成。算笔账:一万块的知识库全量精排是 10000 次模型前向,两阶段把它压到 20 次——精度损失几乎为零,成本降了三个数量级:
from langchain_classic.retrievers.contextual_compression import (
ContextualCompressionRetriever,
)
from langchain_classic.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
ce = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-v2-m3")
reranker = CrossEncoderReranker(model=ce, top_n=5) # 精排后只留 5 条
rerank_retriever = ContextualCompressionRetriever(
base_compressor=reranker,
base_retriever=hybrid, # 粗排直接复用上一节的混合检索
)
hits = rerank_retriever.invoke("协程里怎么安全地取消任务?")
for i, d in enumerate(hits, 1):
print(i, d.page_content[:30])
解读:bge-reranker-v2-m3 是多语言重排模型,中文效果好,首次运行自动从 HuggingFace 下载;top_n=5 是精排后保留的条数。ContextualCompressionRetriever 是通用壳,"先检索再压缩"的任何压缩器(按相似度阈值截断、LLM 重排)都能挂,用法见 cross-encoder 集成文档。粗排不挑算法——这里直接把上一节的 hybrid 当 base_retriever:混合检索把候选捞全,重排把顺序排对,正好接力。延迟上,20 对打分在 CPU 上一般仍在亚秒级;高 QPS 场景再考虑 GPU 部署或 reranker API 服务。
顺带把第 15 篇欠的账还了:嫌 top-4 内容雷同,把向量检索器换成 search_type="mmr"(Maximal Marginal Relevance)——每选一块,都在"与问题相关"和"与已选集合不重复"之间取平衡,fetch_k 控制候选池大小、lambda_mult 在 0(最多样)到 1(最相关)之间调节。
3、查询改写
第三个症状出在问题本身:"咋取消任务来着"——七个字的口语,embedding 提不出多少信号;而文档里写的是"任务取消的标准姿势与资源清理",措辞对不上,相似度直接打折。MultiQueryRetriever 的思路是让 LLM 把一个问题改写成一组不同视角的查询,各查一遍,结果合并去重,相当于一次买多张不同角度的彩票:
import logging
from langchain.chat_models import init_chat_model
from langchain_classic.retrievers.multi_query import MultiQueryRetriever
logging.basicConfig(level=logging.INFO) # 打开 INFO 才能看到生成的变体
mq = MultiQueryRetriever.from_llm(
retriever=vectorstore.as_retriever(search_kwargs={"k": 4}),
llm=init_chat_model("openai:gpt-5"),
include_original=True, # 原问题也参与检索,兜住改写跑偏
)
hits = mq.invoke("咋取消任务来着")
日志里能看到模型生成的变体,大致长这样:
- asyncio 里如何安全地取消一个正在运行的任务?
- task.cancel 的正确用法与注意事项是什么?
- 协程被取消时如何完成资源清理?
解读:变体同时覆盖了"术语化表述"(asyncio、task.cancel)与"目标导向表述"(资源清理),文档无论按哪种措辞写都可能被其中一路命中;include_original=True 让原问题也参与检索。默认改写模板是英文的,中文知识库可以通过 from_llm 的 prompt 参数换掉它,让变体贴着你的领域词汇生成。代价是每次提问多一次 LLM 调用加数倍检索。什么时候值得:问题短、口语化、用户语言与文档语言错位时收益最大;反过来,问题已经又长又专业(工程师直接贴报错栈),改写纯属浪费 token,关掉即可。参数细节见 MultiQueryRetriever 参考。
4、HyDE 假设文档
查询改写仍在"问题空间"里打转,HyDE(Hypothetical Document Embeddings)更激进:先让模型写一段假设答案,拿答案的向量去检索。原理一句话:embedding 模型是在"文档对文档"的语料上训出来的,假设答案与真实文档同为陈述式长文本、处在同一形态空间,往往比"问题对文档"更容易撞上。思路出自HyDE 论文。langchain-classic 里有 HypotheticalDocumentEmbedder,但那是 legacy Chain 时代的 API,手写十几行更清楚,也顺便复习 LCEL:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain.chat_models import init_chat_model
hyde_prompt = ChatPromptTemplate.from_messages([
("system",
"写一段直接回答该问题的技术文档片段,用陈述句,"
"按最可能的实现来写,不要解释你在猜测。"),
("human", "{question}"),
])
hyde_chain = hyde_prompt | init_chat_model("openai:gpt-5") | StrOutputParser()
question = "咋取消任务来着"
hypothetical = hyde_chain.invoke({"question": question})
hits = vectorstore.similarity_search(hypothetical, k=4) # 拿假设答案检索
解读三件事。其一,假设答案的事实可能全错,没关系——我们只用它的"形态",最终答案仍由检索回的真文档支撑。其二,检索用假设答案,生成时喂给模型的仍然是原问题加检索回的真文档,两步各司其职。其三,prompt 里"不要解释你在猜测"这句是关键:不压住它,模型会写出一堆"我不确定,但大概是……",形态又滑回问题空间,HyDE 就白做了。
失效场景也要认账:事实性强的小众领域,比如问内部系统的私有概念,模型没见过,编出的假设答案方向跑偏,向量跟着偏,效果可能比直接用原问题还差。稳妥的做法是把 HyDE 与原问题两路检索的结果做一次融合(比如各取一半再合并),假设答案跑偏时原问题那一路还能兜底。判断标准:通用领域、问句与文档措辞差异大时,先拿固定问题集 A/B,别全量上。
5、元数据过滤
前四个技巧都在改"语义匹配",第五个管"检索范围":问 2024 年的报销标准,召回 2019 年的旧制度;实习生随口一问,检索回高管限阅文档。范围失控有两种治法,按条件由谁决定来分。
5.1、检索前硬过滤
条件由应用层确定——用户身份、租户、时间窗——直接写进检索参数,不劳 LLM。Chroma 的过滤写法:
retriever = vectorstore.as_retriever(search_kwargs={
"k": 4,
"filter": {
"$and": [
{"visibility": "public"}, # 权限:检索层强制收口
{"year": {"$gte": 2024}}, # 业务:只要近两年的文档
]
},
})
hits = retriever.invoke("差旅报销标准是什么?")
解读:filter 走 Chroma 的 where 语法,支持 $eq、$gte、$in、$and 等操作符,过滤发生在向量召回阶段,不匹配的块根本不参与排序。落到第 15 篇的问答链上,就是给 retriever 这个环节加死参数——权限与问题内容无关,所以它不进 prompt、不进模型,永远钉在检索参数里。权限过滤是生产刚需,且只能做在检索层:指望生成端的 prompt"别看不该看的",既不可审计也不可靠——文档已经进了上下文,事后无法证明它没影响输出。
5.2、self-query 拆解
条件藏在用户的自然语言里("2024 年以后生效的报销制度"),就让 LLM 把它拆成"语义查询 + 结构化过滤"两截:
from langchain_classic.chains.query_constructor.schema import AttributeInfo
from langchain_classic.retrievers.self_query.base import SelfQueryRetriever
fields = [
AttributeInfo(name="year", type="integer",
description="文档生效年份,如 2024"),
AttributeInfo(name="source", type="string",
description="文档来源部门,如 finance、hr"),
]
self_query = SelfQueryRetriever.from_llm(
llm=init_chat_model("openai:gpt-5"),
vectorstore=vectorstore,
document_contents="公司内部制度与流程文档",
metadata_field_info=fields,
)
hits = self_query.invoke("2024 年以后生效的差旅报销制度")
解读:AttributeInfo 逐字段告诉 LLM 元数据叫什么、什么类型、怎么用,拆得准不准全看 description 写得细不细;Chroma 的查询翻译器依赖 lark 包,装依赖时已带上。API 细节见 SelfQueryRetriever 参考。一条铁律:
self-query 只用于用户显式表达的业务条件;权限永远走 5.1 的硬过滤,安全边界不能交给 LLM 去"拆"。
6、技巧组合决策
四个技巧不是单选题,一张"症状 → 处方"表对号入座:
| 症状 | 病根 | 处方 |
|---|---|---|
| 型号、错误码、函数名检索不到 | 向量对精确词不敏感 | 混合检索 |
| 答案在库里,排位卡在 top-k 边缘 | 粗排区分度不够 | 两阶段重排 |
| 问题短、口语化,命中忽高忽低 | 问题表达与文档措辞错位 | 查询改写 / HyDE |
| 召回过期或越权内容 | 检索范围失控 | 元数据过滤 |
两个判断。第一,处方可以叠加:hybrid 每路 k=10 当粗排 → cross-encoder 精排 top-5 → 生成,这是生产 RAG 的常见骨架,每层独立可测、出问题好归因;查询改写与元数据过滤按需挂在外层,症状消失了就摘掉。第二,先定病根再开药:多数"答不准"其实是第 15 篇讲过的切分问题,切分没调好就上重排,是在错误的地基上盖楼。而当问题开始需要"查一次不够、要自己决定查不查、查错了要换问法再来",那就不是技巧层的事了——第 17 篇 Agentic RAG 会把检索降级成一件工具,让模型自己当检索策略师。
7、总结
这一篇给朴素流水线补了四味药:混合检索补精确词(BM25 加 EnsembleRetriever,中文记得 jieba 分词);两阶段重排治"排不对"(粗排 20、cross-encoder 精排 5);MultiQuery 与 HyDE 治"问得烂",前者多视角改写、后者拿假设答案撞向量;元数据过滤管范围,权限走硬过滤、业务条件走 self-query。它们共同的底座仍是第 15 篇那条朴素流水线——先让朴素版在真实问题集上跑出基线,再逐个叠技巧,每一步的提升才有归因。
练习:给第 15 篇的知识库补上 BM25 组成 hybrid,再加 cross-encoder 精排,用同一组问题对比三版 top-4(纯向量 / hybrid / hybrid 加重排)的命中变化;挑几个口语化问题分别用 MultiQuery 和 HyDE 检索,看哪个更适合你的语料;最后给 chunk 补上 year、source 之类的 metadata,跑一次 self-query。
对于已经在用朴素 RAG 的同学,建议按"症状 → 处方"表逐项排查,一次只叠一个技巧、用固定问题集量化对比——高级技巧的价值不在炫技,在于让每一分检索质量的提升都可归因、可回滚。


