RAG 优化实战:从精确召回、QA 生成到上下文压缩的全链路工程化方法

阅读对象:正在搭建知识库问答、企业文档助手、医疗/法律/金融领域检索增强应用,但发现"检索到的内容不对""答案经常幻觉""Token 成本高得离谱"的开发者。

阅读收益:本文不会只讲概念,而是沿着一份真实的 RAG 优化实验代码,逐步拆解智能分块、QA 生成优化、高级召回策略、RAG 后处理工程优化四大环节,给出可运行代码、实验结果、调优建议和工程落地清单。


一、先看清问题:为什么你的 RAG 总是"答非所问"?

很多人把 RAG 想成一件很简单的事:

text 复制代码
文档 -> 切块 -> 向量化 -> 存入向量库 -> 用户提问 -> 向量检索 -> 拼进 Prompt -> 大模型回答

这条链路在 Demo 上能跑通,一旦进入真实业务,就会暴露出几个高频问题:

  1. 切分太粗暴。按 500 字或 1000 字硬切,导致一个完整答案被截断,或者一个块里塞进多个无关主题。
  2. 语义匹配错位。用户问的是口语化问题,库里存的是陈述性文档,向量相似度并不总是靠谱。
  3. Top-K 不等于 Top-K 有用。向量检索返回的前 10 条可能只是"看起来相关",大量噪声一起进入大模型。
  4. 上下文过长。为了提升召回,很多人把 Top-20、Top-30 全塞进 Prompt,结果 Token 成本暴涨,模型注意力被稀释。
  5. 缺少工程化后处理。没有重排序、没有压缩、没有查询改写,也没有评估指标,上线后只能靠人工感觉判断好坏。

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_sizechunk_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 分块策略怎么选?

一个简单决策顺序:

  1. 先看文档类型:Markdown、HTML、PDF 转出的结构化文档优先用结构感知分块。
  2. 再看语义粒度:普通新闻、论文、说明书,可以先尝试递归字符分块,再根据检索效果微调。
  3. 对话类数据:优先按轮次、发言人、会议主题切分。
  4. 复杂非结构化数据:考虑后面的 Agent 代理分块,让 LLM 动态决定知识块边界。
  5. 永远做评测 :同一份数据集上对比不同 chunk_sizechunk_overlap 和切分器,观察 Recall 与答案质量,而不是靠感觉。

三、QA 生成优化:让"问题"去匹配"问题"

智能分块解决的是"文档怎么切"。但还有一个更隐蔽的问题:用户提问方式与文档陈述方式存在语义鸿沟

比如库里有一句:

糖尿病患者建议控制碳水摄入,增加低糖蔬菜和优质蛋白。

用户却可能问:

小明的爸爸👨 60 岁血糖 10,一日三餐具体吃什么?

如果直接用用户问题去匹配陈述文档,向量相似度可能不高。但如果我们提前用 LLM 为文档生成几个"用户可能提出的问题",再让用户问题去匹配这些问题,命中率会大幅提升。

3.1 核心思想:为每个文档块创建多个检索入口

QA 生成优化通常这样工作:

  1. 将原始文档按合适粒度切块。
  2. 为每个文档块调用 LLM,生成若干条"能够被该文档回答的问题"。
  3. 向量库里只存储这些生成的问题。
  4. 每条问题通过 doc_id 关联回原始文档块。
  5. 用户提问时,先在"问题库"中检索。
  6. 命中某条问题后,不返回问题本身,而是返回它对应的原始文档块。

这相当于为一个知识点创造了多个不同角度的入口。即使用户措辞刁钻,只要能和其中一条代理问题匹配,就能找到答案。

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,
)

实际项目里,最好把 modelbase_urlapi_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,
)

检索流程变成:

  1. 用户查询向量化。
  2. sub_docs 的问题向量中搜索。
  3. 命中问题的 metadata 里找到 doc_id
  4. 从 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 的思路是:

  1. 让 LLM 生成一个更高层次的"后退问题"。
  2. 用"原始问题 + 后退问题"一起检索。
  3. 把两类结果合并后交给 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.2
  • deepseek-ai/DeepSeek-V4-Flash-0731
  • stepfun-ai/Step-3.7-Flash
  • Qwen/Qwen3.8-Flash-Next
  • Qwen/Qwen3.8-27B
  • Qwen/Qwen3-Next-80B-A3B-Instruct

但也有一部分模型多次运行都输出 0 个知识块:

  • ZhipuAI/GLM-4.7-Flash
  • Tencent-Hunyuan/Hy3
  • MiniMax/MiniMax-M3
  • Qwen/Qwen3.5-27B
  • Qwen/Qwen3-8B

这里"输出 0 个知识块"往往不是模型完全不会做,而是它在 JSON 结构、字段名、指令遵循或输出长度上出了问题,导致 Pydantic 解析失败,而代码在异常时返回了空列表。

