高级检索器
🧩 什么是多步检索 (MultiStepQueryRetriever)?它和 MultiQueryRetriever 有什么区别?¶
MultiStepQueryRetriever 是一种能够执行多轮、迭代式检索的高级检索器。它不仅仅一次性搜索文档,而是先根据初始查询检索一批文档,然后分析这些文档的内容,从中提取新的关键词或子问题,再构造新的查询进行下一轮检索。这个过程可以重复多次,逐步逼近用户真正需要的信息,特别适合需要综合多个信息源、或者初始查询信息不足的复杂推理场景。
MultiQueryRetriever 则是在单轮内生成多个语义相似但表述不同的查询,并行执行检索,最后合并结果。它的目的是通过查询多样性提升召回率,但仍然只做“一次检索”。
核心区别:MultiStepQueryRetriever 像侦探调查,从一条线索出发,不断搜集新线索直到破案;MultiQueryRetriever 则是向多个方向同时撒网,一网打尽。
🔁 描述 MultiStepQueryRetriever 的工作流程:如何根据前一步检索结果调整下一步查询?¶
其工作流程是一个循环迭代过程,直到达到停止条件。
详细步骤:
-
输入初始查询:用户提出一个问题或任务。
-
第一轮检索:使用初始查询,调用基础检索器(如向量库)获取第一批文档。
-
上下文分析:将当前轮检索到的文档内容与对话历史(如果有)一起,交给 LLM,并要求 LLM 执行两个动作:
- 判断是否需要继续:如果已获取的信息足够回答问题,则输出“Final Answer”;否则,输出一个新的检索查询。
-
生成下一步查询:LLM 需要基于已知信息缺失的部分,提出一个更聚焦、更深入的查询。比如,用户问“作者的生平”,第一轮检索到了作者名字和主要作品,LLM 可能生成下一步查询:“XX作者 出生日期 教育背景”。
-
执行新查询:用新生成的查询再次检索,获取第二批文档。
-
累积文档:将新检索到的文档追加到已有的文档集合中,形成不断增长的上下文。
-
循环步骤3-5,直到达到最大步数,或者 LLM 判断已获得足够信息并输出“Final Answer”。
-
返回最终答案:将累积的所有文档作为上下文,由 LLM 生成最终的回答。
如何调整查询:LLM 通过阅读已检索到的文档,能发现还缺少哪些信息。比如一个文档提到了“此项研究基于史密斯等人的工作”,LLM 就会生成查询“史密斯等人 相关研究”,以挖掘深层引用。这种“阅读→识别缺口→生成针对性查询”的循环正是多步检索的核心智能。
LangChain 实现:通常基于 MultiStepQueryRetriever 类,结合一个 LLM 和一个 BaseRetriever。它内部使用一个特殊的 Prompt,指导 LLM 生成结构化输出。
💡 在解决复杂推理问题时,多步检索能比单步检索带来多大提升?请举例。¶
多步检索在处理需要跨文档链接、多跳推理、信息补全的任务时,相比单步检索有质的飞跃。
提升幅度:在如 HotpotQA(多跳问答数据集)等基准上,多步检索相对于单步检索通常能提升 10-30% 的 F1 值。关键原因是,单步检索假设所有必要信息都包含在与问题语义最相似的文档里,但多跳问题的答案往往需要融合来自多个不同文档的线索。
举例:假设问题:“詹姆斯·卡梅隆执导的《阿凡达》的制片人是谁?”
-
单步检索:查询“詹姆斯·卡梅隆 阿凡达 制片人”,向量库可能返回《阿凡达》的剧情介绍,里面提到了导演和主演,但可能没有直接列出制片人。或者返回一堆包含卡梅隆其他电影的文档,信噪比低。模型可能回答错误或遗漏。
-
多步检索过程:
- 初始查询:“詹姆斯·卡梅隆 阿凡达 制片人”。
- 第一轮检索结果:得到《阿凡达》电影页面,内容提到“制片人:乔恩·兰多”。
- LLM 分析:发现直接给出了制片人,可能立即返回“乔恩·兰多”。但如果第一轮结果只提到“由卡梅隆和兰多共同制作”,未明确谁是制片人,LLM 会生成下一步查询:“乔恩·兰多 电影制片”。
- 第二轮检索:获得乔恩·兰多的信息,确认他是制片人。最终答案得以巩固。
在更复杂的例子中,比如“赢得2023年奥斯卡最佳影片的电影的导演还导演过哪些电影?”,单步检索很难一次性拿到所有信息。多步检索会先找到最佳影片《瞬息全宇宙》,再提取导演“关家永与丹尼尔·施纳特”,最后检索这两位导演的作品集。这种链式推理能力是单步检索无法替代的。
⛔ 如何避免多步检索进入死循环?设置最大步数和停止条件。¶
多步检索的最大风险是无限循环或检索到重复信息。防御措施如下:
- 设置最大迭代步数(max_iterations)
最直接的控制:限制循环最多执行 N 次(例如 3-5 次)。超过后,无论是否找到答案,都强制停止,将当前累积文档作为最终上下文返回。这是在 LangChain 中配置 MultiStepQueryRetriever 时的必选参数。
- 明确的停止条件(Stop Condition)
在每次 LLM 决策时,要求它输出一个特殊标记(如“Final Answer:”或“STOP”)。当 LLM 认为现有信息已足够回答,就输出这个标记,循环自动终止。Prompt 设计至关重要。
- 检测信息增益(Information Gain)
计算新检索到的文档与已有文档的重叠率。如果新文档中超过 80% 的内容与已存在文档语义重复,说明检索已陷入冗余,可以提前终止。可以通过计算 TF-IDF 余弦相似度或简单的 Jaccard 指数来实现。
- 查询多样性检查
记录历史查询,避免 LLM 生成与之前高度相似的查询。如果新查询与某个历史查询的语义相似度(用 embedding 比较)高于 0.9,则要求 LLM 重新生成或直接终止。
- 用户问题充分性评估
用一个独立的轻量级模型(或规则)评估当前累积文档是否包含了回答问题的必要实体。如果所有必要实体都出现了,可以终止。
实际代码示例(概念性):
max_steps = 3
for step in range(max_steps):
docs = retriever.get_relevant_documents(query)
context = merge_docs(context, docs)
decision, new_query = llm.decide_next_step(question, context)
if "Final Answer" in decision:
break
query = new_query
📚 解释“父子文档检索” (Parent Document Retriever) 的设计:为什么要检索小块,返回大块?¶
核心矛盾:RAG 检索时,如果直接对完整的原始文档(如一个 PDF 章节)进行切分和嵌入,可能导致检索精度下降,因为长文档的嵌入向量通常更“泛化”,难以精准匹配短查询。但另一方面,回答问题时往往需要该文档的完整上下文,而不仅仅是某个被切出来的碎片。
Parent Document Retriever 的设计:
-
检索小块(Child):将原始文档切分成较小的片段(例如每段 200 tokens),对这些小块生成嵌入向量并存入向量库。检索时,用查询找到最相关的小块。由于小块内容聚焦,嵌入向量能更精确地表征语义,从而提高检索的命中率和精确率。
-
返回大块(Parent):检索到小块后,不直接把这些小块交给 LLM,而是通过预先建立的映射关系,找到每个小块所属的“父文档”(即原始较大的文档或段落,例如整个章节或整个 PDF),然后将这些父文档(完整内容)作为上下文传递给 LLM。这样,LLM 获得的是完整、连贯的信息,而非碎片化文本。
形象比喻:就像图书馆里,你通过目录(索引卡片)找到书的某一页(小块),但借阅时你拿到的是整本书(父文档)。卡片很小、检索精准,但读书需要完整的上下文。
优势:
-
保持检索精度:小块嵌入更准确。
-
保证回答质量:LLM 看到完整上下文,减少断章取义。
-
减少 token 浪费:不用把所有小块都喂给 LLM,而是用一个大块代替多个小块。
🛠️ 在 LangChain 中如何实现 Parent Document Retriever?需要用到哪些存储后端?¶
实现 Parent Document Retriever 需要两个存储:
-
向量存储(Vector Store):用于存储小块的嵌入向量,执行检索。
-
文档存储(Document Store):用于存储父文档,并能通过小块 ID 快速查找父文档。
LangChain 提供了 ParentDocumentRetriever 类,封装了这个流程。
实现步骤:
- 准备文档并切分:使用两种切分器:
- 父切分器(比如
RecursiveCharacterTextSplitter,分块大小 1000)。 -
子切分器(较小的分块大小,比如 300)。
-
初始化存储后端:
- 向量存储:例如
Chroma,用于索引子块。 -
文档存储:使用
InMemoryStore(用于开发/小数据)或RedisStore、ElasticsearchStore等持久化存储,用于存储父文档。 -
创建 ParentDocumentRetriever:传入向量存储、文档存储、父切分器、子切分器等。
-
添加文档:调用
retriever.add_documents(docs),内部会自动将每个文档切分为父块和子块,将子块存入向量库,将父块存入文档库,并自动建立映射。 -
检索:直接调用
retriever.get_relevant_documents(query)。它会先通过向量库检索相关子块,然后根据子块 ID 从文档存储中取出对应的父文档并返回。
代码示例:
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings
# 父切分器与子切分器
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)
# 向量存储
vectorstore = Chroma(embedding_function=OpenAIEmbeddings())
# 文档存储
store = InMemoryStore()
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
parent_splitter=parent_splitter,
child_splitter=child_splitter,
)
retriever.add_documents(your_documents)
# 检索
docs = retriever.get_relevant_documents("your query")
💾 父子文档检索中,小块和大块的映射关系如何存储?一般用什么数据库?¶
映射关系本质上是一个键值对:键为小块(子文档)的唯一标识符,值为对应父文档的 ID 或直接存储父文档对象。LangChain 使用 docstore 抽象来管理这种映射。
存储方式:
-
在
add_documents时,每个父文档被切割为多个子文档。每个子文档生成一个唯一 ID(UUID),并在其metadata中记录父文档 ID。 -
子文档(小块)被存入向量库,同时向量库返回的检索结果中包含了这些子文档对象,每个子文档对象可以通过其 ID 关联到父文档。
-
父文档本身则存储在
docstore中,以父文档 ID 为键。
检索时的映射查找:
-
向量检索返回前 k 个子文档。
-
对于每个子文档,从
docstore中查找其父文档 ID 对应的父文档(或直接通过子文档的metadata["parent_id"])。 -
去重后,返回父文档列表。
常用数据库:
-
InMemoryStore:开发和小规模场景,数据存储在内存中,重启后丢失。
-
RedisStore:高性能、支持持久化,适合生产环境。
-
ElasticsearchStore:如果已经用 Elasticsearch 作为向量库,可以直接利用其存储父文档,无需额外数据库。
-
SQLite/PostgreSQL:通过自定义
BaseStore接口实现,但需要额外开发。
选择建议:如果数据量不大且追求简单,用 InMemoryStore;生产环境推荐 Redis,因为其读写延迟极低,适合高频检索场景。
🔄 如果更新了原始文档,Parent Document Retriever 的映射如何同步更新?¶
更新文档时,最棘手的问题是如何保持父子映射的一致性。目前 LangChain 的 ParentDocumentRetriever 没有内建自动同步机制,需要自行设计更新策略。
策略一:全量重建(最稳妥)
-
当文档库发生任何增、删、改时,清空向量库和 docstore,重新执行
add_documents(all_documents)进行全量索引。 -
优点:逻辑简单,确保完全一致。
-
缺点:数据量大时耗时,索引期间可能影响检索服务。
策略二:增量更新(需定制)
-
新增文档:直接调用
add_documents([new_doc]),会自动生成父子块并添加映射。 -
删除文档:需要先根据文档 ID 找到对应的所有子块 ID,从向量库中删除这些子块的向量,并从 docstore 中删除父文档。可以通过在添加文档时,在子块和父块的 metadata 中记录原始文档 ID(如文件名),删除时按此 ID 过滤删除。
-
修改文档:结合删除旧文档和新增文档两步操作。
-
实现要点:在添加文档时,给父文档和子文档的
metadata注入一个外部唯一标识(如文件路径、数据库 ID),以便后续精准删除。
策略三:软删除与时间戳
-
为每个文档增加时间戳或版本号。
-
检索时,只考虑最新版本的文档,旧版本标记为不可见。这样无需物理删除,但会占用存储空间。
注意:无论采用哪种策略,向量库和 docstore 的操作应在同一个事务中,或者先更新存储再更新索引,以防止中间态导致检索错误。如果使用外部数据库(如 Redis),可以利用其原子性操作。
🤖 假设文档嵌入(HyDE)的工作原理是什么?为什么先生成假答案能提升检索效果?¶
HyDE(Hypothetical Document Embeddings) 是一种巧妙的零样本检索增强技术,特别适用于用户查询与文档之间的语言风格存在差异的场景。
工作原理:
-
用户提出查询问题。
-
使用一个 LLM(如 GPT-3.5)根据该问题,生成一个假设的答案文档(一个虚构的、看起来像是从知识库中能找到的段落)。这个假答案可能包含事实错误,但重要的是它模拟了目标文档的语言风格、用词和结构。
-
将这个生成的假答案(而不是原始查询)输入到 Embedding 模型,获得一个向量表示。
-
用这个向量去检索知识库,找到与之最相似的文档。
为什么能提升检索效果?
-
弥合语义鸿沟:用户查询往往简洁、口语化,而知识库中的文档通常更正式、包含更多专业术语。例如,查询“怎么修漏水的水龙头?”与文档中“水龙头漏水常见原因及维修步骤”在词汇上重叠度低,但语义相关。假答案“要修理漏水的水龙头,你可以先关闭水源,然后...”使用了与文档更接近的措辞和结构,从而能检索到更相关的文档。
-
增强查询表达力:短查询的信息量少,生成的假答案相当于自动进行了查询扩展,丰富了语义特征。
-
利用 LLM 的世界知识:LLM 生成的假答案基于其预训练知识,能提供与目标文档相似的上下文背景,即使它不完全准确。
一句话总结:HyDE 用 LLM 的“想象力”将查询转换为一个更贴近文档库风格的样本,以此作为检索的桥梁。
⛓️ 在 LangChain 中,如何实现 HyDE 检索?写出链的关键步骤。¶
LangChain 提供了 HypotheticalDocumentEmbedder 和 HyDE 相关的集成,但也可以通过组合链轻松实现。
关键步骤:
-
准备一个 LLM:用于生成假答案。通常选择一个快速且有一定生成能力的模型,如 GPT-3.5-turbo。
-
编写生成假答案的 Prompt:要求 LLM 根据用户问题,写一段像是从百科全书或技术文档中摘录的段落。
-
创建 LLMChain:使用上述 Prompt 创建一个
LLMChain。 -
将其封装为 Embeddings:使用
HypotheticalDocumentEmbedder(LangChain 社区版提供),将 LLMChain 和一个标准的 Embeddings 模型(如 OpenAIEmbeddings)结合起来。HypotheticalDocumentEmbedder会先调用 LLMChain 生成假答案,然后将假答案传递给嵌入模型生成向量。 -
构建 Retriever:将这个 HyDE-Embeddings 传递给 VectorStore,并用它来编码所有文档。检索时,用户的查询也会先经过假答案生成,再被编码为向量去检索。
代码示例:
from langchain.chains import LLMChain
from langchain.embeddings import OpenAIEmbeddings
from langchain.embeddings.hypothetical_document import HypotheticalDocumentEmbedder
from langchain.vectorstores import Chroma
# 1. 生成假答案的链
hyde_prompt = PromptTemplate.from_template(
"请根据以下问题,写一段可能出现在知识库中的文档段落,模仿其风格。\n问题:{question}\n段落:"
)
llm_chain = LLMChain(llm=ChatOpenAI(model="gpt-3.5-turbo", temperature=0), prompt=hyde_prompt)
# 2. 包装为 HyDE Embeddings
base_embeddings = OpenAIEmbeddings()
hyde_embeddings = HypotheticalDocumentEmbedder(llm_chain=llm_chain, base_embeddings=base_embeddings)
# 3. 创建 VectorStore(注意:所有文档需用 hyde_embeddings 编码)
vectorstore = Chroma(embedding_function=hyde_embeddings, ...)
# 4. 检索时,用户查询自动通过 hyde_embeddings 先扩写假答案再编码
retriever = vectorstore.as_retriever()
docs = retriever.get_relevant_documents("你的问题")
注意:由于每次检索都要调用一次 LLM 生成假答案,这会增加几秒钟的延迟。可以通过缓存常用查询的假答案来缓解。
🎯 HyDE 在哪些场景下效果尤其明显?在哪些场景下可能无用甚至误导?¶
效果显著的场景
-
查询短、模糊、口语化:例如,电商搜索“跑鞋轻便”,HyDE 能生成“这款跑鞋采用超轻EVA中底,提供卓越的缓震和回弹,非常适合长距离训练”,从而匹配商品详情页的描述。
-
跨领域检索:用户使用非专业词汇提问,但文档用专业术语。如“怎么让电脑更快”,HyDE 生成“系统性能优化包括清理磁盘、增加内存等”,与文档“提升计算机运行速度的方法”匹配。
-
问答式检索:用户以问句形式提问,而知识库是陈述句。HyDE 将问句转为陈述,提高召回。
-
多语言场景:即使用不同语言,HyDE 生成的假答案可以用目标语言生成,从而弥合语言差异。
可能无用或误导的场景
-
精确匹配要求高:如搜索特定法律条文编号、产品型号、代码函数名。HyDE 生成的“假答案”可能用词不精确,导致检索到无关文档。例如,查询“RFC 6749”,HyDE 可能生成描述 OAuth 的文字,但无法准确匹配“RFC 6749”这个字符串。
-
查询本身已经足够详细和准确:用户输入了非常完整的长文本,再生成假答案既浪费时间又可能偏离原意。
-
LLM 生成答案过于普遍或错误:如果 LLM 倾向于生成泛泛之谈,假答案可能缺乏领域特异性,反而降低检索精度。例如,在医学领域,LLM 可能生成不准确的医学描述,导致检索到错误文献。
-
对延迟极度敏感的应用:每次检索都需要调用一次 LLM,增加了 1-3 秒的延迟,不适合需要毫秒级响应的实时服务。
-
成本考虑:大量调用 LLM 产生 token 消耗,对于高频检索系统,成本可能难以承受。
最佳实践:结合查询类型分类器,对模糊、简短的查询启用 HyDE,对精确查询直接检索,以达到效果与成本的平衡。
🧠 什么是自适应 RAG (Adaptive RAG)?如何根据问题类型动态选择检索策略?¶
自适应 RAG(Adaptive RAG)是一种可以根据输入问题的特性和上下文,动态地选择或组合不同检索策略的智能检索增强生成框架。它打破了“一刀切”的检索模式,不再对所有问题使用同一种检索流水线(如总是向量检索→重排序→生成),而是让系统像一个有经验的图书管理员一样,能快速判断用户的意图,然后走最合适的“通道”。
为什么需要自适应?
不同类型的问题,最优的检索策略截然不同:
-
事实型问题(如“2023年诺贝尔物理学奖得主是谁?”):需要精确匹配,关键词检索(BM25)或结构化查询更高效。
-
概念型问题(如“什么是量子纠缠?”):更适合语义检索,向量相似度能很好地捕捉概念。
-
多跳推理问题(如“《哈利波特》中哈利的教父的堂姐的女儿是谁?”):需要多步检索,逐步挖掘。
-
时效性问题(如“最新的苹果发布会有什么产品?”):必须在检索时融入时间过滤。
-
模糊或简短问题(如“那个东西怎么修?”):可能需要查询扩展或 HyDE 来增强语义。 如果对所有问题都用同一种策略,必然在某些场景下表现不佳。自适应 RAG 的目标就是 “根据问题,选择策略”。
如何根据问题类型动态选择?
这通常需要一个路由模块(Router),它负责分析问题,决定走哪条检索路径。路由模块可以是一个经过训练的轻量级分类器,也可以直接由 LLM 完成(通过特定的 Prompt)。
常见的问题分类维度和对应策略:
实现自适应 RAG 的架构:
-
问题分析器:接受用户查询,输出问题的类别和特征(如是否包含数字、是否时效敏感、是否属于定义型等)。
-
策略选择器:根据分析结果,动态调整检索管道的配置。例如:
- 选择检索器类型:向量检索、BM25、混合检索。
- 设置检索参数:k 值、相似度阈值、过滤条件。
- 决定是否启用查询改写、HyDE、多步检索等增强模块。
-
决定是否调用重排序模型,以及重排序的强度。
-
执行检索:使用选定的策略和参数进行检索。
-
生成回答:基于检索结果生成最终答案。
用一个比喻:自适应 RAG 就像一家餐厅的厨师,看到客人点餐后,先判断客人想要的是中餐、西餐还是甜点,然后去不同的备料区(检索器)取食材,再用不同的烹饪方法(检索策略)制作。而不是无论点什么菜都先跑进同一个大仓库翻一通。
LangChain 中的实现思路:虽然 LangChain 没有叫“AdaptiveRAG”的单一类,但我们可以借助 RouterChain、MultiPromptChain 或自定义 Agent 来实现。例如,创建多个针对不同问题类型的专用链(一个用于事实型,一个用于概念型等),然后用 RouterChain 根据 LLM 对问题的判断自动路由到相应的链。
🧱 LangChain 是否有现成的 Adaptive RAG 组件?如果没有,你如何自己实现?¶
LangChain 目前没有一个名为“AdaptiveRAG”的现成组件。但是,它提供了一系列组合工具,让我们可以轻松搭建出自适应检索系统。核心思路是利用 Router 模式或 Agent 模式。
实现方案一:使用 RouterChain(路由链)
LangChain 的 RouterChain 本身可以根据输入动态选择下一个链。我们可以创建多个预配置的检索链(每个对应一种问题类型),然后用 RouterChain 根据问题内容自动路由。
具体步骤:
- 定义多个专用链:
factual_chain:使用高权重的 BM25 + 向量混合检索,甚至直接调用SelfQueryRetriever进行结构化过滤。conceptual_chain:使用 HyDE + 向量检索,重视语义覆盖。multihop_chain:使用MultiStepQueryRetriever。-
等等。
-
创建路由逻辑:编写一个 RouterPrompt,让 LLM 输出要选择哪个链。例如:
- 使用
RouterChain组合这些链。当用户提问时,路由器解析出类别,自动调用对应的专用链。
实现方案二:自定义 AdaptiveRetriever(更灵活)
直接继承 BaseRetriever,在其内部实现动态选择逻辑。这样对外暴露仍是一个统一的 Retriever 接口。
class AdaptiveRetriever(BaseRetriever):
def __init__(self, vector_retriever, bm25_retriever, llm):
self.vector_retriever = vector_retriever
self.bm25_retriever = bm25_retriever
self.llm = llm
def _analyze_query(self, query):
# 用 LLM 分析问题类型和特征(或使用简单规则)
# 返回特征字典,如 {"has_number": True, "is_concept": False}
prompt = PromptTemplate.from_template("分析以下查询的特征:{query}")
return llm.invoke(prompt.format(query=query))
def _get_relevant_documents(self, query):
features = self._analyze_query(query)
# 根据特征动态调整参数
if features.get("is_factual"):
# 事实型:提高 BM25 权重,降低 k 值
ensemble = EnsembleRetriever(
retrievers=[self.bm25_retriever, self.vector_retriever],
weights=[0.7, 0.3]
)
return ensemble.get_relevant_documents(query)[:5]
elif features.get("needs_multihop"):
# 多跳:使用多步检索器
return MultiStepQueryRetriever(...).get_relevant_documents(query)
else:
# 默认:混合检索加 HyDE
hyde_retriever = ... # 构造 HyDE 检索器
return hyde_retriever.get_relevant_documents(query)
方案三:Agent 驱动(最强大)
创建一个 Agent,赋予它多个检索工具(向量工具、关键词工具、多步检索工具等),让 Agent 自己根据问题决定调用哪个工具、以什么参数调用。这是目前最灵活的自适应方案,完全由 LLM 做决策。
关键点:无论哪种实现,都需要对问题特征有准确的判断。可以直接使用 LLM 进行零样本分类,或者训练一个极小的 BERT 分类器来加快速度、降低成本。
🧠 自我反思 RAG (Self-RAG) 是如何让 LLM 对检索到的文档进行自我评价和过滤的?¶
Self-RAG 是一种让 LLM 在生成答案的过程中,主动评价和筛选检索到的文档的技术。它不仅仅是被动接收检索结果,而是像一个严谨的研究员,对每一篇找到的文献都问一句:“这篇文献对我的问题有用吗?它支持还是反驳我的观点?”
Self-RAG 的典型工作流程:
-
检索:根据用户查询,获取一批候选文档。
-
文档评价:LLM 被要求对每个检索到的文档(或文档片段)进行打分或分类。它通常输出两个信号:
- 相关性:这篇文档是否与问题相关?(例如,输出“相关”或“无关”)
-
支持度:如果相关,这篇文档是支持、反对还是中立于用户问题的潜在答案?(如果问题本身有立场)
-
过滤:基于 LLM 的评价,丢弃被标记为“无关”的文档,只保留“相关”的。
-
生成答案:使用被过滤后的文档作为上下文,生成最终答案。同时,LLM 可以在答案中引用这些文档,表明其来源的可信度。
-
自我反思(可选):在生成答案后,模型还可能进行自我检查,例如“我生成的答案是否与提供的文档一致?”如果不一致,则进行修正。
Self-RAG 如何实现?
通常通过特殊训练的 LLM 或巧妙的 Prompt 工程来实现。Meta 等机构发布了专门的 Self-RAG 模型,这些模型在训练时就被教会了输出特殊的反思标记(reflection tokens),如 [Relevant]、[Irrelevant]、[Fully supported]、[Partially supported] 等。在推理时,模型在生成答案的同时输出这些标记,从而实现自我评价。
如果使用通用 LLM(如 GPT-4),也可以通过精心设计的 Prompt 来模拟 Self-RAG 的行为。例如:
你是一个批判性思考者。根据以下问题,判断每个文档片段的“相关性”(相关/无关)和“支持度”(支持/中立/反对)。然后,基于相关文档,给出最终答案。
问题:{question}
文档片段:
{文档1}...
{文档2}...
请输出对每个文档的评价,然后给出答案。
优势:
-
减少无关文档的噪音,提高答案的事实一致性。
-
增加透明度和可信度,用户可以知道答案基于哪些可靠来源。
-
能够处理信息冲突:如果文档之间存在矛盾,Self-RAG 模型可以识别并采取更谨慎的回答策略。
局限性:
-
增加了推理步骤,因此延迟和成本显著增加。
-
评价质量高度依赖 LLM 的能力,如果 LLM 本身判断力有限,可能会误删相关文档。
⚖️ Self-RAG 和 ContextualCompressionRetriever 在思路上有何异同?¶
两者目标一致:从检索到的文档中提取出真正对生成答案有用的部分,滤除噪音。但在实现路径、粒度、智能程度上存在差异。
相同点:
-
都是后检索阶段的文档过滤/压缩技术。
-
都试图解决“检索结果虽然相似但不一定相关”的问题。
-
都可以作为检索管道中的一个中间组件。
不同点:
类比:
-
ContextualCompressionRetriever像一个图书管理员,你在借书前,他帮你把书中与你的问题无关的章节撕掉,只给你有用的几页。他独立于你完成这项工作。 -
Self-RAG则像你自己在读书时,一边读一边在空白处做笔记,标注“这段话有用”、“那段是废话”,最后只根据标注过的内容写读书报告。整个评价过程与你的思考融为一体。
结合使用:在实际系统中,两者可以互补。先用 ContextualCompressionRetriever 快速粗筛,去除大段无关内容;然后在生成答案时,再用 Self-RAG 的 Prompt 让 LLM 对剩下的内容进行精细评价和立场判断。这样既能利用压缩器降低 token 消耗,又能发挥 Self-RAG 的深度理解能力。
🕵️ 你如何在 Agent 中集成高级检索器,让 Agent 自主决定何时检索、检索什么?¶
Agent 是赋予 LLM 工具使用能力的最佳载体。将高级检索器封装为 Agent 的工具,LLM 就可以像使用搜索引擎一样,自主决定“现在我需要查资料了”,然后调用检索工具,并基于检索结果继续思考和回答。
集成步骤:
- 封装检索器为 Tool
使用 LangChain 的
Tool接口,将你的高级检索器包装成一个具有名称和描述的工具。描述要足够清晰,让 LLM 知道何时该用。
from langchain.tools import Tool
advanced_retriever = ... # 你的混合检索器、Self-RAG、或其他自定义检索器
def retrieve_info(query: str) -> str:
docs = advanced_retriever.get_relevant_documents(query)
return "\n\n".join([f"文档{i+1}: {doc.page_content}" for i, doc in enumerate(docs)])
retrieval_tool = Tool(
name="知识库检索",
func=retrieve_info,
description="当你需要查找具体的、事实性的信息,或者需要引用专业知识来回答用户问题时,使用此工具。输入应该是一个明确的搜索查询。"
)
- 创建 Agent
使用
create_openai_functions_agent或create_react_agent,将上述工具与其他可能需要的工具(如计算器、天气API)一起提供给 LLM。
from langchain.agents import create_openai_functions_agent, AgentExecutor
from langchain import hub
from langchain.chat_models import ChatOpenAI
llm = ChatOpenAI(model="gpt-4", temperature=0)
prompt = hub.pull("hwchase17/openai-functions-agent")
agent = create_openai_functions_agent(llm, [retrieval_tool], prompt)
agent_executor = AgentExecutor(agent=agent, tools=[retrieval_tool], verbose=True)
- Agent 的自主决策过程
当用户提问后,LLM 分析问题,如果它判断需要外部知识,就会生成一个函数调用消息,LangChain 框架拦截并执行你的
retrieve_info函数,将返回的文档注入到对话上下文中。然后 LLM 继续基于这些文档生成最终答案。整个过程对用户透明,看起来就像模型自己“思考”后去查了资料。
高级技巧:
-
多工具组合:可以提供多个不同用途的检索器。例如,一个用于精确匹配的 “CodeSearch” 工具,一个用于概念理解的 “WikiSearch” 工具。Agent 可以自己决定用哪个。
-
动态参数:工具函数可以接收更复杂的参数,例如
retrieve(query, time_range, doc_type)。Agent 可以通过函数调用来指定这些参数。 -
限制调用次数:通过 AgentExecutor 的
max_iterations参数防止无限检索,或者在 Prompt 中要求 LLM 最多检索两次。 -
上下文压缩:在工具函数内部,可以先调用粗排再调用重排序,甚至压缩,确保返回给 LLM 的上下文是最相关的,节约 token。
优势:
-
解耦:检索逻辑完全独立于 Agent 的推理逻辑,修改检索器无需改动 Agent。
-
灵活性:LLM 可以完全自主决策,应对各种未知问题。
-
可扩展:可以随时添加新的检索工具。
🚀 讨论一下检索增强生成 (RAG) 中的延迟问题,以及你在 LangChain 中的优化经验。¶
RAG 系统的延迟是影响用户体验的关键瓶颈,它由多个环节叠加而成:检索延迟 + 重排序延迟 + 压缩延迟 + LLM 生成延迟。一个未经优化的 RAG 流程可能耗时 5-10 秒,令人难以忍受。
延迟来源分析:
-
检索延迟:取决于向量数据库的性能和嵌入模型的推理速度。
-
重排序延迟:交叉编码器(如 bge-reranker)通常需要 GPU 推理,每个文档对耗时约 10-30ms,50 个文档就是 500ms-1.5s。
-
压缩延迟:如果使用 LLM 提取关键句,每次调用额外增加 1-3 秒。
-
生成延迟:LLM 生成回答的时间,与上下文长度和生成 token 数正相关。
优化经验(由易到难):
-
结果缓存——立竿见影
-
检索缓存:对于重复查询,缓存检索结果,避免重复向量搜索和重排序。使用 Redis 或内存 LRU 缓存。
-
LLM 响应缓存:对于完全相同的 Prompt 和文档组合,直接返回缓存的答案。
-
嵌入缓存:对于不变的文档库,嵌入向量只需计算一次,持久化存储。
-
选择更快的模型和基础设施
-
嵌入模型:使用轻量级嵌入模型(如
all-MiniLM-L6-v2代替text-embedding-ada-002),甚至将嵌入模型部署在本地 GPU 上加速。 -
向量数据库:使用高性能向量数据库,如 Milvus、Weaviate,并确保索引类型适合你的数据规模(如 IVF_PQ 或 HNSW)。
-
重排序:如果精度要求高,尽量用 GPU 加速重排序模型;如果延迟更敏感,可尝试更小的交叉编码器(如
ms-marco-MiniLM-L-6-v2)或直接用双塔模型的重排序(利用原始相似度分数),甚至跳过重排序。 -
管道优化
-
异步并行:将粗排的向量检索和关键词检索并行执行(
asyncio.gather),而不是串行。 -
早停策略:在重排序或压缩阶段,如果前几个文档的相关性评分已非常高,可以提前终止,不处理后续文档。
-
流式处理:让 LLM 以流式方式生成答案,用户能立即看到第一个 token,降低感知延迟。
-
减少不必要的步骤
-
动态跳过重排序:如果粗排结果中第一名的相似度已经极高(比如 >0.95),可能不需要重排序,直接进入生成。
-
省略压缩:如果检索到的文档本身很短(< 200 tokens),不需要再用 LLM 压缩,直接拼接。
-
使用更短的 Prompt:每次调用 LLM 都消耗输入 token,精简 Prompt 能同时降低延迟和成本。
-
预计算和离线处理
-
离线生成 HyDE 假答案:对于热门查询,可以离线批量生成假答案并缓存其嵌入。
-
预计算文档摘要:在入库时,为每个长文档生成摘要,检索时先匹配摘要,若相关再取全文。这样避免了在线压缩。
实际案例:在一个客服问答系统中,原始 RAG 延迟为 4.2 秒。我们首先对重复查询加 Redis 缓存,命中率约 30%,延迟降到 2.8 秒(命中时)。接着,将重排序模型从 bge-reranker-large 替换为 bge-reranker-base 并 GPU 推理,延迟降至 1.9 秒。最后,优化 LLM 调用,采用流式输出和更简洁的 Prompt,最终达到平均 1.2 秒的端到端延迟。
📚 在检索时,如何处理同义词、缩写等词不匹配问题?LangChain 有无辅助组件?¶
词不匹配是信息检索的经典难题。当用户查询“AI”时,文档中写的是“人工智能”,向量检索对此有一定容忍度,但关键词检索(BM25)则会完全漏掉。
解决方案:
- 语义检索天然缓解
向量嵌入模型能捕捉词语的语义,因此“AI”和“人工智能”在向量空间中距离很近。这是解决词不匹配的第一道防线,因此向量检索是必须的。
- 查询扩展
在检索前,对用户查询进行同义词扩展和缩写补全。例如,将“AI”扩展为“人工智能 OR 机器学习”,将“LSTM”扩展为“长短期记忆网络”。这可以显著提升关键词检索的召回。
-
如何实现:可以构建一个同义词词典,或者使用 LLM 对查询进行改写。
MultiQueryRetriever就是利用 LLM 生成包含同义词的多个查询的典型工具。 -
文档端增强——同义词索引
在索引文档时,为文档中的每个词增加其同义词索引项。这样即使用户使用了不同词语,也能命中。这种方法需要构建一个高质量的同义词库,或者使用预训练的词向量来动态生成同义词。
- 使用混合检索
正如前面所述,向量检索和关键词检索各有所长。向量检索能处理同义词,关键词检索能处理精确词汇(如缩写“CNN”)。混合检索能够互补。
-
LangChain 的辅助组件
-
MultiQueryRetriever:直接用于查询扩展,生成多个包含不同表述和同义词的查询。 -
自定义 Retriever + 同义词表:你可以很容易地写一个 Retriever 包装器,在调用底层检索器前,先用一个同义词词典或 LLM 扩展查询。例如:
class SynonymExpandingRetriever(BaseRetriever):
def __init__(self, base_retriever, synonym_dict):
...
def _get_relevant_documents(self, query):
expanded_queries = [query]
for word, synonyms in self.synonym_dict.items():
if word in query:
for syn in synonyms:
expanded_queries.append(query.replace(word, syn))
# 合并检索所有扩展查询的结果
...
-
ContextualCompressionRetriever:并不直接解决词不匹配,但可以在检索后,用 LLM 过滤,间接容忍部分词不匹配带来的噪声。 -
使用实体链接
对于专有名词、缩写,可以通过实体链接(Entity Linking)技术,将文本中的提及链接到知识库中的唯一实体。这样无论使用哪种表述,都能定位到同一实体。
总结:词不匹配问题的根本解决在于语义理解和知识库的规范化。在 LangChain 中,最常用、最有效的组合是:向量检索(兜底语义) + MultiQueryRetriever(扩展查询),并辅以自定义的同义词词典用于关键词检索。
📝 如果让你从零搭建一套检索评测集,你会如何构造问题和标注相关文档?¶
构建一个高质量的检索评测集是迭代优化 RAG 系统的基础。如果没有它,一切调优都如同盲人摸象。以下是完整的步骤:
- 确定评估目标
你需要评测什么?是整体召回率?还是重排序后的精确率?不同的目标决定了标注的粒度。通常我们评测的是给定查询,检索管道能够返回相关文档的能力。
- 收集代表性查询
查询必须来自真实场景或模仿真实场景。不能只凭空想,因为开发者的思维模式可能与最终用户完全不同。
-
来源一:历史日志。从你的应用中收集真实的用户查询(脱敏后)。这是最宝贵的资源。
-
来源二:专家生成。邀请领域专家,根据文档库内容,从不同角度编写常见问题。包括事实型、概念型、多跳型等。
-
来源三:合成生成。用 LLM 阅读文档后自动生成问题和答案。例如,给 GPT-4 一段文档,让它生成 10 个基于该文档的问题。这种方式效率高,但需要人工审核质量。
-
确定查询集规模和多样性
至少需要 200-500 个查询才能得到有统计意义的评估结果。查询集应覆盖:
-
不同长度:短关键词、完整句子。
-
不同类型:精确查找、概念解释、多跳推理。
-
不同领域:如果文档库涉及多个领域,需保证各领域均有覆盖。
-
标注相关文档(最关键、最耗时)
对于每个查询,需要确定文档库中哪些文档是“相关”的。这通常采用池化评估(Pooling) 方法减少工作量:
-
先用多个不同的检索器(向量检索、BM25、混合检索等)分别检索每个查询的前 k 个结果(比如 k=20)。
-
将这些结果合并去重,形成一个“候选池”。
-
人工标注员(或专家)对这个池中的每个文档进行相关性判断:相关(relevant)、部分相关(partially relevant)、不相关(non-relevant)。
-
未被纳入池中的文档默认视为不相关(这是近似假设,但工程上可接受)。
-
构建评测集 评测集包含一组
(查询, 相关文档列表)的条目。可以采用 JSON 格式存储:
[
{
"query": "什么是量子计算?",
"relevant_docs": ["doc_123", "doc_456"],
"partially_relevant_docs": ["doc_789"]
},
...
]
- 评测指标计算
基于上述标注,可以计算:
-
Recall@k:前 k 个检索结果中包含的相关文档数 / 全部相关文档数。
-
Precision@k:前 k 个检索结果中相关文档的比例。
-
MRR:第一个相关文档排名的倒数。
-
NDCG@k:对于分等级的相关性(相关/部分相关),给予不同权重后计算。
-
持续迭代
评测集不是一成不变的。随着文档库更新、用户行为变化,需要定期补充新的查询和重新标注部分文档。好的评测集就像软件的单元测试,能够保障每次策略变更都是正向的。
🔮 预测一下:RAG 的检索部分未来会如何发展?LangChain 应该提供怎样的抽象?¶
RAG 检索的未来趋势
-
从“检索”到“记忆”与“推理” 未来的检索将不仅是查找相似文档,而是与模型的记忆机制深度融合。模型会有一个“工作记忆”(当前上下文)和一个“长期记忆”(外部知识库),检索将成为一种记忆召回的过程,由模型自己管理何时、如何更新和访问记忆。
-
极致的自适应和多模态融合 检索器会像智能体一样,能自动识别用户意图、问题类型、上下文状态,动态编排检索管道。不仅融合文本,还能同时检索图片、表格、代码等多模态信息,并在统一的表示空间中进行重排序。
-
端到端的可微分检索 检索器和生成器不再是独立的两个模块,而是可以进行端到端训练的联合模型。检索过程将变得更加“软性”,不再是硬性地挑选 top-k,而是学习一个连续的“注意力”分布,生成器可以直接关注知识库中的任意片段。类似
RETRO或Atlas模型已经在探索这条路。 -
事实性与逻辑验证的深度整合 检索回来不仅仅是文本片段,还会附带事实核查的元数据、来源权威度评分。模型会批判性地看待检索到的信息,自我验证逻辑一致性,甚至要求检索器补充证据。
-
隐私保护的检索 随着数据隐私法规趋严,如何在用户数据不出本地的情况下进行有效检索(联邦检索)将成为重要方向。模型可能需要学会理解加密或脱敏后的文本表示。
LangChain 应该提供怎样的抽象?
基于上述趋势,LangChain 未来的抽象应该更高级、更灵活:
-
MemoryAwareRetriever一个能够感知对话历史和用户状态的检索器接口。它不再只是get_relevant_documents(query),而是get_relevant_documents(context: ConversationContext)。ConversationContext 包含了历史、用户偏好、当前任务等丰富信息。 -
PluggableRAGPipeline一个完全可配置、可监控的 RAG 管道抽象,让开发者可以像搭乐高一样组合“检索器、重排序器、压缩器、生成器”,并且每个组件都有标准化的接口和自动的延迟监控。支持动态路由和条件执行。 -
Self-CritiquingRetriever内建反思能力的检索器,可以自我评估检索质量,并自动触发二次检索或查询改写。对开发者屏蔽了复杂的迭代逻辑。 -
多模态和多路编排的融合抽象 提供原生的多路融合(如
EnsembleRetriever的升级版),支持更复杂的融合策略,并允许在配置中指定动态权重。 -
联邦检索与隐私沙箱 在框架层面提供对本地差分隐私检索的支持,使得开发者能更容易地构建符合 GDPR 等法规的 RAG 应用。
总之,LangChain 未来的抽象应该从“提供基础组件让你自由组装”进一步升级为“提供智能模板和自动化决策”,让开发者能专注于业务逻辑,而把检索策略的优化交给框架和内置的 AI 代理。