阅读对象:正在搭建知识库问答、企业文档助手、医疗/法律/金融领域检索增强应用,但发现"检索到的内容不对""答案经常幻觉""Token 成本高得离谱"的开发者。
阅读收益:本文不会只讲概念,而是沿着一份真实的 RAG 优化实验代码,逐步拆解智能分块、QA 生成优化、高级召回策略、RAG 后处理工程优化四大环节,给出可运行代码、实验结果、调优建议和工程落地清单。
一、先看清问题:为什么你的 RAG 总是"答非所问"?
很多人把 RAG 想成一件很简单的事:
text
文档 -> 切块 -> 向量化 -> 存入向量库 -> 用户提问 -> 向量检索 -> 拼进 Prompt -> 大模型回答
这条链路在 Demo 上能跑通,一旦进入真实业务,就会暴露出几个高频问题:
- 切分太粗暴。按 500 字或 1000 字硬切,导致一个完整答案被截断,或者一个块里塞进多个无关主题。
- 语义匹配错位。用户问的是口语化问题,库里存的是陈述性文档,向量相似度并不总是靠谱。
- Top-K 不等于 Top-K 有用。向量检索返回的前 10 条可能只是"看起来相关",大量噪声一起进入大模型。
- 上下文过长。为了提升召回,很多人把 Top-20、Top-30 全塞进 Prompt,结果 Token 成本暴涨,模型注意力被稀释。
- 缺少工程化后处理。没有重排序、没有压缩、没有查询改写,也没有评估指标,上线后只能靠人工感觉判断好坏。
RAG 的优化从来不是单一技巧,而是一套系统工程。本文会围绕下面四个层次展开:
- 精确召回策略:文档怎么切,检索入口怎么建。
- QA 生成优化:如何让"问题"去匹配"问题"。
- 高级 RAG 召回策略:父子文档、Agent 代理分块等更灵活的方法。
- RAG 后处理工程优化:重排序、上下文压缩、Prompt 控制与幻觉防范。
你不需要一次用上所有技巧,但需要理解每个技巧解决什么问题、代价是什么,以及什么时候该用。
二、智能分块:不要把一本好书随机撕成碎片
RAG 的源头是文档分块。分块质量直接决定了检索的上限。如果最相关的答案被切坏,后面无论换多强的模型、做多复杂的检索都很难完全救回来。
2.1 固定长度分块:最简单,但问题也最多
CharacterTextSplitter 是最直观的切法:按预设字符数切割,不考虑文本逻辑。
python
from langchain_text_splitters import CharacterTextSplitter
sample_text = (
"LangChain was created by Harrison Chase in October 2022. "
"It provides a framework for developing applications "
"powered by language models. The library is known "
"for its modularity and ease of use. "
"One of its key components is the TextSplitter class, "
"which helps in document chunking."
)
text_splitter = CharacterTextSplitter(
separator=" ",
chunk_size=100,
chunk_overlap=20,
length_function=len
)
docs = text_splitter.create_documents([sample_text])
print(f"Total number of documents: {len(docs)}")
for i, doc in enumerate(docs):
print(f"Document {i}:")
print(doc.page_content)
print()
运行后得到 4 个块。可以明显看到,每个块都是按照空格附近的边界进行拼接,句子经常被截断:
text
Document 0:
LangChain was created by Harrison Chase in October 2022. It provides a framework for developing
Document 1:
for developing applications powered by language models. The library is known for its modularity and
chunk_size 和 chunk_overlap 是最重要的两个参数:
chunk_size太小,上下文不足,模型无法完整理解概念。chunk_size太大,会引入噪声,降低检索信噪比,同时增加 API 成本。- 常见取值通常围绕嵌入模型的最佳输入长度设计,例如 256、512、1024。
chunk_overlap一般取chunk_size的 10% 到 20%,用来缓解边界切断问题。
但重叠并不能从根本上解决语义断裂。它只是让相邻块之间保留一些重复内容,当某个句子恰好落在边界附近时,至少还能被两个块同时覆盖。
2.2 递归字符分块:在字符切分之上增加优先级
RecursiveCharacterTextSplitter 是 LangChain 里更推荐的通用方案。它不是随便找到一个空格就切,而是按照分隔符优先级递归切分,默认顺序是:
text
["\n\n", "\n", " ", ""]
也就是先尽量按段落切,再按换行切,再按空格切,最后才按字符硬切。
python
from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=100,
chunk_overlap=20,
)
docs = text_splitter.create_documents([sample_text])
for i, doc in enumerate(docs):
print(f"--- Chunk {i + 1} ---")
print(doc.page_content)
对于普通文本,递归分块通常比固定长度分块更稳。它优先保留自然边界,能在不引入额外模型成本的情况下显著减少"一句话被从中间砍断"的概率。
不过,递归分块仍然不理解文档结构。真正的文档往往有标题、列表、表格、代码块、对话轮次。如果我们知道这些结构,就应该利用它。
2.3 结构感知分块:利用 Markdown 标题和 HTML 标签
如果知识库是 Markdown 文档、HTML 页面或富文本,最好的边界往往就是标题层级。
例如一篇技术手册:
markdown
# Chapter 1: The Beginning
## Section 1.1: The Old World
This is the story of a time long past.
## Section 1.2: A New Hope
A new hero emerges.
# Chapter 2: The Journey
## Section 2.1: The Call to Adventure
The hero receives a mysterious call.
我们可以用 MarkdownHeaderTextSplitter 按标题层级切分:
python
from langchain_text_splitters import MarkdownHeaderTextSplitter
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
]
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on
)
md_header_splits = markdown_splitter.split_text(markdown_document)
for split in md_header_splits:
print(f"Metadata: {split.metadata}")
print(split.page_content)
print("-" * 20)
输出会把标题写入 metadata,并把同一小节的内容保留在同一个块里:
text
Metadata: {'Header 1': 'Chapter 1: The Beginning', 'Header 2': 'Section 1.1: The Old World'}
This is the story of a time long past.
这样做至少有三个好处:
- 检索时可过滤:用户问"第二章",系统可以先按标题缩小范围。
- 上下文完整:同一小节内容不会被拆到多个块。
- 元数据更丰富:后续重排序、引用来源展示时都能用上。
2.4 按对话轮次分块:适合客服、访谈和会议纪要
客服对话、访谈记录、会议纪要等场景,如果按固定字符切分,很容易把同一轮问答拆散,或者把不同说话人混在一起。
一个更合理的方式是按"轮次"切分:
python
dialogue = [
"Alice: Hi, I'm having trouble with my order.",
"Bot: I can help with that. What's your order number?",
"Alice: It's 12345.",
"Alice: I haven't received any shipping updates.",
"Bot: Let me check... It seems your order was shipped yesterday.",
"Alice: Oh, great! Thank you.",
]
def chunk_dialogue(dialogue_lines, max_turns_per_chunk=3):
chunks = []
for i in range(0, len(dialogue_lines), max_turns_per_chunk):
chunk = "\n".join(dialogue_lines[i: i + max_turns_per_chunk])
chunks.append(chunk)
return chunks
chunks = chunk_dialogue(dialogue)
for i, chunk in enumerate(chunks):
print(f"--- Chunk {i + 1} ---")
print(chunk)
结果会保留"用户问题 + 客服回答"这样的完整交互。这类结构感知分块不需要调用 LLM,逻辑简单,但实际效果常常比盲目调 chunk_size 要好。
2.5 分块策略怎么选?
一个简单决策顺序:
- 先看文档类型:Markdown、HTML、PDF 转出的结构化文档优先用结构感知分块。
- 再看语义粒度:普通新闻、论文、说明书,可以先尝试递归字符分块,再根据检索效果微调。
- 对话类数据:优先按轮次、发言人、会议主题切分。
- 复杂非结构化数据:考虑后面的 Agent 代理分块,让 LLM 动态决定知识块边界。
- 永远做评测 :同一份数据集上对比不同
chunk_size、chunk_overlap和切分器,观察 Recall 与答案质量,而不是靠感觉。
三、QA 生成优化:让"问题"去匹配"问题"
智能分块解决的是"文档怎么切"。但还有一个更隐蔽的问题:用户提问方式与文档陈述方式存在语义鸿沟。
比如库里有一句:
糖尿病患者建议控制碳水摄入,增加低糖蔬菜和优质蛋白。
用户却可能问:
小明的爸爸👨 60 岁血糖 10,一日三餐具体吃什么?
如果直接用用户问题去匹配陈述文档,向量相似度可能不高。但如果我们提前用 LLM 为文档生成几个"用户可能提出的问题",再让用户问题去匹配这些问题,命中率会大幅提升。
3.1 核心思想:为每个文档块创建多个检索入口
QA 生成优化通常这样工作:
- 将原始文档按合适粒度切块。
- 为每个文档块调用 LLM,生成若干条"能够被该文档回答的问题"。
- 向量库里只存储这些生成的问题。
- 每条问题通过
doc_id关联回原始文档块。 - 用户提问时,先在"问题库"中检索。
- 命中某条问题后,不返回问题本身,而是返回它对应的原始文档块。
这相当于为一个知识点创造了多个不同角度的入口。即使用户措辞刁钻,只要能和其中一条代理问题匹配,就能找到答案。
3.2 环境与模型准备
工程代码里通常会从环境变量读取密钥,并封装嵌入模型。下面是一个典型的 OpenAI 兼容接口封装:
python
import os
from openai import OpenAI
from langchain.embeddings.base import Embeddings
class OpenAIEmbeddings(Embeddings):
def __init__(self, client, model='doubao-embedding-vision-251215'):
self.client = client
self.model = model
def embed_documents(self, texts):
embeddings = []
for t in texts:
resp = self.client.multimodal_embeddings.create(
input=[{"type": "text", "text": t}],
model=self.model
)
embeddings.append(resp.data.embedding)
return embeddings
def embed_query(self, text):
return self.embed_documents([text])[0]
这段代码的重点不是某个具体模型,而是把不同厂商的嵌入接口统一成 LangChain 需要的 Embeddings 接口。只要目标服务兼容 OpenAI 风格,或者我们能写一个适配器,就可以无缝切换。
对话模型部分可以使用 ChatOpenAI 指向不同服务的 Base URL:
python
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model="deepseek-v4-pro-260425",
openai_api_key=doubao_api_key,
base_url=doubao_api_base,
temperature=0.7,
max_tokens=2048,
)
实际项目里,最好把 model、base_url、api_key 都放进配置中心,避免每次换模型都要改代码。
3.3 用 LLM 生成代理问题
准备两个医学文档作为示例:一个关于糖尿病饮食,一个关于牛皮癣与湿疹的区别。每个文档分配一个唯一 doc_id。
然后构造问题生成提示词:
python
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
question_gen_prompt_str = (
'你是一位AI医学专家,请根据以下文档内容,生成3个用户可能会提出的,高度相关的问题。\n'
'只返回问题列表,每个问题占一行,不要有其他前缀或编号.\n'
'文档内容:\n'
'--------------\n'
'{content}\n'
'--------------\n'
)
question_gen_prompt = ChatPromptTemplate.from_template(question_gen_prompt_str)
question_generator_chain = question_gen_prompt | llm | StrOutputParser()
接着遍历原始文档,为每篇文档生成问题,并保存 doc_id:
python
from langchain_core.documents import Document
sub_docs = []
for i, doc in enumerate(docs):
doc_id = doc_ids[i]
generated_questions = question_generator_chain.invoke(
{"content": doc.page_content}
).split("\n")
generated_questions = [
q.strip() for q in generated_questions if q.strip()
]
for q in generated_questions:
sub_docs.append(
Document(
page_content=q,
metadata={"doc_id": doc_id}
)
)
这里有几个细节值得注意:
- 让 LLM 只返回问题列表,避免它输出"好的,以下是三个问题"这种前缀。
- 每个问题都带上原始
doc_id,这是后面MultiVectorRetriever能把问题映射回原文的关键。 - 问题生成数量不是越多越好。数量太少召回入口不够,数量太多会增加索引成本和重复语义,通常每个块生成 2 到 5 个问题是比较稳妥的起点。
3.4 MultiVectorRetriever:存储问题,返回原文
LangChain 提供了 MultiVectorRetriever,它允许一个父文档拥有多个子文档向量。我们可以把"问题"作为子文档,把"原文"作为父文档保存到 docstore。
python
from langchain_chroma import Chroma
from langchain_core.stores import InMemoryStore
from langchain_classic.retrievers import MultiVectorRetriever
vectorstore = Chroma.from_documents(
documents=sub_docs,
embedding=embeddings
)
store = InMemoryStore()
id_key = "doc_id"
retriever = MultiVectorRetriever(
vectorstore=vectorstore,
docstore=store,
id_key=id_key,
)
检索流程变成:
- 用户查询向量化。
- 在
sub_docs的问题向量中搜索。 - 命中问题的 metadata 里找到
doc_id。 - 从 docstore 中取出原始文档返回。
这样用户看到的是完整原文,而不是一条孤零零的生成问题。这个方法适合文档本身较长、答案需要完整上下文、用户问题形态多样的场景。
3.5 混合检索:给语义检索加一个关键词保险
向量检索擅长同义表达,但在专有名词、型号、药品名、法条编号等场景中可能漏召回。BM25 作为关键词检索可以补上这部分能力。
python
from langchain_community.retrievers import BM25Retriever
from langchain_classic.retrievers import EnsembleRetriever
bm25_retriever = BM25Retriever.from_documents(docs)
bm25_retriever.k = 3
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.4, 0.6]
)
weights=[0.4, 0.6] 表示最终排序时 BM25 占 40%,向量检索占 60%。向量检索通常更能理解语义,所以可以适当偏重。
混合检索并不万能,但它是低成本提升鲁棒性的重要手段。尤其是当你的知识库包含大量行业术语、产品型号、药品名称或法律条文编号时,建议默认加上 BM25。
3.6 RAG-Fusion:多次查询,然后用 RRF 找共识
MultiQueryRetriever 的思路是:把用户原始问题改写成多个不同角度的子查询,分别检索,再合并结果。
RAG-Fusion 在此基础上更进一步:使用 Reciprocal Rank Fusion,RRF,倒数排序融合。它不直接叠加相似度分数,而是看文档在不同查询中的排名。
RRF 的核心公式可以写成:
text
score(d) = Σ 1 / (k + rank_i(d))
其中:
d是某个文档。rank_i(d)是文档d在第i次查询中的排名。k是一个平滑常数,常见取 60。
为什么不用原始相似度直接相加?因为不同查询返回的相似度量纲可能不一致。向量搜索分数和 BM25 分数尤其不能直接相加,但"排名"是统一的、可比较的。
实现 RAG-Fusion 时,先生成多个查询:
python
query = "糖尿病患者有什么饮食建议?"
multi_queries = [
"糖尿病患者除了控制饮食,平时做什么运动能帮助降血糖?",
"60岁老人刚查出糖尿病血糖10,一日三餐具体吃什么食物比较好?",
"60岁老人血糖10算严重吗?除了饮食和运动还需要注意什么?",
]
然后对每个查询分别检索,并按排名计算融合分数:
python
from collections import defaultdict
fused_scores = defaultdict(float)
for query in multi_queries:
results = vector_retriever.invoke(query)
for rank, doc in enumerate(results, start=1):
fused_scores[doc.page_content] += 1.0 / (60 + rank)
最终按融合分数降序排列,排在前面的就是"多个查询都认为相关"的共识文档。
RAG-Fusion 的优势是能筛选出更核心的结果,代价是多调用几次检索和一次 LLM 查询改写。对于复杂问题、模糊问题、多意图问题,这个成本通常是值得的。
3.7 Step-Back:先退一步,再一起检索
当用户问题太具体时,向量库可能找不到直接答案。例如:
为什么水循环第一阶段对植物很重要?
如果库里只有"蒸发""蒸腾作用"等零散概念,直接匹配可能失败。
Step-Back 的思路是:
- 让 LLM 生成一个更高层次的"后退问题"。
- 用"原始问题 + 后退问题"一起检索。
- 把两类结果合并后交给 LLM。
例如可以生成:
text
原始问题:为什么水循环第一阶段对植物很重要?
后退问题:水循环包括哪几个阶段?每个阶段的基本过程是什么?
后者更容易在文档中命中背景知识,前者帮助锁定细节。两者结合,最终回答既有背景又有针对性。
实现时可以使用 RunnableParallel:
python
from langchain_core.runnables import RunnableParallel, RunnablePassthrough
step_back_prompt = ChatPromptTemplate.from_template(
"请针对下面的问题,生成一个更通用、更概括的后退问题:\n{question}"
)
step_back_chain = (
RunnableParallel(
original=RunnablePassthrough(),
step_back=step_back_prompt | llm | StrOutputParser()
)
)
Step-Back 不一定适合所有业务,它更适合教育问答、科研资料、复杂技术问题等需要背景知识的场景。
3.8 HyDE:假设性文档嵌入
HyDE,Hypothetical Document Embeddings,中文常叫"假设性文档嵌入",是另一种反直觉但有效的方法。
它不直接向量化用户问题,而是先让 LLM 生成一个"理想答案":
text
用户问题:糖尿病患者有什么饮食建议?
假设答案:糖尿病患者应控制碳水化合物摄入,增加低糖蔬菜,如菠菜、西兰花等,
适量摄入优质蛋白,并避免高糖高脂食物,同时保持规律三餐和适量运动。
然后用这个"假设答案"的向量去检索真实文档。
为什么有效?因为假设答案在语义上非常接近真实答案。它包含了比原问题更丰富的术语和上下文,因此向量更容易命中真正相关的文档。
HyDE 的伪流程:
python
hyde_answer = llm.invoke(
"请回答用户问题,即使不确定也请生成一段合理的假设答案:" + query
)
hyde_docs = vectorstore.similarity_search(hyde_answer)
HyDE 的成本是多调用一次 LLM,并且如果 LLM 生成的假设答案出现严重事实错误,也可能把检索带偏。所以它更适合"用户问题很短、术语很少、直接匹配困难"的场景,不适合对精确关键词要求极高的场景。
四、高级 RAG 召回策略:父子文档与 Agent 分块
前面的方法大多围绕"如何切分"和"如何改写查询"。当文档更长、结构更复杂时,我们还需要更高级的策略。
4.1 父子文档检索器:小块检索,大块返回
父子文档检索器的思想非常优雅:用小块去匹配,用大块去回答。
具体做法:
- 索引阶段:把文档切成很小的 child 块,例如单个句子或 40 字小段,只向量化这些小块。
- 同时保留 parent 块:例如整个段落或 200 字大块。
- 检索阶段:用用户查询匹配 child 块。
- 返回阶段:一旦命中某个 child 块,不返回这个小块,而是返回它所属的 parent 块。
这解决了普通分块中最经典的矛盾:
- 块太小,检索精度高但上下文不足。
- 块太大,上下文完整但检索精度下降。
父子文档检索器用"分而治之"的方式同时拿到了两个好处。
LangChain 中有现成实现:
python
from langchain_classic.retrievers.parent_document_retriever import ParentDocumentRetriever
from langchain_text_splitters import RecursiveCharacterTextSplitter, CharacterTextSplitter
vectorstore = Chroma(
embedding_function=embeddings,
collection_name="split_parents"
)
store = InMemoryStore()
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
child_splitter = CharacterTextSplitter(chunk_size=40, chunk_overlap=10)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
retriever.add_documents(docs)
retrieved_docs = retriever.invoke("糖尿病患者有什么饮食建议?")
运行结果中可以看到,命中的是一个包含完整饮食建议的父文档,长度达到 171 个字符,而不是只有 40 字的小碎片。
父子文档检索器的关键设计是:
child_splitter负责高精度匹配,粒度要小。parent_splitter负责最终输出,粒度要足够承载答案。- child 块之间建议保留 overlap,防止边界信息丢失。
它非常适合技术手册、长报告、医学问答、合同条款等文档。因为这类文档中,某个具体数字或关键词往往只出现在一个小片段里,但完整回答需要整段上下文。
4.2 Agent 代理分块:让 LLM 模拟人类阅读理解
结构感知分块依赖标题、列表、对话轮次等显式结构。但很多文档并没有良好结构,甚至是一大段连续文本。此时可以尝试 Agent 代理分块。
它的核心思想是:让 LLM 像一个科学文档分析师一样,阅读文本后识别出一个个独立概念,并输出结构化知识块。
每个知识块包含三个字段:
chunk_title:知识块标题。chunk_text:从原文中提取并重组的自包含文本。representative_question:一个能被该知识块直接回答的典型问题。
这其实是"分块 + 标题生成 + 代表性问题生成"三件事的合并。
使用 Pydantic 约束输出格式:
python
from pydantic import BaseModel, Field
from typing import List
from langchain_core.output_parsers import PydanticOutputParser
class KnowledgeChunk(BaseModel):
chunk_title: str = Field(description="这个知识块的简洁明了的标题")
chunk_text: str = Field(description="从原文中提取并重组的,自包含的文本内容")
representative_question: str = Field(
description="一个可以被这个块内容直接回答的典型问题"
)
class ChunkList(BaseModel):
chunks: List[KnowledgeChunk]
Prompt 中必须强调:
- 自包含性:每个知识块单独拿出来也应该能读懂。
- 概念单一性:每个知识块只围绕一个核心概念。
- 提取并重组:不是简单复制原文,而是把相关句子组合成通顺段落。
- 严格遵循 JSON 格式:为 Pydantic 解析提供保障。
然后构造 LCEL 链:
python
llm = ChatOpenAI(
model='deepseek-ai/DeepSeek-V4-Pro-0813',
openai_api_key=ms_api_key,
base_url=ms_api_base,
temperature=0.7,
max_tokens=2048,
).bind(response_format={"type": "json_object"})
parser = PydanticOutputParser(pydantic_object=ChunkList)
prompt = PromptTemplate(
template=prompt_template,
input_variables=["paragraph_text"],
partial_variables={"format_instructions": parser.get_format_instructions()},
)
chain = prompt | llm | parser
对于一个"水循环"的科普段落,模型会输出类似下面的知识块:
text
【知识块 1】
标题:水循环的定义与重要性
代表性问题:什么是水循环,它为什么重要?
文本:水循环,也称为水文循环,描述了水在地球表面、之上和之下的连续运动。
这个循环至关重要,因为它确保了水对所有生命形式的可用性。
【知识块 2】
标题:蒸发与蒸腾作用
代表性问题:水循环的第一阶段是什么?蒸发和蒸腾如何发生?
文本:水循环的第一阶段是蒸发......
这些知识块既能直接向量化,也天然携带了标题和代表性问题,等于同时完成了"结构分块"和"QA 生成"。
4.3 实验中的两个重要改进
原始代码第一版把整段水循环文档作为一个段落交给 LLM,虽然也能生成 5 个知识块,但信息密度较高,某些块仍然偏大。
改进一:增加空行,先按段落粗分
python
document = """
水循环,也称为水文循环,描述了水在地球表面、之上和之下的连续运动。
这个循环至关重要,因为它确保了水对所有生命形式的可用性。
循环的第一阶段是蒸发,这是水从海洋、湖泊和河流等表面转化为水蒸气并上升到大气中的过程,
植物的蒸腾作用对此也有贡献。
当温暖、潮湿的空气上升并冷却时,会发生第二阶段:凝结。
在这个阶段,水蒸气变回微小的液态水滴,形成云。
...
"""
把文档按空行拆成 5 个段落,再逐段调用 Agent 分块,最终可以生成 9 个更细、更自包含的知识块。这个实验结果说明:Agent 分块前先做一次简单的结构预切分,能显著提升粒度控制和稳定性。
改进二:不同模型对 JSON 输出和指令遵循能力差异很大
文档中对比了多种模型。部分模型能够稳定生成 5 个知识块,例如:
ZhipuAI/GLM-5.2deepseek-ai/DeepSeek-V4-Flash-0731stepfun-ai/Step-3.7-FlashQwen/Qwen3.8-Flash-NextQwen/Qwen3.8-27BQwen/Qwen3-Next-80B-A3B-Instruct
但也有一部分模型多次运行都输出 0 个知识块:
ZhipuAI/GLM-4.7-FlashTencent-Hunyuan/Hy3MiniMax/MiniMax-M3Qwen/Qwen3.5-27BQwen/Qwen3-8B
这里"输出 0 个知识块"往往不是模型完全不会做,而是它在 JSON 结构、字段名、指令遵循或输出长度上出了问题,导致 Pydantic 解析失败,而代码在异常时返回了空列表。
这个实验给我们的工程启示是:
- 不要把 Agent 分块当成即插即用能力,必须针对具体模型做小样本验证。
- 解析失败要记录日志,不要静默吞掉异常。
- 增加重试机制,尤其是 JSON 格式解析失败时,可以要求模型重新输出。
- 小模型不一定差,大模型也不一定稳,要看指令遵循和结构化输出能力,而不是只看参数规模。
- 先粗分再 Agent 分块,通常比把超长文本直接丢给模型更稳定。
五、RAG 后处理工程优化:让进入 Prompt 的上下文更干净
即使召回做得好,初步检索返回的 Top-K 文档仍然可能包含噪声。把所有结果原封不动地交给 LLM,会带来两个问题:
- Token 浪费:无关片段也占上下文窗口。
- 答案被干扰:模型可能从噪声里"找到"并拼出错误答案。
因此,RAG 不能止步于"检索到",还要做好"检索后处理"。
5.1 后处理的核心目标:提升信噪比
后处理通常包括:
- 重排序,Rerank:把最相关的结果推到最前面,或直接截断低相关结果。
- 去重:合并高度重复的文档片段,避免重复占用上下文。
- 过滤:根据 metadata、时间、权限、文档类型等条件筛掉不符合要求的内容。
- 压缩:只保留每个文档中与查询直接相关的句子,删除背景和无关描述。
- 生成控制:用 Prompt 约束模型只基于检索内容回答,并标注来源。
其中,重排序和上下文压缩是投入产出比最高的两项。
5.2 上下文压缩:只保留与问题相关的部分
ContextualCompressionRetriever 可以在基础检索器之上再包一层压缩器。压缩器会结合用户查询,对每个文档进行"瘦身"。
示例中使用 LLMChainExtractor:
python
from langchain_classic.retrievers import ContextualCompressionRetriever
from langchain_classic.retrievers.document_compressors import LLMChainExtractor
compressor = LLMChainExtractor.from_llm(llm=llm)
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=vector_retriever
)
query_comp = "糖尿病患者有什么饮食建议?"
retrieved_compressed_docs = compression_retriever.invoke(query_comp)
for i, doc in enumerate(retrieved_compressed_docs):
original_len = len(doc.metadata.get('original_content', doc.page_content))
compressed_len = len(doc.page_content)
print(f"文档 {i + 1}(原始长度: {original_len}, 压缩后长度: {compressed_len}):")
print(doc.page_content)
print("-" * 30)
理想情况下,压缩器会把一大段医疗建议压缩成与"饮食"直接相关的几条要点,丢弃"按时服药""定期复查"等虽然相关但并非本次问题核心的内容。
5.3 为什么示例里"压缩后长度"没有变化?
细心的读者会发现,部分运行结果中:
text
文档 1(原始长度: 39, 压缩后长度: 39):
小明的父亲刚查出糖尿病,血糖10,除了吃药,具体哪些食物可以多吃,哪些绝对要避免?
压缩前和压缩后长度一样。这是因为示例中的基础检索器返回的是生成的问题,而不是原始长文档。问题本身已经很短,语义上几乎每个词都与查询相关,因此压缩器没有多少可删内容。
这个现象恰好说明了一个重要问题:压缩器应该作用在真正需要压缩的对象上。
如果你想展示"长文档压缩成短片段"的效果,应该:
- 用父子文档检索器返回父文档。
- 或从 docstore 中映射回原始长文档。
- 再对原始长文档执行上下文压缩。
工程上通常的推荐链路是:
text
原始查询
-> 查询改写 / 扩展
-> 混合检索
-> 重排序
-> 上下文压缩
-> 拼装最终 Prompt
压缩器放在重排序之后,只处理少量高相关文档,既省 Token,又避免过多 API 调用。
5.4 生成控制与幻觉防范
后处理不只发生在检索层,也发生在生成层。至少可以做四件事:
- 强制引用:要求模型回答时标明来源编号。
- 无答案拒绝:当检索结果置信度过低时,让模型回答"未找到相关资料",而不是硬编。
- 事实拆解:把回答拆成可验证声明,再与检索结果比对。
- 答案长度控制:根据业务设置最长生成长度,降低幻觉和成本。
例如 Prompt 可以这样写:
text
请仅根据以下资料回答问题。
如果资料不足以回答,请明确说"当前资料不足"。
回答时必须引用来源编号,例如 [1][2]。
资料:
[1] {doc1}
[2] {doc2}
这类约束不能完全消除幻觉,但可以显著降低"一本正经胡说八道"的概率。
六、把这些策略组合成一套可落地的 RAG 工程
单独看每个策略,会觉得"都很简单"。真正的难点在于组合和评估。下面给出一套可落地的工程路径。
6.1 离线索引阶段
- 文档解析与清洗:PDF、Word、HTML、Markdown 统一转成带结构的文本。
- 结构预切分:按标题、段落、表格、列表先做粗切。
- 智能分块:普通文本用递归分块,复杂文本用 Agent 分块,对话用轮次分块。
- 生成元数据:为每个块保存来源、章节、标题、更新时间、权限、文档类型等。
- 生成检索入口:可选地生成代表性问题、标题、摘要、假设性答案等子文档。
- 构建索引:同时构建向量索引和 BM25 索引。
- 父子映射:如果使用父子检索器,保存 child 到 parent 的映射关系。
- 版本化:文档更新时,保证索引、docstore、元数据一起更新,避免新旧文档混用。
6.2 在线查询阶段
- 查询理解:识别用户意图、实体、时效性要求、权限范围。
- 查询改写:多查询生成、Step-Back、HyDE、同义词扩展等按需使用。
- 多路召回:向量检索 + BM25,必要时加入结构化过滤。
- 融合排序:RRF 或带权融合,得到候选列表。
- 重排序:使用交叉编码器或 LLM Rerank,把最相关结果排到前面。
- 上下文压缩:对长文档做 query-aware 压缩,控制 Token。
- 生成回答:拼装 Prompt,要求引用来源,支持无答案拒绝。
- 返回溯源:把答案、引用来源、置信度一起返回给用户。
6.3 必须做的评估
没有评估的 RAG 优化基本等于盲调。建议至少跟踪以下指标:
- 召回率 Recall@K:正确答案是否出现在 Top-K 中。
- 平均倒数排名 MRR:正确答案第一次出现的位置。
- nDCG:排序质量。
- 答案忠实度 Faithfulness:答案是否忠实于检索内容。
- 答案相关性:答案是否真的回答了问题。
- 拒答准确率:资料不足时是否正确拒绝。
- 延迟与成本:每次查询的耗时、Token 消耗和 API 花费。
评估集要覆盖常见问法、少见问法、多跳问题、边界问题、时效性问题。小规模人工标注加自动化指标结合,是最现实的方案。
6.4 模型与参数选择建议
从文档实验和工程经验看:
- 嵌入模型:优先选择对中文、长文本、领域术语友好的模型,必要时微调或换领域模型。
- 重排序模型:BGE Reranker、Cohere Rerank 或 LLM Rerank 都可以,先在小样本上对比 MRR。
- 对话模型:分块、问题生成、压缩等任务需要较强的指令遵循能力;不要只看通用聊天能力。
- 结构化输出:如果使用 Agent 分块,优先选择 JSON 输出稳定的模型,并做多模型对比。
- chunk 参数 :从
chunk_size=512、chunk_overlap=64这类中等配置开始,再根据评测调优。 - Top-K:召回阶段可以放宽到 20 到 50,重排序后截断到 3 到 8,最后再进入 LLM。
七、常见坑与排查思路
7.1 检索结果看似相关,但模型回答仍然错误
先别急着换模型。检查:
- 正确答案是否在 Top-K 中。
- 如果不在,是切分问题、嵌入问题还是查询改写问题。
- 如果在但排序靠后,优先加重排序。
- 如果在但模型忽略,检查 Prompt 是否强调"仅根据资料回答"。
7.2 上下文压缩没有生效
检查压缩器作用的文档是否为长文档。如果向量库里存的是已经很短的问题或摘要,压缩空间自然很小。此时应回到父子文档检索链路,对父文档做压缩。
7.3 Agent 分块偶尔输出空列表
大概率是 JSON 解析失败。应记录原始模型输出,而不是只记录空列表。可以增加:
- 重试机制。
- 降低
temperature。 - 更换指令遵循更好的模型。
- 使用更简单的 Pydantic 结构。
- 在 Prompt 中给出一个明确 JSON 示例。
7.4 更新文档后检索结果新旧混杂
建立文档版本号,并在 metadata 中记录。每次更新先删除旧 chunk 再写入新 chunk,避免"旧条款 + 新条款"同时出现。
7.5 向量检索对数字、编号、型号不敏感
加入 BM25 混合检索,或对这类实体做显式抽取和过滤。纯向量检索不适合所有问题。
7.6 测试效果好,上线效果差
原因通常是测试问题与真实用户问法分布不一致。日志中收集真实 query,持续补充评估集,并定期回归。
八、结语:RAG 优化的重点不是"更强的大模型",而是"更干净的信息流"
很多人遇到 RAG 效果不好,第一反应是换一个更强的 LLM。但从本文的代码实验中可以看出,大量问题发生在检索链路本身:
- 文档切碎了,答案自然不完整。
- 用户问题与文档表述错位,向量匹配自然不准。
- Top-K 噪声太多,模型自然容易被干扰。
- 上下文过长,成本自然上涨。
更务实的做法是:
- 先做好结构感知分块,保住文档语义。
- 再用 QA 生成为知识点创建多个检索入口。
- 然后用父子文档检索兼顾精度与上下文。
- 最后用重排序 + 上下文压缩控制进入模型的上下文质量。
真正成熟的 RAG 系统,应该像一条精密的信息管道:从海量文档中,把"最相关、最完整、最少噪声"的信息准确地送到模型面前。模型只是最后一步生成器,而前面的每一步,才是拉开产品差距的地方。
如果你想从本文代码开始实践,建议按下面的最小路径复现:
text
RecursiveCharacterTextSplitter
-> QA 问题生成 + MultiVectorRetriever
-> BM25 + 向量混合检索
-> ParentDocumentRetriever
-> Rerank
-> ContextualCompressionRetriever
-> 带来源约束的最终 Prompt
每一步都做评测,保留数据,再决定是否引入更复杂的方法。RAG 优化没有银弹,但有清晰的工程顺序。希望这篇文章能帮你在下一次排查"为什么又答错了"时,少走一些弯路。
附录一:一个可复用的最小 RAG 优化骨架
如果你不想一开始就把系统做得过于复杂,可以从下面这个最小骨架开始。它不一定是最优架构,但足够让你把本文提到的关键策略串起来,并形成一套可以持续迭代的基线。
1. 项目目录
一个清晰的项目结构,比把所有逻辑塞在一个文件里重要得多。建议至少拆成:
text
rag_project/
├── config.py
├── ingest/
│ ├── loaders.py
│ ├── splitters.py
│ └── indexer.py
├── retrieval/
│ ├── embeddings.py
│ ├── retrievers.py
│ ├── rerankers.py
│ └── compressors.py
├── generation/
│ ├── prompts.py
│ └── answerer.py
├── eval/
│ ├── test_set.jsonl
│ └── evaluate.py
└── app.py
这个拆分对应了 RAG 的四个主要阶段:数据导入、索引构建、查询检索、生成回答。后续无论加入 Agent 分块、混合检索还是压缩器,都能找到合适的位置。
2. 配置集中管理
密钥、模型名、Base URL、分块参数、Top-K 都应该集中在 config.py,不要让它们在业务代码里散落。
python
import os
EMBEDDING_API_KEY = os.environ["EMBEDDING_API_KEY"]
EMBEDDING_API_BASE = os.environ["EMBEDDING_API_BASE"]
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
LLM_API_KEY = os.environ["LLM_API_KEY"]
LLM_API_BASE = os.environ["LLM_API_BASE"]
LLM_MODEL = os.getenv("LLM_MODEL", "deepseek-chat")
CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", "512"))
CHUNK_OVERLAP = int(os.getenv("CHUNK_OVERLAP", "64"))
RETRIEVAL_K = int(os.getenv("RETRIEVAL_K", "20"))
FINAL_K = int(os.getenv("FINAL_K", "5"))
这样做的好处是:换模型、调参数、切换环境都不需要改业务逻辑。生产系统中,配置还必须支持多套环境,例如开发、测试、预发布、正式。
3. 统一嵌入接口
不同厂商的嵌入服务接口不一样。我们可以用 LangChain 的 Embeddings 基类封装,或者自己实现两个方法:embed_documents 和 embed_query。
python
class EmbeddingService:
def embed_documents(self, texts):
raise NotImplementedError
def embed_query(self, text):
raise NotImplementedError
无论底层是 OpenAI、豆包、DeepSeek、智谱,还是本地模型,都通过这个接口暴露给上层。上层代码不应该知道某个具体厂商的请求格式。
4. 离线索引:一次构建,多处复用
离线索引流程应该尽量幂等,允许失败重跑。一个简化的流程如下:
python
raw_documents = load_documents(source)
structured_chunks = split_documents(raw_documents)
enriched_chunks = []
for chunk in structured_chunks:
chunk.metadata["doc_id"] = str(uuid.uuid4())
chunk.metadata["updated_at"] = now()
enriched_chunks.append(chunk)
vector_store = build_vector_store(enriched_chunks, embedding_service)
bm25_index = build_bm25_index(enriched_chunks)
doc_store = build_doc_store(enriched_chunks)
如果使用 QA 生成优化,可以在 split_documents 之后、写向量库之前生成代理问题。如果使用父子文档检索,需要同时保存父块和子块的映射。
5. 在线查询:先召回,再精排,再压缩
在线阶段不要直接把 Top-20 塞给模型。推荐先宽召回,再逐步收窄:
python
rewritten_queries = rewrite_query(user_query)
candidate_docs = []
for q in rewritten_queries:
candidate_docs.extend(hybrid_search(q))
candidate_docs = reciprocal_rank_fusion(candidate_docs)
top_docs = rerank(candidate_docs, user_query, top_k=FINAL_K)
compressed_docs = compress(top_docs, user_query)
answer = generate_answer(user_query, compressed_docs)
这条链路把"召回、融合、精排、压缩、生成"五个动作拆开了。每个动作都可以单独替换、单独评测、单独降级。例如某些场景不需要压缩,就跳过 compress;某些场景不需要多查询改写,就只保留一个原始查询。
6. 为什么建议先跑通这个骨架,而不是一步到位?
很多团队一开始就想上 GraphRAG、Agentic RAG、多路召回、复杂重排。结果系统还没稳定,调试成本已经失控。
先跑通最小骨架有三点好处:
- 你能快速定位问题发生在哪个阶段。
- 你能用基线指标证明每个优化是否有效。
- 你能在架构不变的情况下,逐步替换组件。
RAG 工程最重要的不是"用了多少高级名词",而是"每一层是否可观察、可评估、可替换"。
附录二:医疗问答场景落地案例
下面把本文的糖尿病、牛皮癣示例还原成一个完整的医疗问答优化过程,帮助你理解为什么每一步不是孤立存在的。
1. 原始数据与业务目标
假设我们有两篇医疗问答文档:
- 文档 A:一位 60 岁糖尿病患者咨询饮食,医生回复控制碳水、增加低糖蔬菜、适量蛋白质、避免高脂高盐、规律三餐、适量运动。
- 文档 B:用户怀疑自己得了牛皮癣,医生解释牛皮癣和湿疹的区别、治疗方式与日常护理。
业务目标不是"让模型把文档背下来",而是当用户用口语化问题咨询时,系统能精准找到对应文档,并给出可溯源、不夸大的回答。
2. 直接向量检索会遇到什么?
如果直接把文档 A、B 按固定长度切块并向量化,用户问:
text
小明的父亲 60 岁血糖 10,一日三餐具体吃什么?
很可能出现两类问题:
- 文档 A 中的"饮食原则"被切到多个块,检索只能命中其中一小段。
- 用户问题口语化,与文档中的陈述句语义距离较远,向量分数不够突出。
结果是 Top-K 中可能混入牛皮癣内容,或者命中了饮食文档的某一个孤立条目,但缺少完整上下文。
3. 优化步骤一:QA 生成,让问题匹配问题
我们对文档 A 调用 LLM,生成类似下面的问题:
text
糖尿病患者日常适合吃哪些蔬菜、水果和优质蛋白质?
小明的父亲刚查出糖尿病,血糖10,除了吃药,具体哪些食物可以多吃,哪些绝对要避免?
小明的父亲糖尿病初期,一日三餐怎么安排比较合适?
这些问题被写入向量库,同时通过 doc_id 指向文档 A。
当用户再问"我小明的60 岁血糖 10,一日三餐具体吃什么?"时,系统先与这些代理问题匹配。因为"问题 vs 问题"的语义分布比"问题 vs 文档"更接近,召回率明显提升。
4. 优化步骤二:混合检索,防止专有名词漏召回
"糖尿病""血糖 10""牛皮癣"都是明确术语。向量检索擅长语义,但 BM25 在精确词命中上更直接。我们把 BM25 和向量检索以 0.4 / 0.6 的权重融合,得到更稳定的候选集。
text
用户查询
-> BM25 检索原始文档
-> 向量检索代理问题
-> EnsembleRetriever 加权融合
这一步特别适合医学场景,因为疾病名、指标、药品名往往是高价值关键词,不容许因为同义表达而漏掉。
5. 优化步骤三:父子文档,补足上下文
代理问题命中后,如果直接返回问题本身,用户会看到一条孤立的问句,没有答案。如果返回原始整篇文档,又可能太长。父子文档的思路是:
- 子块:短句或小段,用于精确检索。
- 父块:完整段落,用于最终输出。
比如子块命中"增加蔬菜和水果的摄入量",最终返回的父块包含饮食原则的完整条目。这样大模型既能理解上下文,又不会因文本碎片而断章取义。
6. 优化步骤四:上下文压缩,降低 Token
假设经过父子文档返回后,某段父文档仍然包含"按时服药、定期复查、饮食、运动"等多个方面。用户只问饮食,我们不应该把整段都塞进 Prompt。
上下文压缩器会根据查询:
text
糖尿病患者有什么饮食建议?
从文档中筛选出"控制碳水、低糖蔬菜、优质蛋白、避免高脂高盐、规律三餐"等饮食相关句子,丢弃与饮食无关的用药和复诊建议。最终进入模型的上下文更短、更聚焦。
7. 医疗场景必须额外做的事
医疗问答比其他场景更敏感,落地时至少要加四道护栏:
- 来源可信度分级:优先召回经过审核的医学指南、说明书、医生回复。
- 时效性过滤:治疗方案和药物信息要标记更新时间,过时内容降低排序权重。
- 免责声明:回答末尾明确提示"仅供参考,不能替代医生诊断"。
- 高风险问题拦截:涉及急症、自伤、药物剂量等高风险问题时,引导用户就医,而不是生成建议。
RAG 解决的是"找到信息",而医疗产品的合规性、安全性和责任边界,需要产品策略与人工审核共同完成。
8. 这个案例的结论
医疗问答的优化顺序可以总结为:
text
先解决"切得完整" -> 再解决"问得匹配" -> 再解决"返回得完整" -> 最后解决"送得干净"
很多团队一上来就做复杂 Agent,却忽略了分块和 QA 生成。事实上,对大多数知识库问答场景,做好前两步已经能带来非常明显的提升。复杂策略应该是在基础稳定之后,根据评测数据逐步加入的。
附录三:从实验代码反推 RAG 优化路线图
把文档中的实验顺序整理一下,可以得到一个比较务实的路线图。它不追求一次性引入所有高级能力,而是先打地基,再逐层叠加。
第一阶段:先保证"切得好"
这一阶段的目标是建立稳定的索引基线,不建议立即引入 LLM 分块。
可以做的事:
- 统一文档加载与清洗。
- 对 Markdown、HTML 使用结构感知分块。
- 对普通文本使用
RecursiveCharacterTextSplitter。 - 对客服、会议等数据使用轮次分块。
- 给每个块写入稳定的
doc_id、标题、来源、更新时间。
验收标准不是"代码跑通",而是:
text
随机抽取 100 个知识块,人工检查其中 95 个以上是否语义完整、是否主题单一。
如果分块质量差,后面的检索和生成都会受拖累。
第二阶段:先解决"问得到"
这一阶段的核心是让用户问题与知识块之间建立更准确的匹配。
优先顺序建议:
- 先加入 BM25 混合检索,成本最低,对精确词效果明显。
- 再根据业务情况加入 QA 生成,让问题匹配问题。
- 复杂模糊问题较多时,再加入多查询改写、RAG-Fusion 或 Step-Back。
- 用户问题特别短、专业术语少时,可以尝试 HyDE。
每加一个策略,都要在同一份评估集上记录 Recall@K、MRR 和延迟变化。不要让"感觉有效"替代数据。
第三阶段:解决"返回得完整"
召回命中后,还需要保证返回给模型的内容足够完整。
- 如果小块检索导致上下文不足,可以升级为父子文档检索器。
- 如果文档结构复杂、没有明确标题边界,可以尝试 Agent 代理分块。
- 如果多篇文档高度重复,可以增加去重与文档级聚类。
- 如果需要同时保留表格、列表、代码等结构,可以把结构信息写入 metadata。
父子文档检索器特别适合作为这一阶段的默认选择,因为它实现简单,收益明确。
第四阶段:解决"送得干净"
检索到完整内容后,还要降低噪声、控制 Token。
- 先用 Rerank 把最相关结果排到前面。
- 再对少量候选文档做上下文压缩。
- 最后在 Prompt 中强制模型引用来源,并允许"资料不足"拒答。
一个常见错误是跳过 Rerank 直接压缩,导致压缩器处理大量无关文档,既慢又贵。正确顺序是"先排序,再截断,再压缩"。
参数调优顺序
调参时不要同时改五个变量。建议按下面的顺序进行:
- 先固定
chunk_size和chunk_overlap。 - 再调检索
Top-K。 - 然后调 BM25 与向量的融合权重。
- 再调 Rerank 的截断数量。
- 最后调生成模型的
temperature和 Prompt。
每一轮只改变一个变量,才能知道是什么带来了提升。
附录四:关键代码细节与实现思路
这一节把文档里反复出现的几个 LangChain 组件拆开讲清楚。理解它们之后,你完全可以脱离某个框架自行实现,也可以在不同版本之间迁移。
1. 自定义 Embeddings 的本质
Embeddings 需要实现两个方法:
python
def embed_documents(self, texts):
...
def embed_query(self, text):
...
embed_documents 用于批量向量化知识块,embed_query 用于向量化用户查询。很多服务端对二者采用同样的模型,但接口名不同是为了方便未来做差异化处理,例如查询侧使用更轻量的模型,或文档侧增加缓存。
批量嵌入时,建议按模型最大输入长度和限流情况分批调用,避免一次性传入过多文本导致超时或限流。每一批的大小可以从 16、32、64 开始测试。
2. 为什么代理问题需要保留 doc_id?
doc_id 是 QA 生成优化中的关键桥梁。
它的作用不是用来展示,而是用来"回源"。当用户查询命中一条代理问题时,系统必须知道这条问题来自哪个原始文档。没有 doc_id,检索器就只能返回问题本身,最终用户看到的不是答案,而是一句"糖尿病日常吃什么?"。
在 MultiVectorRetriever 中,doc_id 通常保存在子文档的 metadata 中,并通过 id_key 指定。父文档则存储在 docstore 中,键同样是 doc_id。整个回源过程如下:
text
用户查询
-> 向量库命中子问题
-> 读取子问题 metadata.doc_id
-> docstore.get(doc_id)
-> 返回原始父文档
这个模式不仅适用于 QA 生成,也适用于摘要、标题、假设答案等多种子向量场景。
3. ParentDocumentRetriever 的拆解
ParentDocumentRetriever 本质上做两件事:
- 构建索引时,用小分块器生成 child,存入向量库。
- 同时用大分块器生成 parent,存入 docstore,并建立映射。
检索时,它先找到 child,再返回 parent。因此,你可以不依赖 LangChain 的封装,自己用向量库加 KV 存储实现完全相同的效果。
自己实现的好处是控制力更强,可以保存更多中间信息,例如父子块的重叠关系、多个 child 指向同一 parent、parent 的来源页等。缺点是开发和测试成本更高。团队早期可以先用框架版本快速验证,再在需要时替换为自研实现。
4. ContextualCompressionRetriever 的注意点
ContextualCompressionRetriever 需要一个基础检索器和一个压缩器。压缩器通常调用 LLM,因此每次查询都会增加延迟和成本。
使用时要注意:
- 基础检索器的返回数量不宜过大,否则压缩器会被无关文档拖慢。
- 压缩器适合处理长文档,不适合处理已经是短问题的向量。
- 压缩结果可能丢失某些细节,因此关键业务可以先压缩再保留原始文档引用。
- 压缩 Prompt 必须明确要求"只输出与问题相关的原文片段,不要新增内容"。
如果延迟敏感,可以考虑使用交叉编码器直接重排序,替代 LLM 压缩。交叉编码器通常更快,但不能真正缩减文本长度;LLM 压缩可以减少 Token,但更慢。两者可以组合:先 Rerank,再压缩。
5. Agent 分块的异常处理
Agent 分块依赖 LLM 的结构化输出,失败概率比普通切分高。文档实验中,不同模型对同一个任务的表现差异很大。
建议在代码中至少加入以下机制:
python
def agentic_chunker(paragraph_text: str) -> List[KnowledgeChunk]:
for attempt in range(3):
try:
result = chain.invoke({"paragraph_text": paragraph_text})
if result and result.chunks:
return result.chunks
except Exception as exc:
logger.warning("chunking failed, attempt=%s, error=%s", attempt, exc)
return fallback_splitter.split(paragraph_text)
要点是:
- 失败时不能静默返回空列表。
- 可以重试,但要有最大次数。
- 最后必须有降级方案,例如用递归字符分块兜底。
- 原始模型输出要记录,便于定位是 JSON 解析失败、超长输出还是模型拒答。
附录五:线上可观测性与持续优化
RAG 上线不是终点,而是优化的开始。线上系统必须可观察,否则问题会变成黑盒。
1. 至少要记录的字段
每次查询建议记录:
- 用户原始问题。
- 改写后的查询。
- 各检索器返回的文档 ID 和分数。
- 融合后的候选列表。
- Rerank 前后的排序。
- 压缩前后的文本长度。
- 最终 Prompt 的 Token 数。
- 模型回答与引用来源。
- 用户反馈,例如点赞、点踩、复制、继续追问。
这些数据不用全部实时分析,但必须能够回溯。出现"答错"案例时,能快速回答三个问题:召回了什么、排序如何、最终送入模型的是什么。
2. 建立离线评估集
线上日志积累到一定量后,把真实用户问题脱敏,构建评估集。评估集要覆盖:
- 高频问题。
- 长尾问题。
- 多跳问题。
- 无答案问题。
- 需要拒答的敏感问题。
- 时效性问题。
每次改动检索策略、分块策略、模型或 Prompt 时,都用同一套评估集回归。这样优化就不再依赖个人感觉。
3. 监控成本与延迟
RAG 链路包含多次模型调用,成本容易失控。建议分别监控:
- 嵌入成本,主要在离线索引阶段。
- 查询改写成本,每次查询可能调用一次 LLM。
- 压缩成本,每个候选文档可能调用一次 LLM。
- 最终生成成本,与上下文长度强相关。
- 向量库与 BM25 的检索延迟。
- 各环节端到端延迟。
如果某些高级策略对指标提升很小,却显著增加延迟或成本,就应该为它们设置开关,按场景启用。
4. 灰度与降级
生产环境推荐设置多级降级:
text
正常链路:混合检索 -> Rerank -> 压缩 -> 生成
降级链路:混合检索 -> 截断 Top-K -> 生成
兜底链路:BM25 -> 截断 Top-K -> 生成
当 LLM 压缩超时、Rerank 服务不可用或成本超预算时,可以自动降级到更简单的链路,保证基本可用性。RAG 系统的稳定性往往比"偶尔多答对一题"更重要。
附录六:十个高频问题 FAQ
1. 是否所有 RAG 都要用 QA 生成优化?
不是。QA 生成会增加离线 LLM 调用成本,并且如果文档很多,问题生成时间会显著增加。它最适合用户提问方式多变、文档以陈述性知识为主的场景。如果用户问题本身就是规范化的关键词查询,BM25 或直接向量检索可能已经足够。
2. 父子文档检索和 QA 生成可以一起用吗?
可以。例如用 QA 生成的问题作为 child 向量,用完整段落作为 parent 文档。命中问题后,通过 doc_id 返回父文档。这样同时获得"问题匹配问题"和"返回完整上下文"两个优势。
3. Rerank 一定要用 LLM 吗?
不一定。交叉编码器,例如 BGE Reranker,通常比 LLM Rerank 更快、更便宜,适合大规模候选排序。LLM Rerank 更适合候选数量少、语义复杂、需要更强理解的场景。两者可以分阶段使用。
4. 上下文压缩会不会删掉关键信息?
有可能。压缩器本质上是一个有损过程。对于数字、剂量、金额、日期等关键事实,压缩时可能误删。因此关键业务应保留原文引用,并对压缩结果做忠实度校验。必要时跳过压缩。
5. 为什么我的 Agent 分块总是解析失败?
常见原因包括:模型没有按照 JSON 输出、字段名不一致、输出超出 max_tokens 被截断、Prompt 缺少示例。建议先打印原始输出,再针对性地加示例、降低 temperature、换模型或使用更简单的 Schema。
6. 向量检索和 BM25 的权重怎么定?
没有固定答案。可以从 0.5 / 0.5 开始,再根据测试集调整。关键词命中重要的场景,BM25 权重可以更高;语义泛化需求强的场景,向量权重可以更高。
7. 文档更新后索引如何同步?
推荐以 doc_id + 版本号 作为唯一标识。更新时先删除该文档旧版本的所有 chunk,再写入新版本。避免直接追加,否则新旧内容会同时存在,导致答案冲突。
8. RAG 回答一定要引用来源吗?
强烈建议。引用来源不仅能提升可信度,还能帮助用户验证答案,也方便后续错误归因。即使内部工具,也建议保留来源 ID 或链接。
9. 小知识库需要复杂 RAG 吗?
通常不需要。几十篇文档的场景,简单的递归分块、向量检索加 BM25 就能取得不错效果。复杂策略应等到数据规模、问题多样性或质量要求达到一定阈值后再引入。
10. RAG 效果不好时,先排查什么?
先确认答案需要的文档是否被召回。若未召回,排查切分、嵌入和查询改写;若已召回但排序靠后,排查融合与 Rerank;若已送入模型但回答错误,排查 Prompt 和生成控制。定位阶段比盲目换模型更重要。
附录七:从本文实验得到的十条经验
- 分块是天花板。再强的检索也救不回被切坏的答案。
- 问题与问题匹配,通常优于问题与文档匹配。
- 混合检索是低成本高回报的默认配置。
- 父子文档能同时兼顾检索精度和上下文完整度。
- Agent 分块前先粗切,稳定性和粒度都会更好。
- 不同模型的结构化输出能力差异很大,必须实测。
- 压缩器要作用于长文档,而不是已经很短的代理问题。
- 先重排再压缩,能降低延迟和成本。
- 没有评估集的 RAG 优化等于盲调。
- RAG 的目标不是堆高级策略,而是让最相关的信息以最干净的形式进入模型。
结语补记
如果只从本文带走一个观点,我希望是:RAG 优化是信息流工程,而不是模型魔法。当你把切分、召回、融合、重排、压缩、生成每一段管道都打磨清楚,答案质量会自然提升;当你跳过中间环节,只想靠更大的模型硬扛,成本和风险都会迅速上升。
从一份看似普通的实验代码出发,我们能观察到的其实是一个完整的方法论:先发现真实痛点,再用最小可运行实验验证策略,最后把有效策略工程化。希望你也能按照这个路径,在自己的知识库问答项目里做出可量化、可复现、可维护的改进。
附录八:RAG 优化检查清单
这份清单用于在项目启动前、上线前和效果排查时快速自查。它不是标准答案,而是一组容易遗漏但影响很大的问题。
数据与切分
-
文档是否已经完成清洗:是否去掉了页眉页脚、导航栏、乱码、重复段落和低质量 OCR 文本。清洗不干净,再好的分块器也只能在垃圾上切块。
-
是否保留文档结构:Markdown 标题、HTML 标签、表格、列表、代码块是否进入 metadata,而不是被简单当作纯文本。结构信息在过滤、排序和引用展示时非常有用。
-
分块是否语义完整:随机抽检时,每个块是否围绕一个主题,是否存在句子被拦腰截断的情况。人工抽检比任何参数说明都更有说服力。
-
是否有重叠设计:相邻块之间是否设置了合理重叠,重叠比例是否与块大小匹配。没有重叠时,边界信息很容易丢失;重叠过大又会造成大量重复索引。
-
元数据是否稳定唯一 :每个块是否都有
doc_id、来源、版本号、更新时间。没有稳定 ID,更新和回源都会变得困难。
检索与排序
-
是否同时构建了向量索引和关键词索引:纯向量检索在精确词、型号、编号、法律条文等场景中可能漏召回。BM25 或类似关键词检索通常成本很低,却能把鲁棒性提上来。
-
是否记录了各检索器的独立分数:不同检索器分数量纲不同,不能直接相加。记录原始分数有助于选择融合策略,也有助于问题排查。
-
是否使用 RRF 或带权融合:如果多路召回各自为政,最终排序很容易被某一路的高分噪声带偏。统一的排名融合比直接拼结果更稳定。
-
是否做了 Rerank:初步检索返回的候选可以放宽到 20 至 50 条,但进入模型前必须经过精排和截断。没有 Rerank 时,Top-K 只是"差不多相关",而不是"最相关"。
-
是否做了权限与时效过滤:不同用户能看到的文档范围是否在检索层过滤。权限问题不能依赖模型自觉,必须在数据进入 Prompt 前解决。
生成与安全
-
Prompt 是否要求引用来源:回答是否带来源编号或链接,是否方便用户回溯和系统追责。引用是 RAG 产品可信度的基础。
-
是否允许模型拒绝回答:当资料不足、置信度过低或问题超出知识库范围时,模型是否被允许说"不知道"。不允许拒答,模型就会倾向于编造。
-
是否对高风险场景做了拦截:医疗急症、法律建议、金融交易、安全操作等问题是否走人工或特殊流程。生成式回答不能替代专业判断。
-
是否限制答案长度:无意义的超长回答不仅消耗 Token,也更容易产生前后矛盾和幻觉。答案长度应根据业务设计上限。
-
是否做事实一致性校验:关键答案中的数字、日期、名称、条款是否与来源文档一致。必要时用规则或额外模型校验。
工程与评估
-
是否保存查询日志:日志是否包含原始问题、改写查询、检索结果、最终 Prompt、模型回答和用户反馈。没有日志,线上问题只能靠猜。
-
是否建立回归评估集:是否有固定数据集覆盖高频、长尾、多跳、无答案、敏感问题等类型。没有评估集,任何优化都无法被证明。
-
是否监控延迟与成本:是否分别统计嵌入、查询改写、Rerank、压缩、生成各环节的耗时和 Token。成本失控往往发生在某个容易被忽略的小环节。
-
是否有降级方案:Rerank 或压缩服务异常时,是否能自动回退到简单链路。可用性优先于"偶尔多答对一题"。
-
是否定期复盘 bad case:是否每周或每月从线上日志中抽出一批失败案例,定位问题阶段,并补充到评估集。持续复盘才能让系统越用越好。
附录九:工业级 RAG 系统架构说明
当系统从 Demo 走向生产,架构需要处理的不只是"检索 + 生成",还包括并发、缓存、安全、权限、多租户、流式输出和可观测性。下面是一个简化但完整的架构视图。
1. 接入层
接入层负责协议处理、鉴权、限流、请求参数校验和日志采集。无论对外提供 HTTP API、WebSocket、企业内部机器人,还是嵌入办公软件,接入层都应该与 RAG 核心逻辑解耦。
接入层需要记录:
- 调用方身份。
- 用户所在租户或组织。
- 请求时间与耗时。
- 用户反馈与会话上下文。
- 是否命中缓存。
2. 查询理解层
查询理解层负责把用户输入变成更适合检索的形式。它可能包括:
- 意图识别:用户是想问事实、做比较、求操作步骤,还是闲聊。
- 实体抽取:识别产品名、疾病名、法条号、日期、地点等关键实体。
- 查询改写:多查询生成、Step-Back、HyDE、同义词扩展。
- 权限解析:确定当前用户可访问的数据范围。
这层不一定要一次性全做。可以从最简单的查询改写开始,再逐步增加。
3. 多路召回层
多路召回层的目标是"宁可多召回,不要漏召回"。常见召回通道包括:
- 向量检索:语义匹配。
- BM25 或全文检索:关键词匹配。
- 结构化过滤:按时间、部门、产品、标签过滤。
- 知识图谱:适合实体关系和复杂多跳问题。
- 人工规则:适合高频且边界清晰的场景。
每个通道返回自己的候选列表和分数。多路召回的代价是候选数量增加,但只要后面有 Rerank,宽召回通常是划算的。
4. 融合与重排层
融合层把多路候选统一排序。可以用带权融合、RRF 或学习排序模型。
重排层进一步用交叉编码器或 LLM 对候选进行精细排序。重排后的候选数量应控制在一个较小范围,例如 3 到 8 条,然后才进入压缩或生成。
一个实用的参数策略是:
text
向量检索:Top 50
BM25:Top 50
RRF 融合:Top 20
Rerank:Top 5
LLM 上下文压缩:逐条处理 Top 5
最终生成:拼接压缩后的 3 至 5 条
具体数字因业务而异,但这个"宽召回、窄输入"的结构是通用的。
5. 上下文构建层
上下文构建层负责把选中的文档变成最终 Prompt。它需要处理:
- 去重:同一内容可能在多个文档中重复出现。
- 截断:根据模型上下文窗口和预算截断长文档。
- 排序:把最相关的内容放在靠前位置。
- 引用编号:为每条上下文分配稳定编号,供生成时引用。
- 模板拼装:把系统指令、对话历史、上下文、用户问题组合起来。
上下文构建的质量,直接决定模型看到的信息是否干净、完整、易理解。
6. 生成层
生成层调用大模型输出答案。除了基础回答,生产系统还可能需要:
- 流式输出:提升用户体验。
- 结构化输出:用于表格、JSON、多段回答等固定格式。
- 引用解析:把回答中的
[1][2]映射回来源链接。 - 拒答判断:根据置信度和安全策略决定是否回答。
- 后处理:去除敏感信息、添加免责声明、格式化排版。
生成层应保持轻量,不要把太多业务规则硬编码进模型调用代码。
7. 缓存层
RAG 系统中,很多查询具有重复性或相似性。缓存可以显著降低成本。
至少可以设置两类缓存:
- 精确查询缓存:相同问题在短时间内直接返回缓存答案。
- 检索缓存:相同或相似查询复用检索结果。
缓存策略要结合数据更新频率设计。知识库更新后,相关缓存必须失效,否则会返回过期答案。
8. 可观测层
可观测层贯穿所有环节,负责采集日志、指标和链路追踪。推荐使用结构化日志,并为每个请求生成一个 trace_id。
核心指标包括:
- 各阶段耗时。
- 各阶段 Token 消耗。
- 召回率、MRR、nDCG。
- 答案忠实度。
- 用户满意度和拒绝率。
- 缓存命中率。
- 错误率和降级次数。
只有把这些指标沉淀下来,RAG 优化才能从"项目经验"变成"组织能力"。
附录十:从实验中总结的决策树
面对一个 RAG 效果问题,可以用下面的决策树快速定位。
第一层:正确答案是否被召回?
如果没有,继续追问:
- 文档是否被正确解析和切分?
- 答案是否被切到多个块中?
- 用户问题和知识块是否存在语义错位?
- 嵌入模型是否适配当前领域?
- 是否需要 BM25 补充精确词召回?
对应策略依次是:结构感知分块、QA 生成、混合检索、领域嵌入或微调、关键词索引。
第二层:正确答案被召回,但排序是否靠前?
如果答案在候选集里,但最终没有进入模型上下文,那么问题通常在融合或重排。
检查:
- 多路分数是否没有统一融合。
- 是否缺少 Rerank。
- Top-K 截断是否过早。
- 元数据过滤是否误伤了正确文档。
对应策略是 RRF、交叉编码器或 LLM Rerank,并适当放宽中间候选数量。
第三层:答案进入模型,但模型是否使用了它?
如果正确内容已经进入 Prompt,但模型仍然答错,那么问题在生成层。
检查:
- Prompt 是否要求"仅根据资料回答"。
- 上下文是否过长,导致关键信息被稀释。
- 上下文顺序是否合理。
- 模型是否存在先验偏见,忽略了资料。
对应策略是重写生成 Prompt、压缩上下文、把最相关文档放在更靠前的位置、选择指令遵循更好的模型。
第四层:答案正确,但用户不信任或体验差?
如果答案事实正确,但缺少来源、格式混乱、没有拒答机制,产品体验仍然会打折扣。
检查:
- 是否提供可点击来源。
- 是否区分"确定回答"和"可能回答"。
- 是否在资料不足时诚实说明。
- 是否输出过长或过短。
对应策略是增加引用、置信度提示、拒答话术和输出格式控制。
最终建议
不要把每个问题都当作"模型不够强"。先沿"召回、排序、上下文、生成、体验"这条链路逐层排查,通常很快就能定位。RAG 系统的可维护性,正体现在你能不能稳定地重复这个排查过程,而不是每次都从头猜。
延伸阅读与后续实践
如果你想把本文内容继续深入,建议按下面的主题展开:
- 评测体系:学习 RAGAS、TruLens 或自建指标,建立忠实度、相关性、上下文召回等评估。
- Rerank 专题:对比 BGE Reranker、Cohere Rerank 和 LLM Rerank 的精度、延迟与成本。
- 查询改写专题:研究多查询、Step-Back、HyDE、查询分解在不同数据上的收益。
- Agentic RAG:把检索工具、生成工具、路由和反思机制组合起来,处理更复杂问题。
- 知识图谱与 RAG 融合:在实体关系、多跳推理和可解释性上进一步提升。
- 多模态 RAG:扩展到图片、表格、音视频等非纯文本内容。
学习路径上,建议先做三个实验:固定分块与递归分块对比、直接检索与 QA 生成对比、有无 Rerank 对比。每个实验都记录召回指标、端到端延迟和 Token 成本。跑完这三个实验,你会对 RAG 优化形成更稳定的判断。
附录十一:模型选择与成本测算
RAG 的优化方案通常不是零成本的。很多策略虽然能提升召回,但也会增加 LLM 调用次数和 Token 消耗。我们需要把它们放进同一个成本框架里评估。
1. 成本来自哪里?
一次完整的 RAG 查询可能包含以下模型调用:
- 查询改写:一次 LLM 调用。
- 多查询生成:一次 LLM 调用,输出多条查询。
- Step-Back 或 HyDE:额外一次 LLM 调用。
- 重排序:如果使用 LLM Rerank,每个候选文档可能调用一次。
- 上下文压缩:每个候选文档可能调用一次。
- 最终生成:一次 LLM 调用,输出答案。
此外,离线阶段还有:
- QA 生成:每个文档块调用一次 LLM。
- Agent 分块:每个段落调用一次 LLM。
- 摘要或标题生成:按需调用。
因此,一个高级 RAG 链路可能一次查询调用三到六次模型。单次调用不贵,但乘以上线流量后,成本会迅速上升。
2. 如何做成本测算?
先定义一批典型查询,统计每种策略的调用次数和平均 Token。然后估算:
text
单次查询成本 = 查询改写成本 + 检索成本 + 重排成本 + 压缩成本 + 生成成本
月度成本 = 单次查询成本 × 月查询量
检索成本通常较低,但也要考虑向量库和 BM25 的机器资源。压缩和重排往往比想象中贵,因为它们按候选文档数量重复调用。
3. 什么时候值得花更多钱?
判断标准不是"策略是否高级",而是"指标提升是否值回成本"。
可以这样评估:
- 如果 QA 生成让召回率提升 20%,且离线成本可以接受,就值得。
- 如果 HyDE 只提升 2%,却让每次查询多一次 LLM 调用,就可能不值得。
- 如果 Rerank 让答案正确率提升 15%,通常值得。
- 如果上下文压缩减少 40% 的生成 Token,同时不显著降低质量,通常值得。
建议每个策略都记录两个数字:指标提升幅度、单次查询成本变化。两者结合,才能做出理性决策。
4. 模型分层策略
并不是所有环节都需要最强模型。可以根据任务难度使用不同模型:
- 分块和问题生成:中等模型通常足够。
- 查询改写:中等模型通常足够。
- Rerank:可以用专门的交叉编码器,也可以用中等 LLM。
- 上下文压缩:需要较强指令遵循,但未必要用最大模型。
- 最终生成:对质量影响最大,通常使用最强模型。
模型分层能显著降低成本,同时保持最终答案质量。
附录十二:RAG 优化中的反直觉结论
1. 更大的块不一定更好
直觉上,块越大,上下文越完整。但块太大时,向量表示会被多个主题稀释,检索精度下降,Token 成本也会增加。合适粒度比绝对大小更重要。
2. 更复杂的检索不一定更准
多查询、Step-Back、HyDE 都是有效工具,但它们都有自己的适用条件。盲目叠加,可能让噪声也一起增加。复杂策略应该由评测数据驱动,而不是由技术流行度驱动。
3. 向量相似度高不等于答案正确
向量模型衡量的是语义相似性,而不是"能否回答问题"。一段与问题高度相似的背景介绍,可能并不包含答案。因此需要 Rerank 和生成层进一步判断。
4. 召回越多不一定越好
如果缺少精排和压缩,召回 Top-50 再把 50 条全塞给模型,模型很可能被噪声淹没。宽召回必须配合窄输入。
5. QA 生成看似增加工作量,实际可能降低整体成本
QA 生成虽然需要离线调用 LLM,但如果它能让正确答案稳定进入前几名,就可以减少为了召回而设置的超大 Top-K,也能减少后续压缩和重排压力。局部成本增加,可能换来整体成本下降。
6. 上下文压缩不一定会减少 Token
如果压缩器处理的本来就是短文本,或者压缩 Prompt 本身很长,压缩后的净收益可能很小,甚至反而增加成本。压缩要作用于真正的长文档,并且要计算完整链路成本。
7. 混合检索常常比单独优化向量模型更划算
当系统在专有名词、型号、编号上漏召回时,很多人的第一反应是换更强的向量模型。其实先加一个 BM25 往往更便宜,也能快速验证问题是否出在关键词匹配上。
8. Agent 分块不一定比规则分块好
Agent 分块适合复杂、非结构化文本,但对于结构清晰的 Markdown,规则分块更快、更稳定、更便宜。不要为了"智能化"而放弃简单可靠的方案。
9. 模型越大,结构化输出未必越稳
文档实验显示,部分大模型或中等模型能稳定输出知识块,也有一些模型多次返回空列表。结构化输出能力与参数规模并不完全正相关,必须实测。
10. 最好的优化可能是先做评测
很多团队在没有评估集的情况下反复调参,最终不知道哪个改动有效。建立一个小型但稳定的评估集,常常比引入新策略更重要。
附录十三:给不同团队的落地建议
创业团队或小型项目
建议先做减法:
- 先跑通"递归分块 + 向量检索 + BM25 + 简单 Prompt"。
- 用 50 到 100 条真实问题建立评估集。
- 只加入最明显有效的策略,例如 QA 生成或父子文档。
- 成本敏感时,优先做缓存和模型分层。
小项目的优势是迭代快,劣势是资源少。不要一开始就搭建复杂架构,先验证用户价值。
企业知识库项目
建议更重视权限、版本、安全和可观测性:
- 分块和索引阶段必须记录来源、部门、更新时间、权限标签。
- 检索层必须做权限过滤,而不是依赖模型。
- 建立文档更新与索引同步机制。
- 对回答做引用溯源和合规审核。
- 设置降级链路,保证核心业务稳定。
企业项目通常对稳定性和可解释性的要求高于对"偶尔更强"的要求。
研究或原型验证
建议把重点放在可复现实验上:
- 固定数据集和评估指标。
- 每个策略单独实验,记录结果。
- 同时记录成本、延迟和失败案例。
- 尝试 Agent 分块、HyDE、Step-Back 等较新策略,观察边界条件。
研究项目的价值不在于直接生产,而在于验证哪些策略在什么条件下有效。
最后再总结
这篇文章从一个很具体的问题出发:为什么文档切碎了、问题匹配不上、召回有噪声、上下文太长,最终答案就会变差。我们沿着代码实验,走完了从智能分块、QA 生成、高级召回到上下文压缩的完整路径。
真正有效的 RAG 优化,不是追逐每一个新名词,而是建立一条清晰的信息管道。管道的一端是海量文档,另一端是大模型生成。你的工作,就是让这条管道每一段都尽可能干净、准确、可观察。
当你下次面对一个答错的 case 时,不妨先停下来问自己四个问题:
- 正确内容有没有被切坏?
- 正确内容有没有被召回?
- 正确内容有没有排在前面?
- 正确内容有没有被模型真正使用?
顺着这四个问题排查,比立刻换模型、立刻加 Agent、立刻上图谱,更能帮你找到答案。
附录十四:如何从 0 到 1 复现本文实验
如果你希望把文章中的策略真正跑起来,可以按照下面的顺序复现。这个流程不要求你拥有很强的工程背景,但建议至少熟悉 Python 环境和基础的 LangChain 使用。
第一步:准备环境
推荐使用 Python 3.11 或以上版本,并创建独立虚拟环境。核心依赖包括:
text
langchain
langchain-openai
langchain-chroma
langchain-community
langchain-classic
langchain-text-splitters
pydantic
chromadb
不同版本的 LangChain 组件名和导入路径可能不同。文档实验中已经出现 langchain-community 被标记为 sunset 的提示,因此复现时建议优先使用官方迁移后的独立包,并锁定版本,避免被弃用警告干扰。
第二步:准备最小知识库
不要一上来就用几万篇文档。先准备 10 到 20 篇短文档,覆盖不同类型:说明文、问答文、列表、表格、对话。数量少,方便快速查看分块结果和检索结果。
每篇文档保存为 Markdown 或纯文本,并记录来源和更新时间。小规模数据能让你更直观地观察每个策略的效果差异。
第三步:先跑通固定分块与递归分块
分别使用 CharacterTextSplitter 和 RecursiveCharacterTextSplitter 对同一批文档切分,打印每个块的内容。人工检查哪些块被截断、哪些块主题混乱。这个步骤虽然简单,但能帮助你建立对分块质量的直觉。
第四步:建立向量索引并做直接检索
选择一个兼容 OpenAI 接口的嵌入服务,封装 embed_documents 和 embed_query。把文档块写入 Chroma,然后用 10 个典型问题做检索。记录每个问题的 Top-K 是否包含正确内容。
如果这个阶段的召回率已经很高,就不需要急着引入复杂策略。如果召回率低,再继续下一步。
第五步:加入 QA 生成
为每个文档块生成 3 条代表性问题,写入向量库,并通过 doc_id 回源到原始文档。使用 MultiVectorRetriever 完成"命中问题、返回原文"。对比直接检索与 QA 生成后的召回变化。
第六步:加入 BM25 混合检索
用 BM25Retriever 和向量检索组成 EnsembleRetriever。对比纯向量、纯 BM25、混合检索三者的结果。特别关注包含型号、编号、专业术语的问题。
第七步:尝试父子文档检索
把文档拆成小子块和大父块,用 ParentDocumentRetriever 检索。观察最终返回的上下文是否更完整,以及是否比直接返回小分块更适合生成回答。
第八步:加入重排序与上下文压缩
如果候选文档较多,先加入 Rerank 截断到少量结果,再对长文档做上下文压缩。记录压缩前后长度和最终答案质量。特别注意压缩器是否真的减少了无关内容,而不是只做表面改写。
第九步:建立评估表
把每个策略的实验结果记录在同一张表里。推荐字段包括:
| 策略 | Recall@5 | MRR | 平均延迟 | 平均 Token | 人工判断 |
|---|---|---|---|---|---|
| 直接向量检索 | |||||
| 向量 + BM25 | |||||
| QA 生成 | |||||
| 父子文档 | |||||
| Rerank + 压缩 |
有了这张表,你就不会再凭感觉决定是否保留某个策略。
第十步:沉淀 bad case
把失败的查询、检索结果和最终回答保存下来。每隔一段时间复盘一次,找出问题发生在哪一层,并把新发现的问法补充到评估集中。这是让 RAG 系统持续变好的最朴素方法。
最终交付清单
本文已经覆盖的内容可以概括为:
- 固定长度分块、递归分块、结构感知分块、对话轮次分块。
- QA 生成、多向量检索、混合检索、RAG-Fusion、Step-Back、HyDE。
- 父子文档检索、Agent 代理分块、多模型结构化输出对比。
- 重排序、上下文压缩、生成控制与幻觉防范。
- 离线索引、在线查询、评估体系、成本测算和降级方案。
- 医疗领域落地案例、检查清单、决策树、复现步骤。
你可以把本文当作一份 RAG 优化的工作手册:先按检查清单自检,再按决策树定位问题,最后用复现步骤验证策略。真正动手跑起来,比反复阅读概念更重要。
发布后记
写作本文的初衷,是看到很多 RAG 项目把大部分精力放在模型和框架上,却忽略了检索链路中那些"不起眼但决定成败"的细节。文档切分、问题生成、混合检索、父子文档、重排序和上下文压缩,每一项单独看起来都不复杂,但组合起来就是一个完整的信息系统工程。
技术博客的意义,不只是记录一段代码,而是把一次真实实验中的判断、取舍和踩坑过程留下来。本文尽量保留了可运行代码、实验输出和决策依据,就是希望读者在阅读后,不是只记住几个名词,而是能够回到自己的项目里,明确下一步应该验证什么。
如果你在复现或落地过程中遇到新的问题,建议把失败的检索链路保存下来:原始问题、切分结果、候选文档、最终 Prompt 和模型回答。把这些材料整理清楚,很多时候答案已经浮出水面。RAG 优化没有终点,只有一轮又一轮更清晰的迭代。