Skip to content

第三篇:Embedding、向量数据库与 RAG 实战【进阶选读】

⏱ 推荐分享时长:60 分钟

设计思路:这是整个系列的进阶选读篇。第一篇 1.8 已经讲清了 Embedding 和 RAG 的基础概念,本篇面向需要深入落地 RAG 的同事——知识库问答、智能客服、文档搜索、代码检索,只要涉及"让 AI 基于私有数据回答问题",就需要这篇的内容。

整体逻辑:真实问题引入 → Embedding 深入 → 模型选型 → 向量数据库 → 分块策略 → RAG 架构 → 进阶优化 → PDF 问答实战

3.1 为什么需要这篇——从一个真实场景说起

开场演示

现在就来做个实验。把一份公司内部的技术文档丢给 ChatGPT:

你:"我们的用户服务(user-service)怎么部署到 K8s 集群?需要配置哪些环境变量?"

ChatGPT:"抱歉,我没有关于贵公司 user-service 的具体部署文档。
不过,一般来说,部署到 K8s 需要以下步骤:
1. 编写 Dockerfile...
2. 创建 Deployment YAML..."

问题:它给的是通用答案,不是你公司的答案。

为什么 ChatGPT 答不了?

  1. 知识截止:模型训练数据有截止日期,没有最新信息
  2. 私有数据:你的内部文档不在互联网上,模型从没见过
  3. 窗口限制:即使你想粘贴进去,几十页文档也超出上下文窗口

三种思路对比

方案一:直接粘贴进对话框
┌────────────────────────┐
│ 把50页文档粘贴进去...    │  ❌ 文档太长,超出上下文窗口
│                        │  ❌ 即使能放进去,"Lost in the Middle"
│                        │  ❌ 每次对话都要重新粘贴
└────────────────────────┘

方案二:微调模型
┌────────────────────────┐
│ 用内部文档微调模型       │  ❌ 成本高(需要 GPU + 数据标注)
│                        │  ❌ 文档更新了就要重新训练
│                        │  ❌ 微调适合"风格"不适合"知识"
└────────────────────────┘

方案三:RAG(检索增强生成)✅
┌────────────────────────┐
│ 文档变成可搜索的知识库    │  ✅ 只检索相关片段,不超窗口
│ 用户提问时检索相关内容    │  ✅ 文档更新只需重新索引
│ 把检索结果注入 Prompt    │  ✅ 答案有据可查,可引用溯源
└────────────────────────┘

RAG 的核心链路预览

  离线阶段(建索引,只做一次)                     在线阶段(回答问题,每次请求)
 ┌─────────────────────────────┐        ┌──────────────────────────────────┐
 │                             │        │                                  │
 │  📄 文档    →  ✂️ 分块       │        │  ❓ 用户提问                      │
 │                ↓            │        │      ↓                          │
 │            🔢 向量化         │        │  🔢 向量化                       │
 │  (Embedding)   ↓            │        │      ↓                          │
 │            💾 存入向量数据库  │        │  🔍 向量检索 → 返回相关片段        │
 │                             │        │      ↓                          │
 └─────────────────────────────┘        │  📝 拼入 Prompt → LLM 生成回答   │
                                        │      ↓                          │
                                        │  ✅ 回答 + 引用来源              │
                                        └──────────────────────────────────┘

带着问题往下学

  1. "向量化"是怎么把文字变成可搜索的? → 3.2 Embedding
  2. Embedding 模型这么多,该选哪个? → 3.3 模型选型
  3. 向量存在哪里?怎么快速查? → 3.4 向量数据库
  4. 文档太长怎么切分才合理? → 3.5 分块策略
  5. RAG 完整流程到底怎么走? → 3.6 RAG Pipeline
  6. 检索到了但答案不准怎么办? → 3.7 进阶优化

3.2 什么是 Embedding

一句话理解

把文本变成一组数字(向量),语义相似的文本在向量空间中距离相近。

为什么需要这么做?因为计算机不理解"React 性能优化"和"前端渲染速度提升"是类似的意思,但它能计算两组数字之间的距离。Embedding 就是这个"翻译"过程——把人类语言翻译成机器能比较的数学表示。

直观演示:3 行代码理解语义搜索

python
from openai import OpenAI

client = OpenAI()

# 把三句话变成向量
texts = [
    "React 列表渲染性能优化",
    "前端页面渲染速度如何提升",
    "Go 语言微服务架构设计",
]

response = client.embeddings.create(
    model="text-embedding-3-small",
    input=texts,
)

# 取出向量
vectors = [item.embedding for item in response.data]

# 计算相似度(余弦相似度)
import numpy as np

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

print(f"React优化 vs 前端渲染: {cosine_similarity(vectors[0], vectors[1]):.3f}")
# → 0.891(非常相似!虽然没有共同关键词)

print(f"React优化 vs Go微服务: {cosine_similarity(vectors[0], vectors[2]):.3f}")
# → 0.234(不太相关)

print(f"前端渲染 vs Go微服务: {cosine_similarity(vectors[1], vectors[2]):.3f}")
# → 0.198(不太相关)

关键发现

  • "React 列表渲染性能优化"和"前端页面渲染速度如何提升"没有任何共同关键词,但相似度高达 0.891
  • 传统关键词搜索找不到这个关联,语义搜索可以
  • 这就是 Embedding 的力量——它理解的是意思,不是字面

Embedding 的维度

每个文本被编码成一个固定长度的数字列表,这个长度就是"维度":

"React 性能优化" → [0.12, -0.34, 0.56, ..., 0.78]
                    └──────── 1536 个数字 ────────┘
                              (维度 = 1536)
