对话式RAG与检索策略
本篇承接 02_检索链与RAG实战。前两篇已经解决了”如何把文档做成可检索知识库”和”如何搭出一条基础 RAG 问答链”。但真实项目中的 RAG 很快会继续演进:多轮对话需要利用历史改写查询,结构化条件需要从自然语言中抽出,单一检索器往往又不够稳。于是,检索层会进一步扩展为三类策略:history-aware retrieval、self-query retrieval、ensemble retrieval。
1 适用场景与问题类型
1.1 上下文省略型问题
基础 RAG 默认假设:用户本轮问题本身就足够完整,可以直接拿去检索。
但多轮对话里,这个前提经常不成立:
- “LangChain 是什么时候开源的?”
- “它的作者后来做了什么?”
第二问里的”它”如果直接送去检索,召回效果通常会很差,因为向量库并不知道”它”指的是 LangChain。
1.2 结构化过滤与混合召回
除了上下文省略,还常见两类问题:
-
用户在自然语言里夹带结构化过滤条件
例如:“找 2020 年之后、评分高于 8 分的科幻电影” -
用户既包含语义意图,也包含精确关键词
例如:“PEP 703 和禁用 GIL 的实现思路是什么?”
这三类问题分别对应不同策略:
| 问题类型 | 更适合的策略 |
|---|---|
| 代词、省略、上下文引用 | create_history_aware_retriever |
| 自然语言中的过滤条件 | SelfQueryRetriever |
| 关键词 + 语义混合需求 | EnsembleRetriever / hybrid search |
核心理解基础 RAG 解决的是”能不能检索”;这篇讲的内容解决的是”在复杂输入下,如何检得更准”。
2 History-aware retrieval
2.1 create_history_aware_retriever
create_history_aware_retriever 可以理解为一个带查询改写能力的 retriever 包装层。
它的核心逻辑很直接:
- 没有
chat_history:当前input直接送给底层 retriever - 有
chat_history:先用 Prompt + LLM 把当前问题改写成独立可理解的问题 - 再把改写后的问题送给 retriever
这和 04_Prompt编排与会话历史 中的 RunnableWithMessageHistory 很像,但关注点不同:
RunnableWithMessageHistory解决整条链如何自动带历史create_history_aware_retriever解决检索前是否要先利用历史改写查询
2.2 查询改写与历史注入
检索器的输入通常是一个字符串查询,不是消息列表。
因此,“把所有聊天历史直接拼成一长串查询文本”通常并不是好方案:
- 噪声太多
- 最后一问的意图被稀释
- Token 变多,但召回不一定提升
更稳的做法是分两步:
- 根据
chat_history把最后一问改写成完整问题 - 用这个完整问题检索
而在最终回答阶段,再把 chat_history 与检索上下文一起送给 LLM。
2.3 完整示例
# pip install langchain langchain-openai langchain-community faiss-cpu
from langchain.chains import create_history_aware_retriever, create_retrieval_chainfrom langchain.chains.combine_documents import create_stuff_documents_chainfrom langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholderfrom langchain_core.messages import HumanMessage, AIMessagefrom langchain_openai import ChatOpenAI, OpenAIEmbeddingsfrom langchain_community.vectorstores import FAISS
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")vectorstore = FAISS.from_texts( [ "LangChain 由 Harrison Chase 于 2022 年 10 月开源。", "Harrison Chase 后来创立了 LangChain 相关公司,并推动 LangSmith、LangGraph 等产品发展。", "LangGraph 是 LangChain 生态中的有状态 Agent 编排框架。", ], embeddings,)retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
contextualize_q_prompt = ChatPromptTemplate.from_messages([ ("system", "给定聊天历史和用户最新问题,如果问题引用了历史上下文," "请将它改写成一个脱离聊天历史也能独立理解的问题。" "只做改写,不要回答问题。"), MessagesPlaceholder(variable_name="chat_history"), ("human", "{input}"),])
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
history_aware_retriever = create_history_aware_retriever( llm, retriever, contextualize_q_prompt)
qa_prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个知识库问答助手。请基于以下参考资料回答用户问题。" "如果资料中没有答案,请明确说明不知道。\n\n" "{context}"), MessagesPlaceholder(variable_name="chat_history"), ("human", "{input}"),])
question_answer_chain = create_stuff_documents_chain(llm, qa_prompt)rag_chain = create_retrieval_chain(history_aware_retriever, question_answer_chain)
chat_history = [ HumanMessage(content="LangChain 是什么时候开源的?"), AIMessage(content="LangChain 由 Harrison Chase 于 2022 年 10 月开源。"),]
result = rag_chain.invoke({ "input": "它的作者后来做了什么?", "chat_history": chat_history,})
print(result["answer"])2.4 ConversationalRetrievalChain 迁移
旧教程里经常会见到 ConversationalRetrievalChain,但当前官方已经明确给出迁移方向:
ConversationalRetrievalChain已废弃- 推荐改用
create_history_aware_retriever + create_retrieval_chain
这样拆开的好处是:
- 查询改写和回答生成职责更清晰
- retriever、Prompt、combine documents 策略都更容易替换
- 更符合 LCEL / Runnable 的组合方式
2.5 检索层与生成层的 chat history
对话式 RAG 里,历史通常会出现在两个位置:
| 位置 | 作用 |
|---|---|
| 检索前 | 把省略问题改写成独立问题 |
| 回答时 | 让最终回答保持对话连续性 |
只做回答层历史注入、不做检索层 query rewrite,是一个非常常见的误区。
3 SelfQueryRetriever
3.1 作用
SelfQueryRetriever 解决的不是多轮代词问题,而是:如何从自然语言里抽出结构化过滤条件。
例如:
“找两部 2010 年之后、评分高于 8 分的科幻电影”
这里同时包含:
- 主题语义:科幻电影
- 结构化过滤:年份 > 2010,评分 > 8,数量 = 2
普通向量检索通常只擅长第一层。SelfQueryRetriever 的思路则是:先用 LLM 生成查询词和 metadata filter,再去调用底层向量库。
3.2 前提条件
要用好 SelfQueryRetriever,一般需要满足:
- 文档 metadata 设计清晰
- metadata 字段有明确类型
- 底层向量库支持 metadata filtering
- 安装
lark
关键前提SelfQueryRetriever 无法凭空创造 metadata。如果你的文档一开始没有存 year、genre、rating 这类字段,后面就没有过滤基础。
3.3 示例
# pip install langchain langchain-openai langchain-chroma lark
from langchain_core.documents import Documentfrom langchain.chains.query_constructor.schema import AttributeInfofrom langchain.retrievers.self_query.base import SelfQueryRetrieverfrom langchain_openai import ChatOpenAI, OpenAIEmbeddingsfrom langchain_chroma import Chroma
docs = [ Document( page_content="一名宇航员穿越虫洞寻找新家园。", metadata={"genre": "science fiction", "year": 2014, "rating": 8.6}, ), Document( page_content="玩具们在主人不在时会活过来。", metadata={"genre": "animated", "year": 1995, "rating": 8.3}, ),]
vectorstore = Chroma.from_documents( docs, embedding=OpenAIEmbeddings(model="text-embedding-3-small"), collection_name="self_query_demo",)
metadata_field_info = [ AttributeInfo(name="genre", description="电影类型", type="string"), AttributeInfo(name="year", description="电影上映年份", type="integer"), AttributeInfo(name="rating", description="电影评分,范围 1-10", type="float"),]
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
retriever = SelfQueryRetriever.from_llm( llm, vectorstore, "电影简介", metadata_field_info, enable_limit=True, verbose=True,)
results = retriever.invoke("找两部 2010 年之后评分高于 8 分的科幻电影")for doc in results: print(doc.page_content, doc.metadata)3.4 适用场景
- 商品、电影、论文、工单等 metadata 丰富的数据集
- 用户经常把筛选条件写在自然语言里的场景
- 不希望继续维护脆弱的手写解析规则
3.5 不适用场景
- metadata 很脏、字段含义不稳定
- 底层向量库不支持过滤
- 用户问题几乎没有结构化条件,纯语义检索就够
4 EnsembleRetriever 与混合检索
4.1 关键词检索与向量检索
向量检索擅长语义相似,但对某些精确关键词并不总是最优:
PEP 703- 错误码
- 类名 / 函数名
- 产品 SKU
这些场景里,关键词检索(如 BM25)往往同样重要。因此实际系统常会采用:
- BM25 负责关键词召回
- Dense Retriever 负责语义召回
EnsembleRetriever负责融合排序
4.2 rank fusion
EnsembleRetriever 的本质不是简单拼接结果,而是做排名融合(rank fusion)。
官方 API 中可配置:
retrieversweightscid_key
其中:
weights用于控制不同检索器的重要性id_key用于文档去重识别
4.3 示例
# pip install langchain langchain-openai langchain-community faiss-cpu rank-bm25
from langchain.retrievers import EnsembleRetrieverfrom langchain_community.retrievers import BM25Retrieverfrom langchain_community.vectorstores import FAISSfrom langchain_openai import OpenAIEmbeddings
texts = [ "PEP 703 提出了让 CPython 支持可选的无 GIL 模式。", "Python 3.12 改进了错误提示信息。", "LangChain 使用 Runnable 作为统一抽象。", "GIL 是 Python 多线程性能讨论中的核心概念。",]
bm25_retriever = BM25Retriever.from_texts(texts)bm25_retriever.k = 3
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")vectorstore = FAISS.from_texts(texts, embeddings)vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
ensemble_retriever = EnsembleRetriever( retrievers=[bm25_retriever, vector_retriever], weights=[0.4, 0.6],)
docs = ensemble_retriever.invoke("PEP 703 和禁用 GIL 的实现思路是什么?")for doc in docs: print(doc.page_content)4.4 适用场景
| 场景 | 为什么适合混合检索 |
|---|---|
| 技术文档搜索 | 既有概念语义,也有 API 名称、版本号、错误码 |
| 法律 / 规范文档 | 条款号重要,语义近邻也重要 |
| 企业知识库 | 术语别名和自然语言描述并存 |
| 商品检索 | SKU / 品牌词 + 语义需求同时存在 |
4.5 与 SelfQueryRetriever 的关系
两者解决的问题并不相同:
SelfQueryRetriever:从自然语言中抽出过滤条件EnsembleRetriever:把多个召回来源融合起来
因此在复杂系统里,它们可以组合:
- 先用
SelfQueryRetriever做 metadata-aware dense retrieval - 再和 BM25 / 全文搜索一起做
EnsembleRetriever - 最后接
Reranker
5 高级RAG流水线
更成熟的 RAG 系统一般会分成几层:
- 文档层:Load / Split / Embed / Store
- 召回层:Vector / BM25 / SelfQuery / ParentDocument
- 融合层:Ensemble / MultiQuery / MMR
- 精排层:Reranker / Compression
- 生成层:Prompt + LLM
- 会话层:History-aware rewrite + history-aware answer
6 常见错误与排查
- 只把
chat_history用在回答阶段,不用在检索阶段 - 使用
SelfQueryRetriever,但没有明确 metadata schema - 混合检索没有做去重,导致上下文重复
- 高级检索能力堆得过多,延迟失控
7 总结
7.1 三类策略各自解决什么问题
| 策略 | 解决的问题 | 核心收益 |
|---|---|---|
create_history_aware_retriever | 多轮对话中的代词、省略、上下文引用 | 让检索问题更完整 |
SelfQueryRetriever | 自然语言中的结构化筛选条件 | 让检索更可控 |
EnsembleRetriever | 单一路径召回不稳 | 让召回更全面 |
7.2 一句话总结
一句话总结对话式 RAG 解决”用户没把问题说全”,SelfQueryRetriever 解决”用户把筛选条件说在自然语言里”,EnsembleRetriever 解决”单一检索器不够稳”。三者合起来,才更接近真实生产环境里的检索层设计。
相关笔记
- 前置:01_文档索引构建
- 前置:02_检索链与RAG实战
- 相关:04_Prompt编排与会话历史
- 相关:05_Runnable绑定、配置与监听