RAG 全链路:Document Loader → 分块 → Embedding → 检索 → 重排 → 生成


一、一句话理解

RAG = 先搜后答。在 LLM 回答之前,先到知识库里翻一遍资料,把相关内容塞进 Prompt,让 LLM 照着资料回答。

类比:开卷考试 vs 闭卷考试。

纯 LLM(闭卷)RAG(开卷)
靠训练时记住的知识现场翻书找答案
知识截止于训练日期知识实时更新
容易幻觉(编造)有据可查(引用原文)
不知道就说不知道(偶尔)找不到就说找不到(更可靠)

二、2026 关键论断

纯 Top-K 余弦相似度 RAG 已死。生产系统至少需要 混合检索 + 重排 + 系统评估 三件套。

  • 混合检索(BM25 + 稠密 + RRF)比纯向量检索 NDCG@10 高 15~25 个点

  • Cross-Encoder 重排是 单一最高 ROI 的升级:80ms 延迟换两位数召回提升

  • 没有评估的 RAG 是盲人摸象——你以为好,实际一测 Recall 可能不到 50%


三、RAG 全链路总览


flowchart LR

    A["Document<br/>Loader<br/>读文件"] --> B["分块策略<br/>Chunking<br/>切文档"]

    B --> C["Embedding<br/>向量化<br/>转数字"]

    C --> D["向量数据库<br/>存储+索引<br/>(Chroma)"]

    E["用户<br/>查询"] --> F["查询处理<br/>HyDE/分解"]

    F --> G["混合检索<br/>BM25+稠密<br/>+RRF融合"]

    D --> G

    G --> H["Reranker<br/>Cross-Encoder<br/>精排"]

    H --> I["LLM 生成<br/>Prompt+上下文<br/>+引用"]

RAG 全链路分三个阶段:离线入库(一次做好) → 在线检索(每次查询跑) → 生成(拼 Prompt 回答)。


四、Document Loader——文档加载

1.1 选错 Loader = 后面全白干

Document Loader 是整个 RAG 管线的入口,质量决定天花板。加载器错了,后续所有环节都在处理垃圾数据。

1.2 主流 Loader 选型

Loader适合谁优势避坑
PyMuPDFLoader文字可选的 PDF极速、内存低、大文件友好不支持扫描件
UnstructuredPDFLoader复杂布局(标题/正文/表格混排)自动识别结构+内置 OCR依赖重
PDFPlumberLoader财务报表、数据表表格提取最强纯文本一般
LlamaParse手写/图表/多模态文档VLM 驱动,自动纠错云端付费
TextLoader.txt / .md零依赖仅纯文本
CSVLoader表格数据每行一个 Document列格式需固定
DirectoryLoader批量读文件夹支持 glob 匹配要配 loader_cls

2026 推荐路径:


flowchart TD

    A["文档类型是?"] --> B["文字可选 PDF"]

    A --> C["扫描件/图片"]

    A --> D["纯文本/CSV"]

    B --> E["简单布局?"] --> F["PyMuPDFLoader 🏆"]

    B --> G["复杂布局?"] --> H["UnstructuredPDFLoader"]

    B --> I["全是表?"] --> J["PDFPlumberLoader"]

    C --> K["预算够?"] --> L["LlamaParse"]

    C --> M["本地部署?"] --> N["Surya / docTR"]

    D --> O["TextLoader / CSVLoader"]

重要:为每个 Document 注入丰富的 metadata(source、page、section)。80% 的"RAG 答错"本质是元数据过滤失败——返回了过期或错误范围的文档。

1.3 代码示例

 
from langchain_community.document_loaders import PyMuPDFLoader, TextLoader, CSVLoader, DirectoryLoader
 
# PDF
 
loader = PyMuPDFLoader("docs/report.pdf")
 
docs = loader.load()  # 每页一个 Document
 
for doc in docs:
 
    doc.metadata["source"] = "report.pdf"
 
# 整个目录
 
loader = DirectoryLoader(path="./docs", glob="**/*.txt", loader_cls=TextLoader)
 
docs = loader.load()
 

五、分块策略 Chunking——RAG 质量的第一个天花板

2.1 为什么分块决定 RAG 天花板

Embedding 模型有输入长度限制(一般 8192 tokens)。但更重要的原因是:分块质量直接决定检索能不能找到相关内容。

维度影响
每块是否包含完整语义单元决定了 LLM 能否理解上下文
查询能否命中相关块决定了召回率
Token 利用效率决定了运行成本

