2026 前端 AI Agent 工程化实战营系列第 12 篇

本文整理自《2026 前端 AI Agent 工程化实战营》课程资料,作为系列文章第 12 篇。


theme: channing-cyan

generated-image-1778897132775.png

本章内容信息密度较高:不仅涵盖完整的 RAG 知识体系,也结合项目细节展开,因此阅读与知识提取的难度会稍高、耗时也更长。此外,RAG 的概念与优化实操远非一篇文章能够讲透;待项目完成后,我也会在附篇中继续拓展。

第十章已经把 Token 成本变成了“可见、可控、可优化”的资源。但还有一个更基础的问题需要解决:模型并不掌握企业最新的业务知识

GPT-4o 见过大量公开互联网语料,却不会自动读取公司上周更新的《SaaS 安全策略 v3.2》,也不会掌握那本 600 页的《内部 API 设计规范》。如果把这些资料直接写入 system prompt,工具描述可能膨胀到 5000 tokens,对话历史也可能扩展到 30000 tokens,系统会重新陷入第十章讨论过的“上下文膨胀”问题。

让模型“读懂”业务,本质上需要两个能力:

  1. 按需检索:用户问什么,就只把和这个问题相关的几段知识提供给模型。
  2. 持续更新:知识库新增/修改文档后,不重训模型、不改代码,下一次问答就能命中。

这两点合起来就是本章的主角——RAG(Retrieval-Augmented Generation,检索增强生成)。它不是某个具体框架,而是一种把“外部知识库”和“语言模型”连接起来的工程模式。

本章从 RAG 的本质出发,依次展开向量是什么、Embedding 模型如何训练、文档如何切分、向量库如何选型、检索如何提升召回率、重排如何工作,再进一步介绍 HyDE / Self-RAG / CRAG / Adaptive RAG / Graph-RAG 等 2024–2026 年逐渐成为主流的高级模式,最后把这些能力接入第八九章的 LangGraph Agent。

本章验收点


11.1 RAG 的本质

11.1.1 一句话定义

RAG = 先检索 + 再生成。 用户提问时,系统先从外部知识库中检索出最相关的几段内容,再连同问题一起提供给 LLM,让模型基于“现场资料”回答,而不是依赖训练时的记忆进行无依据生成。

可以把 LLM 类比为“开卷考试的学生”:闭卷考试(没有 RAG)只能依赖训练时学到的“教材”;开卷考试(带 RAG)则可以现场查阅业务知识库,并基于真实文档作答。

11.1.2 没有 RAG 时会发生什么

考虑一个客服场景。用户问:“我们这套 SaaS 的企业版每月最多能创建多少个项目?”

没有 RAG 的纯 LLM 回答

模型 → "通常情况下,企业版的项目数量限制为 100–500 个,
具体取决于您所购买的版本。建议您查阅产品文档或联系销售。"

这类回答表面完整,但本质上是模型推测——它并不知道当前产品企业版的真实上限。这就是幻觉(hallucination):模型用”看起来合理”的表述填补了”它不知道”的事实。

带 RAG 的回答

flowchart LR
    Q["用户问题:<br>企业版每月最多<br>创建多少项目?"] --> R["检索:<br>搜知识库找'企业版项目限额'相关文档"]
    R --> K["命中:<br>《价格政策 v2.1》第 3 节<br>'企业版: 单工作区 200 个项目'"]
    K --> G["LLM:<br>基于真实文档生成回答"]
    G --> A["回答:<br>根据《价格政策 v2.1》,<br>企业版单工作区最多 200 个项目..."]

模型从推测式回答变成了“按文档说话”。这正是 RAG 解决的核心问题:让 LLM 的回答有据可查

但要注意:RAG 不是“防幻觉开关”。它只能把幻觉问题从“模型凭空编造”转化为“检索是否召回正确资料、上下文是否足够、模型是否忠实引用资料”的工程问题。如果检索结果本身错误、权限过滤遗漏、chunk 切分不当,LLM 仍然可能基于错误上下文生成错误答案。

11.1.3 RAG 的完整链路全景

一次完整的 RAG 流程其实分两条线:离线的“建索引”和在线的“问答”。

generated-image-1778897131754.png

flowchart TB
    subgraph 离线 ["离线建索引(一次性/定期)"]
        D1["原始文档<br>PDF/Word/Markdown"] --> D2["文档切分<br>(Chunking)"]
        D2 --> D3["Embedding 模型<br>文本 → 向量"]
        D3 --> D4["向量数据库<br>HNSW/IVF 索引"]
    end

    subgraph 在线 ["在线问答(每次请求)"]
        Q1["用户问题"] --> Q2["Embedding 模型<br>问题 → 向量"]
        Q2 --> Q3["向量检索<br>Top-K 相似 chunk"]
        Q3 --> Q4["重排<br>Cross-Encoder"]
        Q4 --> Q5["拼 Prompt:<br>问题 + 检索结果"]
        Q5 --> Q6["LLM 生成回答"]
    end

    D4 -.->|查询时使用| Q3

这张图是理解本章的主线。本章 11.2–11.6 都是在拆解其中的一个关键环节:

章节 对应环节 关键决策
11.2 向量本质 为什么向量能表达语义?余弦相似度为什么有效?
11.3 Embedding 模型 OpenAI / BGE / Cohere 选谁?维度多少合适?
11.4 文档切分 按字数 / 按句子 / 按段落 / Parent-Child?chunk_size 如何选择?
11.5 向量数据库 pgvector / Pinecone / Milvus?HNSW vs IVF?
11.6 生成环节 检索到的 5 段如何组织进 Prompt?Stuff / Map-Reduce / Refine?
11.7 评估 如何理解 Recall@K / MRR / NDCG?如何评测 faithfulness?
11.8 提升召回 Query 改写 / 混合检索 / 重排 / HyDE……
11.9 高级模式 HyDE / Self-RAG / CRAG / Adaptive / Graph-RAG
11.10 集成 Agent RAG 作为 Tool 接入 LangGraph

11.1.4 RAG vs 微调 vs 长上下文:一张表看清差异

读者最常问的一个问题:

现在 GPT-4o 已经支持 128K 上下文、Claude 支持 200K 上下文,是否可以直接把整本文档放进上下文,而不再建设 RAG 流程?

或者另一个问题:

如果目标是让模型“懂业务”,是否可以直接用业务数据微调一个领域模型?

这三条路(RAG / 微调 / 长上下文)确实都能”让模型懂业务”,但工程权衡完全不同:

维度 RAG 微调(Fine-tuning) 长上下文塞文档
知识时效 实时(文档改了下次问就生效) 滞后(知识冻结在训练时刻,新文档要重训) 实时(每次都塞最新文档)
加新知识成本 加一个文档 → embedding → 入库(几秒~几分钟) 从小规模 API fine-tuning 的几十美元,到领域模型训练的数千 / 数万美元都有可能;真正成本通常来自数据清洗、标注、评估和反复迭代 改 prompt 模板(瞬间)
单次推理成本 中(检索几十 ms + LLM 调用) 低(只是 LLM 调用) 极高(每次都把整本文档放入上下文,Token 爆炸)
可解释性 (能告诉用户”答案来自《文档 X》第 N 节”) 弱(知识混进权重,无法溯源) 中(答案在上下文里,但不知道用了哪一段)
私有数据安全 取决于部署方式:Embedding、向量库、LLM 都自托管时最强;若调用外部 Embedding / LLM API,检索片段仍可能出域 中(通常要把私有数据交给训练平台) 中到强(取决于是否把完整文档发给外部模型)
回答风格定制 弱(受 Prompt 控制,能力上限是 base 模型) (能改输出风格、语气、思维链)
知识规模上限 极高(百万级文档可索引) 中(受训练数据规模和成本约束) 低(受上下文窗口约束,几十 K Token 就到顶)
冷启动门槛 低(一台服务器 + 一个 Embedding 模型就能跑) 高(要 GPU、训练数据、调参经验)
典型适用场景 业务知识库、产品文档问答、客服 FAQ、企业内搜 改写风格、行业术语、专属推理范式 单文档总结、合同审查、临时一次性问答

image.png

结论

很多生产系统实际上采用“微调基础模型 + RAG 接入业务知识”的组合方案,这两条路线并不冲突。

经验法则

99% 的”让企业业务落地 AI 客服 / AI 搜索 / AI 助手”需求,第一步选型通常都是 RAG。微调和长上下文要么成本更高、要么维护难度更大,只在特定场景才划算。

11.1.5 RAG 的简化心智模型

RAG 的本质可以概括为:

把检索系统当成 LLM 的“长期记忆”,把 LLM 当成“会读会写的执行器”。

两者结合后,LLM 不再只依赖”训练时记忆”,而是优先依赖”现场资料”,幻觉率通常会显著下降,知识更新成本也大幅降低。但最终质量仍取决于检索召回、重排、权限过滤、Prompt 约束和评估闭环。

接下来,11.2 将系统展开”向量”的数学本质,11.3 则进入 Embedding 模型选型。


11.2 向量的数学本质

要让计算机判断”两段文本意思相不相近”,必须先把文本变成数字——这就是 Embedding(嵌入向量) 的本质:把任意一段文本映射到一个高维空间里的点,让”意思相近”对应”距离相近”。

image 1.png

11.2.1 从词到向量:Embedding 具体是什么

最简单的理解:

"猫"        → [0.21, -0.83, 0.42, ..., 0.05]   # 384 维向量
"小猫"      → [0.23, -0.79, 0.39, ..., 0.07]   # 与"猫"非常接近
"路由器"    → [-0.51, 0.12, 0.66, ..., -0.33]  # 与"猫"完全不同
"网络设备"  → [-0.48, 0.15, 0.61, ..., -0.30]  # 与"路由器"非常接近

向量本身的每一维都是模型学出来的”隐含特征”——你无法解释第 17 维具体代表什么(这就是为什么 Embedding 常被叫做”黑盒表示”),但作为整体,距离表达了语义关系

11.2.2 余弦相似度的几何直觉

两个向量靠不靠近,如何衡量?最常用的有三种距离/相似度:

度量 公式 含义
欧氏距离 $\sqrt{\sum (a_i - b_i)^2}$ 两点之间的直线距离,关心”绝对位置”
余弦相似度 $\cos\theta = \dfrac{a \cdot b}{|a||b|}$ 两个向量夹角的余弦,关心”方向”
点积 $a \cdot b = \sum a_i b_i$ 同方向且都大 → 大;正交 → 0;反方向 → 负

为什么文本检索几乎清一色用余弦相似度?两个原因:

(1) Embedding 模型训练时通常做了 L2 归一化

也就是说,模型输出的向量长度都是 1($|v|=1$)。在这种约束下:

$$ \cos\theta = \dfrac{a \cdot b}{|a||b|} = a \cdot b $$

而且 $|a-b|^2 = 2 - 2(a \cdot b)$。因此在 Top-K 排序意义上,余弦相似度、点积和欧氏距离通常会给出等价排序:余弦越大,欧氏距离越小。但它们的数值含义并不相同,不能简单说三者“完全相同”。

工程上常用余弦相似度,是因为它直接刻画向量方向,并且输出在 $[-1, 1]$ 区间内,便于跨样本比较。不过在真实 embedding 分布中,分数通常会集中在较窄范围,不一定覆盖完整 $[-1, 1]$。

(2) “方向”才是语义,模长不要过度解释

可以这样直觉理解:

检索关心的是”意思像不像”,所以通常会先做 L2 归一化,再用余弦相似度或归一化后的点积比较方向。

flowchart LR
    subgraph 二维示意 ["二维示意(实际是 384 / 768 / 1536 维)"]
        O["原点 O"]
        A["A: '猫'"]
        B["B: '小猫'"]
        C["C: '路由器'"]
    end
    O --> A
    O --> B
    O --> C

二维想象中,A 和 B 夹角很小(cos ≈ 0.95),A 和 C 几乎垂直(cos ≈ 0.05)。在 384 维空间里,这种”几乎垂直”的现象会更显著——高维空间的随机向量大多近乎正交,所以余弦 0.7 和 0.9 在 384 维空间里的差距远比 2D 直觉强。这既是高维的”诅咒”,也是高维的”馈赠”:让相关与不相关的区分非常清晰。

高维诅咒 vs 高维馈赠

所以在文本 Embedding 检索中,余弦相似度或归一化后的点积通常是默认选择;但如果某个模型的官方文档明确推荐点积或 L2 距离,应优先遵循模型卡和向量库的推荐配置。

11.2.3 距离度量速查表

你的场景 用什么 原因
文本检索(最常见) 余弦相似度 L2 归一化后方向最稳定
推荐系统(隐式反馈) 点积 关心”激活强度 × 匹配度”
图像检索(已归一化) 余弦或点积 等价
异常检测 欧氏距离 关心”偏离正常区域多远”

本章后面的所有代码示例统一使用余弦相似度。需要注意的是,不同向量库和模型的默认距离度量可能不同;生产环境应以模型卡、向量库文档和离线评测结果为准。

11.2.4 🤖 用 AI 生成本节代码(向量相似度工具)

完整 Prompt(可直接粘贴到 Cursor / Claude 执行)

**背景**:
- 项目位于当前工作区,第十一章 RAG 系统代码统一放在 services/chat/rag/ 下。
- 本节是 11.2,目标是产出一个零依赖的纯函数模块,用于演示余弦相似度 / 欧氏距离的计算,并验证"L2 归一化后余弦 = 点积"。
- 不依赖 numpy / pgvector,纯 TypeScript 实现,方便测试和读者本地跑。

**任务**:
1. 在 services/chat/rag/embedding/ 下新建 similarity.ts,导出:
   - dot(a: number[], b: number[]): number
   - l2Norm(v: number[]): number
   - normalize(v: number[]): number[]   // 返回 L2 归一化后的新向量
   - cosineSimilarity(a: number[], b: number[]): number
   - euclideanDistance(a: number[], b: number[]): number
2. 所有函数对长度不一致的输入要抛 RangeError('向量维度不匹配')。
3. 在 services/chat/test/chapter11-rag.spec.ts 内 describe '11.2.4 相似度' 下补充:
   - 单位向量自相似 = 1
   - 反方向向量相似 = -1
   - 正交向量相似 = 0
   - 归一化后 cosineSimilarity === dot(容差 1e-9)
   - 维度不匹配抛错

**约束**:
- 零外部依赖
- 不要使用 Float32Array,保持 number[],便于读者理解
- 测试用 bun:test 风格,mock-first,无网络无 DB

**输出**:
- 完整 similarity.ts
- 补全 chapter11-rag.spec.ts 中 11.2.4 的 describe 块

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.2.4"

覆盖:单位向量自相似 = 1、反向 = -1、正交 = 0、归一化后余弦 = 点积、维度不匹配抛错。

image 2.png

11.2.5 Embedding 模型如何训练出来(对比学习)

读者经常停在”模型能把文本变向量”这一层,但模型为什么能学出”相似的文本距离近”这个性质,需要再往下挖一层。答案是 对比学习(Contrastive Learning)

核心思想

给模型一堆”正样本对”(意思相近的两段文本)和”负样本对”(意思无关的两段文本),训练时强行让正样本向量靠近、负样本向量远离。

(1) 正负样本如何构造

样本类型 构造方式 代表模型
正样本 问答对 / 双语对照 / 同义改写 / 同主题摘要 Sentence-BERT、E5
正样本(自监督) 同一句话两次过 dropout → 得到两个略不同向量 SimCSE(开创性)
负样本(简单) 一个 batch 内随机抽其他样本 几乎所有模型
难负样本(hard negative) 看起来很像但实际无关的样本(如 BM25 检索到的非相关结果) BGE、E5-large

难负样本挖掘是现代 Embedding 模型质量的关键——简单负样本太容易区分,模型学不到细粒度差异;难负样本逼迫模型”在很像的两段文本里找出真正的相关”,因此 BGE、E5、Cohere v3 这些近年来的强模型都重度使用 hard negative mining。

(2) InfoNCE 损失函数

对比学习最经典的损失叫 InfoNCE(Noise-Contrastive Estimation)

$$ \mathcal{L} = -\log \dfrac{\exp(\text{sim}(q, k^+) / \tau)}{\exp(\text{sim}(q, k^+) / \tau) + \sum_{i=1}^{N} \exp(\text{sim}(q, k_i^-) / \tau)} $$

直观解释:

(3) 训练目标和检索任务的对齐

flowchart LR
    A["训练目标:<br>sim(query, doc+) >> sim(query, doc-)"] --> B["学到的向量空间:<br>相关文本距离近"]
    B --> C["下游检索任务:<br>用余弦找 Top-K"]
    C --> D["天然有效"]