模型维度精度存储速度
text-embedding-3-small1536标准6 KB/向量
text-embedding-3-large307212 KB/向量
BGE-M310244 KB/向量

类比

  • 3072 维像超高清照片(4K)——细节丰富,文件大
  • 1536 维像高清照片(1080P)——日常够用
  • 256 维像缩略图——粗略预览,体积小

维度越高,语义表达能力越强,但存储和计算成本也越高。大多数场景 1024~1536 维是最佳平衡点。

不只是文本:多模态 Embedding

现代 Embedding 模型不仅能处理文字,还能处理图片、音频、视频:

同一个向量空间中:

  "一只猫在沙发上睡觉"  → [0.45, 0.12, -0.33, ...]
  🖼️ [猫睡觉的照片]     → [0.43, 0.14, -0.31, ...]  ← 距离很近!
  🖼️ [汽车在公路上]     → [-0.22, 0.67, 0.15, ...]  ← 距离很远

→ 你可以用文字搜索图片,或者用图片搜索文字!

应用场景

  • 电商:用文字描述搜索商品图片("红色碎花连衣裙")
  • 设计:用草图搜索相似设计稿
  • 视频:用文字描述搜索视频片段

MRL(Matryoshka 套娃表示)

MRL 是一种实用技巧——高维向量可以截断到低维仍然可用:

原始向量(3072 维):[0.12, -0.34, 0.56, 0.78, 0.23, ..., 0.45]
                     └────────────── 3072 个数字 ──────────────┘

截断到 1024 维:     [0.12, -0.34, 0.56, 0.78, 0.23, ..., 0.67]
                     └────────── 1024 个数字 ──────────┘
                     精度损失约 5%,存储节省 67%

截断到 256 维:      [0.12, -0.34, 0.56, ..., 0.91]
                     └──── 256 个数字 ────┘
                     精度损失约 15%,存储节省 92%

为什么叫"套娃"? 就像俄罗斯套娃一样,大向量里面"嵌套"着一个可用的小向量。不需要重新计算,直接截断前 N 维就行。

实际意义

  • 百万级文档时,3072 维 → 256 维可以从 12GB 存储降到 1GB
  • 先用低维做粗筛(快),再用高维做精排(准)
  • OpenAI text-embedding-3 系列和多数新模型都支持 MRL

3.3 Embedding 模型选型

2026 年主流模型全景

模型厂商模态维度核心优势定价
Gemini Embedding 2Google文本/图像/视频/音频/PDF768五模态,跨语言综合第一$0.006/M tokens
Jina Embeddings v4Jina AI文本/图像2048三个 LoRA 适配器切换场景,MRL 优秀$0.018/M tokens
Voyage Multimodal 3.5Voyage AI文本/图像/视频1024均衡无短板,MRL 压缩第一$0.02/M tokens
text-embedding-3-largeOpenAI文本3072API 生态最成熟,MRL 支持$0.13/M tokens
text-embedding-3-smallOpenAI文本1536便宜,快速验证首选$0.02/M tokens
Cohere Embed v4Cohere文本1024MTEB 榜单常客,自带 Reranker$0.1/M tokens
Qwen3-VL-Embedding阿里文本/图像2048开源多模态,2B 跨模态超闭源免费(自部署)
BGE-M3智源文本1024开源中文标杆,100+ 语言免费(自部署)
mxbai-embed-largeMixedBread文本1024轻量级(335M),资源受限场景免费(自部署)

MTEB 排行榜解读

MTEB(Massive Text Embedding Benchmark)是 Embedding 模型最权威的评测榜单。

怎么看榜单

MTEB 榜单列很多,但你只需关注这几个:

1. Retrieval Average(检索平均分)← 做 RAG 最重要的指标
2. Classification(分类)← 做文本分类时关注
3. Clustering(聚类)← 做主题聚类时关注
4. Overall Average(综合平均)← 整体能力参考

做 RAG 选模型 → 只看 Retrieval Average 列就够

榜单地址:MTEB Leaderboard

选型核心维度

维度说明测试方法
跨语言能力中文 query 能否准确检索英文文档准备中英文混合测试集
长文档能力文本超过 4K Token 时精度是否衰减用长短文本对比检索效果
MRL 维度压缩截断到低维后精度损失多少同一测试集比较不同维度
多模态需求是否需要处理图文混合文档根据业务场景判断

选型决策图

你的场景是什么?

├── 快速验证 / Demo
│   └── → OpenAI text-embedding-3-small(便宜、API 简单、$0.02/M tokens)

├── 正式项目 · 纯文本
│   ├── 中文为主 → BGE-M3(开源免费,中文能力强)
│   ├── 英文为主 → Cohere Embed v4(MTEB 榜单前列)
│   └── 中英混合 → text-embedding-3-large 或 BGE-M3

├── 正式项目 · 图文混合
│   ├── 预算充足 → Gemini Embedding 2(五模态全覆盖)
│   ├── 需要本地部署 → Qwen3-VL-Embedding(开源可商用)
│   └── 均衡选择 → Jina Embeddings v4

├── 资源受限(边缘端 / 低配服务器)
│   └── → mxbai-embed-large (335M) 或 nomic-embed-text (137M)

└── 🔑 无论选哪个:先用自己的业务数据跑测试!
    公开 Benchmark ≠ 你的业务效果

3.4 相似度计算与向量数据库

相似度度量

拿到两个向量后,如何判断它们有多"相似"?

余弦相似度(最常用)

            A · B           向量A和B的点积
cos(θ) = ────────── = ─────────────────────
          |A| × |B|     A的长度 × B的长度

范围:-1 到 1
  1  = 完全相同方向(语义一致)
  0  = 完全正交(不相关)
 -1  = 完全相反方向(语义相反)

