RAG(Retrieval-Augmented Generation,检索增强生成)是 2023 年以来 AI 工程里被讨论最多、踩坑也最多的技术。它让 LLM 能"查资料后再回答",几乎是企业落地 AI 的事实标准。但很多团队的 RAG 上线后效果不好 —— 不是因为 LLM 不行,而是 RAG 的每一个工程细节都能让效果腰斩。这篇文章把 RAG 的完整流程讲透,从最朴素的 Naive RAG 一直走到生产级的 Advanced RAG。
RAG 解决什么问题
LLM 直接回答有三个根本局限:
- 知识截止日期:训练数据有 cutoff,之后的事件不知道。
- 私有知识不知道:公司内部文档、特定行业数据,LLM 没见过。
- 幻觉:不知道时也会编,且编得很自信。
RAG 的思路极其直接:先从知识库里检索相关内容,再让 LLM 基于这些内容回答。LLM 不再凭"记忆"作答,而是基于"给定的参考资料"作答。这同时解决了三个问题:时效性、私有性、可溯源。
Naive RAG:最朴素的流程
# 索引阶段(离线)
1. 加载文档(PDF / Word / Markdown / 网页 / 数据库)
2. 切分成 chunks
3. 用 embedding 模型把每个 chunk 转成向量
4. 存进向量数据库
# 查询阶段(在线)
1. 用户提问
2. 把问题 embed
3. 向量库找最相似的 top-k chunks
4. 把 chunks + 原问题塞进 prompt,让 LLM 回答
用 LangChain 写出来不到 20 行:
from langchain_community.document_loaders import DirectoryLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain.chains import RetrievalQA
# 索引
docs = DirectoryLoader("./knowledge", glob="**/*.md").load()
chunks = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50).split_documents(docs)
vectorstore = Chroma.from_documents(chunks, OpenAIEmbeddings())
# 查询
qa = RetrievalQA.from_chain_type(
llm=ChatOpenAI(model="gpt-4"),
retriever=vectorstore.as_retriever(search_kwargs={"k": 4}),
)
answer = qa.invoke({"query": "如何配置 nginx HTTPS?"})
能跑,但远不够生产用。下面我们看每一步可以怎么优化。
第一关:文档加载与清洗
看似最不起眼的一步,实际是 RAG 质量的根基。
常见格式与坑
- PDF:布局复杂(双栏、表格、图片)。
PyMuPDF比PyPDF2准确。带表格的用Unstructured或Tabula单独提取表格。 - Word / PPT:用
python-docx/python-pptx,但格式信息很容易丢。 - HTML:先用
BeautifulSoup去掉导航 / 广告 / 脚注,只留正文。 - 扫描件 / 图片:OCR(Tesseract / 阿里 RPA / Azure Document Intelligence)。
清洗
import re
def clean(text):
text = re.sub(r'\s+', ' ', text) # 合并空白
text = re.sub(r'(?:页\s*\d+\s*/\s*\d+)', '', text) # 去页码
text = re.sub(r'\[\d+\]', '', text) # 去脚注引用
return text.strip()
第二关:Chunking(切分)
切太小 → 缺上下文;切太大 → embedding 模糊、检索不准。这是 RAG 最常被低估的关键步骤。
固定大小切分
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 中文约 500 字
chunk_overlap=50, # 相邻 chunk 重叠 50 字,避免边界丢信息
separators=["\n\n", "\n", "。", "!", "?", " "], # 优先按段落、句号切
)
chunks = splitter.split_text(text)
语义切分
检测相邻句子的 embedding 距离,距离大的地方才切 —— 自动按"话题边界"切。LangChain 的 SemanticChunker、LlamaIndex 的 SemanticSplitterNodeParser 都做这件事。
结构化切分
Markdown / HTML / 代码文档有天然结构(标题、章节、函数)。按结构切,每个 chunk 加上它的层级标签:
# 一段 chunk 的实际内容会是:
{
"text": "...具体段落内容...",
"metadata": {
"doc_id": "manual-001",
"section": "3.2.1 HTTPS 配置",
"parent_section": "3 安全设置",
"page": 42,
}
}
这些 metadata 在检索时非常有用 —— 你可以"只在某一章节内检索",或者让 LLM 看到 chunk 来自哪里。
给 chunk 加摘要 / 父子结构
高级做法:每个 chunk 配一段它的"父级摘要"。检索时匹配小 chunk(精准),但 LLM 看的是 chunk + 父级摘要(全局上下文)。这种"Parent-Child Chunking" 或 Small-to-Big 模式是生产 RAG 的标配。
第三关:Embedding 与检索
(上一篇文章已经详细讲过 embedding,这里只列 RAG 相关要点)
检索策略
- 纯向量检索:简单,但对术语精确匹配差。"GPT-4.1" 这种专有名词,向量可能不如关键词准。
- BM25 检索:Elasticsearch 经典算法,对精确匹配好,但不理解同义词。
- 混合检索(Hybrid):同时跑向量 + BM25,用 RRF(Reciprocal Rank Fusion)合并结果。生产 RAG 几乎都用这种。
def rrf_merge(rankings, k=60):
"""Reciprocal Rank Fusion: 多个排序融合"""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
vector_results = vector_db.search(query_emb, top_k=20)
bm25_results = es.search(query, top_k=20)
merged = rrf_merge([vector_results, bm25_results])[:10]
Reranker:让结果更准
检索召回 20 个 chunk,但前 5 个不一定真的最相关。再用一个交叉编码器(Cross-Encoder)给"query × doc 对"打分重排:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-large")
candidates = retrieve(query, top_k=20)
pairs = [(query, c.text) for c in candidates]
scores = reranker.predict(pairs)
reranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)[:5]
Reranker 比 Bi-Encoder(embedding)精度高,但慢得多 —— 所以只用在"召回完之后的精排"环节。这是把 RAG 准确率从 60% 提到 85%+ 最有效的一步。
第四关:Prompt 与生成
RAG_PROMPT = """
你是一名技术问答助手。请仅基于下面的"参考资料"回答用户问题。
如果参考资料里没有答案,回答"我在已有资料中找不到这个信息"。
回答时请引用资料编号,例如 [1] [2]。
参考资料:
{context}
用户问题:{question}
回答:
"""
def format_context(chunks):
return "\n\n".join(f"[{i+1}] {c.text} (来源:{c.metadata.get('source')})"
for i, c in enumerate(chunks))
prompt = RAG_PROMPT.format(
context=format_context(retrieved_chunks),
question=user_question,
)
answer = llm.generate(prompt)
几个关键设计点:
- "仅基于参考资料":强约束,降低幻觉。
- "找不到就说找不到":避免编造。
- 引用编号:让答案可溯源,用户能看哪段来自哪。
- 来源信息:每个 chunk 带 source(文件名 / URL / 章节),让用户能跳到原文。
Query Rewriting:让查询更好被检索
用户的问题往往口语化,直接 embed 不准。先用 LLM 改写一下:
REWRITE_PROMPT = """
把下面的用户问题改写成 3 个不同表达的检索查询,以更好地匹配文档。
原问题:{question}
输出 3 个查询,每行一个。
"""
queries = llm.generate(REWRITE_PROMPT.format(question=user_q)).split("\n")
# ["nginx 怎么配置 HTTPS", "nginx SSL 证书设置", "nginx TLS 启用方法"]
# 分别检索,合并结果
all_chunks = []
for q in queries:
all_chunks.extend(retrieve(q, top_k=10))
all_chunks = dedupe(all_chunks)
这个技巧叫 Multi-Query Retrieval,有时能让召回率提升 30%。
HyDE:让 LLM 先编一个"假答案"
更激进的查询改写:让 LLM 先"假装回答"问题(可能是错的),然后用"假答案"去检索 —— 答案的 embedding 通常比问题的 embedding 更接近真实文档(都是陈述句):
HYDE_PROMPT = "请简短回答这个问题(不超过 100 字):{question}"
hypothetical = llm.generate(HYDE_PROMPT.format(question=user_q))
chunks = vector_db.search(embed(hypothetical), top_k=10)
对 LLM 真不知道的领域,HyDE 反而不太行(假答案太离谱)。一般做兜底:既检索原问题又检索 HyDE 答案,RRF 合并。
Self-Query:让 LLM 提取过滤条件
用户问"2024 年发布的关于 React 的文章" —— "2024 年" 应该作为元数据过滤而不是语义检索。Self-Query 让 LLM 把自然语言分解成"语义查询 + 结构化过滤":
{
"query": "React",
"filter": {"year": {"$eq": 2024}, "topic": {"$eq": "React"}}
}
然后向量库做"过滤 + 语义检索"的组合查询。Pinecone、Milvus、pgvector 都支持元数据过滤。
Agentic RAG:让 LLM 自己决定要不要检索
简单问题不需要检索,复杂问题可能要多次检索。Agentic RAG 让 LLM 自己决定:
tools = [
{"name": "search_knowledge", "description": "搜索内部文档"},
{"name": "search_web", "description": "搜索互联网"},
]
# LLM 决定要不要、用哪个工具
response = llm.with_tools(tools).invoke(user_q)
if response.tool_calls:
for call in response.tool_calls:
result = execute_tool(call.name, call.args)
# 把结果喂回 LLM,可能再循环
这就是 ReAct / Self-RAG / CRAG(Corrective RAG)等高级模式的基本思路。最适合那些"问题复杂、需要多步推理"的场景。
评估 RAG 效果
RAG 评估比单独评 LLM 难,因为有"检索"和"生成"两个环节都可能错。常用指标:
- Hit Rate / Recall@k:正确答案是不是在 top-k 召回里?
- MRR(Mean Reciprocal Rank):正确答案排第几?
- Faithfulness:回答内容是否完全基于检索到的资料?
- Answer Relevance:回答和问题是否相关?
- Context Recall / Precision:检索到的资料是否完整 / 是否有冗余?
工具:RAGAS(自动用 LLM 评估)、TruLens、DeepEval。生产 RAG 必须把这些指标做进 CI,模型 / chunk size / 检索器换一次就跑一遍评估。
RAG 的常见坑
坑 1:Chunk 不带上下文。 "本章讨论 X" 这段切出来后,LLM 不知道 X 是啥。修复:每个 chunk 前面加文档/章节标题作为前缀。
坑 2:检索召回 0 个相关结果但还硬答。 检索分数都很低时,直接返回"我不知道"比强答更好。设阈值:相似度 < 0.5 时直接告知用户。
坑 3:用户问题包含多个子问题。 "React 和 Vue 有什么区别,以及哪个更适合大型项目?" —— 单次检索 + 单次生成搞不定。要么拆问题分别检索,要么用 Agentic RAG 多轮处理。
坑 4:文档更新没同步到向量库。 知识库改了内容但向量库还是旧 embedding,答案陈旧。要做增量索引 + 版本管理(每个 chunk 带 doc_version,文档更新时删旧加新)。
坑 5:幻觉来自"似是而非"的检索。 检索召回了不相关但有关键词的 chunk,LLM 强行用它编答案。修复:strong system prompt + reranker + 阈值过滤。
RAG vs 长上下文 vs 微调
2024 年 Claude / Gemini 支持 1M+ 上下文,有人喊"RAG 已死"。实际:
- RAG:成本低,知识可控,可追溯,可增量更新。但召回质量靠功夫。
- 长上下文:简单粗暴,把所有资料塞进去。但贵(token 多)、慢(每次都全量处理)、且实测 "lost in the middle"(中间内容效果差)。
- 微调:把知识"烧进"模型,效果好但成本高、知识更新难、还可能影响通用能力。
实际生产里三者通常组合:核心通用知识用微调,实时/动态知识用 RAG,极少数极长上下文用长 context。不是替代关系,是互补。
多模态 RAG:不只是文本
真实文档常含图、表、公式。把它们也纳入检索:
- 图片:用 CLIP / SigLIP 把图片 embed 到同一个空间,用户文字查询也能匹配到图。
- 表格:用 GPT-4V 或 Qwen-VL 把表格转成结构化数据 + 摘要,分别索引。
- 公式:LaTeX 公式 → 自然语言描述 → embed。
Graph RAG:用知识图谱代替向量
Microsoft 2024 年开源的 GraphRAG 提出:对企业知识库,先用 LLM 抽取"实体 + 关系"构建知识图谱,查询时基于图谱做局部子图召回。优势:
- 能回答"关于 X 的所有事"这种问题(向量 RAG 因为 chunk 分散答不好)。
- 跨文档关联(同一个实体在不同文档里的信息能聚合)。
代价:索引成本高(每个 chunk 都要让 LLM 抽实体),且更新麻烦。适合"知识结构稳定 + 需要全局视角"的场景。
常见的"RAG 看着没问题但效果差"的原因
线上 RAG 上线后,排查问题的固定路线:
- 检索没召到正确文档:看 top-10 里有没有正确文档。没有 → 改 chunking / 加 reranker / 混合检索。
- 正确文档在 top-10 但排很后:加 reranker,或者调 chunk 让正确内容更聚焦。
- 正确文档排第一但 LLM 没用:看 prompt 是否强调"基于参考资料",看 LLM 是否倾向于编造。
- 问题本身超出知识库:加 fallback —— 检索分数低于阈值时回答"不知道"。
这种"分阶段诊断"方法比"换个 LLM 看看"高效得多。每个阶段都有明确的可测量指标和明确的优化手段。
写在最后
RAG 的精髓在于"把对的资料给 LLM"—— LLM 本身的智能不变,但有了好的资料,它的表现就像换了一个领域专家。整个 RAG 流程里,LLM 只占最后一步,前面"怎么切、怎么存、怎么检索、怎么重排、怎么 prompt"才是工程上的难点,也是效果差异的来源。
给一个工程心得:RAG 不是装好就完事,是要持续优化的系统。上线第一版用 Naive RAG 跑起来,然后用真实用户问题 / 评估集去定位"哪一步在出错",一步步加 Reranker、调 chunk、做混合检索、加 Query Rewriting。每个改进都用 RAGAS 测一遍,有数据再上。这种"测量驱动的迭代"是 RAG 工程的灵魂 —— 没有它,你永远不知道改对了还是改坏了。
—— 别看了 · 2026