这正是 Embedding 模型能够用于检索 的原因:训练目标本身就是“让相似文本距离更近”,检索任务可以直接复用这一性质。

(4) 代表模型一览

模型 创新点 备注
Sentence-BERT (2019) 孪生 BERT + 平均池化 + cosine 损失 开山之作,奠定双塔范式
SimCSE (2021) dropout 当数据增强 → 同句两次 forward 当正样本对 启发自监督对比学习浪潮
Contriever (2022) 完全自监督,无需任何监督数据 不需要打标,规模可大
E5 (2022) “task prefix”(query: / passage:)+ 大规模弱监督预训练 多语言效果好
BGE (2023, 智源) 中英文最佳之一,三阶段训练(预训练 → 通用对比 → 任务微调) 中文 RAG 首选
OpenAI text-embedding-3-large 3072 维(可截断)+ 大规模工程 闭源但易用

11.2.6 Embedding 模型的内部流程与 Pooling 策略

Sentence-BERT 类模型的内部 forward 流程:

flowchart TB
    A["输入文本:<br>'今天天气真好'"] --> B["Tokenizer:<br>['[CLS]', '今', '天', '天', '气', '真', '好', '[SEP]']"]
    B --> C["Transformer Encoder:<br>输出每个 token 的隐状态<br>[CLS] [今] [天] ... [SEP]<br>每个都是 768 维"]
    C --> D["Pooling 策略:<br>把多个 token 向量<br>合成一个句子向量"]
    D --> E["L2 归一化:<br>‖v‖ = 1"]
    E --> F["输出: 768 维句子向量"]

第 4 步 Pooling 是把”一堆 token 向量”合成”一个句子向量”的关键操作。常见三种策略:

Pooling 策略 做法 优点 缺点 谁在用
Mean / Average Pooling 所有 token 向量按 attention_mask 加权平均 鲁棒、平滑,捕捉全局语义 长文本”被稀释”——主旨被无关 token 拉平 Sentence-BERT 默认、本项目 MiniLM-L12-v2;E5 常见实现也使用 average pooling,并配合 query: / passage: 前缀
CLS Pooling 取 [CLS] 特殊 token 的向量作为句向量 BERT 原生设计 [CLS] 就是用来代表全句 必须在大量监督任务上微调 [CLS] 才有用 BGE 部分模型常用 CLS pooling;具体以模型卡和实现为准
Max Pooling 每一维取所有 token 中的最大值 捕捉”最突出的特征” 容易丢上下文,对长文本不友好 较少单独使用

注意:闭源模型的 Pooling 不要反推

OpenAI、Cohere、Voyage 等闭源 Embedding API 只暴露最终句向量,不公开内部 pooling 细节。工程上只需要关注它们的输入上限、输出维度、推荐距离度量、价格和评测效果,不应假设其内部一定使用 mean pooling 或 CLS pooling。

注意:长文本被稀释问题

mean / average pooling 在长文本上有一个隐患:512 个 token 取平均时,主旨可能被 400 个无关 token “拉平”,向量失去判别力。这是为什么 文档切分(11.4)非常关键——把长文档切成足够聚焦的小块,每个块的向量才不会被无关内容稀释。

参考本项目的实现 services/chat/src/document/embedding.service.ts(已存在)使用的是 Xenova/paraphrase-multilingual-MiniLM-L12-v2 + mean pooling + L2 归一化的标准 Sentence-BERT 范式:

/**
 * EmbeddingService — 本地向量生成
 *
 * 使用@xenova/transformers (Transformers.js) 在 Node.js 环境运行。
 *
 * 模型: Xenova/paraphrase-multilingual-MiniLM-L12-v2
 *   - 12 层 Transformer Encoder(蒸馏版 BERT)
 *   - 输出维度: 384 维(适合 pgvector + HNSW 的常见配置;具体维度上限要看 pgvector 版本、索引类型和字段类型)
 *   - 支持 50+ 语言(含中文),专为短文本相似度优化
 */

后面 11.3 节会展开”OpenAI / BGE / 本地 MiniLM 如何选”。

11.2.7 维度大小如何选择

Embedding 维度(dim)通常在 256–3072 之间。

维度 代表模型 特点
384 MiniLM-L12-v2 蒸馏小模型,本地推理快、向量库存储省
768 BGE-base、E5-base、all-mpnet-base-v2 中等精度,性价比之选
1024 BGE-large、E5-large、Cohere v3 高精度,主流大模型默认值
1536 OpenAI text-embedding-ada-002 / 3-small OpenAI 历史默认
3072 OpenAI text-embedding-3-large(可截断到 256/1024/3072) 顶级精度,按位截断

实战取舍

注意:向量维度还要受向量库限制

维度不是越大越好,还要看向量库和索引是否支持。例如 pgvector 的 vector + HNSW 索引存在维度上限,不同版本、字段类型(vector / halfvec / bit / sparsevec)限制不同。OpenAI text-embedding-3-large 默认 3072 维,接 pgvector HNSW 时通常要考虑用 dimensions 参数截断到 1024 / 1536,或者改用 halfvec / 其他向量库。上线前务必用当前 pgvector 版本文档确认。

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.2"

覆盖:normalize 后向量范数为 1、余弦 ∈ [-1, 1] 严格成立、不同维度向量计算抛错、空向量抛错。

image 3.png

本节小结


11.3 Embedding 模型选型

image 4.png

11.2 讲清楚”向量是什么、如何训出来的”之后,工程上的下一个问题是:在众多 Embedding 模型中,应该如何选型?

11.3.1 选型的四个维度

  1. 任务匹配度:你的检索语料是中文 / 英文 / 多语种?是短文本(FAQ)还是长文档(合同)?
  2. 精度 vs 成本:闭源 API(OpenAI / Cohere)开箱即用但有费用;开源(BGE / E5 / MiniLM)需自托管但零边际成本。
  3. 维度选择:384 / 768 / 1024 / 1536 / 3072,越大越准但存储和检索成本同步上升(11.2.7 已展开)。
  4. 可控性:是否需要本地化部署(合规、私有数据不出域)?是否要做领域微调?

11.3.2 当前主流 Embedding 模型对比(截至 2026 年初)

模型 维度 中文 类型 上下文窗口 典型场景
OpenAI text-embedding-3-small 1536(可截 256/512/1024) 中等 闭源 API 8K 通用、易用、$0.02/M tokens
OpenAI text-embedding-3-large 3072(可截) 闭源 API 8K 高精度 SaaS、$0.13/M tokens
Cohere embed-v3 1024 闭源 API 常见版本上下文较短,具体以当前模型文档的 Max Tokens 字段为准 多语言、压缩感知(int8)
BGE-large-zh-v1.5 1024 优秀 开源 512 tokens 中文 RAG 首选
BGE-M3 1024 开源 8192 tokens 长文档 + 多语种 + 多检索范式融合;但长输入会显著增加显存和延迟,仍然需要合理 chunking
E5-large-v2 1024 开源 512 tokens 英文为主、有 task prefix
MiniLM-L12-v2 (multilingual) 384 开源 128 tokens 短文本、低成本、本地
Voyage-3 1024 闭源 API 32K 长文档、高精度(最近上升明显)

模型生态变化非常快,上面是 2026 年初的概况。MTEB 排行榜(Hugging Face MTEB Leaderboard)每月都在变,上线前请查最新榜单。价格、上下文窗口、输出维度和可截断能力也会随 API 版本变化,生产选型时必须以官方模型文档为准。

11.3.3 MTEB 基准是什么

MTEB(Massive Text Embedding Benchmark) 是 Hugging Face 维护的 Embedding 模型评测体系,覆盖:

读 MTEB 榜单时要关注两点:

  1. 任务对齐:做检索就看 Retrieval(占 RAG 中的关键)和 Reranking 这两栏,不要被 STS 平均分迷惑
  2. 语种对齐:MTEB-zh(中文榜)和 MTEB-en(英文榜)分开看,BGE 在中文榜常年第一不代表它在英文榜也第一

11.3.4 选型决策树

flowchart TD
    A[需要 Embedding] --> B{中文为主?}
    B -->|是| C{允许调外网?}
    B -->|否| D{多语种?}
    C -->|是| E["OpenAI text-embedding-3-large<br>+ Voyage-3"]
    C -->|否| F["BGE-large-zh-v1.5<br>or BGE-M3"]
    D -->|是| G["BGE-M3 or E5-multilingual"]
    D -->|否| H["E5-large-v2<br>or text-embedding-3-large"]
    F --> I{文档很长?}
    I -->|是| J["BGE-M3(8K 上下文)"]
    I -->|否| K["BGE-large-zh-v1.5<br>(512 token 已足够)"]

11.3.5 本项目当前选择:MiniLM-L12-v2(multilingual)

本项目 services/chat/src/document/embedding.service.ts 已经在用 Xenova/paraphrase-multilingual-MiniLM-L12-v2

这是一个”教学和开发期的合理选择”。生产环境如果中文检索精度不够,第一优先级换成 BGE-large-zh-v1.5(开源、不出域);如果接受调外网 API,OpenAI text-embedding-3-large 是接入成本很低的选择,但要同步检查向量维度与向量库索引是否兼容。若使用 pgvector HNSW,通常需要通过 dimensions 参数降维,或调整字段类型 / 向量库方案。

11.3.6 Bi-Encoder vs Cross-Encoder:检索器与重排器的本质区别

这是 RAG 高阶选型里最容易被忽略的一个区分。理解它之后,你才能看懂 11.8.4 节的”重排器为什么必须存在”。

Bi-Encoder(双塔模型)

flowchart LR
    Q[查询 Q] --> EQ[Encoder] --> VQ[Query 向量]
    D[文档 D] --> ED[Encoder] --> VD[Doc 向量]
    VQ -.-> S[cosine相似度]
    VD -.-> S

Cross-Encoder(交叉编码)

flowchart LR
    Q["查询 Q"] --> CAT["拼接: Q [SEP] D"]
    D["文档 D"] --> CAT
    CAT --> EE["单一 Encoder + 分类头"]
    EE --> SCORE["直接输出相关性分数 0~1"]

对比一览

维度 Bi-Encoder Cross-Encoder
结构 两段文本独立编码 两段文本拼接后一起编码
精度 受限(向量空间瓶颈) (细粒度 token 交互)
速度 极快(文档向量可预计算并入库) 慢(每对都要重新计算)
可扩展性 百万级文档(向量入库 + ANN 检索) 几十到几百候选(无法预计算)
典型代表 Sentence-BERT、BGE、E5、OpenAI BGE-reranker、Cohere rerank-v3、mxbai-rerank
典型用法 初次检索(topK=20–100) 重排(topK=20 → top5)

配对工作流——工业 RAG 的标准两阶段架构

flowchart LR
    Q[用户问题] --> BI["Bi-Encoder 初筛<br>10 万 chunk → Top-50<br>(快,但召回 ≠ 精确)"]
    BI --> CE["Cross-Encoder 重排<br>Top-50 → Top-5<br>(慢,但精确度高)"]
    CE --> LLM[LLM 生成]

为什么不直接用 Cross-Encoder 检索?计算成本不可接受——10 万个 chunk 都要分别和 Query 拼接并重新计算,每次查询需要执行 10 万次完整 Transformer 推理。

为什么不只用 Bi-Encoder?精度不足——双塔架构无法捕捉”Q 里的’蓝牙围栏’和 D 里的’BLE 信标’是同义”这种细粒度对齐。

RAG 系统通常采用这种”漏斗式”流程

阶段 输入规模 算法 速度 输出规模
1. 向量检索 100 万 chunk HNSW + Bi-Encoder < 100 ms Top-50
2. 关键词检索(可选) 100 万 chunk BM25 < 50 ms Top-50
3. 融合(可选) 上面两组 RRF < 1 ms Top-50
4. 重排 Top-50 Cross-Encoder 100–500 ms Top-5
5. LLM 生成 Top-5 GPT-4o 等 1–5 s 回答

11.8.4 会展开重排器的具体调用代码。需要先明确:初筛用双塔,精排用交叉——这是 RAG 检索质量的关键架构经验。

11.3.7 Embedding 模型升级 / 切换的工程注意事项

最常见的事故:入库时用了 model A,查询时用了 model B。两个模型生成的向量空间完全不一样,检索结果会变成纯噪音。

工程上必须做到:

  1. 数据库表里存 model_name——每条 chunk 记录它是哪个模型生成的
  2. 查询时校验——查询用的模型 == 入库时的模型
  3. 换模型 = 全量重新 embedding + 重建索引——不能”老数据不动新数据用新模型”
  4. 换 chunk 策略也要重建——chunk 边界变了,旧向量和旧引用位置都不再可靠
-- 推荐的 chunk 表 schema
CREATE TABLE document_chunks (
  id          UUID PRIMARY KEY,
  documentId  UUID NOT NULL,
  content     TEXT NOT NULL,
  chunkIndex  INT NOT NULL,
  embedding   vector(384) NOT NULL,
  modelName   VARCHAR(128) NOT NULL  -- ← 关键: 记录生成它的模型
);

常见变更的处理方式可概括如下:

变更类型 是否重新 Embedding 是否重建索引 备注
新增文档 是,增量处理 通常否,索引自动维护 写入新 chunk 与向量即可
修改文档 是,局部或整篇重算 通常否 建议删除旧 chunk 后写入新 chunk
删除文档 通常否 可硬删除或软删除 chunk
更换 Embedding 模型 是,全量 新旧模型向量空间不能混用
更换 chunk 策略 是,全量 chunk 边界、引用位置和向量全部变化

11.12 FAQ 的 Q4 会详细展开这个常见问题。

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.3"

覆盖:模型名校验失败抛错、不同 dim 的向量不能写入同一字段、双塔检索 vs 交叉重排的 mock 流程。

image 5.png

本节小结


11.4 文档切分(Chunking)

11.3 选好了 Embedding 模型,下一个工程问题就来了:一份 100 页的 PDF,如何变成”可检索的若干小块”? 这就是 Chunking(文档切分)

切分看上去简单,但它是整条 RAG 链路里最影响最终效果的环节之一。切得太小,每个 chunk 信息不完整,模型读了等于没读;切得太大,向量被”稀释”(11.2.6 提过的长文本问题),检索召回率断崖式下跌。

image 6.png

11.4.1 为什么要切

三个理由:

  1. Embedding 模型有最大输入长度——MiniLM 是 128 token,BGE-large 常见为 512 token,BGE-M3 可到 8192 token,但长输入会显著增加延迟和显存
  2. 长文本会稀释主旨——无论是 mean pooling 还是其他句向量生成方式,一段 5000 字的文档通常都会混入太多无关信息,让向量不够聚焦
  3. 检索粒度要和”用户问题”匹配——用户问的是一个具体问题,应该召回与问题相关的”段落”而不是”整本书”

11.4.2 切分粒度的工程取舍

切得太小:

切得太大:

经验数值

文档类型 推荐 chunk_size 推荐 chunk_overlap
技术文档 / API 文档 500–800 字 50–100 字
FAQ / 客服记录 200–400 字 30–50 字
长篇报告 / 合同 800–1200 字 100–200 字
聊天日志 300–500 字 50 字
代码文件 按函数/类切,不按字数 0 字(不重叠)

本项目当前使用 chunkSize: 500, chunkOverlap: 50

@Injectable()
export class ChunkService {
  private readonly splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,
    chunkOverlap: 50,
  });

11.4.3 五种主流切分策略

(1) 固定字数切分(Fixed-size chunking)

最原始的方法:按固定字数切分,比如每 500 字切一次。

"...产品支持 SSO 登录。管理员可在控制台创建用户组,配置组级权限。批量导入用户支持 CSV 格式...|...续上一段:"
                                                   ^^^^切在这里——半句话切断了

问题:可能切在半句话中间,破坏语义。

(2) 递归字符切分(Recursive Character Splitting)

LangChain RecursiveCharacterTextSplitter 的默认策略:

优先级: ["\n\n", "\n", "。", "!", "?", ",", " ", ""]

按”段落 → 行 → 句号 → 问号 → 逗号 → 空格 → 字符”的顺序逐级尝试,优先在自然边界切。如果某个段落太长,就用下一级(句号)切。

这是生产环境中最常见的默认方案,本项目也采用了这一策略。

(3) 按句子 / 按段落切分(Sentence / Paragraph splitting)

用 NLP 工具(spaCy / nltk / jieba)做精确句子边界识别。中文要特别小心”句号” vs “顿号” vs “省略号”。

# 伪代码示例
from jieba import lcut_for_search
sentences = re.split(r'(?<=[。!?\.\!\?])\s*', text)

适用场景:FAQ、问答对、对话日志(每条对话就是一个 chunk)。

(4) 语义切分(Semantic chunking)