这个实验给我们的工程启示是:

  • 不要把 Agent 分块当成即插即用能力,必须针对具体模型做小样本验证。
  • 解析失败要记录日志,不要静默吞掉异常。
  • 增加重试机制,尤其是 JSON 格式解析失败时,可以要求模型重新输出。
  • 小模型不一定差,大模型也不一定稳,要看指令遵循和结构化输出能力,而不是只看参数规模。
  • 先粗分再 Agent 分块,通常比把超长文本直接丢给模型更稳定。

五、RAG 后处理工程优化:让进入 Prompt 的上下文更干净

即使召回做得好,初步检索返回的 Top-K 文档仍然可能包含噪声。把所有结果原封不动地交给 LLM,会带来两个问题:

  1. Token 浪费:无关片段也占上下文窗口。
  2. 答案被干扰:模型可能从噪声里"找到"并拼出错误答案。

因此,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,除了吃药,具体哪些食物可以多吃,哪些绝对要避免?

压缩前和压缩后长度一样。这是因为示例中的基础检索器返回的是生成的问题,而不是原始长文档。问题本身已经很短,语义上几乎每个词都与查询相关,因此压缩器没有多少可删内容。

这个现象恰好说明了一个重要问题:压缩器应该作用在真正需要压缩的对象上

如果你想展示"长文档压缩成短片段"的效果,应该:

  1. 用父子文档检索器返回父文档。
  2. 或从 docstore 中映射回原始长文档。
  3. 再对原始长文档执行上下文压缩。

工程上通常的推荐链路是:

text 复制代码
原始查询
  -> 查询改写 / 扩展
  -> 混合检索
  -> 重排序
  -> 上下文压缩
  -> 拼装最终 Prompt

压缩器放在重排序之后,只处理少量高相关文档,既省 Token,又避免过多 API 调用。

5.4 生成控制与幻觉防范

后处理不只发生在检索层,也发生在生成层。至少可以做四件事:

  • 强制引用:要求模型回答时标明来源编号。
  • 无答案拒绝:当检索结果置信度过低时,让模型回答"未找到相关资料",而不是硬编。
  • 事实拆解:把回答拆成可验证声明,再与检索结果比对。
  • 答案长度控制:根据业务设置最长生成长度,降低幻觉和成本。

例如 Prompt 可以这样写:

text 复制代码
请仅根据以下资料回答问题。
如果资料不足以回答,请明确说"当前资料不足"。
回答时必须引用来源编号,例如 [1][2]。
资料:
[1] {doc1}
[2] {doc2}

这类约束不能完全消除幻觉,但可以显著降低"一本正经胡说八道"的概率。


六、把这些策略组合成一套可落地的 RAG 工程

单独看每个策略,会觉得"都很简单"。真正的难点在于组合和评估。下面给出一套可落地的工程路径。

6.1 离线索引阶段

  1. 文档解析与清洗:PDF、Word、HTML、Markdown 统一转成带结构的文本。
  2. 结构预切分:按标题、段落、表格、列表先做粗切。
  3. 智能分块:普通文本用递归分块,复杂文本用 Agent 分块,对话用轮次分块。
  4. 生成元数据:为每个块保存来源、章节、标题、更新时间、权限、文档类型等。
  5. 生成检索入口:可选地生成代表性问题、标题、摘要、假设性答案等子文档。
  6. 构建索引:同时构建向量索引和 BM25 索引。
  7. 父子映射:如果使用父子检索器,保存 child 到 parent 的映射关系。
  8. 版本化:文档更新时,保证索引、docstore、元数据一起更新,避免新旧文档混用。

6.2 在线查询阶段

  1. 查询理解:识别用户意图、实体、时效性要求、权限范围。
  2. 查询改写:多查询生成、Step-Back、HyDE、同义词扩展等按需使用。
  3. 多路召回:向量检索 + BM25,必要时加入结构化过滤。
  4. 融合排序:RRF 或带权融合,得到候选列表。
  5. 重排序:使用交叉编码器或 LLM Rerank,把最相关结果排到前面。
  6. 上下文压缩:对长文档做 query-aware 压缩,控制 Token。
  7. 生成回答:拼装 Prompt,要求引用来源,支持无答案拒绝。
  8. 返回溯源:把答案、引用来源、置信度一起返回给用户。

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=512chunk_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 噪声太多,模型自然容易被干扰。
  • 上下文过长,成本自然上涨。

更务实的做法是:

  1. 先做好结构感知分块,保住文档语义。
  2. 再用 QA 生成为知识点创建多个检索入口。
  3. 然后用父子文档检索兼顾精度与上下文。
  4. 最后用重排序 + 上下文压缩控制进入模型的上下文质量。

