RAG 知识库调优方案深度解析:学术界检索前优化实践
背景介绍
在构建基于大语言模型(LLM)的检索增强生成(Retrieval-Augmented Generation, RAG)系统时,检索质量直接决定了最终回答的准确性和可靠性。工业界常见的开源方案如 QAnything 和 RagFlow 已经提供了基础架构,但学术界针对特定场景的优化研究层出不穷。
本文重点梳理来自学术界的一系列 RAG 优化方案,特别是检索前(Pre-Retrieval)的优化手段。主要关注优化方案对应的设计思想、实现细节以及实际效果评估,旨在为开发者提升 RAG 服务效果提供理论依据和实践参考。所有的实现代码均基于 LangChain 框架完成。
基础架构与分类
在综述论文《Retrieval-Augmented Generation for Large Language Models: A Survey》中,介绍了三种不同的 RAG 架构:
- Native RAG:原始 RAG 架构,对应最基础的 RAG 流程。即用户提问 -> 向量数据库检索 -> 拼接上下文 -> LLM 生成。这与搭建离线私有大模型知识库的标准流程基本一致。
- Advanced RAG:高级 RAG 架构,在原始 RAG 基础上增加了优化手段。例如之前实践过的 RAG Rerank(重排序)优化手段就属于高级 RAG 中的 Post-Retrieval(检索后优化)阶段。
- Modular RAG:模块化 RAG 架构,通过模块化的架构设计,提供灵活的功能组合,方便实现功能强大的 RAG 服务,允许动态调整检索策略。
本篇文章主要实践的是高级 RAG 架构中的 Pre-Retrieval(检索前优化)手段。目前学术界有大量相关论文研究,本文选择其中几种有代表性的方案进行深度解析和实践。
优化方案详解
1. HyDE (Hypothetical Document Embeddings)
原理分析
HyDE 的优化手段来自于论文《Precise Zero-Shot Dense Retrieval without Relevance Labels》。其核心痛点在于:用户原始的问题通常较短且抽象,与需要检索的文档片段在向量空间中的相似度不接近,导致向量检索效果不佳。
HyDE 的设计思想如下:
- 假设文档生成:根据原始问题使用大模型生成一个假设性的文档(Hypothetical Document)。可以理解为让大模型先'假装'给出答案,此答案中可能存在幻觉,但语义上更接近真实文档的风格。
- 向量检索:基于生成的假设文档进行向量检索,而不是直接使用原始问题。
为什么有效? 直观理解,与大模型生成的答案语义上接近的更有可能是所需的答案。大模型是通过大量原始文档训练出来的,因此生成的假设文档在语言风格和词汇分布上与原始文档更为接近,从而更容易被向量检索模型匹配到。
实现方案
LangChain 已经原生支持了 HyDE,可以通过 from langchain.chains import hyde 进行使用。它提供了一个向量化查询的转换支持。
核心方法逻辑如下:
def embed_query(self, text: str) -> List[float]:
"""
Generate a hypothetical document and embedded it.
1. 通过大模型生成文本对应的响应
2. 将生成的响应向量化
3. 返回用于检索的向量
"""
# 通过大模型生成文本对应的响应
var_name = self.llm_chain.input_keys[0]
result = self.llm_chain.generate([{var_name: text}])
documents = [generation.text for generation in result.generations[0]]
# 文档向量化
embeddings = self.embed_documents(documents)
return self.combine_embeddings(embeddings)
熟悉 LangChain 的研发同学应该都了解这个方法的用途,主要是用于将原始文本向量化,方便进行后续的向量检索。常规的文本向量化是直接调用 self.embed_documents() 将原始查询 text 向量化。但是在 HyDE 中会增加一个大模型生成回答的流程 self.llm_chain.generate([{var_name: text}]),接下来将大模型的回答向量化,并使用此向量进行检索。
Prompt 设计
HyDE 中使用不同的 Prompt 来适应不同场景。这部分可以在 from langchain.chains.hyde import prompts 中看到。我们以 web_search 为例,对应的应该是文本搜索的场景,Prompt 如下所示:
from langchain_core.prompts.prompt import PromptTemplate
web_search_template = """Please write a passage to answer the question
Question: {QUESTION}
Passage:"""
web_search = PromptTemplate(template=web_search_template, input_variables=["QUESTION"])
可以看到 Prompt 也相对简单容易理解,引导模型生成一段包含答案信息的文本。
实践效果与局限
理想很丰满,实践下来发现 HyDE 实际效果存在不确定性。在实际测试中,大模型给出的响应与原始知识库中的文档表达形式并不总是接近,导致最终测试时原始 query 可以检索到部分相关文档,使用大模型给出的回答进行检索则可能完全检索不到任何内容。
目前来看,HyDE 在大模型可以给出与文档类似的表达形式的内容时可能会有一些效果。预期对大模型的选型和使用场景上都有明显要求,如果基座模型能力不足或领域知识匮乏,使用不当可能会导致效果更差,甚至引入额外的延迟成本。
2. Rewrite-Retrieve-Read
原理分析
Rewrite-Retrieve-Read 的想法来自于论文《Query Rewriting for Retrieval-Augmented Large Language Models》。其核心思想是用户的原始问题检索效果不佳,往往是因为问题表述模糊、缺少上下文或指代不明。通过大模型进行重写,可以提升问题对应的检索能力。
实际上线 RAG 服务的工程师应该有类似的遭遇,用户的问题都是千奇百怪的,确实存在原始问题检索效果不佳的情况。Rewrite-Retrieve-Read 就是基于大模型提供的能力进行了 Query Rewriting(查询重写)。
实现方案
Rewrite-Retrieve-Read 的实现方案相对简单,使用大模型直接重写问题。对应的实现可以参考 LangChain 的模板机制。
核心功能代码如下:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
template = """Provide a better search query for \n\nweb search engine to answer the given question, end \nthe queries with '**'. Question: \n{x} Answer:"""
rewrite_prompt = ChatPromptTemplate.from_template(template)
def _parse(text):
return text.strip("**")
rewriter = rewrite_prompt | ChatOpenAI(temperature=0) | StrOutputParser() | _parse
可以看到 LangChain 中设计了一个特殊的 Prompt 进行了原始问题的转换,思路相对简单。这里使用了 ChatOpenAI 作为重写模型,并设置了 temperature=0 以保证输出的稳定性。
实践效果
最终实际测试下来,效果不是特别稳定。部分问题转换后的效果更好,能够命中更多相关文档;部分效果更差,可能是因为重写过程引入了噪声或偏离了原意。从目前来看,这与大模型本身的能力存在较大关系,尤其是对于复杂指令遵循能力的依赖。
3. Query2Doc
原理分析
Query2Doc 的想法来自于论文《Query2doc: Query Expansion with Large Language Models》,是在 HyDE 的想法上进行了一些提升。
原始的 HyDE 是使用大模型生成的答案(Hypothetical Document)进行检索,而 Query2Doc 则会将生成的答案与原始问题进行拼接,之后使用拼接得到的内容进行检索。这种混合信息的方式旨在保留原始意图的同时增加语义密度。
针对不同的检索方案,拼接方案有所差异:
- 稀疏检索(Sparse Retrieval):其中的
q为原始问题,d为生成的回答。可以看到q会被重复n次,并与d拼接起来,以增强关键词的权重。 - 密集检索(Dense Retrieval):直接将原始问题
q与大模型生成的回答d进行了拼接,中间使用分隔符[SEP]进行了分隔。
实现方案
可以参考 HyDE 进行简单调整即可实现 Query2Doc。简单的示例代码如下:
def embed_query(self, text: str) -> List[float]:
"""
Generate a hypothetical document and embedded it.
1. 通过大模型生成文本对应的响应
2. 拼接原始查询与生成的响应
3. 文档向量化
"""
# 通过大模型生成文本对应的响应
var_name = self.llm_chain.input_keys[0]
result = self.llm_chain.generate([{var_name: text}])
documents = [generation.text for generation in result.generations[0]]
# 拼接原始查询与生成的响应,使用空格作为分隔符 SEP
documents = [f"{text} [SEP] {doc}" for doc in documents]
# 文档向量化
embeddings = self.embed_documents(documents)
return self.combine_embeddings(embeddings)
实践效果
实际测试下来,相对原始的 HyDE 方案效果更好。因为保留了原始 Query 的信息,减少了纯生成内容的幻觉风险。但是实际效果改善不明显,说明单纯的拼接可能不足以解决深层的语义鸿沟问题。
综合对比与最佳实践
为了更直观地比较上述三种 Pre-Retrieval 优化手段,我们整理了以下对比表:
| 方案名称 | 核心机制 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| HyDE | 生成假设文档检索 | 能利用 LLM 生成丰富语义 | 增加延迟,可能引入幻觉 | 问题简短,文档风格统一 |
| Rewrite-Retrieve-Read | 重写查询语句 | 修正模糊查询,提升召回 | 依赖重写模型能力,不稳定 | 用户查询口语化严重 |
| Query2Doc | 查询 + 文档拼接 | 平衡意图与语义密度 | 效果提升有限 | 通用场景,中等复杂度 |
实施建议
- 性能权衡:所有 Pre-Retrieval 优化都会增加一次 LLM 调用,这会显著增加系统的延迟和 Token 成本。在生产环境中,建议先进行小规模 A/B 测试,评估收益是否覆盖成本。
- 模型选型:优化效果高度依赖于底层大模型的质量。对于 HyDE 和 Query2Doc,建议使用具备较强推理能力和指令遵循能力的模型。
- 组合策略:可以将 Pre-Retrieval 与 Post-Retrieval(如 Rerank)结合使用。例如先用 HyDE 扩大召回范围,再用 Cross-Encoder 进行精排,往往能获得最佳效果。
- 监控与反馈:建立完善的日志系统,记录 Query、Rewritten Query、Retrieved Documents 和 Final Answer,以便后续分析优化方向。
总结
本次测试了目前比较常规的几种 Pre-Retrieval 优化手段,包括 HyDE、Rewrite-Retrieve-Read 和 Query2Doc。从目前来看,优化思想都相对容易理解,都是在尝试利用大模型提供的能力优化了原始 query 难以检索的问题。
但是实际测试下来,改善效果相对有限,不如之前测试过的 Rerank 机制那么立竿见影。这主要是因为检索前的优化更多依赖于语义空间的映射,而检索后的重排序能更精准地计算相关性分数。
后续会进一步调研其他可能的优化方案,例如多路召回融合、自适应检索等,欢迎关注后续更新。同时,建议在具体业务场景中,结合数据特点选择合适的优化策略,避免盲目追求新技术而忽略工程落地的稳定性。