更高级:用 Embedding 模型计算相邻句子的相似度,当相似度突然下降时(话题切换点)进行切分。

句 1: "我们的安全策略要求 SSO 登录。"     ←┐
句 2: "管理员需要配置 SAML 元数据。"      ←┤ 相似度高 → 一块
句 3: "另外,关于计费方式..."             ← 突变点:相似度断崖式下跌
句 4: "企业版按年付费..."                ←┐
句 5: "支持发票和合同形式..."            ←┘ 又是一块

LangChain 提供了 SemanticChunker,但它对每对句子都要算一次相似度,成本不低。适合”内容主题切换频繁但段落界限不清晰”的文档。

(5) Parent-Child 切分(小块检索 + 大块生成)

这是工业 RAG 的高阶玩法:

flowchart LR
    DOC[原始文档] --> PARENT["切大块: 1500 字<br>(用于喂给 LLM)"]
    PARENT --> CHILD["再切小块: 200 字<br>(用于向量检索)"]
    CHILD --> VEC[入向量库]

    Q[用户问题] --> SEARCH[在小块中检索]
    SEARCH --> MATCH[命中小块 chunk X]
    MATCH --> LOOKUP["回查 X 所属的大块"]
    LOOKUP --> CONTEXT[返回大块作为 LLM 上下文]

这种”检索用小块,生成用大块”的设计,能同时优化”召回精度”和”生成质量”。LangChain 的 ParentDocumentRetriever 是这种模式的官方实现。

11.4.4 重叠(chunk_overlap)为什么必要

考虑两段紧邻的切分:

chunk_A: "...支持 OIDC 协议,用户登录后会获得 JWT。Token 有效期默认 24 小时。"
chunk_B: "管理员可以调整 Token 过期时间,最长可设置为 30 天..."

如果用户问”JWT 默认有效期多长”,只命中 A 没问题。如果用户问”Token 最长能设多久”,只命中 B——但 B 一开头就是”管理员可以调整 Token 过期时间”,没说是哪种 Token、没说背景。这时如果 A 和 B 之间没有重叠,模型读了 B 也不知道在讲哪个系统的 Token。

重叠(overlap)= 让相邻 chunk 共享一小段上下文

chunk_A: "...支持 OIDC 协议,用户登录后会获得 JWT。Token 有效期默认 24 小时。"
chunk_B: "Token 有效期默认 24 小时。管理员可以调整 Token 过期时间,最长可设置为 30 天..."
                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 重叠的 50 字

重叠的代价:存储和向量计算成本增加 ~10%。收益:召回率显著提升,是值得的。

11.4.5 中文切分的特殊问题

中文没有空格,标点也比英文丰富。几个常见问题:

  1. 句号、问号、感叹号的全角/半角混用。 vs .,正则要全覆盖
  2. 省略号是 6 个字符还是 1 个 token... vs ……
  3. 专有名词不能从中间切:「人工智能与机器学习」不能切成「人工」+「智能与机器学习」
  4. 数字、日期、单位连续2026 年 5 月 15 日 14:30,正则切句容易把数字切散

最稳的中文切分方法:

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500,
  chunkOverlap: 50,
  separators: ['\n\n', '\n', '。', '!', '?', ';', ',', ' ', ''],
});

注意 separators 里加了中文全角标点。

11.4.6 切分流程参考实现

本项目 services/chat/src/document/chunk.service.ts 已经实现了完整的”提取 → 切分 → 向量化 → 入库”流水线:

async processDocument(documentId: string, userId: string): Promise<void> {
  const doc = await this.prisma.documents.findUnique({
    where: { id: documentId },
  });
  if (!doc) throw new NotFoundException('文档不存在');
  if (!doc.filePath) throw new NotFoundException('文档文件路径不存在');

  // ...

  try {
    const text = await extractText(doc.filePath, doc.mimeType);
    const chunks = await this.splitter.splitText(text);

    await this.prisma.document_chunks.deleteMany({ where: { documentId } });

    const vectors = await this.embedding.embedTexts(chunks);

    for (let i = 0; i < chunks.length; i++) {
      const created = await this.prisma.document_chunks.create({
        data: { documentId, content: chunks[i], chunkIndex: i },
      });

      const vector = `[${vectors[i].join(',')}]`;
      await this.prisma.$executeRaw`
        UPDATE document_chunks
        SET embedding =${vector}::vector
        WHERE id =${created.id}
      `;
    }
  }
}

完整流水线(建议放到 services/chat/rag/chunking/ 下作为可独立测试的模块):

// services/chat/rag/chunking/document-chunker.ts
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';

export interface ChunkOptions {
  chunkSize?: number;
  chunkOverlap?: number;
  separators?: string[];
}

export interface Chunk {
  index: number;
  content: string;
  startOffset: number;
  endOffset: number;
}

export async function chunkText(
  text: string,
  options: ChunkOptions = {},
): Promise<Chunk[]> {
  const {
    chunkSize = 500,
    chunkOverlap = 50,
    separators = ['\n\n', '\n', '。', '!', '?', ';', ',', ' ', ''],
  } = options;

  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize,
    chunkOverlap,
    separators,
  });
  const pieces = await splitter.splitText(text);

  let cursor = 0;
  return pieces.map((content, index) => {
    const startOffset = text.indexOf(content, cursor);
    const endOffset = startOffset + content.length;
    cursor = endOffset - chunkOverlap; // 留出 overlap 区域
    return { index, content, startOffset, endOffset };
  });
}

11.4.7 Parent-Child 切分的实现示例

// services/chat/rag/chunking/parent-child-chunker.ts
export interface ParentChildChunks {
  parents: Chunk[];                 // 大块,给 LLM 用
  children: Array<Chunk & { parentIndex: number }>; // 小块,给检索用
}

export async function chunkParentChild(
  text: string,
  parentSize = 1500,
  childSize = 200,
): Promise<ParentChildChunks> {
  const parents = await chunkText(text, { chunkSize: parentSize, chunkOverlap: 100 });

  const children: Array<Chunk & { parentIndex: number }> = [];
  for (const parent of parents) {
    const subChunks = await chunkText(parent.content, { chunkSize: childSize, chunkOverlap: 30 });
    for (const sub of subChunks) {
      children.push({ ...sub, parentIndex: parent.index });
    }
  }
  return { parents, children };
}

关键点

11.4.8 🤖 用 AI 生成本节代码(文档切分流水线)

完整 Prompt(可直接粘贴到 Cursor / Claude 执行)

**背景**:
- 项目位于当前工作区,第十一章 RAG 系统代码统一放在 services/chat/rag/ 下。
- 已有 services/chat/src/document/chunk.service.ts,使用 RecursiveCharacterTextSplitter (chunkSize=500, overlap=50),但缺少 Parent-Child 切分能力和明确的中文 separators。
- 本节目标是把切分能力下沉到一个零依赖(除 @langchain/textsplitters 外)的纯函数模块,方便复用与单测。

**任务**:
1. 在 services/chat/rag/chunking/ 下新建 document-chunker.ts,导出:
   - chunkText(text, options): Promise<Chunk[]>
   - 接口 Chunk { index, content, startOffset, endOffset }
   - 默认 chunkSize=500, chunkOverlap=50, separators=['\n\n','\n','。','!','?',';',',',' ','']
2. 在同目录下新建 parent-child-chunker.ts,导出 chunkParentChild(text, parentSize, childSize)
3. 在 services/chat/test/chapter11-rag.spec.ts 中 describe '11.4 文档切分':
   - 11.4.3 默认 chunk_size 500 切 1200 字文本得 3 个 chunk
   - 11.4.4 重叠 50 字时,相邻 chunk 末尾==下一 chunk 开头
   - 11.4.5 中文标点优先切分: '...第一段。\n第二段...' 切点在 '。' 或 '\n',不在词中
   - 11.4.7 Parent-Child: parents.length < children.length, 每个 child 的 parentIndex 必有对应 parent

**关键约束**:
- 不要去引入新的 NLP 依赖(spaCy / jieba),仅使用 @langchain/textsplitters
- 中文 separators 必须显式声明,覆盖全角标点
- startOffset/endOffset 必须能在原文中通过 substring 精确还原 chunk.content
- 不修改既有 services/chat/src/document/chunk.service.ts,只在 rag/chunking/ 新增

**输出**:
- 完整 document-chunker.ts、parent-child-chunker.ts
- 补全 chapter11-rag.spec.ts 的 11.4 用例

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.4"

覆盖:默认 chunk_size、overlap 正确性、中文标点优先切分、Parent-Child 切分中 children 数 > parents 数 且每个 child 的 parentIndex 在 parents 范围内。

image 7.png

11.4.9 切分策略选择速查

你的场景 推荐策略 chunk_size
标准 RAG 起步 RecursiveCharacterTextSplitter 500/50
短问答 / FAQ 按问答对切分 1 条问答 = 1 块
长合同 / 长报告 Parent-Child parent 1500 / child 200
主题切换频繁 Semantic chunking 动态
代码库 按函数/类切 不限字数

本节小结


11.5 向量数据库

向量切好、嵌好之后,下一个问题是:几十万、几百万、几千万条向量,如何高效找到与查询向量最相似的 Top-K 结果?

答案就是向量数据库(Vector Database)。它不是普通数据库的”附带功能”,而是为高维向量相似度检索专门设计的存储 + 索引系统。

11.5.1 向量数据库选型一览

产品 类型 适合规模 特点
pgvector Postgres 扩展 < 1000 万 chunk 本项目在用,已有 Postgres 就是首选
Pinecone SaaS 闭源 任意 全托管、按用量付费、运维零负担
Milvus 开源分布式 千万 - 百亿 大厂常用,Kubernetes 部署
Qdrant 开源/SaaS 任意 Rust 写的,单机性能极强,支持 payload 过滤
Weaviate 开源/SaaS 任意 自带模块化 Embedding,GraphQL 接口
Chroma 开源 小规模 / 原型 Python 友好,本地开发首选
Faiss 算法库 任意 Meta 出品,不是数据库而是索引库
Elasticsearch 8.0+ 全文搜索 任意 k-NN 索引 + BM25 关键词二合一

选型决策

11.5.2 KNN vs ANN:为什么”近似”是正确的工程选择

generated-image-1778897132829.png

配图:HNSW 与 IVF 的近似最近邻检索

KNN(K-Nearest Neighbors,精确最近邻)

最朴素的实现:

def knn_search(query_vec, all_vecs, k=5):
    distances = [cosine(query_vec, v) for v in all_vecs]  # O(n) 次距离计算
    return sorted(zip(distances, all_vecs), reverse=True)[:k]

100 万向量?算 100 万次余弦距离。1000 万?算 1000 万次。这是 O(n),规模上去后单次查询要几秒甚至几十秒,无法上生产。

ANN(Approximate Nearest Neighbors,近似最近邻)

核心思路:用索引结构(图、树、哈希)把搜索空间剪枝,不再扫描全部向量,只看”看起来很可能是邻居的少量候选”。

def ann_search(query_vec, index, k=5, ef=50):
    candidates = index.search_with_pruning(query_vec, ef)  # 只看 ef 个候选
    return sorted_by_distance(candidates)[:k]

代价:会漏掉一小部分真正的最近邻(这就是”近似”的含义)。收益:速度快 100–1000 倍

为什么”近似”是正确的工程选择

很多读者第一次听到”近似最近邻”会本能反感:“漏掉的那部分会不会就是最重要的?” 工程上回答这个问题需要看具体数字。下面的数字只是经验级示例,会受数据规模、维度、硬件、过滤条件、索引参数和实现差异影响,不能直接当作生产 SLA:

方案 召回率(Recall@10) 查询延迟(100 万向量)
KNN(精确) 100% 几秒 - 几十秒
HNSW (ef=50) 95–98% 1–10 ms
HNSW (ef=200) 99–99.5% 10–30 ms
IVF (nprobe=10) 90–95% 5–20 ms
IVF + PQ 量化 85–92% 1–5 ms

结论:在多数 RAG 场景里,召回率 95% + 速度快 1000 倍,通常比召回率 100% + 慢 1000 倍更实用。但“近似”的代价是否可控,必须通过自己的评测集验证 Recall@K、NDCG@K、P95 延迟和内存占用。

主流 ANN 算法

算法 数据结构 优势 代表实现
HNSW 多层小世界图 召回率高、查询快、对参数不敏感 pgvector、Milvus、Qdrant、Faiss
IVF 倒排索引(k-means 聚类) 内存省、构建快、适合超大规模 Faiss、Milvus
LSH 局部敏感哈希 实现简单 早期产品,现已被前两者取代
PQ(量化) 乘积量化 极致省内存(向量压缩 8–32 倍) 常和 IVF 组合使用:IVF + PQ
Annoy 随机投影树 Spotify 出品 老牌,现也被 HNSW 取代

接下来 11.5.3、11.5.4 分别展开 HNSW 和 IVF——RAG 选型时 99% 会落在这两者之一。

11.5.3 HNSW(Hierarchical Navigable Small World)

HNSW 是目前最主流的 ANN 算法,pgvector、Milvus、Qdrant 等都把它作为默认索引。

直觉:六度分隔 + 高速公路

HNSW 利用了两个深刻的现实直觉:

(1) 小世界图(Small World)

社交网络中”任意两人之间最多经过 6 个中间人就能连上”——这就是六度分隔理论。其本质是:图中只要有少量”长程连接”(远距离朋友),任意两点都能在很少步数内可达。

向量空间中也一样:每个向量随机连一些远距离邻居 + 一些近距离邻居 → 整张图就具备了”小世界性质”,从任意点出发都能快速逼近目标。

(2) 分层 = 高速公路 + 国道 + 乡道

flowchart TB
    subgraph L2 [Layer 2: 顶层稀疏图 - 高速公路]
        L2_A[节点1] --- L2_B[节点47]
        L2_B --- L2_C[节点283]
    end
    subgraph L1 [Layer 1: 中层 - 国道]
        L1_A[..] --- L1_B[..]
        L1_B --- L1_C[..]
        L1_C --- L1_D[..]
    end
    subgraph L0 [Layer 0: 底层完整图 - 乡道]
        L0_A[所有向量都在这一层]
    end

    L2 --> L1
    L1 --> L0

这张图是帮助理解的类比,不是 HNSW 真实内存结构的精确画法。它想表达的是:先在稀疏高层快速接近目标区域,再到底层做更细的邻域搜索。

查询过程(层级跳跃)

  1. 从顶层一个入口点开始
  2. 在当前层贪心走最近的邻居(远距离跳跃)
  3. 走到”再无更近邻居”时,下钻到下一层
  4. 重复,最终在底层得到 Top-K

直觉上就像”先用高速公路跳到目的城市附近 → 再用国道到目的县 → 最后用乡道找到具体地址”。

关键参数(HNSW 用 pgvector 创建索引时的实战参数)

CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- 查询时
SET hnsw.ef_search = 100;
参数 含义 实战建议
m 每层每个节点的最大连接数 中小规模 16;超大规模 32–48
ef_construction 建索引时每个节点考察的候选数 64–200,越大索引质量越好但建库慢
ef_search 查询时每层考察的候选数 50–200 之间动态调,质量 vs 延迟的旋钮

调参经验

  1. 召回率不够 → 优先调大 ef_search(不用重建索引)
  2. 建库太慢 → 调小 ef_construction(但召回率会受影响)
  3. 大规模数据集 → 调大 m(更稠密的图),但内存占用上升
  4. ef_search 永远要 ≥ k(要返回 Top-10,就至少设为 ef=10)

HNSW 的代价

在 pgvector 中创建 HNSW 索引很慢——100 万 chunk 可能要 10–30 分钟。更推荐先批量完成无索引入库,再统一执行 CREATE INDEX 建好索引,比“插一条建一条”快得多。

11.5.4 IVF(Inverted File Index,倒排索引)

HNSW 之外的另一种主流 ANN 算法。原理完全不同。

直觉:先分大区,再在大区内细查

flowchart TB
    subgraph "Step 1: 离线 - K-means 聚类"
        D[所有向量] --> KM["K-means<br>聚成 N 个簇<br>(如 N=1024)"]
        KM --> C["每个簇有一个<br>质心向量(centroid)"]
    end
    subgraph "Step 2: 在线 - 查询时"
        Q[查询向量] --> NEAR["找最近的 nprobe 个簇<br>(如 nprobe=10)"]
        NEAR --> EXACT["在这 10 个簇内<br>做精确 KNN"]
        EXACT --> TOPK[Top-K 结果]
    end