实测(500 篇中文技术文档):语义分块 Recall@5 = 84.2%,比固定分块 67.3% 高出 17 个点

2.2 四种主流策略

策略Recall@5速度Token 消耗复杂度适用场景
固定分块67.3%🟢 快快速原型
语义分块84.2%🟡 中低(-32%)长文档 QA
递归分块81.5%🟢 快代码/结构化文档
Late Chunking~83%🟡 中法律/长上下文

2.3 固定分块(Fixed-size)

最简单的做法:按固定 Token 数切,overlap 保证连续性。

 
def fixed_chunking(text: str, chunk_size: int = 512, overlap: int = 50):
 
    tokens = text.split()
 
    chunks = []
 
    for i in range(0, len(tokens), chunk_size - overlap):
 
        chunk = " ".join(tokens[i:i + chunk_size])
 
        if chunk.strip():
 
            chunks.append(chunk)
 
    return chunks
 

overlap 从 0 提到 50,Recall@5 提升约 8~12%——几十个字符的重叠,代价几乎为零,收益却很大。

2.4 语义分块(Semantic Chunking)

用 Embedding 算相邻句子的”意思距离”,发现断层就切一刀。

 
def semantic_chunking(sentences, model, threshold=0.7, max_size=512):
 
    embeddings = model.encode(sentences)
 
    chunks, current = [], []
 
    for i, sent in enumerate(sentences):
 
        if i > 0:
 
            sim = cosine_similarity(embeddings[i], embeddings[i-1])
 
            if sim < threshold:  # 意思变了,切
 
                chunks.append(" ".join(current))
 
                current = []
 
        current.append(sent)
 
    if current:
 
        chunks.append(" ".join(current))
 
    return chunks
 

类比:看电视剧——画面风格突然变了(转场),你知道换场景了。语义分块就是检测这个”转场”。

2.5 递归分块(RecursiveCharacterTextSplitter)⭐ 最推荐

按优先级递归切:段落 → 行 → 句号 → 分号 → 逗号 → 字符,保证尽量在语义完整处下刀。

 
from langchain_text_splitters import RecursiveCharacterTextSplitter
 
splitter = RecursiveCharacterTextSplitter(
 
    chunk_size=500,
 
    chunk_overlap=100,
 
    separators=["\n\n", "\n", "。", ";", ",", " ", ""],
 
)
 
chunks = splitter.split_documents(docs)
 

类比:拆乐高——先拆大模块(段落),再拆小部件(句子),尽量不破坏原有结构。

2.6 chunk_size 怎么选?

场景chunk_sizeoverlap理由
简历解析300~50050~100短文本,小块精准
技术文档500~1000100~200代码+说明需上下文
长篇报告1000~2000200~300叙事需要更大窗口
FAQ/客服200~40030~80问答短小独立

没有万能参数。从 500/100 开始,有数据了再调。


六、Embedding——向量化

3.1 一句话

文字 → 向量,语义相近的向量在空间里挨着。详见 04-Embedding向量化原理+语义搜索场景

3.2 2026 实战选型

场景推荐模型维度理由
本地/中文/免费BAAI/bge-m31024多语言统一向量空间,开源 MIT
本地/长文档jina-embeddings-v4102432K 上下文
精度最高(有 GPU)NV-Embed-v2 / Qwen3-Embedding4096开源最强
API 省心text-embedding-3-small1536$0.02/MTok,性价比之王
本地轻量Nomic Embed v1.5768CPU 可用,274MB
 
from chromadb.utils import embedding_functions
 
# 中文推荐:BGE-M3
 
ef = embedding_functions.SentenceTransformerEmbeddingFunction(
 
    model_name="BAAI/bge-m3"
 
)
 

⚠️ 同一个 Collection 必须始终用同一个嵌入模型。混用 = 尺子量重量。


七、检索 Retrieval——找到最像的几块

4.1 Naive RAG 为什么不够?

问题表现原因
长尾召回崩塌搜”GB/T 19001-2025”返回一堆无关结果稀有词被 Embedding 抹平了
宽泛查询精度崩塌搜”请假政策”返回 10 个几乎一样的 HR 段落向量过于接近
多跳查询断裂”我们引入 TCS 那个季度的净利润?” 需要两步推理单次检索不够

2026 年的答案:混合检索(BM25 + 稠密 + RRF)+ 查询变换 + Reranker。

4.2 混合检索——生产标配