真正成熟的 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_documentsembed_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 个以上是否语义完整、是否主题单一。

如果分块质量差,后面的检索和生成都会受拖累。

第二阶段:先解决"问得到"

这一阶段的核心是让用户问题与知识块之间建立更准确的匹配。

优先顺序建议:

  1. 先加入 BM25 混合检索,成本最低,对精确词效果明显。
  2. 再根据业务情况加入 QA 生成,让问题匹配问题。
  3. 复杂模糊问题较多时,再加入多查询改写、RAG-Fusion 或 Step-Back。
  4. 用户问题特别短、专业术语少时,可以尝试 HyDE。

每加一个策略,都要在同一份评估集上记录 Recall@KMRR 和延迟变化。不要让"感觉有效"替代数据。

第三阶段:解决"返回得完整"

召回命中后,还需要保证返回给模型的内容足够完整。

  • 如果小块检索导致上下文不足,可以升级为父子文档检索器。
  • 如果文档结构复杂、没有明确标题边界,可以尝试 Agent 代理分块。
  • 如果多篇文档高度重复,可以增加去重与文档级聚类。
  • 如果需要同时保留表格、列表、代码等结构,可以把结构信息写入 metadata。

父子文档检索器特别适合作为这一阶段的默认选择,因为它实现简单,收益明确。

第四阶段:解决"送得干净"

检索到完整内容后,还要降低噪声、控制 Token。

  • 先用 Rerank 把最相关结果排到前面。
  • 再对少量候选文档做上下文压缩。
  • 最后在 Prompt 中强制模型引用来源,并允许"资料不足"拒答。

一个常见错误是跳过 Rerank 直接压缩,导致压缩器处理大量无关文档,既慢又贵。正确顺序是"先排序,再截断,再压缩"。

参数调优顺序

调参时不要同时改五个变量。建议按下面的顺序进行:

  1. 先固定 chunk_sizechunk_overlap
  2. 再调检索 Top-K
  3. 然后调 BM25 与向量的融合权重。
  4. 再调 Rerank 的截断数量。
  5. 最后调生成模型的 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 和生成控制。定位阶段比盲目换模型更重要。


附录七:从本文实验得到的十条经验

  1. 分块是天花板。再强的检索也救不回被切坏的答案。
  2. 问题与问题匹配,通常优于问题与文档匹配
  3. 混合检索是低成本高回报的默认配置
  4. 父子文档能同时兼顾检索精度和上下文完整度
  5. Agent 分块前先粗切,稳定性和粒度都会更好
  6. 不同模型的结构化输出能力差异很大,必须实测
  7. 压缩器要作用于长文档,而不是已经很短的代理问题
  8. 先重排再压缩,能降低延迟和成本
  9. 没有评估集的 RAG 优化等于盲调
  10. RAG 的目标不是堆高级策略,而是让最相关的信息以最干净的形式进入模型

结语补记

如果只从本文带走一个观点,我希望是:RAG 优化是信息流工程,而不是模型魔法。当你把切分、召回、融合、重排、压缩、生成每一段管道都打磨清楚,答案质量会自然提升;当你跳过中间环节,只想靠更大的模型硬扛,成本和风险都会迅速上升。

从一份看似普通的实验代码出发,我们能观察到的其实是一个完整的方法论:先发现真实痛点,再用最小可运行实验验证策略,最后把有效策略工程化。希望你也能按照这个路径,在自己的知识库问答项目里做出可量化、可复现、可维护的改进。


附录八:RAG 优化检查清单

这份清单用于在项目启动前、上线前和效果排查时快速自查。它不是标准答案,而是一组容易遗漏但影响很大的问题。

数据与切分

  1. 文档是否已经完成清洗:是否去掉了页眉页脚、导航栏、乱码、重复段落和低质量 OCR 文本。清洗不干净,再好的分块器也只能在垃圾上切块。

  2. 是否保留文档结构:Markdown 标题、HTML 标签、表格、列表、代码块是否进入 metadata,而不是被简单当作纯文本。结构信息在过滤、排序和引用展示时非常有用。

  3. 分块是否语义完整:随机抽检时,每个块是否围绕一个主题,是否存在句子被拦腰截断的情况。人工抽检比任何参数说明都更有说服力。

  4. 是否有重叠设计:相邻块之间是否设置了合理重叠,重叠比例是否与块大小匹配。没有重叠时,边界信息很容易丢失;重叠过大又会造成大量重复索引。

  5. 元数据是否稳定唯一 :每个块是否都有 doc_id、来源、版本号、更新时间。没有稳定 ID,更新和回源都会变得困难。