步骤

  1. 离线:把所有向量用 k-means 聚成 N 个簇(每个簇一个 centroid)
  2. 在线查询
    • 算 query 和所有 N 个 centroid 的距离 → 找最近的 nprobe 个簇
    • 只在这 nprobe 个簇内的向量里做精确 KNN
  3. 假设总向量 = 100 万,N=1024,nprobe=10
    • 不用 IVF:扫 100 万次
    • 用 IVF:扫 1024 次(找 centroid)+ 约 10000 次(10 个簇 × 平均 ~1000 个向量/簇)
    • 加速比约 100 倍

关键参数(IVF)

参数 含义 实战建议
nlist 簇的数量 N 通常 = √(向量总数),100 万取 1024–4096
nprobe 查询时考察的簇数 1–N 之间,越大召回越高但越慢

IVF 和 PQ 量化的常见组合:IVF + PQ

PQ(Product Quantization,乘积量化):把每个 1024 维向量拆成 8 段、每段 128 维,每段做向量量化(用 256 个 codebook 向量近似),最终 1024×4 字节(float32)→ 8×1 字节 = 8 字节,内存压缩 512 倍

IVF + PQ 组合可以让 10 亿向量在几十 GB 内存里检索,是 Facebook、Yandex 等大公司的标配。代价是召回率会下降到 85–92%。

11.5.5 HNSW vs IVF 对比

维度 HNSW IVF
数据结构 多层小世界图 k-means 聚类 + 倒排表
召回率 高(95–99.5%) 中高(90–95%)
查询延迟 极快(1–10 ms) 快(5–20 ms)
内存占用 (图结构常驻内存) 小(只存 centroid + 量化向量)
构建速度
可删除性 弱(需 rebuild) 较好(直接从簇中删)
适合规模 < 1000 万 1000 万 - 百亿
代表实现 pgvector / Qdrant / Milvus 默认 Faiss / Milvus 大规模场景

选择经验

11.5.6 pgvector 实战配置

本项目使用 pgvector,建库脚本通常长这样。

注意:先确认维度和索引类型

pgvector 的字段类型和索引类型都有维度限制。以常见配置为例,vector + HNSW 对维度有上限;如果使用 OpenAI text-embedding-3-large 默认 3072 维,可能需要先用 dimensions 参数降维,或改用 halfvec / 其他向量库。不要只看模型效果,也要确认“模型输出维度 × 向量库字段 × 索引类型”三者兼容。

注意:权限过滤必须发生在检索阶段

企业 RAG 不能先把全库 Top-K 检出来,再让 LLM “不要回答无权限内容”。正确做法是在 SQL / 向量库查询阶段就加上 userIdworkspaceIdteamIddocumentType、时间范围等 metadata 过滤条件,否则可能把无权限片段送入模型上下文,造成数据泄露。

-- 1. 开启 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS vector;

-- 2. 建表
CREATE TABLE document_chunks (
  id          UUID PRIMARY KEY,
  documentId  UUID NOT NULL,
  content     TEXT NOT NULL,
  chunkIndex  INT NOT NULL,
  embedding   vector(384) NOT NULL,
  modelName   VARCHAR(128) NOT NULL,
  createdAt   TIMESTAMPTZ DEFAULT NOW()
);

-- 3. 建 HNSW 索引(在数据全部导入后做)
CREATE INDEX idx_chunks_embedding ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- 4. 查询时调整 ef_search
SET hnsw.ef_search = 100;

-- 5. 检索(余弦距离)
SELECT dc.id, dc.content, 1 - (dc.embedding <=> $1::vector) AS score
FROM document_chunks dc
JOIN documents d ON d.id = dc."documentId"
WHERE d."userId" = $2
  AND d."workspaceId" = $3
  AND d."documentType" = ANY($4)
ORDER BY dc.embedding <=> $1::vector
LIMIT 5;

<=> 是 pgvector 提供的余弦距离运算符(距离 = 1 - 相似度)。本项目的 SearchService 实现:

const rows = await this.prisma.$queryRaw<
  Array<{
    chunk_id: string;
    document_id: string;
    content: string;
    score: string | number;
    chunk_index: number;
  }>
>`
  SELECT
    dc.id             AS chunk_id,
    dc."documentId"   AS document_id,
    dc.content        AS content,
    dc."chunkIndex"   AS chunk_index,
    1 - (dc.embedding <=>${vecRaw}) AS score
  FROM document_chunks dc
  JOIN documents d ON d.id = dc."documentId"
  WHERE d."userId" =${userId}
    AND d."workspaceId" =${workspaceId}
    AND dc.embedding IS NOT NULL
  ORDER BY dc.embedding <=>${vecRaw}
  LIMIT${topK}
`;

11.5.7 存储与运维成本估算

100 万 chunk、384 维向量、HNSW 索引:

项目 大小估算
向量本身(4 byte × 384 × 100 万) ~1.5 GB
HNSW 索引(m=16,约向量大小的 1.5–2x) ~3 GB
chunk 文本(平均 500 字 × 100 万) ~500 MB
Postgres 表头 / WAL / 索引元数据 ~500 MB
总计 ~5.5 GB

如果是 1024 维向量,存储和内存都会 乘 2.67 倍(≈ 15 GB);3072 维则 ≈ 44 GB。这就是为什么 11.2.7 强调”小规模选小维度”。

11.5.8 🤖 用 AI 生成本节代码(pgvector 仓储 + 索引脚本)

完整 Prompt(可直接粘贴到 Cursor / Claude 执行)

**背景**:
- 项目位于当前工作区,已有 services/chat/src/document/search.service.ts 实现 pgvector 余弦检索;本节目标是把"仓储层 + 索引脚本"独立到 services/chat/rag/retrieval/ 下,便于复用与单测。
- 数据库迁移已建表 document_chunks(embedding vector(384), modelName varchar),HNSW 索引尚未建。

**任务**:
1. 在 services/chat/rag/retrieval/ 下新建 vector-store.ts,导出:
   - 接口 VectorStoreRecord { id, documentId, content, chunkIndex, embedding: number[], modelName }
   - upsertChunks(prisma, records)
   - similaritySearch(prisma, queryVector, options): SearchResult[]
   - 必须在 similaritySearch 入参检查 queryVector 长度与库中向量维度一致,否则抛 RangeError
2. 在 services/chat/scripts/ 下新建 create-hnsw-index.sql:
   - 启用 vector 扩展
   - 在 document_chunks(embedding) 上建 HNSW 索引:m=16, ef_construction=64, ops=vector_cosine_ops
   - 注释说明:先全量入库再建索引
3. 在 services/chat/test/chapter11-rag.spec.ts 中 describe '11.5 向量数据库':
   - 11.5.2 KNN 暴力实现作为 baseline,验证小数据集上"暴力 vs ANN"结果一致性(mock 数据 50 条向量)
   - 11.5.6 余弦相似度计算的 score = 1 - 距离 一致性

**关键约束**:
- vector-store.ts 不直接 import pgvector 客户端,仅通过 prisma.$queryRaw 操作
- queryVector 与 record embedding 维度必须严格一致;用类型 [number, ...number[]] 也行,但运行时 length 校验是必须
- HNSW 索引脚本要写在 .sql 里而不是迁移文件,方便上线时手动批准执行

**输出**:
- vector-store.ts 完整代码
- create-hnsw-index.sql
- chapter11-rag.spec.ts 中 11.5 用例

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.5"

覆盖:维度不一致抛错、KNN 暴力 baseline 与 mock ANN 结果一致性、cosine 距离与相似度互转。

image 8.png

本节小结


11.6 生成环节:检索结果如何提供给 LLM

11.2–11.5 把检索这一半 RAG 解决了。下一半是生成:检索到 Top-K(典型 5 段)后,如何组合到 Prompt 里、如何让 LLM 基于这些内容回答?

image 9.png

11.6.1 Prompt 模板的标准结构

你是一个基于知识库回答问题的助手。请严格根据下方提供的 [上下文] 回答用户问题。

[规则]
- 只用上下文中的信息回答,不要凭借常识或推测
- 如果上下文不足以回答,明确说"根据提供的资料,我无法确定..."
- 引用来源时标注 [文档 X, 第 Y 段]
- 用简洁清晰的中文回答

[上下文]
{retrieved_chunks_with_citations}

[用户问题]
{user_question}

[回答]

四个关键设计点:

  1. 明确角色:限定模型只能基于上下文,不能编造
  2. 回退策略:明确告诉它”找不到时如何说”,否则模型仍可能编造
  3. 要求引用:让模型必须标注来源,方便用户溯源
  4. 检索结果在前,问题在后:符合 KV-Cache 复用规律(前缀稳定);问题在后让模型读完资料再回答

11.6.2 检索结果的注入格式

检索结果最好带上可追溯元信息,而不是只传 chunk 文本。常见字段包括 sourceTitlesourceUrlsectionTitlepageNumberstartOffsetendOffsetchunkIddocumentVersion。这样前端才能做“点击引用 → 跳转原文 → 高亮段落”,评测系统也能校验引用是否真实。

过于简化的写法(不推荐):

[上下文]
SSO 配置需要在控制台启用 SAML。
管理员可以在用户组设置权限。
批量导入支持 CSV 格式。
...

问题:模型不知道每段来自哪里重要性如何

推荐写法(带元信息):

[上下文]
---
[来源: SSO 配置指南.md, 第 2.3 节, 相关性 0.91]
SSO 配置需要在控制台启用 SAML。具体步骤:进入「设置」→「身份验证」→「启用 SAML」...

---
[来源: 用户权限手册.md, 第 5 节, 相关性 0.87]
管理员可以在用户组设置权限。支持三类预设角色...

---
[来源: 数据导入说明.md, 第 1.1 节, 相关性 0.78]
批量导入支持 CSV 格式...
---

元信息带来三个好处:

11.6.3 引用回写:让模型在回答里标注来源

进阶版 Prompt 加一条规则:

- 回答时必须在每个事实后面标注引用,格式 [文档名, 第 N 节]

LLM 的输出会变成:

SSO 的配置需要在控制台启用 SAML [SSO 配置指南.md, 第 2.3 节]。
管理员可以在用户组里设置三类预设角色 [用户权限手册.md, 第 5 节]。

前端展示时可以把这些 [...] 渲染成可点击的链接,跳转到对应文档/章节。这就是企业内搜(Glean、Notion AI)的标准交互。

11.6.4 四种 Prompt 组合策略

当检索结果较多时(比如 Top-10、Top-20),不能直接放入一个 Prompt。LangChain 把这一类组合方式总结为四种:

(1) Stuff(填塞,最常用)

把所有检索结果直接拼进一个 Prompt。

[上下文]
chunk1 + chunk2 + ... + chunk5

这是 99% 的 RAG 实战场景

(2) Map-Reduce

每个 chunk 单独问 LLM 一次(“你能从这段内容里提取出和问题相关的信息吗”)→ 把所有结果汇总成最终答案。

flowchart TB
    Q[问题] --> M1[LLM: chunk1 + Q]
    Q --> M2[LLM: chunk2 + Q]
    Q --> M3[LLM: chunk3 + Q]
    M1 --> R[Reducer: LLM 汇总]
    M2 --> R
    M3 --> R
    R --> A[最终答案]

(3) Refine(迭代精炼)

flowchart LR
    C1[chunk1] --> L1["LLM: 初稿"]
    L1 --> L2["LLM: chunk2 + 初稿 → 精炼"]
    L2 --> L3["LLM: chunk3 + 上轮 → 再精炼"]
    L3 --> A[最终答案]

(4) Map-Rerank

每个 chunk 让 LLM 给出一个”打分 + 答案”,取分数最高的那个。

策略 调用次数 速度 适用 Top-K 典型场景
Stuff 1 ≤ 10 标准 RAG
Map-Reduce K + 1 ≤ 100 跨文档总结
Refine K 慢(串行) ≤ 20 长文档分析
Map-Rerank K ≤ 50 单点问答

11.6.4.1 上下文压缩:把 Top-K 变成可用上下文

当检索返回 Top-20 / Top-50 时,即使上下文窗口足够,也不应该把所有 chunk 原样塞给 LLM。更稳的做法是增加一层 Contextual Compression(上下文压缩)

flowchart LR
    A["向量 / BM25 检索<br>Top-50"] --> B["重排<br>Top-8"]
    B --> C["压缩 / 去重<br>保留相关句子"]
    C --> D["拼 Prompt<br>控制 Token"]
    D --> E["LLM 生成"]

常见压缩方式:

上下文压缩和第十章的 Token 成本控制直接相关:它减少输入 token,同时提升上下文信噪比。但压缩不能改变原意,尤其不能删除时间、范围、否定词和权限条件。

11.6.5 防幻觉的 Prompt 工程清单

即使有检索结果,LLM 仍然可能生成超出资料范围的内容。下面是一个验证过的防幻觉清单:

  1. 强限定严格根据提供的资料回答,资料未覆盖的内容不要推测
  2. 明确回退如果资料不足以回答,回复 "根据提供的资料,我无法确定..."
  3. 要求引用每句结论必须标注来源 [文档名, 第N节]
  4. 要求结构化如果问题是"对比 A 和 B",请以表格形式回答
  5. 温度调低temperature: 0.1(不需要创造性,需要准确性)
  6. 拒答示例(few-shot):在 prompt 里给一个”无法回答”的示例,模型更容易模仿这种回退行为

11.6.6 输出格式控制

业务系统的回答往往不是纯文本,而是要带结构(用于前端渲染、下游 Agent 消费)。常见三种:

(1) JSON 结构化回答

const schema = z.object({
  answer: z.string(),
  citations: z.array(z.object({
    source: z.string(),
    section: z.string().optional(),
    confidence: z.number(),
  })),
  needsMoreInfo: z.boolean(),
});
const result = await model.withStructuredOutput(schema).invoke(prompt);

(2) Markdown 带引用

## 配置 SSO

1.进入控制台 「设置 → 身份验证」 [SSO 配置指南.md, 第 2.3 节]
2.启用 SAML 协议并填入 IdP 元数据 [SSO 配置指南.md, 第 2.4 节]

(3) UI Protocol 事件流

参考第七章 UI Protocol,把”检索过程”和”回答内容”分别打包成 SSE 事件流,前端边检索边渲染。

11.6.7 一个完整的 RAG Pipeline 参考实现

整合 11.4–11.6 的所有思路:

// services/chat/rag/pipeline/rag-pipeline.ts
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
import { similaritySearch, type VectorStoreRecord } from '../retrieval/vector-store';

export interface RagAskInput {
  question: string;
  userId: string;
  topK?: number;
  model: BaseChatModel;
}

export interface RagAskOutput {
  answer: string;
  citations: Array<{
    chunkId: string;
    documentId: string;
    score: number;
  }>;
  retrievedChunks: VectorStoreRecord[];
}

const RAG_SYSTEM_PROMPT = `你是一个基于知识库的问答助手。请严格根据[上下文]回答用户问题。

规则:
- 只用上下文中的信息回答,不要凭借常识或推测
- 如果上下文不足以回答,明确说"根据提供的资料,我无法确定..."
- 每句结论后用 [chunkId] 标注引用来源
- 简洁清晰,最多 5 段`;

export async function ragAsk(input: RagAskInput): Promise<RagAskOutput> {
  const { question, userId, topK = 5, model } = input;

  // Step 1: 检索
  const chunks = await similaritySearch(question, userId, topK);

  // Step 2: 拼接 Prompt 上下文
  const contextBlock = chunks
    .map(
      (c, i) =>
        `[chunkId:${c.chunkId}, 来源:${c.documentId}, 相关性:${c.score.toFixed(2)}]\n${c.content}`,
    )
    .join('\n\n---\n\n');

  const userMessage = `[上下文]\n${contextBlock}\n\n[用户问题]\n${question}`;

  // Step 3: LLM 生成
  const response = await model.invoke([
    new SystemMessage(RAG_SYSTEM_PROMPT),
    new HumanMessage(userMessage),
  ]);

  return {
    answer: String(response.content),
    citations: chunks.map((c) => ({
      chunkId: c.chunkId,
      documentId: c.documentId,
      score: c.score,
    })),
    retrievedChunks: chunks,
  };
}

这就是一个最小但完整的 RAG Pipeline。后面 11.8 节会展开”召回率不够该如何改进”,11.10 节会把它包装成 LangGraph Agent 的工具。

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.6"

覆盖:Prompt 模板拼接正确、检索 0 结果时回退到”无法确定”、引用列表与检索结果数量一致、温度参数透传到 model.invoke。

image 10.png


11.7 召回率与准确率:RAG 系统如何评估

11.6 让 RAG 完成基础运行了。下一个问题:它具体跑得好不好?

工程上有一个反直觉的事实:RAG 的好坏 90% 是检索决定的,10% 是 LLM 决定的。模型再强,检索给的资料是错的,回答就一定是错的。所以评估 RAG 第一步是评估检索。