方法优势NDCG@10延迟
BM25(词法)精确匹配 SKU/法规编号0.515ms
稠密(语义)近义词、概念匹配0.6240ms
混合+RRF 🏆覆盖双方弱点0.7455ms
混合+Cross-Encoder最高精度0.83130ms

类比:BM25 是翻字典(查字面),稠密是找同义词(查意思),两个人一起投票比任何一个人都准。

4.3 RRF(Reciprocal Rank Fusion)——融合之王

 
def rrf_fuse(results_lists, k=60, top_n=20):
 
    """无需调参,80% 场景优于学习式融合"""
 
    scores = {}
 
    for results in results_lists:
 
        for rank, doc_id in enumerate(results, start=1):
 
            scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
 
    ranked = sorted(scores.items(), key=lambda x: -x[1])
 
    return [doc_id for doc_id, _ in ranked[:top_n]]
 

RRF 的核心思想:A 排第 1 + B 排第 10 = 总分 1/61 + 1/70 ≈ 0.031,比只有 A 排第 3(1/63)还高。跨系统的一致性比单一系统的高排名更重要。

4.4 查询变换(Query Transformation)

用户的问题往往不适合直接拿去搜。原因:搜索需要”关键词密集的陈述句”,但用户问的是”简短模糊的疑问句”。

类比:你问朋友”那个红红的圆的东西是啥”——朋友知道你在说苹果,因为你们共享语境。但搜索引擎没有这个语境。你需要先把问题翻译成更接近目标文档的语言


flowchart LR

    Q["用户原始查询<br/>'那个红红的圆的东西是啥'"] --> T["查询变换<br/>翻译成'搜索语言'"]

    T --> T1["HyDE:假装已有答案,<br/>生成一段'苹果是一种水果...'"]

    T --> T2["查询分解:<br/>拆成多个子问题"]

    T --> T3["多查询扩展:<br/>生成3~5个改写版本"]

    T1 --> S["向量检索"]

    T2 --> S

    T3 --> S

HyDE(Hypothetical Document Embedding)

核心思想: 不搜用户的问题,而是先让 LLM 假装这个问题已经有答案了,然后搜那段”假答案”。

 
def hyde_retrieve(query: str, llm, embedder, vector_db, top_k: int = 20) -> list:
 
    # Step 1: 让 LLM 假装写一段"关于这个问题的文章"
 
    hypothetical = llm.invoke(
 
        f"请写一段 100 字左右的段落,详细回答以下问题:\n{query}\n"
 
        f"要求:陈述句,信息密集,像教科书或百科条目。"
 
    )
 
    # Step 2: 嵌入这段"假文章",而不是原问题
 
    hyde_vec = embedder.encode(hypothetical)
 
    # Step 3: 用假文章的向量去搜
 
    return vector_db.search(hyde_vec, top_k=top_k)
 

为什么 HyDE 有效?

用户 query假想答案
长度5~15 词,稀疏100~200 词,密集
词汇可能没有文档中的关键词覆盖文档常用词汇
语义模糊意图明确陈述
嵌入质量向量可能漂移更接近目标文档的分布

类比:你想在图书馆里找一本”讲好吃红果子的书”,但书名是《苹果栽培技术》。直接用 query 搜 = 对图书管理员说”好吃红果子”,HyDE = 先写一段”苹果是一种蔷薇科水果…”的纸条,再拿纸条去找。纸条和书名的用词更匹配。

HyDE 的坑:

问题表现对策
LLM 生成质量差假答案离题万里给 LLM 更具体的 prompt 模板
额外延迟+200ms LLM 调用小模型(Haiku)做 HyDE
不适合事实性 query”2025年营收多少?” 假答案可能编错数字这类 query 直接搜,不做 HyDE

查询分解(Query Decomposition)

用户的一个问题可能包含多个子问题。拆开搜,再合并。

 
def decompose_query(complex_query: str, llm) -> list[str]:
 
    """把复杂查询拆成多个独立子查询"""
 
    prompt = f"""将以下问题拆解为多个独立的子问题,每个子问题只需要检索一个事实。
 
每个子问题应该可以直接在文档中搜索。
 
原始问题:{complex_query}
 
输出格式:
 
1. [子问题1]
 
2. [子问题2]
 
..."""
 
    response = llm.invoke(prompt)
 
    # 解析返回的子问题列表
 
    sub_queries = [line.split(". ", 1)[1] for line in response.strip().split("\n")]
 
    return sub_queries
 
# 第二次检索依赖第一次的结果 → 可以用中间结果拼接最终查询
 