类比:把两个向量想象成从原点出发的两支箭头。余弦相似度衡量的是箭头方向的夹角,不关心箭头长短。方向越接近,相似度越高。

python
import numpy as np

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

# 语义相似的文本
sim1 = cosine_similarity(embed("React hooks 教程"), embed("React 钩子使用指南"))
# → 0.92

# 语义不相关的文本
sim2 = cosine_similarity(embed("React hooks 教程"), embed("今天北京天气不错"))
# → 0.15

其他度量方式

度量特点适用场景
余弦相似度衡量方向,对向量长度不敏感大多数场景首选
欧几里得距离(L2)衡量绝对距离归一化后的向量
点积(Inner Product)归一化后等价于余弦相似度部分场景计算更快

简单结论

选余弦相似度就对了。除非你有特殊需求且充分了解不同度量的差异,否则余弦相似度是最安全的选择。

为什么需要专门的向量数据库

场景:你有 100 万条文档,每条变成 1536 维向量
     用户提问 → 变成向量 → 找到最相似的 Top 5

暴力计算:
  逐个比较 100万 次余弦相似度
  100万 × 1536 × 4字节 ≈ 6 GB 内存
  每次查询需要遍历全部 → 延迟 > 1 秒
  ❌ 慢!用户等不了

向量数据库(使用 ANN 索引):
  预先建立索引结构(HNSW / IVF)
  查询时只需比较少量候选(~1%)
  延迟 < 10 毫秒,准确率 > 95%
  ✅ 快!用户无感

核心概念:ANN 近似最近邻

ANN(Approximate Nearest Neighbor)的核心思想:不追求 100% 精确的最近邻,而是用极小的精度损失换取数量级的速度提升

两种主流索引类型

索引类型原理类比特点
HNSW多层图结构,层层缩小搜索范围坐电梯——先到大致楼层,再走楼梯精确定位查询快、内存占用大
IVF先聚类分桶,只在相关的桶里搜索图书馆分区——先定位"科技类"书架,再在书架上找内存省、需要训练
HNSW 工作原理(简化):

Layer 2:   A ────── B ────── C        (稀疏层,快速定位大区域)
            │                │
Layer 1:   A ── D ── B ── E ── C      (中间层,缩小范围)
            │    │    │    │    │
Layer 0:   A─F─D─G─B─H─E─I─C─J       (底层,精确搜索)

查询过程:
1. 从 Layer 2 开始,找到离目标最近的节点(比如 B)
2. 下降到 Layer 1,在 B 附近找更近的节点
3. 下降到 Layer 0,精确搜索
→ 只遍历了少量节点,而不是全部 100 万个

主流向量数据库对比

数据库语言定位数据规模特色部署方式
ChromaDBPython轻量嵌入式< 100 万pip install 即用嵌入进程 / Docker
QdrantRust高性能百万~千万性能优秀,SDK 完善Docker / Cloud
MilvusGo/C++分布式亿级分布式,大规模生产K8s / Cloud
WeaviateGo全功能百万~千万混合检索内置Docker / Cloud
Pinecone-全托管 SaaS亿级零运维Cloud only
pgvectorCPG 插件< 百万复用现有 PG随 PG 部署

选型快速对比

                  开发体验        性能         规模         运维成本
ChromaDB          ⭐⭐⭐⭐⭐       ⭐⭐          ⭐⭐          ⭐⭐⭐⭐⭐
pgvector          ⭐⭐⭐⭐         ⭐⭐⭐         ⭐⭐          ⭐⭐⭐⭐
Qdrant            ⭐⭐⭐⭐         ⭐⭐⭐⭐⭐      ⭐⭐⭐⭐       ⭐⭐⭐
Milvus            ⭐⭐⭐          ⭐⭐⭐⭐⭐      ⭐⭐⭐⭐⭐     ⭐⭐
Weaviate          ⭐⭐⭐⭐         ⭐⭐⭐⭐       ⭐⭐⭐⭐       ⭐⭐⭐
Pinecone          ⭐⭐⭐⭐⭐       ⭐⭐⭐⭐       ⭐⭐⭐⭐⭐     ⭐⭐⭐⭐⭐

团队落地建议

你的场景是什么?

├── Demo / 快速验证 / 小项目(< 10 万条)
│   └── → ChromaDB(pip install chromadb,零配置)

├── 前端 Node.js 项目
│   ├── 小规模 → ChromaDB(JS SDK)
│   └── 中规模 → Qdrant(Node.js SDK 成熟)

├── 后端 Go 项目
│   ├── 中规模 → Qdrant(Go SDK 完善)
│   └── 大规模 → Milvus(Go SDK + 分布式能力)

├── 已有 PostgreSQL
│   └── → 先试 pgvector,够用就不引入新组件

└── 起步路径:
    ChromaDB 跑通 Demo → 验证效果 → 数据量大了迁移到 Qdrant / Milvus
python
# ChromaDB 最小示例(5 行代码跑通向量存储和检索)
import chromadb

client = chromadb.Client()
collection = client.create_collection("docs")

# 存入文档(自动计算 Embedding)
collection.add(
    documents=[
        "React 虚拟列表可以解决大数据量渲染卡顿问题",
        "Go 的 goroutine 实现了轻量级并发",
        "Kubernetes 通过 Pod 实现容器编排",
    ],
    ids=["doc1", "doc2", "doc3"],
)

# 语义检索
results = collection.query(
    query_texts=["前端列表渲染性能优化怎么做"],
    n_results=2,
)
print(results["documents"])
# → [["React 虚拟列表可以解决大数据量渲染卡顿问题", ...]]