image 11.png

11.7.1 检索质量的三大指标

Recall@K(召回率)

前 K 个检索结果中,找到的”真正相关文档”占所有相关文档的比例。

公式:

$$ \text{Recall@K} = \dfrac{\text{Top-K 中相关文档数}}{\text{知识库中所有相关文档数}} $$

例:用户问”如何配置 SSO”,知识库里有 3 篇相关文档,检索 Top-5 中命中 2 篇,则 Recall@5 = 2/3 ≈ 0.67。

典型目标:Recall@5 ≥ 0.8,Recall@10 ≥ 0.9。

MRR(Mean Reciprocal Rank,平均倒数排名)

第一个相关结果的排名的倒数,对所有查询取平均。

公式:

$$ \text{MRR} = \dfrac{1}{|Q|} \sum_{q \in Q} \dfrac{1}{\text{rank}_q^{\text{first relevant}}} $$

例:3 个测试问题,第一个相关结果分别排在第 1、第 3、第 2 位 → MRR = (1 + 1/3 + 1/2) / 3 ≈ 0.61。

典型目标:MRR ≥ 0.7(也就是说,第一个相关结果平均排在前 1–2 位)。

NDCG@K(Normalized Discounted Cumulative Gain)

综合考虑”相关性 + 位置”的指标。位置越靠前权重越大。

公式(简化):

$$ \text{DCG@K} = \sum_{i=1}^{K} \dfrac{\text{rel}_i}{\log_2(i+1)} $$

$$ \text{NDCG@K} = \dfrac{\text{DCG@K}}{\text{IDCG@K}} $$

其中 IDCG 是”理想排序”下的 DCG(即所有相关文档按相关性从高到低排好的最优值)。

典型目标:NDCG@10 ≥ 0.65。

三大指标对比

指标 关心 不关心 典型场景
Recall@K “该召回的全召回了吗” 召回的位置 候选生成阶段
MRR “第一个相关在前面吗” K 以外的命中 单点问答
NDCG@K “排序质量好不好” 综合检索评估

工程上:RAG 系统至少同时跟踪 Recall@K 和 NDCG@K,前者反映”召回完整性”,后者反映”排序精度”。

11.7.2 端到端生成质量的评估指标

检索完了,LLM 也答完了,整个回答是不是好?需要另一组指标:

指标 含义 评测方式
Faithfulness(忠实度) 回答是否完全基于检索结果,没有幻觉 LLM-as-judge:把回答和检索结果给另一个 LLM 判断
Answer Relevancy(答案相关性) 回答是否真正回答了用户问题 LLM-as-judge
Context Precision(上下文精度) 检索结果里有多少是真正用上的 LLM 判断 + 引用回写校验
Context Recall(上下文召回) 真正能回答问题的内容是否都被检索到 需要 ground truth
Answer Correctness(答案正确性) 和参考答案的语义一致性 用 Embedding 算相似度 + LLM judge

11.7.3 RAGAS 评估框架(自动化评测)

11.7.2 的指标都要”人工对答案”来算,规模化如何处理?现在有现成的开源框架:

下面是用 RAGAS 评一组 RAG 输出的最小示例(Python 伪代码)。需要注意:RAGAS 本身更常见的形态是 Python 评测库,并不是默认自带标准 REST API 的服务。Node.js 项目如果要在 CI 中调用,可以额外封装一个内部 Python 微服务,例如提供 POST /evaluate 端点。

# pip install ragas
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall
from datasets import Dataset

eval_data = Dataset.from_list([
    {
        "question": "企业版每月最多创建多少个项目?",
        "answer": "根据《价格政策 v2.1》,企业版单工作区最多 200 个项目。",
        "contexts": ["《价格政策 v2.1》第 3 节:企业版单工作区项目上限为 200..."],
        "ground_truth": "企业版单工作区最多 200 个项目。",
    },
    # ... 更多评测样本
])

result = evaluate(
    eval_data,
    metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
    llm=eval_llm,  # 通常用 GPT-4o 作 judge
)
print(result)
# {'faithfulness': 0.95, 'answer_relevancy': 0.91, 'context_precision': 0.88, 'context_recall': 0.90}

集成到 CI 的标准流程

flowchart LR
    A[准备评测集\n50-200 条问答] --> B[每次 PR 触发\nRAG 系统运行]
    B --> C[RAGAS 批量评测]
    C --> D{所有指标\n≥ 阈值?}
    D -->|否| E[阻断合并\n报告退化原因]
    D -->|是| F[允许合并]

image.png 实战建议:

11.7.4 评测集如何构造

flowchart TB
    A[业务日志\n用户真实问题] --> C[评测集种子]
    B[产品对FAQ经验] --> C
    C --> D[人工标注\nground truth 答案 + 相关文档]
    D --> E[评测集 v1.0\n50-200 条]
    E --> F[每月新增 5-10 条\n覆盖新出现的问题]

image.png 种子来源:

  1. 业务日志中的高频问题(最重要,反映真实分布)
  2. 客服 FAQ 文档(成熟问题)
  3. 对抗性问题(“知识库里没有”的问题,验证”无法回答”是否回退正确)
  4. 多跳问题(需要综合多个文档才能回答)

评测集本身也要版本化、纳入 Git 仓库。

一条更接近真实工程的评测样本可以长这样:

{
  "question": "企业版每月最多能创建多少个项目?",
  "groundTruth": "企业版单工作区最多 200 个项目。",
  "expectedDocIds": ["pricing-v2.1"],
  "relevantChunkIds": ["pricing-v2.1-sec3"],
  "hardNegativeChunkIds": ["pricing-v1.8-sec3", "team-plan-limit"],
  "shouldRefuse": false
}

这里的 hardNegativeChunkIds 很重要:它们通常“看起来很像”,但答案不对。例如旧版价格政策、其他套餐限制、相似产品线说明。把 hard negative 纳入评测,才能发现“检索到了相似文本,但不是正确文本”的问题。

11.7.5 🤖 用 AI 生成本节代码(评测指标 + RAGAS 集成)

完整 Prompt(可直接粘贴到 Cursor / Claude 执行)

**背景**:
- 项目位于当前工作区,第十一章 RAG 系统代码统一放在 services/chat/rag/ 下。本节目标是建立"检索 + 生成"双层评测能力:
  · 检索层用纯函数计算 Recall@K / MRR / NDCG@K,无外部依赖
  · 生成层接入 RAGAS(独立服务),仅在 CI 触发,不耦合到主进程
- 评测集放在 services/chat/test/fixtures/rag-eval-set.json,每条 { question, expectedDocIds, groundTruth }

**任务**:
1. 在 services/chat/rag/evaluation/ 下新建 retrieval-metrics.ts,导出:
   - recallAtK(retrievedIds: string[], relevantIds: string[], k: number): number
   - mrr(rankedListsPerQuery: string[][], relevantPerQuery: string[][]): number
   - ndcgAtK(retrievedIds: string[], relevantIds: string[], k: number): number
   - 所有函数纯函数,零依赖
2. 在 services/chat/rag/evaluation/ 下新建 ragas-runner.ts:
   - 通过 HTTP 调用团队自行封装的 RAGAS REST 服务(Python 微服务,端点 POST /evaluate;RAGAS 本身是 Python 库,不默认提供该端点)
   - 入参 { samples: Array<{ question, answer, contexts, ground_truth }>, metrics: string[] }
   - 出参 { [metric: string]: number }
   - 实现 timeout=60s 与可重试 3 次
3. 在 services/chat/test/chapter11-rag.spec.ts 中 describe '11.7 评估':
   - 11.7.1 Recall@K = 1 当所有 relevant 都在 Top-K
   - 11.7.1 MRR 第一个相关在第 1 位 → 1.0;第 2 位 → 0.5
   - 11.7.1 NDCG@K 单个完全命中 = 1.0
   - 11.7.3 ragas-runner 在 RAGAS 不可用时返回 null + warn,不抛错

**关键约束**:
- 检索指标完全离线、无依赖
- RAGAS 接入失败必须降级(不能阻塞主流程)
- 测试用 mock fetch / mock prisma,不能依赖真实 RAGAS 服务

**输出**:
- retrieval-metrics.ts、ragas-runner.ts
- chapter11-rag.spec.ts 中 11.7 用例

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.7"

覆盖:Recall@K 边界值(K=0、Top-K 全命中、零命中)、MRR 单 query 单相关、NDCG@K 单点 vs 多点排名差异、RAGAS runner 在服务不可用时降级。

image 12.png


11.8 提升召回率的策略

11.7 给出了”评什么”的指标。但当指标不达标——召回率只有 60% 而你需要 90% 时,如何改进?这一节是 RAG 工程优化的核心战场

image 13.png

按效果从高到低、改造成本从低到高,五个标准策略。

Query 改写的风险

Query Rewrite / Multi-Query 很有效,但不能无脑改写。改写器必须保留用户问题中的关键约束:

工程上建议同时保留 originalQueryrewrittenQueries,评测时分别记录是哪一个 query 命中了最终答案,方便排查“改写改变原意”的问题:

flowchart LR
    A["Query 改写\n(成本极低)"] --> B["多路召回\n(成本低)"]
    B --> C["混合检索\n向量 + BM25\n(成本中)"]
    C --> D["重排序\nCross-Encoder\n(成本中)"]
    D --> E["HyDE 等高级模式\n(成本高,11.9 节)"]

11.8.1 Query 改写

用户的原始问题往往短、模糊、口语化,向量空间里不一定对得上文档的”长、规范、书面化”表达。先用 LLM 把问题改写成更利于检索的形式,再去检索

三种改写方式:

(1) 同义改写(Query Expansion)

原问题: "如何登录"
改写后: "如何登录系统" / "用户登录步骤" / "系统认证方法"

把多个改写都拿去检索,结果合并。

(2) 子问题分解(Sub-query Decomposition)

复杂问题拆成多个简单问题,分别检索:

原问题: "对比企业版和专业版的 SSO 配置、用户上限、计费方式"
拆解:
  1. 企业版 SSO 配置
  2. 专业版 SSO 配置
  3. 企业版用户上限
  4. 专业版用户上限
  5. 企业版计费
  6. 专业版计费

每个子问题独立检索后合并结果。

(3) 历史上下文回填

对话场景下,用户的问题往往依赖前文:

用户: "我们的 SSO 如何配置?"
助手: "..."
用户: "那企业版呢?"   ← 这里"企业版"什么意思全靠上下文

改写后:

"企业版的 SSO 如何配置?"

LangChain 提供 RephraseQueryRetriever,本质就是用 LLM 把对话上下文 + 当前问题改写成自包含的查询。

实现示例

// services/chat/rag/retrieval/query-rewriter.ts
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { z } from 'zod';

const REWRITE_SCHEMA = z.object({
  queries: z.array(z.string()).min(1).max(5),
});

const REWRITE_SYSTEM = `你是一个查询改写助手。把用户的原始问题改写为 1-3 个更利于检索的版本:
- 保持原意
- 用规范的书面表达
- 复杂问题可以拆成多个子问题
返回 JSON: { "queries": ["改写1", "改写2", ...] }`;

export async function rewriteQuery(
  model: BaseChatModel,
  originalQuery: string,
  conversationHistory?: string,
): Promise<string[]> {
  const userMessage = conversationHistory
    ? `历史对话:\n${conversationHistory}\n\n当前问题:${originalQuery}`
    : originalQuery;

  const result = await model
    .withStructuredOutput(REWRITE_SCHEMA)
    .invoke([
      { role: 'system', content: REWRITE_SYSTEM },
      { role: 'user', content: userMessage },
    ] as any);

  return result.queries;
}

11.8.2 多路召回(Multi-Query Retrieval)

把 Query 改写得到的多个查询分别检索后合并去重

flowchart LR
    Q[原问题] --> R[Query 改写: 得到 3 个 query]
    R --> Q1[query 1 → Top-10]
    R --> Q2[query 2 → Top-10]
    R --> Q3[query 3 → Top-10]
    Q1 --> M[合并 + 去重]
    Q2 --> M
    Q3 --> M
    M --> TOP[最终 Top-K]

image.png 合并策略:

11.8.3 混合检索(Hybrid Retrieval):向量 + BM25

向量检索的盲点:它依赖”语义相似”,但对”完全相同的关键词”反而不一定敏感

考虑两个查询:

查询 A: "如何用 OAuth2 配置 SSO"
文档 X: "OAuth2 协议详细说明..."

向量召回率高吗?不一定——文档 X 没有”配置 SSO”这个表达,向量空间里距离可能不近。但如果用关键词检索”OAuth2”,精确命中

结论:向量检索擅长”语义模糊匹配”,关键词检索擅长”精确实体匹配”。两者结合 = 混合检索

11.8.3.1 BM25 / TF-IDF / RRF 原理

要看懂混合检索的代码,先把这三个算法原理过一遍。

TF-IDF(Term Frequency - Inverse Document Frequency)

最基础的关键词权重算法:

$$ \text{TF-IDF}(t, d, D) = \text{TF}(t, d) \times \log \dfrac{|D|}{|{d \in D : t \in d}|} $$

直观解释:

直觉:

缺陷

BM25

TF-IDF 的工业升级版:

$$ \text{BM25}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot \dfrac{f(t, d) \cdot (k_1 + 1)}{f(t, d) + k_1 \cdot \left(1 - b + b \cdot \dfrac{|d|}{\text{avgdl}}\right)} $$

其中:

BM25 在 TF-IDF 上做了两个关键改进:

  1. 词频饱和:通过 $\dfrac{f \cdot (k_1+1)}{f + k_1}$ 让 TF 不再线性增长。当 $k_1=1.5$ 时,f 从 1 到 10 的得分不是 10 倍而是约 2.7 倍——符合直觉
  2. 文档长度归一化:分母里加入 $b \cdot \dfrac{|d|}{\text{avgdl}}$,让长文档的 TF 被”打折”,避免长文章靠堆字数获胜。

BM25 是 Elasticsearch / Lucene / OpenSearch 等全文搜索引擎的默认算法,常年是关键词检索的工业标准。

flowchart LR
    A[查询: SSO 配置] --> B[BM25 算法]
    B --> C["对每个文档算分数:\nIDF(SSO) × 饱和TF(SSO,d) ×\n长度归一化"]
    C --> D[按分数排序]
    D --> E[Top-K 文档]

实现库

RRF(Reciprocal Rank Fusion,倒数排名融合)

向量检索给出一个排序,BM25 给出另一个排序——如何合并?

最朴素的想法:归一化分数后加权求和。问题:向量相似度(0–1)和 BM25 分数(0–几十)量纲完全不同,强行归一化容易出问题。

RRF 的思路非常聪明:不用分数,只用排名。公式:

$$ \text{RRF}(d) = \sum_{r \in R} \dfrac{1}{k + \text{rank}_r(d)} $$

其中 $R$ 是所有的排序列表(向量 / BM25),$\text{rank}_r(d)$ 是 d 在列表 r 中的排名(1-indexed),$k$ 是常数(典型值 60)。

举例:

文档 向量排名 BM25 排名 RRF 得分(k=60)
A 1 5 1/61 + 1/65 ≈ 0.0317
B 3 1 1/63 + 1/61 ≈ 0.0322
C 2 12 1/62 + 1/72 ≈ 0.0300

最终融合排名:B > A > C。

为什么 k=60?这是 Cormack 等人在 2009 年原始论文中实验得出的经验值。直觉:

混合检索实现

// services/chat/rag/retrieval/hybrid-search.ts
export interface HybridSearchOptions {
  topK?: number;
  vectorWeight?: number;  // 不使用,因为 RRF 不依赖加权
  rrfK?: number;          // 默认 60
}

export async function hybridSearch(
  prisma: any,
  query: string,
  userId: string,
  options: HybridSearchOptions = {},
): Promise<SearchResult[]> {
  const { topK = 5, rrfK = 60 } = options;

  // 1. 并行执行两路检索
  const [vectorResults, bm25Results] = await Promise.all([
    vectorSearch(prisma, query, userId, topK * 4),  // 多召回一些作为重排候选
    bm25Search(prisma, query, userId, topK * 4),
  ]);

  // 2. RRF 融合
  const scoreMap = new Map<string, number>();

  for (let i = 0; i < vectorResults.length; i++) {
    const id = vectorResults[i].chunkId;
    const rank = i + 1;
    scoreMap.set(id, (scoreMap.get(id) ?? 0) + 1 / (rrfK + rank));
  }

  for (let i = 0; i < bm25Results.length; i++) {
    const id = bm25Results[i].chunkId;
    const rank = i + 1;
    scoreMap.set(id, (scoreMap.get(id) ?? 0) + 1 / (rrfK + rank));
  }

  // 3. 按 RRF 分数排序、取 Top-K
  const merged = [...vectorResults, ...bm25Results];
  const dedupedById = new Map<string, SearchResult>();
  for (const r of merged) dedupedById.set(r.chunkId, r);

  return [...dedupedById.values()]
    .map((r) => ({ ...r, score: scoreMap.get(r.chunkId) ?? 0 }))
    .sort((a, b) => b.score - a.score)
    .slice(0, topK);
}