类比:你问”2019年上映的那部讲太空的国产科幻片票房多少”——正常人会先确定”哦你说的是《流浪地球》“,再查”流浪地球票房”。两次检索,第一次的结果作为第二次的输入。

多查询扩展(Multi-Query Expansion)

同一个问题,让 LLM 从多个角度改写,然后各自检索,RRF 融合。

 
def expand_query(query: str, llm, n: int = 5) -> list[str]:
 
    """生成 n 个不同角度的改写查询"""
 
    prompt = f"""请从 {n} 个不同的角度改写以下问题。
 
每个改写应使用不同的词汇和表述方式,覆盖可能的同义词和上下文。
 
原始问题:{query}
 
输出每行一个改写:"""
 
    response = llm.invoke(prompt)
 
    variants = [q.strip() for q in response.strip().split("\n") if q.strip()]
 
    return [query] + variants  # 包含原版
 
# 各搜各的,RRF 融合结果
 
all_results = [vector_db.search(q, top_k=10) for q in queries]
 
final = rrf_fuse(all_results, k=60, top_n=20)
 

Step-Back Prompting(退一步思考)

实用技巧:当问题太具体导致搜不到时,先退一步到更抽象的层面。

原始查询Step-Back 查询
”Python @dataclass 的 frozen=True 有什么作用""Python dataclass 的原理和参数"
"如何用 LangChain 实现 RAG""RAG 的通用架构"
"Chroma 的 ef_construction 怎么调""HNSW 索引参数调优”
 
def step_back(query: str, llm) -> str:
 
    prompt = f"""原始问题:{query}
 
这个问题太具体了,可能无法直接匹配到文档。
 
请生成一个"退一步"的、更通用的查询,来覆盖原始问题所需的背景知识。
 
通用查询应该是一个完整的知识点名称,而不是一个问题。"""
 
    return llm.invoke(prompt)
 

四种技术选型速查

技术适合场景不适合场景延迟
HyDE模糊/开放式查询精确事实查询+200ms
查询分解多跳推理问题简单事实查询+300ms
多查询扩展同义词多/表述多样的领域术语高度统一的领域+100ms
Step-Back具体到搜不到、需要背景知识搜索已经能直接命中+100ms

4.5 元数据过滤——零成本提效

给每个 chunk 打好标签(来源/日期/分类),检索时前置过滤:

 
results = collection.query(
 
    query_texts=["请假政策"],
 
    n_results=5,
 
    where={
 
        "$and": [
 
            {"category": "HR政策"},
 
            {"date": {"$gte": "2025-01-01"}}
 
        ]
 
    }
 
)
 

80% 的”RAG 答错”本质是元数据过滤失败——搜到了过期文件。


八、重排 Reranking——让最好的排到最前面

5.1 两阶段架构


flowchart LR

    A["用户查询"] --> B["Bi-Encoder 粗筛<br/>Top-50<br/>~40ms"]

    B --> C["Cross-Encoder 精排<br/>Top-5<br/>~80ms"]

    C --> D["LLM 生成"]

维度Bi-Encoder(向量检索)Cross-Encoder(Reranker)
编码方式分别编码 query 和 doc拼接在一起编码
速度🟢 极快🟡 每对 1~2ms
精度🟡 中🟢 (Attention 能看到交互)
能否预编码?✅ 文档可离线算好❌ 必须在线实时算

类比:Bi-Encoder 是 HR 筛简历(30 秒扫一份,挑出 50 份),Cross-Encoder 是技术面(逐份细看 10 分钟,找出最好的 5 份)。

5.2 2026 主流 Reranker

模型参数量语言许可推荐场景
bge-reranker-v2-m3 🏆568M中/英/多语言MIT 开源中文首选,自部署
bge-reranker-v2-gemma2B中/英开源精度最高(需 8GB+ 显存)
Cohere Rerank v3.5不公开100+ 语言API 付费零 GPU
Jina Reranker v2278M中/英/日/德CC BY-NC-4.0评估可用,商用需付费

5.3 实战代码

 
from FlagEmbedding import FlagReranker
 
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)
 
query = "Ubuntu 安装 Docker"
 
candidates = ["Docker 是容器平台", "sudo apt install docker.io", "Python 用于数据科学"]
 
scores = reranker.compute_score([[query, c] for c in candidates])
 
# [2.13, 6.82, -2.34]  ← 负分表示完全不相关!
 

5.4 避坑指南