3.5 文本分块策略(Chunking)

为什么要分块

问题 1:Embedding 模型有上下文窗口限制
  text-embedding-3-small 最大 8191 Token
  一篇 50 页的技术文档远超这个限制 → 必须切分

问题 2:大块文本检索精度低
  一个 5000 字的 chunk 里可能只有一句话和问题相关
  → 检索回来一大段"废话",LLM 找不到关键信息

所以:分块 = 让每一块都"小而精",既不超窗口,又能精准匹配

4 种常见分块方法

我们用同一段文档来对比不同切法的效果:

markdown
原始文档:
# React 性能优化指南

## 虚拟列表
当列表数据量超过 1000 条时,直接渲染会导致页面卡顿。
推荐使用 react-window 或 react-virtuoso 实现虚拟滚动。
虚拟列表的核心原理是只渲染可视区域内的 DOM 元素。

## React.memo
React.memo 是一个高阶组件,用于避免不必要的重渲染。
当组件的 props 没有变化时,React.memo 会跳过渲染。
注意:React.memo 只做浅比较,对象和数组需要保持引用稳定。

方法 1:固定大小分块

每 200 字符切一刀,不管内容:

Chunk 1: "# React 性能优化指南\n\n## 虚拟列表\n当列表数据量超过 1000 条时,直接渲染会导致页面卡顿。\n推荐使用 react-window 或 react-virtuoso 实现虚拟滚动。\n虚拟列表的核心原理"

Chunk 2: "是只渲染可视区域内的 DOM 元素。\n\n## React.memo\nReact.memo 是一个高阶组件,用于避免不必要的重渲染。\n当组件的 props 没有变化时,React.memo 会跳过"

Chunk 3: "渲染。\n注意:React.memo 只做浅比较,对象和数组需要保持引用稳定。"

❌ 问题:"虚拟列表的核心原理"和"是只渲染可视区域内的DOM元素"被切断了!

方法 2:递归字符分块(推荐 ✅)

python
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=300,
    chunk_overlap=50,  # 50字符重叠
    separators=["\n\n", "\n", "。", ",", " ", ""],  # 优先按段落切
)

chunks = splitter.split_text(document)
切分逻辑:先按 "\n\n"(段落)切 → 段落太长就按 "\n"(行)切 → 还太长按句号切

Chunk 1: "# React 性能优化指南\n\n## 虚拟列表\n当列表数据量超过 1000 条时,直接渲染会导致页面卡顿。推荐使用 react-window 或 react-virtuoso 实现虚拟滚动。虚拟列表的核心原理是只渲染可视区域内的 DOM 元素。"

Chunk 2: "## React.memo\nReact.memo 是一个高阶组件,用于避免不必要的重渲染。当组件的 props 没有变化时,React.memo 会跳过渲染。注意:React.memo 只做浅比较,对象和数组需要保持引用稳定。"

✅ 按段落自然切分,语义完整

方法 3:语义分块(Semantic Chunking)

python
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

splitter = SemanticChunker(
    OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",
)
切分逻辑:
1. 先把文档拆成句子
2. 计算相邻句子的 Embedding 相似度
3. 相似度突然下降的地方就是"语义断点"
4. 在断点处切分

效果:同一个主题的内容被放在一起,不会把"虚拟列表"和"React.memo"混在一个chunk

✅ 语义最完整
❌ 需要调用 Embedding API(额外成本)
❌ 分块速度较慢

方法 4:按文档结构分块

python
from langchain_text_splitters import MarkdownHeaderTextSplitter

splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[
        ("#", "title"),
        ("##", "section"),
    ]
)
切分逻辑:利用 Markdown 标题作为天然分隔符

Chunk 1: {title: "React 性能优化指南", section: "虚拟列表", content: "当列表..."}
Chunk 2: {title: "React 性能优化指南", section: "React.memo", content: "React.memo是..."}

✅ 保留了文档层级结构(检索时知道"这段属于哪个章节")
✅ 特别适合 Markdown、HTML 等有明确结构的文档

Chunk Size 与 Overlap 调优

         Chunk Size 太大                    Chunk Size 太小
    ┌──────────────────────┐          ┌──────────────────────┐
    │ 一个 chunk 2000 字     │          │ 一个 chunk 50 字      │
    │ 其中只有一句和问题相关   │          │ "推荐使用react-window" │
    │ → 检索回来一大段"废话"  │          │ → 缺少上下文           │
    │ → LLM 被干扰          │          │ → "为什么推荐?"不知道  │
    └──────────────────────┘          └──────────────────────┘

              最佳平衡点
    ┌──────────────────────┐
    │ Chunk Size: 500~1000 字符│
    │ Overlap: 100~200 字符   │
    │ → 每块信息完整         │
    │ → 重叠部分保持上下文连贯│
    └──────────────────────┘

通用经验值

文档类型推荐 Chunk Size推荐 Overlap
技术文档800~1200200
对话记录300~50050
法律合同1000~1500300
代码文件按函数/类切0(结构化切分)

最佳实践

  1. 先用 RecursiveCharacterTextSplitter(LangChain 默认推荐),大多数场景够用
  2. 如果文档有明确结构(Markdown/HTML),用结构化分块
  3. 追求极致效果时,试试语义分块
  4. 一定要根据业务场景 A/B 测试——同一个参数在不同数据集上表现可能完全不同

🔗 回到 Mini-OpenClaw

第二篇的 Mini-OpenClaw 中,memory_indexer.pyMEMORY.md 使用的是 LlamaIndex 的默认分块策略(类似递归字符分块),chunk_size 约 1024 Token,overlap 约 200 Token。对于 Markdown 格式的记忆文件来说,这个配置是合理的。