11.8.4 重排序(Re-ranking)

11.3.6 铺垫过:Bi-Encoder 粗排(快)→ Cross-Encoder 精排(准) 是工业 RAG 的标准两阶段架构。

为什么粗排出来还要再排一次

向量检索是 Bi-Encoder——Query 和 Document 各自独立编码后比较向量距离。这有两个内在限制:

  1. 细粒度对齐能力弱:模型无法直接看到 Q 里的具体词和 D 里的具体词如何对齐
  2. 训练目标和实际任务有 gap:训练时的”正样本对”分布和你的业务问题分布不一定一致

Cross-Encoder 把 Q 和 D 拼接后一起进 Transformer,注意力机制让每个 Q 的 token 都能直接看到 D 的每个 token,精度显著高。

主流重排模型

模型 类型 中文 特点
BGE-reranker-large 开源 优秀 中文 RAG 首选
BGE-reranker-v2-m3 开源 优秀 支持多语种、长文档
Cohere rerank-v3 API 商用 SaaS、按调用付费
Jina Reranker API/开源 开源版可本地部署
mxbai-rerank-large 开源 英文为主

实现示例

// services/chat/rag/retrieval/reranker.ts
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';

export interface RerankerClient {
  rerank(query: string, documents: string[]): Promise<Array<{ index: number; score: number }>>;
}

export async function rerankResults(
  reranker: RerankerClient,
  query: string,
  candidates: SearchResult[],
  topK = 5,
): Promise<SearchResult[]> {
  const documents = candidates.map((c) => c.content);
  const scored = await reranker.rerank(query, documents);

  return scored
    .sort((a, b) => b.score - a.score)
    .slice(0, topK)
    .map((s) => ({
      ...candidates[s.index],
      score: s.score,  // 用 reranker 的分数覆盖原向量分数
    }));
}

在 RAG 流水线中插入重排

flowchart LR
    Q[用户问题] --> R1[Query 改写] --> R2[多路召回\n向量 + BM25 + RRF\nTop-50]
    R2 --> R3[Cross-Encoder 重排\nTop-50 → Top-5]
    R3 --> R4[拼 Prompt + LLM]

image.png

实测经验:在 11.6 的 baseline RAG 上加一层重排,Recall@5 可以从 0.65 提升到 0.85+,NDCG@10 提升 10–20 个百分点。几乎是免费的午餐(除了多一次 API 调用的成本)。

11.8.5 元数据过滤(Metadata Filtering)

不是所有提升召回的策略都靠”检索算法”。有时候改约束条件就能精准击中目标

每个 chunk 入库时,除了向量还要存元数据(document_id、author、tags、updated_at 等)。检索时先按元数据过滤,再做向量检索:

SELECT id, content, 1 - (embedding <=> $1::vector) AS score
FROM document_chunks
WHERE "userId" = $2
  AND "documentType" = 'security_policy'     -- 元数据过滤
  AND "updatedAt" > NOW() - INTERVAL '6 month' -- 时效过滤
ORDER BY embedding <=> $1::vector
LIMIT 5;

效果:

11.8.6 五种策略组合实战

flowchart TB
    Q[用户问题] --> S1["1. Query 改写\n(LLM 同义/拆解)"]
    S1 --> S2["2. 多路并行检索\n各改写各跑一次"]
    S2 --> S3["3. 混合检索\n向量 + BM25 + RRF"]
    S3 --> S4["4. 元数据过滤\nWHERE user_id, document_type..."]
    S4 --> S5["5. 重排\nCross-Encoder"]
    S5 --> A[Top-K → LLM]

image.png

每个策略相对 baseline 的提升参考(不同业务差异很大,仅供数量级直觉):

策略 Recall@5 提升 实现成本
Query 改写 +5–10%
多路召回 +3–5%
混合检索(向量 + BM25 + RRF) +10–20%
元数据过滤 +5–15%(看业务)
重排序(Cross-Encoder) +15–25%

实战中最少要做”混合检索 + 重排”,这两步是工业 RAG 的及格线。Query 改写和元数据过滤按业务需要补充。

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.8"

覆盖:Query 改写返回 1–3 条改写(失败回退原句)、RRF 融合两个排序列表头部一致性、混合检索去重 + 端到端 Top-K、reranker mock 调用后顺序按新分数(越界 index 被过滤)。

image 14.png


11.9 RAG 高级模式

11.8 的五个策略已经能把 baseline RAG 从”能用”推到”好用”。但 2023–2026 年学术界和工业界又跑出了一批结构性创新——它们不是简单的”参数调优”,而是改变了”检索 + 生成”两步流水线本身的结构。

image 15.png

本节展开 5 个最有影响力的模式:HyDE / Self-RAG / CRAG / Adaptive-RAG / Graph-RAG。每个模式给出:

11.9.1 HyDE(Hypothetical Document Embeddings)

论文:Precise Zero-Shot Dense Retrieval without Relevance Labels (Gao et al., 2022)

解决的问题

向量检索的一个隐藏假设:“问题”和”文档”在向量空间里距离应该接近。但实际上:

两者结构、用词、表达完全不对称,向量空间里未必真的近——这就是为什么”看起来很相关”的文档可能并未被召回。

核心思想

让 LLM 先**“幻想”一个答案**(即使是错的也无所谓)→ 用幻想答案的 Embedding 去检索(而不是用问题的 Embedding)。

flowchart LR
    Q[用户问题:\n企业版用户上限是多少] --> LLM["LLM:\n根据训练知识幻想一个答案"]
    LLM --> H["幻想答案:\n企业版单工作区最多 500 用户...\n(可能是错的)"]
    H --> EMB[Embedding]
    EMB --> SEARCH[向量检索]
    SEARCH --> R["命中真实文档:\n《企业版规格》:\n实际是 200 用户"]

为什么这能 work

伪代码

// services/chat/rag/retrieval/hyde.ts
export async function hydeSearch(
  model: BaseChatModel,
  searchFn: (query: string) => Promise<SearchResult[]>,
  question: string,
  topK = 5,
): Promise<SearchResult[]> {
  // Step 1: 让 LLM 幻想一个答案
  const hypothetical = await model.invoke([
    { role: 'system', content: '请用一段简短的事实陈述回答下面的问题。如果不知道,编一个看起来合理的答案。50-150 字。' },
    { role: 'user', content: question },
  ] as any);

  const hypotheticalText = String(hypothetical.content);

  // Step 2: 用幻想答案做向量检索
  return searchFn(hypotheticalText);
}

效果

在 BEIR、TREC 等零样本检索基准上,HyDE 相对 baseline 向量检索的 Recall@10 提升 10–15%。中文场景表现类似。

什么时候用 vs 不用

11.9.2 Self-RAG

论文:Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection (Asai et al., 2023)

解决的问题

baseline RAG 是”无脑检索”——每个问题都会检索一次。但:

盲目检索会引入噪音:检索到无关文档反而把 LLM 带偏。

核心思想

让模型用特殊 reflection token 自己决定”要不要检索 / 检索到的东西好不好 / 回答好不好”。

四类 reflection token:

Token 含义 决策点
[Retrieve] 这个问题需不需要外部检索? yes / no / continue
[IsREL] 检索到的这段内容相关吗? relevant / irrelevant
[IsSUP] 我的回答有这段内容支撑吗? fully / partially / no
[IsUSE] 我的最终回答有没有用? 1-5 分

流程图

flowchart TB
    Q[用户问题] --> R0{Retrieve?}
    R0 -->|No| GEN_NORAG[LLM 直接回答]
    R0 -->|Yes| SEARCH[检索 Top-K]

    SEARCH --> REL{IsREL?}
    REL -->|Irrelevant| FILTER[过滤掉]
    REL -->|Relevant| GEN[基于这段生成]

    GEN --> SUP{IsSUP?}
    SUP -->|No| RETRY["重新检索 or 标注'不确定'"]
    SUP -->|Yes| USE{IsUSE?}

    USE -->|≥ 3| OUTPUT[输出答案]
    USE -->|&lt; 3| RETRY

image.png

伪代码

// services/chat/rag/pipeline/self-rag.ts
export async function selfRagAsk(
  model: BaseChatModel,
  searchFn: (q: string) => Promise<SearchResult[]>,
  question: string,
): Promise<{ answer: string; reflection: any }> {
  // Step 1: 判断是否需要检索
  const needRetrieve = await classifyRetrieveNeed(model, question);
  if (!needRetrieve) {
    const direct = await model.invoke(question);
    return { answer: String(direct.content), reflection: { retrieved: false } };
  }

  // Step 2: 检索 + 评估相关性
  const chunks = await searchFn(question);
  const relevantChunks = [];
  for (const c of chunks) {
    const isRel = await judgeRelevance(model, question, c.content);
    if (isRel) relevantChunks.push(c);
  }

  if (relevantChunks.length === 0) {
    return { answer: '根据现有资料,我无法回答这个问题。', reflection: { retrieved: true, relevant: 0 } };
  }

  // Step 3: 生成答案
  const draft = await generateWithContext(model, question, relevantChunks);

  // Step 4: 评估支撑度
  const isSupported = await judgeSupport(model, draft, relevantChunks);
  if (!isSupported) {
    return { answer: `${draft}\n\n(注:该回答可能未完全由提供的资料支撑)`, reflection: { supported: false } };
  }

  return { answer: draft, reflection: { retrieved: true, relevant: relevantChunks.length, supported: true } };
}

什么时候用 vs 不用

11.9.3 CRAG(Corrective RAG)

论文:Corrective Retrieval Augmented Generation (Yan et al., 2024)

解决的问题

Self-RAG 用”模型自我评估”判断检索质量,但 LLM 评估自己生成的内容存在自我偏见(更倾向认为自己生成的好)。CRAG 用一个独立的轻量评估器给检索结果打分,并按分数走三条不同路径。

核心思想

flowchart TB
    Q[用户问题] --> S[向量检索 Top-K]
    S --> E[轻量评估器\n打分 0-1]
    E --> G{分数?}
    G -->|>0.7 Correct| USE[直接用]
    G -->|0.3-0.7 Ambiguous| KNOW[知识精炼\n+ Web 搜索补充]
    G -->|<0.3 Incorrect| REWRITE[改写查询\n+ Web 搜索]
    USE --> GEN[LLM 生成]
    KNOW --> GEN
    REWRITE --> GEN

image.png

三档决策:

分数 标签 策略
> 0.7 Correct 检索结果质量高,直接拼 Prompt
0.3 - 0.7 Ambiguous 拆解检索内容(去掉噪音段落) + 补充 Web 搜索
< 0.3 Incorrect 检索完全失败,改写查询 + Web 搜索兜底

伪代码

// services/chat/rag/pipeline/crag.ts
export async function cragAsk(
  evaluator: { score(query: string, doc: string): Promise<number> },
  searchFn: (q: string) => Promise<SearchResult[]>,
  webSearchFn: (q: string) => Promise<string[]>,
  model: BaseChatModel,
  question: string,
): Promise<string> {
  const chunks = await searchFn(question);
  const scored = await Promise.all(
    chunks.map(async (c) => ({ chunk: c, score: await evaluator.score(question, c.content) })),
  );

  const avgScore = scored.reduce((a, b) => a + b.score, 0) / scored.length;

  let finalContext: string[];

  if (avgScore > 0.7) {
    // Correct
    finalContext = scored.map((s) => s.chunk.content);
  } else if (avgScore > 0.3) {
    // Ambiguous: 保留高分段 + 补 Web
    const filtered = scored.filter((s) => s.score > 0.5).map((s) => s.chunk.content);
    const web = await webSearchFn(question);
    finalContext = [...filtered, ...web];
  } else {
    // Incorrect: 改写 + Web
    const rewritten = await rewriteQueryForWeb(model, question);
    const web = await webSearchFn(rewritten);
    finalContext = web;
  }

  return generateWithContext(model, question, finalContext);
}

评估器如何选

什么时候用 vs 不用

11.9.4 Adaptive-RAG

论文:Adaptive-RAG: Learning to Adapt Retrieval-Augmented Large Language Models through Question Complexity (Jeong et al., 2024)

解决的问题

不同复杂度的问题需要不同的 RAG 路径:

问题示例 复杂度 最佳策略
“今天几号?” 简单 / 不需检索 No-RAG
“什么是 SSO?” 单跳 Single-Step RAG
“对比 A、B、C 三个方案的成本、合规、扩展性” 多跳 Multi-hop RAG

Self-RAG / CRAG 是”事后纠正”,Adaptive-RAG 是”事前路由”——更省成本。

核心思想

flowchart LR
    Q[用户问题] --> C[复杂度分类器]
    C -->|Simple| A[No-RAG: LLM 直接回答]
    C -->|Single-hop| B[单次检索 + 生成]
    C -->|Multi-hop| M["多跳检索:\n拆解为子问题 →\n逐个检索 → 综合"]
    A --> OUT[输出]
    B --> OUT
    M --> OUT

image.png

分类器可以是:

伪代码

// services/chat/rag/pipeline/adaptive-rag.ts
type Complexity = 'simple' | 'single_hop' | 'multi_hop';

export async function adaptiveRagAsk(
  classifier: { classify(q: string): Promise<Complexity> },
  searchFn: (q: string) => Promise<SearchResult[]>,
  model: BaseChatModel,
  question: string,
): Promise<string> {
  const complexity = await classifier.classify(question);

  if (complexity === 'simple') {
    const direct = await model.invoke(question);
    return String(direct.content);
  }

  if (complexity === 'single_hop') {
    const chunks = await searchFn(question);
    return generateWithContext(model, question, chunks);
  }

  // multi_hop: 拆解 + 逐个检索 + 综合
  const subQuestions = await decomposeIntoSubQuestions(model, question);
  const allChunks: SearchResult[] = [];
  for (const sub of subQuestions) {
    const chunks = await searchFn(sub);
    allChunks.push(...chunks);
  }
  return generateWithContext(model, question, dedupe(allChunks));
}

与第八章 Classifier / 第九章 Supervisor 的关联

第八章的 triageNode(intent classifier)、第九章的 supervisorNode(专家选择器),本质上都是前置路由器——按问题特征决定后续走哪条路径。

Adaptive-RAG 把同一思路下沉到了 RAG 内部:问题进 RAG 模块前先分类,决定走 No-RAG / Single / Multi-hop 哪一支。

这意味着:你可以在第八九章已有的 Multi-Agent 架构上,把”是否检索”“单跳还是多跳”作为路由维度,和”安全 / 合规 / 功能 / 性能”专家路由叠加。RAG 不再是工具,而是 Agent 内部的一种受控决策

什么时候用 vs 不用

11.9.5 Graph-RAG(GraphRAG)

项目:Microsoft GraphRAG (2024) — 论文:From Local to Global (Edge et al., 2024)

解决的问题

传统 RAG 擅长 “事实查询”——「企业版用户上限是多少」。但弱于 “全局推理”

这类问题答案散落在几十、几百个文档里,没有任何单一 chunk 能直接命中——但传统 RAG 只能召回 5 个 chunk 喂给 LLM。

核心思想

把”知识库”从”chunk 集合”升级成”知识图谱”:

  1. 离线阶段:用 LLM 从每个文档里提取实体 + 关系(如「产品 A —使用→ 技术 B」)
  2. 构建图:所有文档的实体关系汇总成一个大图
  3. 社区检测:用图算法(Leiden / Louvain)把图分成多个”社区”(相关实体聚集的子图)
  4. 社区摘要:用 LLM 对每个社区生成一份摘要
  5. 检索时:根据问题类型,返回社区摘要(全局问题)或具体 chunk(事实问题)
flowchart TB
    subgraph 离线
        D[原始文档] --> EXT[LLM 提取\n实体 + 关系]
        EXT --> G[构建图谱]
        G --> CD[社区检测\nLeiden 算法]
        CD --> CS[每个社区\nLLM 生成摘要]
    end
    subgraph 在线
        Q[用户问题] --> CL{全局 or 事实?}
        CL -->|全局| RS[返回相关社区摘要]
        CL -->|事实| RC[返回 chunk]
        RS --> LLM
        RC --> LLM
    end

image.png

伪代码(关键步骤)

// services/chat/rag/pipeline/graph-rag.ts

