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

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

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


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

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

文档 -> 切块 -> 向量化 -> 存入向量库 -> 用户提问 -> 向量检索 -> 拼进 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 是最直观的切法:按预设字符数切割,不考虑文本逻辑。

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 个块。可以明显看到,每个块都是按照空格附近的边界进行拼接,句子经常被截断:

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 里更推荐的通用方案。它不是随便找到一个空格就切,而是按照分隔符优先级递归切分,默认顺序是:

["\n\n", "\n", " ", ""]

也就是先尽量按段落切,再按换行切,再按空格切,最后才按字符硬切。

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 页面或富文本,最好的边界往往就是标题层级。

例如一篇技术手册:

# 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 按标题层级切分:

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,并把同一小节的内容保留在同一个块里:

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 按对话轮次分块:适合客服、访谈和会议纪要

客服对话、访谈记录、会议纪要等场景,如果按固定字符切分,很容易把同一轮问答拆散,或者把不同说话人混在一起。

一个更合理的方式是按“轮次”切分:

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 兼容接口封装:

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}],
                mode
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值