3.6 RAG 检索增强生成

为什么需要 RAG

问题说明RAG 如何解决
知识截止模型训练数据有截止日期知识库可实时更新
幻觉没有依据时模型会编造答案基于检索到的真实文档
私有数据企业数据不在训练集中把私有数据变成可检索的知识库
Token 限制不能把所有文档塞进上下文只检索最相关的几个片段

RAG vs 微调(Fine-tuning)

维度RAG(开卷考试)微调(考前突击)
类比带着参考书去考试提前把知识背到脑子里
知识更新更新文档即可,秒级生效需要重新训练,小时~天级
成本向量数据库 + Embedding APIGPU 训练成本
准确性答案有引用来源,可追溯可能产生幻觉
适用知识查询、文档问答风格调整、格式定制
门槛低(只需 API 调用)高(需要标注数据 + GPU)

关键结论

90% 的场景 RAG 优先。只有当 RAG 满足不了(比如需要模型输出特定风格、严格遵守复杂格式)时,才考虑微调。两者不互斥,可以组合使用。

经典 RAG Pipeline

完整的数据流分为三个阶段:

═══════════════════════════════════════════════════════════════
  阶段一:Indexing(离线建索引,只做一次或定期更新)
═══════════════════════════════════════════════════════════════

  📄 原始文档                                    💾 向量数据库
  ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
  │ PDF      │    │ Chunk 1  │    │ [0.12,…] │    │          │
  │ Markdown │ →  │ Chunk 2  │ →  │ [0.34,…] │ →  │ ChromaDB │
  │ HTML     │    │ Chunk 3  │    │ [0.56,…] │    │ Qdrant   │
  │ Word     │    │ ...      │    │ ...      │    │ pgvector │
  └──────────┘    └──────────┘    └──────────┘    └──────────┘
    文档加载          分块           Embedding         存储
  (Document Loader) (Splitter)   (Embedding Model) (Vector Store)


═══════════════════════════════════════════════════════════════
  阶段二:Retrieval(在线检索,每次请求都执行)
═══════════════════════════════════════════════════════════════

  ❓ 用户提问                        🔍 向量检索
  ┌──────────────┐    ┌──────────┐    ┌──────────────────┐
  │ "user-service │    │ [0.15,…] │    │ Chunk 7 (0.94)   │
  │  怎么部署?"   │ →  │          │ →  │ Chunk 12 (0.89)  │
  └──────────────┘    └──────────┘    │ Chunk 3 (0.85)   │
     用户问题          Embedding       └──────────────────┘
                                        Top-K 相关片段


═══════════════════════════════════════════════════════════════
  阶段三:Generation(在线生成,每次请求都执行)
═══════════════════════════════════════════════════════════════

  ┌─────────────────────────────────────┐
  │ System Prompt:                       │
  │ "根据以下参考资料回答用户问题。        │
  │  如果资料中没有相关信息,请说不知道。   │
  │                                     │
  │ 参考资料:                           │
  │ [Chunk 7] user-service 部署需要...   │
  │ [Chunk 12] 环境变量配置包括...        │     ┌────────────────┐
  │ [Chunk 3] K8s YAML 示例..."         │ →   │ ✅ 准确的回答    │
  │                                     │     │ + 引用来源       │
  │ 用户问题:user-service 怎么部署?      │     └────────────────┘
  └─────────────────────────────────────┘
                LLM 生成回答

代码实现:完整 RAG Pipeline

python
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

# ═══ 阶段一:Indexing ═══

# 1. 加载文档
loader = PyPDFLoader("internal-docs.pdf")
documents = loader.load()

# 2. 分块
splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
)
chunks = splitter.split_documents(documents)

# 3. Embedding + 存储
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",  # 持久化到磁盘
)

# ═══ 阶段二 + 三:Retrieval + Generation ═══

# 4. 构建检索器
retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 5},  # 返回 Top 5
)

# 5. RAG Prompt 模板
RAG_PROMPT = ChatPromptTemplate.from_template("""
根据以下参考资料回答用户的问题。
要求:
1. 只基于参考资料回答,不要编造
2. 如果参考资料中没有相关信息,请明确说"根据已有资料无法回答"
3. 在回答末尾标注引用来源

参考资料:
{context}

用户问题:{question}
""")

# 6. 构建 RAG Chain
llm = ChatOpenAI(model="gpt-4o", temperature=0)

rag_chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | RAG_PROMPT
    | llm
    | StrOutputParser()
)

# 7. 提问
answer = rag_chain.invoke("user-service 怎么部署到 K8s?")
print(answer)

🔗 回到 Mini-OpenClaw

Mini-OpenClaw 的 RAG 模式就是这个经典 Pipeline 的实现:

  • Indexingmemory_indexer.pyMEMORY.md 做分块 + 向量索引
  • Retrievalsearch_knowledge_base Tool 执行向量检索
  • Generation:Agent 将检索结果注入上下文后生成回答

区别在于:Mini-OpenClaw 是 Agentic RAG——Agent 自主决定何时调用检索(而不是每次都检索),下一节详细讲。


3.7 进阶检索与优化策略

从"能用"到"好用"的关键——当基础 RAG 效果不够好时,这些策略可以帮你大幅提升。

问题:纯向量检索擅长"语义匹配",但遇到精确关键词时可能失灵:

文档:"user-service 使用 gRPC 协议通信,端口号为 50051"

查询:"user-service 的 gRPC 端口是多少?"

纯向量检索:可能返回 ✅(语义相关)
纯关键词检索:一定返回 ✅(精确匹配 "gRPC 端口")

但如果查询是:
"微服务之间怎么通信?"