// 1. 离线建图(每次知识库更新时跑)
async function buildKnowledgeGraph(documents: Document[]): Promise<KnowledgeGraph> {
  const allTriples: Triple[] = [];
  for (const doc of documents) {
    const triples = await llmExtractTriples(doc.content);
    // triples 形如 [{ head: '产品A', relation: '使用', tail: '技术B' }, ...]
    allTriples.push(...triples);
  }
  return assembleGraph(allTriples);
}

// 2. 社区检测 + 摘要
async function buildCommunities(graph: KnowledgeGraph): Promise<Community[]> {
  const communities = leidenClustering(graph); // 图聚类
  return Promise.all(
    communities.map(async (c) => ({
      id: c.id,
      entities: c.entities,
      summary: await llmSummarize(c.entities, c.edges),
    })),
  );
}

// 3. 在线查询
async function graphRagAsk(
  question: string,
  graphIndex: { searchCommunities: (q: string) => Promise<Community[]>; searchChunks: (q: string) => Promise<SearchResult[]> },
  model: BaseChatModel,
): Promise<string> {
  const isGlobal = await classifyGlobalVsLocal(model, question);

  if (isGlobal) {
    const communities = await graphIndex.searchCommunities(question);
    const summaries = communities.map((c) => c.summary).join('\n\n---\n\n');
    return generateWithContext(model, question, [summaries]);
  } else {
    const chunks = await graphIndex.searchChunks(question);
    return generateWithContext(model, question, chunks.map((c) => c.content));
  }
}

关键成本警告

Graph-RAG 的离线建图阶段非常贵:

什么时候用 vs 不用

11.9.6 五种高级模式选型速查

flowchart TD
    A[需要超出 baseline RAG] --> B{问题类型?}
    B -->|短/口语化| C[HyDE]
    B -->|混杂场景\n含闲聊| D[Self-RAG]
    B -->|检索质量不稳/\n需 Web 兜底| E[CRAG]
    B -->|复杂度差异大| F[Adaptive-RAG]
    B -->|跨文档全局推理| G[Graph-RAG]

image.png

模式 一句话总结 主要成本 适用规模
HyDE 用 LLM 幻想答案再检索 +1 次 LLM 调用 任意
Self-RAG 模型自我决策检索 + 反思 +3-5 次 LLM 调用 任意
CRAG 独立评估器 + Web 兜底 +1 次评估器 + 可能的 Web 搜索 任意
Adaptive-RAG 前置分类决定 RAG 路径 +1 次分类器 任意
Graph-RAG 知识图谱 + 社区摘要 离线建图昂贵 1 万-100 万文档

警告:不要过度迷信高级模式 工业实践中 80% 的 RAG 系统不需要这些高级模式。先把 baseline + 11.8 的五个策略(Query 改写 / 多路 / 混合 / 重排 / 元数据过滤)打磨到 Recall@10 ≥ 0.85,再考虑高级模式。 否则你会得到一个调试困难、监控复杂、成本高昂的”看起来很厉害的 RAG”,但实际效果可能还不如调好的 baseline。

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.9"

覆盖:HyDE 用幻想答案替换原始 query 去检索、Adaptive-RAG 在 simple / single_hop / multi_hop 三档路径上分别走 0 次 / 1 次 / N 次检索。

image 16.png


11.10 集成到 LangGraph Agent

11.2–11.9 把 RAG 自身讲透了。现在回到第八九章的视角:Agent 系统如何用上 RAG?

答案非常简单:把 RAG 包装成一个 Tool,让 LangGraph 的 Agent 节点像调用任何普通工具一样调用它。

image 17.png

11.10.1 RAG 作为工具的设计原则

不是”把 RAG 流水线放入某个 Agent 节点里硬编码”,而是:

flowchart TB
    subgraph Agent [LangGraph Agent]
        SUP[Supervisor / 业务 Agent]
        SUP --> T1[search_knowledge_base Tool]
        SUP --> T2[create_ticket Tool]
        SUP --> T3[其他工具]
    end

    subgraph RAG [RAG 子系统]
        T1 -.->|调用| PIPE[RAG Pipeline\n11.6 ragAsk]
        PIPE --> VEC[向量检索]
        PIPE --> RERANK[重排]
        PIPE --> LLM[生成]
    end

image.png

这样做有三个好处:

  1. 可组合:Agent 可以决定”先检索一次再决定要不要再查”或”一次检索就够”,灵活性强
  2. 可观测:每次工具调用都会写入 LangGraph 的 messages,调试、回放、Token 统计无缝衔接第十章
  3. 可独立部署:RAG 子系统可以单独跑成 Service,多个 Agent 共享一份向量库

11.10.2 定义 RAG 工具

// services/chat/rag/agent/rag-tool.ts
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { ragAsk } from '../pipeline/rag-pipeline';

export function createRagTool(deps: { model: BaseChatModel; userId: string }) {
  return tool(
    async ({ question, topK }: { question: string; topK?: number }) => {
      const result = await ragAsk({
        question,
        userId: deps.userId,
        topK: topK ?? 5,
        model: deps.model,
      });
      return JSON.stringify({
        answer: result.answer,
        citations: result.citations.map((c) => ({
          chunkId: c.chunkId,
          documentId: c.documentId,
          score: Number(c.score.toFixed(3)),
        })),
      });
    },
    {
      name: 'search_knowledge_base',
      description:
        '根据问题检索企业内部知识库,返回基于知识库的回答和引用来源。' +
        '适用于查询业务规则、产品文档、内部规范、历史决策等需要从知识库找答案的场景。' +
        '不适用于:闲聊、纯计算、时间查询。',
      schema: z.object({
        question: z.string().describe('用户的问题,应当是自然语言完整问句'),
        topK: z.number().optional().describe('检索结果数量,默认 5'),
      }),
    },
  );
}

description 是 LLM 决定是否调用工具的依据,所以要清晰描述”适用场景”和”不适用场景”——这是第四章和第八章的老教训。

11.10.3 把 RAG 工具挂载到第九章的专家 Agent

回顾第九章的 Functional Expert(功能需求评审专家),它本来就有 read_requirement / check_existing_features 等工具。加入 RAG 后:

// services/chat/src/llm/graph/experts.ts (示例片段)
import { createRagTool } from '../../rag/agent/rag-tool';

export function buildFunctionalExpertTools(model: BaseChatModel, userId: string) {
  return [
    // 既有工具
    readRequirementTool,
    checkExistingFeaturesTool,
    // 新增 RAG 工具
    createRagTool({ model, userId }),
  ];
}

功能专家在执行时,遇到”用户需求里提到了某个内部术语,我不知道是什么”的时候,会自主调用 search_knowledge_base("XX 模块是什么"),从内部文档中查到定义,再继续推理。

这就是 RAG-as-Tool 的强大之处:不需要硬编码”什么时候 RAG”,让 Agent 自己决定。

11.10.4 RAG 节点 vs RAG 工具:两种模式取舍

第三种集成方式:把 RAG 做成 LangGraph 的一个独立节点

flowchart LR
    A[用户输入] --> R[RAG 节点\n强制每次都检索]
    R --> S[Supervisor]
    S --> E[Expert 节点]

image.png

对比:

模式 优点 缺点 适合
RAG 作为 Tool 灵活、按需调用、模型自主决策 模型可能”该用没用” Multi-Agent / 复杂业务
RAG 作为节点 强制检索,行为可预测 不该检索时也检索,引入噪音 单一场景(如纯客服 FAQ)
RAG 作为前置预处理 把检索结果注入 State,所有 Agent 都能读 同样有”不该用也注入”的问题 中小复杂度

实战推荐

11.10.5 与第十章 Token 经济学的协同

接入 RAG 工具后,新的成本来源出现

  1. 检索调用:每次 RAG Tool 调用都执行一次 Embedding + 向量检索(成本低,几 ms)
  2. 重排调用(如果用):Cross-Encoder 调用(中等成本)
  3. RAG 内部的 LLM 调用ragAsk 内部还有一次 LLM 生成
  4. Agent 外层的 LLM 调用:Agent 节点拿到 RAG 结果后还要再调一次 LLM 综合

最容易超预算的是 #3 + #4——一次用户问答可能触发 2 次 LLM 调用

回到第十章的预算控制:

// services/chat/rag/agent/rag-tool.ts (含预算保护)
import { resolveBudgetAction } from '../../llm/cost/budget-policy';

export function createRagTool(deps: { /*...*/ }) {
  return tool(
    async ({ question, topK }) => {
      // 预算检查
      const stats = await tokenUsageService.getMonthlyStats(deps.userId);
      const action = resolveBudgetAction({
        budgetUsedPercent: stats.percentUsed,
        agentName: 'rag_tool',
      });
      if (action.action === 'reject') {
        return JSON.stringify({ error: 'budget_exceeded', message: action.reason });
      }

      // 正常 RAG 流程
      const result = await ragAsk({ /*...*/ });
      return JSON.stringify(result);
    },
    { /*...*/ },
  );
}

rag_tool 不在 HIGH_RISK_AGENTS 列表里,预算紧张时会被降级(10.9 节)——这是合理的:预算花完时优先保供给关键 Agent,RAG 可以等下个计费周期再开

11.10.6 端到端流水线全景

把第八九章 + 本章 RAG 串起来看:

flowchart TB
    USER[用户消息] --> TRIAGE[Triage 分类]
    TRIAGE --> EXTRACT[需求提取]
    EXTRACT --> CLARIFY[澄清 HITL]
    CLARIFY --> SUP[Supervisor 选专家]
    SUP --> EXP[Expert ReAct 循环]

    subgraph 工具池
        T_REQ[read_requirement]
        T_FEAT[check_existing_features]
        T_RAG[search_knowledge_base 🆕]
        T_OTHER[其他业务工具]
    end

    EXP -.->|调用工具| T_REQ
    EXP -.->|调用工具| T_FEAT
    EXP -.->|调用工具| T_RAG
    EXP -.->|调用工具| T_OTHER

    T_RAG -.-> RAG_SUB
    subgraph RAG_SUB [RAG 子系统]
        QR[Query 改写] --> HS[混合检索]
        HS --> RR[重排] --> GEN[LLM 生成]
    end

    EXP --> AGG[Aggregator]
    AGG --> RISK[Risk Agent]
    RISK --> CRITIC[Summary/Critic-Refine]
    CRITIC --> OUTPUT[最终输出]

image.png

11.10.7 🤖 用 AI 生成本节代码(RAG-as-Tool 接入)

完整 Prompt(可直接粘贴到 Cursor / Claude 执行)

**背景**:
- 项目位于当前工作区,第八九章已完成 LangGraph Multi-Agent 主图(services/chat/src/llm/graph/experts.ts)。
- 本章 11.6 已实现 ragAsk(services/chat/rag/pipeline/rag-pipeline.ts)。
- 本节目标是把 RAG 包装成 LangChain Tool,并接入第九章 Functional Expert 的工具池中,集成第十章的预算控制。

**任务**:
1. 在 services/chat/rag/agent/rag-tool.ts 中导出 createRagTool(deps): StructuredTool
   - 入参 schema: { question: string; topK?: number }
   - description 明确"适用 / 不适用"场景
   - 内部先调 resolveBudgetAction 做预算检查;reject 时返回 { error: 'budget_exceeded' }
   - 否则调用 ragAsk,把结果序列化为 JSON 字符串返回(LangChain 工具返回必须是 string)
2. 在 services/chat/src/llm/graph/experts.ts(教学示例,不直接覆盖现有文件)中演示:
   - 在 buildFunctionalExpertTools 等位置 import 并加入工具列表
   - 用伪代码标注"接入示例,不表示主图已经集成"
3. 在 services/chat/test/chapter11-rag.spec.ts 中 describe '11.10 集成 Agent':
   - mock ragAsk 返回 { answer, citations }
   - mock resolveBudgetAction 分别返回 allow / reject
   - 用例 1:allow 时工具调用返回的 JSON.parse 含 answer / citations
   - 用例 2:reject 时返回 error: 'budget_exceeded'
   - 用例 3:tool 的 description 字符串包含 '不适用' 关键词,避免 LLM 在闲聊场景误调用

**关键约束**:
- 不修改既有 services/chat/src/llm/graph/experts.ts,只在 rag/agent/ 下新增
- LangChain 工具的返回必须 string,序列化用 JSON.stringify
- 预算检查放在最前面(避免调用昂贵的 ragAsk 后才发现超预算)
- description 直接抄 11.10.2 中的版本,要求 LLM 能据此判断"该不该调"

**输出**:
- 完整 rag-tool.ts
- chapter11-rag.spec.ts 中 11.10 用例
- 文档示例代码不要落到主图文件,仅 doc 中说明接入方式

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.10"

覆盖:tool description 含”适用 / 不适用”、预算 allow 时正常返回、预算 reject 时返回 budget_exceeded、返回值是 JSON 字符串可被 LangGraph parse、citations 按 chunkId 去重。

image 18.png


11.11 RAG 全景回顾

走到这里,本章 11.1–11.10 已经把 RAG 拆成 9 个独立环节深入讲过。回头看,一次完整的工业级 RAG 请求究竟经历了什么

11.11.1 从用户问题到最终回答:完整时序图

sequenceDiagram
    autonumber
    participant U as 用户
    participant A as LangGraph Agent
    participant T as RAG Tool
    participant Q as Query 改写
    participant V as 向量检索
    participant B as BM25 检索
    participant R as Cross-Encoder 重排
    participant L as LLM 生成

    U->>A: "如何配置 SSO?"
    A->>A: Supervisor 决定调用 search_knowledge_base
    A->>T: search_knowledge_base("如何配置 SSO?")
    T->>Q: 改写为多个 query
    Q-->>T: ["如何配置 SSO", "SSO 启用步骤", "单点登录设置"]

    par 并行检索
        T->>V: 向量检索(每个 query)
        V-->>T: Top-20 向量结果
    and
        T->>B: BM25 关键词检索
        B-->>T: Top-20 关键词结果
    end

    T->>T: RRF 融合 → Top-50 候选
    T->>R: Cross-Encoder 重排
    R-->>T: Top-5 精排结果
    T->>L: 拼 Prompt: 问题 + Top-5 + 系统指令
    L-->>T: 带引用的回答
    T-->>A: { answer, citations }
    A->>L: 让 Agent 综合 RAG 结果给最终答案
    L-->>A: 最终回答
    A-->>U: 最终回答 + 引用

11.11.2 数据流维度

flowchart LR
    subgraph Offline [离线 - 建索引]
        DOC[文档源] --> PARSE[解析:\nPDF/Word/MD]
        PARSE --> CHUNK[切分:\nRecursive 500/50]
        CHUNK --> EMB[Embedding:\nMiniLM-L12-v2]
        EMB --> STORE[pgvector\n+ HNSW 索引\n+ modelName 标记]
    end

    subgraph Online [在线 - 检索 + 生成]
        Q[用户问题] --> QR[Query 改写]
        QR --> HS[混合检索\nVector + BM25 + RRF]
        STORE -.->|读| HS
        HS --> RR[Cross-Encoder 重排]
        RR --> PROMPT[拼 Prompt]
        PROMPT --> LLM[LLM 生成]
        LLM --> CITE[输出 + 引用回写]
    end

11.11.3 决策维度地图

每个环节都有一个或多个工程决策。把它们整理成一张速查图:

环节 你必须做出的决策 默认建议
Embedding 模型 OpenAI / BGE / 本地 MiniLM?维度? 中文 BGE-large-zh-v1.5;开发期 MiniLM
切分策略 字数 / 递归 / 语义 / Parent-Child?chunk_size?overlap? RecursiveCharacterTextSplitter, 500/50
向量数据库 pgvector / Pinecone / Milvus? 已有 Postgres → pgvector
ANN 算法 HNSW / IVF / IVF+PQ?参数? < 1000 万用 HNSW(m=16, ef_c=64)
检索方式 纯向量 / 混合?是否重排? 至少”混合 + 重排”
重排模型 BGE-reranker / Cohere / Jina? 中文 BGE-reranker-v2-m3
Prompt 模板 Stuff / Map-Reduce / Refine? Top-K ≤ 10 用 Stuff
高级模式 HyDE / Self-RAG / CRAG / Adaptive / Graph? 先打磨 baseline,再考虑
评估方式 Recall@K / MRR / NDCG / RAGAS? NDCG@10 + RAGAS faithfulness
Agent 集成 Tool / 节点 / 预处理? Multi-Agent → Tool 模式

11.11.4 成本与质量的”性价比阶梯”

flowchart LR
    A[Baseline\nVector + Stuff\nRecall ~60%] --> B[+ Query 改写\nRecall ~65%]
    B --> C[+ 混合检索\nRecall ~75%]
    C --> D[+ Cross-Encoder 重排\nRecall ~85%]
    D --> E[+ HyDE 等高级\nRecall ~88%]
    E --> F[+ Graph-RAG\n全局推理能力]