❌ 错误✅ 正确
粗检索只取 Top-5,Reranker 没候选可排粗检索至少 Top-20~50
用 Reranker 代替向量检索永远两阶段:粗筛 + 精排
chunk 太碎或太大chunk_size 5121024,overlap 100200
忽略许可证直接用 Jina Reranker商用时确认许可证(CC BY-NC ≠ 开源)

九、生成 Generation——最后的拼图

6.1 核心公式

 
def build_rag_prompt(query, chunks):
 
    context = "\n\n".join(
 
        f"[Chunk {i}] {chunk['content']}"
 
        for i, chunk in enumerate(chunks[:5], 1)
 
    )
 
    return f"""请基于以下参考资料回答问题。
 
如果找不到答案,直接说"根据已有资料无法确定"。
 
引用时标注【Chunk N】。
 
【参考资料】
 
{context}
 
【问题】
 
{query}"""
 

6.2 2026 Prompt 原则

Context over Cleverness——模型足够聪明了,Prompt 的胜负手不是技巧,而是信息密度和约束清晰度。

原则做法
结构化分隔用标记明确区分 Context / Query / 输出格式
输出契约明确要求:引用来源、置信度、最大长度
强制引用每个断言附带【Chunk N】,降低幻觉
Prompt Caching稳定部分(System+Context)放前面缓存,省 70~90% 成本

6.3 幻觉防控

手段做法
强制引用每个断言必须附【Chunk N】
无答案声明”找不到就说不知道”,禁止编造
RAGAS FaithfulnessLLM-as-Judge 评估是否忠于检索内容
置信度分级高/中/低,低置信度标注”仅供参考”

十、RAGAS 评估——别当盲人

7.1 四大指标

指标含义考什么
Context Precision检索到的上下文有多少是真正相关的检索的精准度
Context Recall需要的上下文有多少被检索到了检索的覆盖率
Faithfulness回答是否忠于检索到的上下文有没有瞎编
Answer Relevancy回答是否回答了用户的问题有没有答非所问

7.2 一句话策略

先跑 Context Recall 和 Faithfulness——前者查检索漏没漏,后者查 LLM 有没有乱说。两个及格了再优化 Precision 和 Relevancy。



十一、全链路延迟预算(p95 < 800ms)


flowchart LR

    A["混合检索<br/>60ms"] --> B["Reranker<br/>80ms"]

    B --> C["LLM TTFT<br/>250ms"]

    C --> D["流式生成<br/>400ms"]

    D --> E["总计<br/>~790ms ✅"]


▶ 对应原理:13-RAG检索增强生成

▶ 对应原理:39-记忆系统vs-RAG本质区别

速记卡(面试闪卡)

Q1:一句话讲清「RAG 全链路:Document Loader → 分块 → Embedding → 检索 → 重排 → 生成」到底是什么?

A:RAG 先搜后答:把外部资料塞进 Prompt 让 LLM 照着答,像开卷考试,比闭卷更准更可溯源。

Q2:一、2026 关键论断 —— 怎么理解?

A:纯 Top-K 余弦 RAG 已死,生产至少要「混合检索+重排+评估」三件套。Cross-Encoder 重排是单一最高 ROI 升级:80ms 换两位数召回提升。

Q3:二、分块策略:RAG 的天花板 —— 怎么理解?

A:分块质量决定检索能不能找到。语义分块 Recall@5 比固定分块高 17 个点;递归分块最推荐(先拆段落再拆句子,尽量不破结构),像拆乐高。

Q4:三、检索:混合+RRF+查询变换 —— 怎么理解?

A:混合检索 BM25+稠密+RRF 覆盖双方弱点(NDCG@10 达 0.74);查询变换用 HyDE/分解/多查询把模糊问翻译成「搜索语言」,像先写纸条再找书。

Q5:四、重排与生成 —— 怎么理解?

A:Bi-Encoder 粗筛 Top-50,Cross-Encoder 精排 Top-5(HR 筛简历 vs 技术面);生成时强制引用【Chunk N】防幻觉,Context over Cleverness 重信息密度。

Q6:核心速记主线有哪些?

  • 本质:先搜后答的开卷考试

  • 三件套:混合检索+重排+评估

  • 分块:语义/递归分块定召回上限

  • 生成:强制引用防幻觉,Context over Cleverness

口诀

A:RAG 先搜后答,

开卷胜闭卷;

混合加重排,

引用防幻觉。

相关链接


技术学习路线图 > RAG

相关链接