纯向量检索:返回 ✅(理解"通信"和"gRPC协议"的语义关联)
纯关键词检索:可能返回 ❌(没有"通信"这个关键词)

解决方案两种检索互补——向量检索覆盖"意思相近",BM25 关键词检索覆盖"精确匹配"。

python
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

# 向量检索器
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})

# BM25 关键词检索器
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 10

# 混合检索(各占 50% 权重)
hybrid_retriever = EnsembleRetriever(
    retrievers=[vector_retriever, bm25_retriever],
    weights=[0.5, 0.5],
)

results = hybrid_retriever.invoke("user-service 的 gRPC 端口")

效果对比

查询:"user-service gRPC 端口配置"

纯向量检索 Top 3:
  1. gRPC 服务配置说明(✅ 相关)
  2. 微服务通信架构概述(⚠️ 大方向对但不精确)
  3. HTTP API 端口配置(❌ 不是 gRPC)

混合检索 Top 3:
  1. user-service gRPC 端口号 50051(✅ 精确命中)
  2. gRPC 服务配置说明(✅ 相关)
  3. user-service 部署配置(✅ 相关)

重排序(Re-ranking)

问题:向量检索返回的 Top-K 排序不一定准——第 3 名可能比第 1 名更相关。

解决方案:先用向量检索召回较多候选(Top-20),再用更精确的 Cross-Encoder 模型重新排序,最终取 Top-5。

向量检索(快但粗):
  召回 Top-20 候选 → 毫秒级

Cross-Encoder 重排序(慢但精):
  对 20 个候选逐一精排 → 百毫秒级
  重新排序后取 Top-5

为什么 Cross-Encoder 更准?
  向量检索:query 和 doc 分别编码,只比较向量距离(Bi-Encoder)
  重排序:query 和 doc 拼在一起输入模型,联合编码(Cross-Encoder)
  → 能捕捉更细粒度的语义关系
python
from langchain.retrievers import ContextualCompressionRetriever
from langchain_cohere import CohereRerank

# 基础检索器(召回 Top-20)
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 20})

# Cohere Reranker(精排 → Top-5)
reranker = CohereRerank(model="rerank-v3.5", top_n=5)

# 组合
reranking_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=base_retriever,
)

results = reranking_retriever.invoke("user-service 怎么部署?")

效果提升:根据实测数据,加上 Re-ranking 后 RAG 的准确率通常能提升 10~25%。这是性价比最高的优化手段之一。

查询改写(Query Transformation)

问题:用户的提问方式可能和文档的表述方式不一样。

用户问:"服务部署不上去怎么办?"
文档写的是:"deployment 失败的排查步骤"

→ 虽然说的是同一件事,但用词差异大,向量检索可能不够精准

HyDE(Hypothetical Document Embeddings)

核心思想:先让 LLM 生成一个"假设性答案",用答案的 Embedding 去检索(比问题本身的 Embedding 更接近目标文档)。

传统方式:
  用户问题 "服务部署不上去怎么办?"
    → Embedding → 检索
    → 可能匹配不到"deployment 失败排查"

HyDE 方式:
  用户问题 "服务部署不上去怎么办?"
    → LLM 生成假设性答案:
      "当 deployment 失败时,常见原因包括:
       1. 镜像拉取失败
       2. 资源配额不足
       3. 配置文件错误..."
    → 用这个假设性答案的 Embedding 去检索
    → 更容易匹配到"deployment 失败的排查步骤"!
python
from langchain.chains import HypotheticalDocumentEmbedder

hyde_embeddings = HypotheticalDocumentEmbedder.from_llm(
    llm=ChatOpenAI(model="gpt-4o-mini"),
    base_embeddings=OpenAIEmbeddings(),
    prompt_key="web_search",
)

# 用 HyDE embeddings 替换普通 embeddings
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=hyde_embeddings,
)

Multi-Query

一个问题改写成多个角度,分别检索后合并去重:

原始问题:
  "React 列表渲染卡顿怎么优化?"

改写为多个查询:
  1. "React 虚拟列表实现方案"
  2. "前端大数据量渲染性能优化"
  3. "react-window react-virtuoso 使用教程"

→ 三个查询分别检索 → 合并结果 → 去重 → 覆盖面更广

Step-back Prompting

先问一个更宏观的问题,获取背景知识:

原始问题:
  "为什么 user-service 在 K8s 中 OOM 重启?"

Step-back 问题:
  "K8s 中 Pod OOM 的常见原因有哪些?"

→ 先检索 OOM 的通用知识
→ 再结合 user-service 的具体文档回答

Agentic RAG(2026 趋势)

传统 RAG 是固定流水线——每次提问都走 "检索 → 生成" 的固定路径。

Agentic RAG 让 Agent 自主决策

传统 RAG(固定 Pipeline):
  用户提问 → 检索 → 生成
  每次都检索,即使是 "你好" 这种不需要检索的问题

Agentic RAG(Agent 决策):
  用户提问 → Agent 判断:
    ├── "你好" → 直接回答(不需要检索)
    ├── "user-service怎么部署" → 触发检索 → 生成
    ├── 检索结果不够 → 换个查询词重新检索
    └── 需要多个数据源 → 分别检索后综合回答
python
# Agentic RAG 的核心:把检索包装成 Tool,让 Agent 自主决定是否调用
from langchain_core.tools import tool

@tool
def search_docs(query: str) -> str:
    """搜索内部技术文档。当用户询问公司内部的技术细节、
    部署配置、服务文档等信息时使用此工具。"""
    results = retriever.invoke(query)
    return "\n\n".join(doc.page_content for doc in results)

# Agent 自己决定是否调用 search_docs
agent = create_react_agent(
    model=llm,
    tools=[search_docs, web_search, calculator],
)