image.png

每一步都不应跳过——先做 baseline,再按”召回率不达标的具体瓶颈”逐步升级。直接上 Graph-RAG 是工程灾难:调试地狱 + 高成本 + 多数业务用不上。

11.11.5 RAG 系统的可观测性

第七章 UI Protocol、第十章 Token 采集让 RAG 流水线可观测:

// 推荐的 trace 字段
interface RagTrace {
  requestId: string;
  question: string;
  rewrittenQueries: string[];
  retrievedCount: { vector: number; bm25: number; merged: number; reranked: number };
  topKChunks: Array<{ chunkId: string; documentId: string; score: number }>;
  tokensUsed: { rewrite: number; rerank: number; generate: number; total: number };
  costUsd: number;
  latencyMs: { rewrite: number; retrieve: number; rerank: number; generate: number; total: number };
  feedback?: { thumbsUp: boolean; reason?: string };
}

每次 RAG 调用都写一条 trace 到数据库,配合第十章的 TokenUsageService 做联合查询:

11.11.6 把 RAG 当成”持续运营”的系统而不是”一次部署”

RAG 不是搭起来就完事的——它需要持续运维:

周期 任务 责任
每天 监控 trace 看延迟 / 成本 / Recall@K 趋势 SRE
每周 审查”thumbs down”样本,标注新评测集 业务运营
每月 跑 RAGAS 全量评测,对比上月趋势 算法工程
每季 评估 Embedding 模型是否需要升级 / 微调 算法工程
文档更新时 触发增量入库 + 索引更新 工程

本节配套用例bun test test/chapter11-rag.spec.ts -t "11.11"

覆盖:Query 改写 → 混合检索 → 重排 → RAG Pipeline → RAG-as-Tool 的完整 mock 端到端串联,并在过程中同步打印 Recall@3 / NDCG@3 让读者直观看到”评测指标如何贯穿整条流水线”。


11.12 RAG 常见问题排查 FAQ

实际上线后,RAG 系统报障的方向比较收敛。下面 5 个问题覆盖了 90% 的工程事故。

image 19.png

Q1:用户反馈”检索结果完全不相关”,往哪查?

按这个顺序逐步排查:

flowchart TB
    A[用户反馈:检索不相关] --> S1{1. 入库时 modelName\n和检索时 modelName 一致?}
    S1 -->|不一致| F1[修:换回原模型 或 全量重建]
    S1 -->|一致| S2{2. 这段内容真的在\n知识库里吗?}
    S2 -->|不存在| F2[修:补充文档/确认入库成功]
    S2 -->|存在但没召回| S3{3. 把 Top-K 调到 50\n能召回吗?}
    S3 -->|能| F3[修:召回数太少 / 加重排 / 改 chunk]
    S3 -->|不能| S4{4. 用 BM25 关键词\n能命中吗?}
    S4 -->|能| F4[修:向量空间表达不足 / 用混合检索]
    S4 -->|不能| F5[修:chunk 切分粒度有问题 / 内容真的找不到]

image.png

最常见的根因(按出现频率):

  1. Embedding 模型升级后老数据没重建索引(占故障 40%+)
  2. chunk 切得太大或太小(语义被稀释 or 信息不完整)
  3. 检索 K 太小(只取 Top-3 漏掉真正相关的)
  4. 用户问法和文档表达完全脱节(这种情况 HyDE 或 Query 改写效果显著)

Q2:LLM 回答里出现明显的”幻觉”——内容知识库里没有

幻觉的 4 种典型成因:

现象 根因 解决
模型把无关 chunk 当相关用了 检索召回了无关内容 加重排、提高检索分数阈值
检索是对的,但模型超出资料发挥 Prompt 里没有强限定 加”严格基于资料”指令 + 拒答示例
模型在多个 chunk 间”脑补连接” Prompt 没要求引用 强制要求每句标注 [chunkId]
temperature 太高 模型自由发挥 temperature=0 或 0.1

防幻觉的 Prompt 模板(11.6.5 已铺垫,这里再强化一次):

你是基于知识库回答问题的助手。请严格根据下方[上下文]回答。

[绝对规则]
1. 只用[上下文]里的信息,不要凭借常识或推测
2. 每句话必须在末尾标注引用:[chunkId: xxx]
3. 如果[上下文]不足以回答,回复:「根据提供的资料,我无法确定答案。」
4. 不允许编造、推断、扩展[上下文]里没有的内容

[上下文]
{retrieved_chunks_with_chunkIds}

[问题]
{user_question}

最后兜底:在 LLM 输出后做一道”引用回写校验”——把模型答案里的每个 [chunkId: xxx] 拿出来,确认对应 chunk 确实在本次检索结果里;否则把这条标记为”高幻觉风险”,人工审核或自动重试。

Q3:中文检索效果差,英文知识库效果好

几乎一定是这三个问题之一:

(1) 用了纯英文 Embedding 模型

OpenAI text-embedding-ada-002、E5-large-v2 等模型在中文上表现明显不如 BGE / Cohere multilingual。

修复:换 BGE-large-zh-v1.5(中文最佳)或 BGE-M3(多语种最佳)。

(2) Tokenizer 对中文不友好

某些 Embedding 模型用 BPE tokenizer,把中文切成”一个汉字 = 多个 token”或更奇怪的子词,导致语义破碎。

修复:用 SentencePiece 或专为中文优化的 tokenizer,BGE / E5 系列都做了。

(3) BM25 中文分词没配置

如果用 Postgres 全文检索(tsvector),默认是英文分词器,中文无法正确切词。

修复:用 pg_jieba 扩展或 Elasticsearch + IK 分析器。

Q4:换了 Embedding 模型后,老数据查询返回乱七八糟的结果

这是 RAG 工程里最经典的事故,对应 11.3.7 提过的”入库 / 查询模型必须一致”。

根因

正确的迁移流程

flowchart TB
    A[决定换模型] --> B["新建一张表\ndocument_chunks_v2 vector(1024)"]
    B --> C[后台 worker 全量重建:\n旧 chunk → 新模型 embedding → 入新表]
    C --> D[双写一段时间\n验证新表质量]
    D --> E[切换查询读新表]
    E --> F[下线老表]

image.png

强制约束(11.3.7 强调过):

ALTER TABLE document_chunks ADD COLUMN modelName VARCHAR(128) NOT NULL DEFAULT 'unknown';

并在 SearchService 里显式校验

const expectedModel = this.embedding.modelName;
const actualModels = await this.prisma.$queryRaw<{ modelName: string }[]>`
  SELECT DISTINCT "modelName" FROM document_chunks WHERE "userId" =${userId}
`;
if (actualModels.some((m) => m.modelName !== expectedModel)) {
  throw new Error(
    `Embedding model mismatch: index has${actualModels.map((m) => m.modelName).join(',')} but query uses${expectedModel}`,
  );
}

这个校验加上去后,类似事故再也不会”静默失败”。

Q5:向量库查询慢 / 偶发超时

按延迟数量级排查:

延迟 可能根因 解决
> 500 ms HNSW 索引没建(暴力扫表) CREATE INDEX ... USING hnsw
100 - 500 ms ef_search 调得太大 降到 50–100
50 - 100 ms 知识库太大 + 没用过滤 加元数据 WHERE 提前过滤
< 50 ms Embedding 接口慢 Embedding 调用是瓶颈,不是向量库

典型常见问题

  1. 每次查询都同步调用 Embedding API——OpenAI Embedding 接口本身就要 100-300ms。加缓存:热问题的 query embedding 用 Redis 缓存 5 分钟。
  2. 未启用连接池——Postgres 默认连接数有限,并发 RAG 请求时排队。用 PgBouncer 或 Prisma 的连接池配置
  3. HNSW 索引被写阻塞——大批量入库时 ef_construction 高会拖累查询。入库走单独的”建库通道”,避开高峰。
  4. 没有读写分离——查询和入库都打主库。入库走主库,查询走只读副本

性能基线(pgvector + HNSW,100 万 chunk + 384 维 + 单机 8C16G):

如果实际延迟显著高于这个数量级,按上表逐项排查。


11.13 RAG 术语速查表

按字母顺序排列。本章涉及的核心术语都收录在此,便于回查。

术语 一句话解释 关联章节
Adaptive-RAG 前置分类器决定走 No-RAG / Single-hop / Multi-hop 路径 11.9.4
ANN(Approximate Nearest Neighbors) 近似最近邻搜索,用索引剪枝换速度 11.5.2
Answer Relevancy 答案相关性,回答是否真正回应用户问题 11.7.2
avgdl BM25 中的”平均文档长度”,用于长度归一化 11.8.3.1
BEIR 检索基准集合,零样本检索能力评测 11.9.1
Bi-Encoder(双塔模型) 两段文本独立编码后比较向量距离 11.3.6
BM25 TF-IDF 工业升级版,加入饱和 + 长度归一化 11.8.3.1
Chunking(切分) 把长文档切成小块以便检索 11.4
chunk_overlap 相邻 chunk 共享的字符数,保留上下文 11.4.4
chunk_size 每个 chunk 的目标字数 11.4.2
CLS Pooling 取 [CLS] token 作为句向量,BGE / E5 默认 11.2.6
Cohere Rerank Cohere 的商用 Cross-Encoder 重排 API 11.8.4
Community Detection 图聚类算法(如 Leiden),Graph-RAG 用 11.9.5
Contrastive Learning(对比学习) 让正样本距离近、负样本距离远的训练范式 11.2.5
Context Precision 检索结果里有多少被真正用上 11.7.2
Context Recall 该被检索到的内容是否被检索到 11.7.2
Cosine Similarity(余弦相似度) 文本检索默认距离度量 11.2.2
CRAG(Corrective RAG) 独立评估器 + Web 兜底的纠错型 RAG 11.9.3
Cross-Encoder(交叉编码) 两段文本拼接后一起编码,精度高于 Bi-Encoder 11.3.6
DCG / NDCG 按位置打折的累积增益,评检索排序质量 11.7.1
ef_construction HNSW 建索引时考察的候选数 11.5.3
ef_search HNSW 查询时考察的候选数,质量 vs 延迟的旋钮 11.5.3
Embedding 文本到高维向量的映射 11.2.1
Faithfulness(忠实度) 回答是否完全基于检索结果 11.7.2
Few-shot Examples Prompt 里嵌入示例帮助模型对齐输出风格 11.6.5
Graph-RAG 知识图谱 + 社区摘要的 RAG 变体 11.9.5
Hard Negative Mining 难负样本挖掘,BGE / E5 训练关键 11.2.5
HNSW Hierarchical Navigable Small World,多层小世界图索引 11.5.3
Hybrid Search(混合检索) 向量 + BM25 双路检索后融合 11.8.3
HyDE(Hypothetical Document Embeddings) 先让 LLM 幻想答案再用它去检索 11.9.1
InfoNCE Loss 对比学习的标准损失函数 11.2.5
IsREL / IsSUP / IsUSE Self-RAG 的 reflection token 11.9.2
IVF(Inverted File Index) 倒排文件索引,k-means 聚类剪枝 11.5.4
k₁ / b BM25 的两个核心超参(饱和、长度归一化强度) 11.8.3.1
K-means 把向量聚成 N 个簇的经典算法,IVF 用 11.5.4
KNN(K-Nearest Neighbors) 精确最近邻,O(n) 暴力搜索 11.5.2
L2 归一化 把向量长度变成 1,让余弦 = 点积 11.2.2
LLM-as-judge 用强 LLM 给生成结果打分的评测范式 11.7.2
m(HNSW 参数) 每层每个节点的最大连接数 11.5.3
Map-Reduce / Refine / Stuff 检索结果组合策略:填塞 / 映射归约 / 迭代精炼 11.6.4
Mean Pooling 取所有 token 向量平均,最常见的池化策略 11.2.6
Metadata Filtering 在向量检索前先按字段过滤,缩小搜索空间 11.8.5
MRR(Mean Reciprocal Rank) 第一个相关结果的倒数排名 11.7.1
MTEB 主流 Embedding 模型评测基准 11.3.3
Multi-Query Retrieval 多路召回,把改写后的 N 个 query 各自检索 11.8.2
NDCG@K 前 K 结果的排名质量评分 11.7.1
nlist / nprobe IVF 的簇数 / 查询时考察的簇数 11.5.4
Parent-Child Chunk 小块检索 + 大块生成,精度 + 上下文兼得 11.4.7
pgvector Postgres 的向量扩展,本项目用 11.5.6
Pooling 把多个 token 向量合成一个句向量的操作 11.2.6
PQ(Product Quantization) 乘积量化,向量压缩 8-32 倍 11.5.4
Query Expansion 查询改写为多个同义形式 11.8.1
Query Rewriting 用 LLM 把口语化问题改成书面化查询 11.8.1
RAGAS 自动评测 RAG 的开源框架(Python) 11.7.3
Recall@K 前 K 结果中找到的相关文档比例 11.7.1
Re-ranking(重排序) 用 Cross-Encoder 对粗排结果重新打分 11.8.4
Reflection Token Self-RAG 引入的特殊控制 token 11.9.2
RRF(Reciprocal Rank Fusion) 用倒数排名融合多个排序列表,k=60 11.8.3.1
Self-RAG 模型自我控制检索 + 反思的 RAG 变体 11.9.2
SemanticChunker 按相邻句子相似度突变点切分 11.4.3
Sentence-BERT 双塔 BERT,对比学习 Embedding 鼻祖 11.2.5
SimCSE dropout 当数据增强的对比学习模型 11.2.5
Small-World Graph(小世界图) 六度分隔现象,HNSW 的数据结构直觉 11.5.3
Stuff Chain 把所有检索结果一次性塞 Prompt 11.6.4
System Prompt 系统提示词,限定 LLM 行为 11.6.1
Task Prefix E5 等模型在输入前加 query: / passage: 区分角色 11.2.5
TF-IDF 词频 × 逆文档频率,关键词权重经典算法 11.8.3.1
Tokenizer 把文本切分为模型词表单位 11.2.1
Top-K 检索返回前 K 个最相似的结果 11.6
TruLens LLM 应用观测和评测框架 11.7.3
Vector Database 专为向量相似度检索设计的数据库 11.5.1
Voyage / Cohere / BGE 主流 Embedding 模型品牌 11.3.2

11.14 本章小结

image 20.png

这一章把 RAG 从”听过的概念”变成了”完整、可落地、能调优、可监控”的工程系统:

本章应该已经回答的核心问题

如果有任何一个还讲不清楚,回头精读对应小节——本章的目标是让你用自己的话能向同事讲明白

➡️ 后续章节预告

RAG 让模型 “读懂” 你的业务知识库,但模型还需要 “动手” 调用业务系统——查订单、建工单、发邮件、改配置、触发流程……每一种”调用外部能力”的方式都需要一套接口约定。

第四章我们已经看到了 LangChain Tool 的写法,第八九章用它接入了若干工具。但当工具数量上百、需要跨团队 / 跨服务 / 跨语言、需要支持权限和审计时,“每个团队各自实现 Tool”会陷入碎片化泥潭。

2024 年 Anthropic 提出了 MCP(Model Context Protocol)——LLM 与外部工具 / 资源之间的”USB-C 标准”。它把”工具 / 资源 / 提示词模板”统一成一套协议,让 LLM 客户端(IDE / Agent / 桌面应用)可以即插即用任何符合 MCP 的服务器。

下一章会做三件事

  1. 拆开 MCP 协议:什么是 MCP Server / Client / Resource / Tool / Prompt,相比第四章的 LangChain Tool 多了什么、少了什么
  2. 自建一个 MCP Server:把本章的 search_knowledge_base 工具按 MCP 协议封装,让任何 MCP Client(如 Cursor、Claude Desktop)都能直接调用
  3. 让 LangGraph Agent 通过 MCP 与本章 RAG 检索能力解耦协作——RAG 服务可以独立部署 / 独立扩缩容 / 跨团队复用

一句话锚点:第十一章解决了”让模型读懂业务”,第十二章解决”让模型调用业务”——两者合起来,模型才能真正变成业务系统的”操作员”,而不只是”问答机”。

写在最后🧪

这里是言萧凡的 AI 编程实验室。我会在这里持续记录和分享 AI 工具、编程实践,以及那些值得沉淀下来的高效工作方法。不只聊概念,也尽量分享能直接上手、能够复用的经验。希望这间小小的实验室,能陪你一起探索、实践和成长。2026 年,一起进步。

有兴趣的话可以添加我的微信号【Cookieboty】一起交流,不仅是编程也可以是畅谈人生。