检索与排序

  1. 是否同时构建了向量索引和关键词索引:纯向量检索在精确词、型号、编号、法律条文等场景中可能漏召回。BM25 或类似关键词检索通常成本很低,却能把鲁棒性提上来。

  2. 是否记录了各检索器的独立分数:不同检索器分数量纲不同,不能直接相加。记录原始分数有助于选择融合策略,也有助于问题排查。

  3. 是否使用 RRF 或带权融合:如果多路召回各自为政,最终排序很容易被某一路的高分噪声带偏。统一的排名融合比直接拼结果更稳定。

  4. 是否做了 Rerank:初步检索返回的候选可以放宽到 20 至 50 条,但进入模型前必须经过精排和截断。没有 Rerank 时,Top-K 只是"差不多相关",而不是"最相关"。

  5. 是否做了权限与时效过滤:不同用户能看到的文档范围是否在检索层过滤。权限问题不能依赖模型自觉,必须在数据进入 Prompt 前解决。

生成与安全

  1. Prompt 是否要求引用来源:回答是否带来源编号或链接,是否方便用户回溯和系统追责。引用是 RAG 产品可信度的基础。

  2. 是否允许模型拒绝回答:当资料不足、置信度过低或问题超出知识库范围时,模型是否被允许说"不知道"。不允许拒答,模型就会倾向于编造。

  3. 是否对高风险场景做了拦截:医疗急症、法律建议、金融交易、安全操作等问题是否走人工或特殊流程。生成式回答不能替代专业判断。

  4. 是否限制答案长度:无意义的超长回答不仅消耗 Token,也更容易产生前后矛盾和幻觉。答案长度应根据业务设计上限。

  5. 是否做事实一致性校验:关键答案中的数字、日期、名称、条款是否与来源文档一致。必要时用规则或额外模型校验。

工程与评估

  1. 是否保存查询日志:日志是否包含原始问题、改写查询、检索结果、最终 Prompt、模型回答和用户反馈。没有日志,线上问题只能靠猜。

  2. 是否建立回归评估集:是否有固定数据集覆盖高频、长尾、多跳、无答案、敏感问题等类型。没有评估集,任何优化都无法被证明。

  3. 是否监控延迟与成本:是否分别统计嵌入、查询改写、Rerank、压缩、生成各环节的耗时和 Token。成本失控往往发生在某个容易被忽略的小环节。

  4. 是否有降级方案:Rerank 或压缩服务异常时,是否能自动回退到简单链路。可用性优先于"偶尔多答对一题"。

  5. 是否定期复盘 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 时,不妨先停下来问自己四个问题:

  1. 正确内容有没有被切坏?
  2. 正确内容有没有被召回?
  3. 正确内容有没有排在前面?
  4. 正确内容有没有被模型真正使用?

顺着这四个问题排查,比立刻换模型、立刻加 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 或纯文本,并记录来源和更新时间。小规模数据能让你更直观地观察每个策略的效果差异。

第三步:先跑通固定分块与递归分块

分别使用 CharacterTextSplitterRecursiveCharacterTextSplitter 对同一批文档切分,打印每个块的内容。人工检查哪些块被截断、哪些块主题混乱。这个步骤虽然简单,但能帮助你建立对分块质量的直觉。

第四步:建立向量索引并做直接检索

选择一个兼容 OpenAI 接口的嵌入服务,封装 embed_documentsembed_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 优化没有终点,只有一轮又一轮更清晰的迭代。

相关推荐
绘梨衣5471 天前
PDF跨页表格处理方案(极简落地版 + 工具对比)
python·rag
玫幽倩1 天前
2026第二届湾区杯网络安全大赛决赛(AI专项赛道静态题wp)
pytorch·python·ai·agent·ctf·rag·湾区杯
慧都小妮子2 天前
Word/Excel/PPT 如何稳定导出 Markdown?文档 SDK 三线能力拆解
.net·markdown·知识库·aspose·rag·文档转换·文档互操作
艺杯羹2 天前
告别粗暴向量检索:GraphRAG图谱增强与Agentic自适应路由落地实战
人工智能·知识图谱·rag·ai agent·graphrag
不是株2 天前
RAG 从提问到回答:查询改写、多路召回、精排与上下文生成
rag
智码看视界2 天前
Day72-文档预处理实战:PDF解析 + 文本切块的正确姿势
pdf·pdfbox·预处理·rag·文档解析·tika·文本切块
yxlalm3 天前
SpringAI+RAG-检索文档变知识:从上传到精准检索的完整链路
spring·知识库·rag
宁渡AI大模型3 天前
河南宁渡科技有限公司|宁渡课堂 AI 全栈面试分享,RAG 项目面试深挖问题解析
人工智能·机器学习·rag
XLYcmy3 天前
DeepMMSearch-R1: Empowering Multimodal LLMs in Multimodal Web Search论文分享
llm·sft·强化学习·多模态·苹果·rag·检索