Mini-OpenClaw 的 search_knowledge_base 就是 Agentic RAG 的雏形——Agent 根据问题判断是否需要查知识库。

RAG 质量评估

指标衡量什么怎么理解
Faithfulness答案是否忠于检索结果"回答的内容是不是都来自文档"(防止幻觉)
Relevance检索结果是否和问题相关"找到的文档有没有用"
Answer Correctness答案是否正确"最终回答对不对"
python
# 使用 Ragas 评估 RAG 质量
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision

results = evaluate(
    dataset=test_dataset,  # 准备好的测试集
    metrics=[faithfulness, answer_relevancy, context_precision],
)
print(results)
# faithfulness: 0.92(92% 的回答忠于文档)
# answer_relevancy: 0.88(88% 的回答和问题相关)
# context_precision: 0.85(85% 的检索结果精确)

常见问题诊断

症状可能原因解决方案
检索不到相关文档分块太大 / Embedding 质量差 / 查询表述和文档差异大调小 chunk_size + 换 Embedding 模型 + 混合检索
检索到了但答案不对检索结果不够精确,排在前面的不是最相关的加 Re-ranking + 增大召回量(k=20 → rerank → top 5)
答非所问LLM 没好好利用检索结果优化 RAG Prompt 模板(强调"只基于参考资料回答")
回答太笼统检索到的片段缺乏细节调小 chunk_size + 增大 k 值
多个主题混淆检索结果来自不同文档,互相矛盾增加元数据过滤(按文档来源分类)
RAG 效果不好时的排查顺序:

Step 1:检查分块 → 看看 chunk 是不是太大/太小/切断了关键信息
Step 2:检查检索 → 打印 Top-K 结果,看是不是检索到了相关内容
Step 3:检查 Prompt → 看 LLM 拿到的上下文是不是合理
Step 4:试试混合检索 + Re-ranking → 往往能大幅改善
Step 5:换 Embedding 模型 → 用自己的数据做对比测试

3.8 实战:基于 LangChain 实现 PDF 智能问答

把全篇知识串成一个完整的、可运行的项目。

项目目标

上传 PDF → 解析 → 分块 → 向量化 → 对话式问答(带引用溯源)

效果演示:

📄 上传:React-Performance-Guide.pdf(30页技术文档)

❓ 用户:"虚拟列表的原理是什么?推荐用哪个库?"

✅ 回答:
"虚拟列表的核心原理是只渲染可视区域内的 DOM 元素,
而不是渲染全部数据。当用户滚动时,动态替换可视区域
的内容。

推荐两个库:
- react-window:轻量级,适合简单列表
- react-virtuoso:功能丰富,支持动态高度

📖 引用来源:
- 第 12 页 "虚拟列表优化方案"
- 第 15 页 "推荐工具库对比""

技术选型

┌──────────────────────────────────────────────┐
│              PDF 智能问答系统                  │
├───────────────┬──────────────────────────────┤
│   核心框架     │  LangChain                    │
│   PDF 解析    │  PyPDFLoader                  │
│   Embedding   │  OpenAI text-embedding-3-small │
│   向量存储     │  ChromaDB                     │
│   LLM        │  GPT-4o(或 DeepSeek)         │
│   后端        │  FastAPI                       │
│   前端        │  Gradio(快速 UI)             │
└───────────────┴──────────────────────────────┘

完整实现

Step 1:安装依赖

bash
pip install langchain langchain-openai langchain-chroma langchain-community
pip install pypdf chromadb gradio

Step 2:PDF 加载与分块

python
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

def load_and_split_pdf(pdf_path: str):
    # 加载 PDF(每页作为一个 Document,保留页码元数据)
    loader = PyPDFLoader(pdf_path)
    pages = loader.load()

    # 分块
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=200,
        separators=["\n\n", "\n", "。", ",", " ", ""],
    )
    chunks = splitter.split_documents(pages)

    print(f"PDF 共 {len(pages)} 页,分成 {len(chunks)} 个 chunks")
    return chunks

Step 3:Embedding + 向量存储

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

def create_vectorstore(chunks, persist_dir="./chroma_db"):
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=persist_dir,
    )

    print(f"已将 {len(chunks)} 个 chunks 存入向量数据库")
    return vectorstore

Step 4:构建 RAG Chain(带引用溯源)

python
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

def create_rag_chain(vectorstore):
    retriever = vectorstore.as_retriever(
        search_type="similarity",
        search_kwargs={"k": 5},
    )

    prompt = ChatPromptTemplate.from_template("""
你是一个专业的文档问答助手。请根据以下参考资料回答用户的问题。

要求:
1. 只基于参考资料回答,不要编造信息
2. 如果参考资料中没有相关信息,请明确说"根据文档内容无法回答此问题"
3. 回答末尾附上引用来源(标注页码)
4. 使用清晰的结构化格式回答

参考资料:
{context}

用户问题:{question}

请回答:""")

    llm = ChatOpenAI(model="gpt-4o", temperature=0)

    # 格式化检索结果(保留页码信息)
    def format_docs(docs):
        formatted = []
        for doc in docs:
            page_num = doc.metadata.get("page", "未知")
            formatted.append(
                f"[第 {page_num + 1} 页]\n{doc.page_content}"
            )
        return "\n\n---\n\n".join(formatted)

    rag_chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )

    return rag_chain, retriever

Step 5:支持多轮对话

python
from langchain_core.messages import HumanMessage, AIMessage

class ChatWithPDF:
    def __init__(self, vectorstore):
        self.rag_chain, self.retriever = create_rag_chain(vectorstore)
        self.chat_history = []

    def ask(self, question: str) -> str:
        # 如果有历史对话,把历史上下文拼入问题
        if self.chat_history:
            history_text = "\n".join(
                f"{'用户' if isinstance(m, HumanMessage) else 'AI'}: {m.content}"
                for m in self.chat_history[-6:]  # 保留最近 3 轮
            )
            contextualized_question = (
                f"对话历史:\n{history_text}\n\n当前问题:{question}"
            )
        else:
            contextualized_question = question

        # 检索 + 生成
        answer = self.rag_chain.invoke(contextualized_question)

        # 记录对话历史
        self.chat_history.append(HumanMessage(content=question))
        self.chat_history.append(AIMessage(content=answer))

        return answer

Step 6:Gradio UI

python
import gradio as gr

# 全局状态
chat_instance = None

def upload_pdf(file):
    global chat_instance
    chunks = load_and_split_pdf(file.name)
    vectorstore = create_vectorstore(chunks)
    chat_instance = ChatWithPDF(vectorstore)
    return f"✅ PDF 已加载!共 {len(chunks)} 个文档片段,可以开始提问了。"

def ask_question(question, history):
    if chat_instance is None:
        return history + [[question, "❌ 请先上传 PDF 文件"]]
    answer = chat_instance.ask(question)
    return history + [[question, answer]]

with gr.Blocks(title="PDF 智能问答") as app:
    gr.Markdown("# 📄 PDF 智能问答系统")
    gr.Markdown("上传 PDF 文档,然后用自然语言提问")

    with gr.Row():
        pdf_upload = gr.File(label="上传 PDF", file_types=[".pdf"])
        upload_status = gr.Textbox(label="状态", interactive=False)

    pdf_upload.change(upload_pdf, inputs=pdf_upload, outputs=upload_status)

    chatbot = gr.Chatbot(label="对话", height=500)
    question_input = gr.Textbox(
        label="提问",
        placeholder="请输入你的问题...",
    )
    question_input.submit(
        ask_question,
        inputs=[question_input, chatbot],
        outputs=chatbot,
    )

app.launch()

进阶优化

在基础版本跑通后,可以逐步加上这些优化:

加入 Re-ranking

python
from langchain.retrievers import ContextualCompressionRetriever
from langchain_cohere import CohereRerank

# 在 create_rag_chain 中替换 retriever
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 20})
reranker = CohereRerank(model="rerank-v3.5", top_n=5)
retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=base_retriever,
)

混合检索

python
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

bm25 = BM25Retriever.from_documents(chunks)
bm25.k = 10

vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})

hybrid = EnsembleRetriever(
    retrievers=[vector_retriever, bm25],
    weights=[0.6, 0.4],  # 向量检索权重略高
)

更换 Embedding 模型对比效果

python
# 对比不同 Embedding 模型的效果
configs = [
    ("text-embedding-3-small", 1536),
    ("text-embedding-3-large", 3072),
]

for model_name, dim in configs:
    embeddings = OpenAIEmbeddings(model=model_name)
    vs = Chroma.from_documents(chunks, embeddings)
    retriever = vs.as_retriever(search_kwargs={"k": 5})

    # 用测试集评估
    for q, expected in test_cases:
        results = retriever.invoke(q)
        score = evaluate_retrieval(results, expected)
        print(f"{model_name}: {q} → score={score:.2f}")

与 Mini-OpenClaw 对照

维度本实战(经典 RAG)Mini-OpenClaw(Agentic RAG)
检索触发每次提问都检索Agent 自主判断是否检索
Pipeline固定:检索 → 生成灵活:Agent 决定流程
数据源单一 PDFMEMORY.md + 可扩展
适用场景文档问答(结构化)通用 Agent(开放式)

两种模式各有优势

  • 经典 RAG:简单可靠、延迟可控、适合"文档问答"这类明确的检索场景
  • Agentic RAG:灵活智能、适合复杂场景,但需要更好的 Agent 能力和护栏机制

串联回顾

这个项目用到了第三篇的哪些知识点?

知识点在项目中的体现
3.2 EmbeddingOpenAIEmbeddings 把文档片段变成向量
3.3 模型选型选择 text-embedding-3-small 做快速验证
3.4 向量数据库ChromaDB 存储和检索向量
3.5 分块策略RecursiveCharacterTextSplitter,chunk_size=1000,overlap=200
3.6 RAG Pipeline完整的 Indexing → Retrieval → Generation 流程
3.7 进阶优化Re-ranking + 混合检索 + 效果对比

总结

第三篇到这里就结束了。回顾我们学到的内容:

✅ 为什么需要 RAG(3.1):私有数据 + 知识截止 + 幻觉 → RAG 是最佳方案
✅ Embedding 深入(3.2):语义搜索的原理、维度含义、多模态、MRL 套娃
✅ 模型选型(3.3):2026 年主流模型全景、MTEB 榜单、选型决策图
✅ 向量数据库(3.4):相似度度量、ANN 索引、6 款数据库对比
✅ 分块策略(3.5):4 种切法对比、Chunk Size/Overlap 调优
✅ RAG Pipeline(3.6):Indexing → Retrieval → Generation 完整流程
✅ 进阶优化(3.7):混合检索、Re-ranking、HyDE、Agentic RAG、质量评估
✅ PDF 问答实战(3.8):完整可运行的项目 + 进阶优化 + 与 Mini-OpenClaw 对比

至此,AI 基础知识分享系列全部三篇完成

从核心概念到 Agent 开发再到 RAG 落地,一条线讲下来,你已经具备了在业务中应用 AI 的完整知识体系。🎉

读文档、看源码、写代码,理解 AI Agent 本质 🤖