L3.1 RAG 检索增强生成 

目标:不只是"会用 RAG",而是理解每个环节的为什么,能在生产环境做正确取舍。


前置认知:什么时候该用 RAG?

RAG 不是银弹,先搞清楚它解决什么、不解决什么。

场景

方案

LLM 答非所问、知识胡编

✅ RAG

通用知识(常识、百科)

❌ 不需要 RAG,LLM 本身知道

需要最新信息(新闻、价格)

✅ RAG(实时检索)

私有制度/文档/流程

✅ RAG

需要 LLM 学会某种写作风格

❌ 微调 Fine-tuning

结构化数据查询(订单、库存)

❌ SQL / API

Java 类比:RAG = 数据库索引查资料,不是微调 = 训练新算法。


 一、整体架构全景

┌─────────────────────────────────────┐
 │ RAG 全景图 │
 └────────────────┬────────────────────┘
 │
 ┌───────────────────────────┼───────────────────────────┐
 │ │ │
 ┌────▼─────┐ ┌──────▼──────┐ ┌─────▼─────┐
 │ Ingest │ │ Query │ │ LLM │
 │ │ │ │ │ 生成 │
 └────┬─────┘ └──────┬──────┘ └─────┬─────┘
 │ │ │
 ▼ ▼ │
 ┌─────────┐ ┌──────────┐ │
 │Chunking │ ┌─────────┐ │Embedding│◄──────────用户问题 │
 │切分 │ │Embedding│ │向量化 │ │
 └────┬────┘ │向量化 │ └────┬────┘ │
 │ └────┬────┘ │ │
 ▼ ▼ ▼ │
 ┌──────────┐ ┌──────────┐ ┌──────────┐ │
 │向量数据库│ │向量数据库│ │向量检索 │◄──── Top-K │
 │ 存储 │ │ 存储 │ │排序结果 │ │
 └─────────┘ └─────────┘ └────┬─────┘ │
 │ ┌──────────▼────┐
 ┌────▼────┐ │ 拼装Prompt │
 │Rerank │ │ LLM生成回答 │
 │重排序 │ └───────────────┘
 └────┬────┘
 │
 ┌────▼────┐
 │混合检 │
 │索/查询 │ ← 进阶技巧
 │改写 │
 └────────┘

Java 三层架构对照

  • Ingest ≈ DAO 层(数据入库)
  • Query ≈ Service 层(业务编排)
  • LLM 生成 ≈ Controller 层(结果输出)

 二、Chunking — 切分的艺术

2.1 为什么必须切分?

LLM Context 有上限(4K~128K tokens),而且 Token 上限 ≠ 有效输入

  • 系统 Prompt 占 ~500-1000 tokens
  • 对话历史占 ~N tokens
  • 留给上下文的只剩下一部分

一本 300 页的 PDF 直接塞进去?Token 爆了。

2.2 切分策略对比

策略

原理

优点

缺点

固定字符

每 N 字符一切,用重叠衔接

简单稳定

可能切断句子/语义单元

滑动窗口重叠

窗口大小 W,重叠 O

相邻块有上下文连贯性

存储膨胀(~W/(W-O)倍)

语义段落

用 NLP 识别句子/段落边界

语义完整

实现复杂,效果不一定更好

递归字符

\n\n

\n

→ 句子 优先级降级切分

最常用,符合语义

需要调参

文档结构感知

Markdown/HTML 标题层级

保留标题层级信息

依赖文档格式

实用推荐:固定字符 500~1000 + 重叠 50~100,是大多数场景的稳健起点。

2.3 重叠为什么是必要的?

块1: [0:500] "本文介绍了Spring Boot的虚拟线程特性..."
块2: [400:900] "虚拟线程是Java 21引入的轻量级线程..." ← 块1末尾和块2开头重叠

如果用 | 分隔,没有重叠:

块1: "本文介绍了Spring Boot的虚拟线程特性...(在第500字处切断)"
块2: "JVM调度而非OS调度..." ← "JVM调度"指什么?丢失了主语

Java 类比:数据库跨页查询时,LIMIT 500 OFFSET 0 的结果和 LIMIT 500 OFFSET 500 的结果断在半句,JOIN 时需要靠 ORDER BY 和重叠索引字段对齐。RAG 的重叠就是防止段落的语义在边界处被切断。

2.4 Chunk size 怎么选?

使用场景

推荐 Chunk Size

理由

问答(精准)

200~500 字

问题短,相关段落也短,精确匹配

摘要生成

1000~2000 字

需要足够上下文

多跳推理

500~800 字

中等大小,保留逻辑链

经验法则:先设为 500,上线后根据 Recall 调整。


 三、Embedding — 向量化的深度原理

3.1 直观理解:Embedding 是什么?

Embedding 本质是把文字映射到向量空间,让语义相近的内容在空间中距离近。

文字: "如何开通云服务器"
 ↓ Embedding 模型
向量: [ 0.12, -0.34, 0.78, -0.21, ...] (N维)

相似问题: "购买CVM实例的步骤"
向量: [ 0.11, -0.33, 0.79, -0.20, ...] ← 距离近 ↑

无关问题: "今天吃什么"
向量: [-0.45, 0.22, -0.11, 0.33, ...] ← 距离远 ↓

3.2 Embedding 模型是怎么训练的?

训练目标是让正样本(同义句对)距离近,负样本(无关句对)距离远

loss = max(0, 负样本距离 - 正样本距离 + margin)

训练数据:大量 (query, document) pairs,比如搜索点击日志。

3.3 关键参数:归一化

embeddings = model.encode(text, normalize_embeddings=True)

为什么要归一化? 归一化后,余弦相似度 = 向量内积。计算从 cos(θ) = A·B/(|A||B|) 简化成 A·B快很多且数值稳定

3.4 中文 Embedding 模型选型

模型

维度

特点

适用场景

bge-small-zh-v1.5

512

CPU 可跑,推理快(~50ms/条)

优先选,性价比最高

text-embedding-3-small

1536

OpenAI API,通用好

需要调用 API 时

m3e-base

768

中文优化,开源可本地

中文场景生产可用

BAAI/bge-large-zh-v1.5

1024

精度更高,推理慢

精度优先场景

Java 类比:Embedding 模型 = 消息摘要算法(MD5/SHA-256),但输出是 N 维浮点向量而不是固定哈希值。bge-small ≈ 轻量级摘要算法,快但精度有限。


 四、向量检索 — 索引的深度原理

4.1 为什么不能暴力检索?

假设知识库有 100 万条 Chunk,每条 512 维向量:

100万 × 512 维 × 4字节(float) = 2GB 内存
每条检索需要做 100万 次向量点积运算 → ~几秒/次查询 ❌

必须用**近似最近邻(ANN)**索引。

4.2 主流 ANN 算法

HNSW(Hierarchical Navigable Small World)

原理:多层图结构,上层稀疏、下层密集,搜索从顶层最快定位近邻区域。

层3: A ─── B ─── C ← 最稀疏,相隔最远
 │
层2: D ──[A]── E ─── F ← 中层
 │
层1: G ──[D]── H ──[E]── I ← 最密集,所有邻居

优势:近似 O(log N) 搜索速度,精度高(~95%+ recall)。
缺点:内存占用大,建索引慢。

Java 类比:HNSW = 跳表(Skip List)的多层级思想,上层快速缩小范围,下层精确定位。类似数据库的 B+Tree 根节点 → 中间节点 → 叶节点的层次结构。

IVF(Inverted File Index)

原理:先对向量做 K-Means 聚类,搜索时只扫描最近的 K 个聚类中心。

聚类1: [向量1, 向量2, 向量3, ...] ← "云计算"相关
聚类2: [向量4, 向量5, 向量6, ...] ← "数据库"相关
聚类3: [向量7, 向量8, 向量9, ...] ← "网络"相关

搜索"云服务器"→ 只需扫描聚类1,不用扫全部 100 万。

HNSW + IVF 组合:工业级向量库(ChromaDB/FAISS)的默认配置。

4.3 相似度:余弦 vs 点积 vs L2

度量

公式

适用场景

前提

余弦相似度

cos(θ) = A·B/|A||B|

语义相似度(长度无关)

归一化后 = 内积

点积(内积)

A·B = Σaᵢbᵢ

未归一化的向量

必须先归一化

L2 欧氏距离

√Σ(aᵢ-bᵢ)²

距离敏感任务

受向量长度影响

实际工程结论

  • 用 Embedding 模型默认的归一化 + 余弦相似度(等价于内积)
  • 除非你有特殊理由,否则不要用 L2 距离

 五、Rerank — 重排序的深度原理

5.1 为什么向量检索结果需要重排?

向量检索找的是语义最接近的 K 个,但 RAG 最终只要 3~5 个塞进 Prompt。

问题在于:Top-5 可能内容高度重叠,都在讨论同一件事,Context 冗余。

5.2 MMR 算法(Maximal Marginal Relevance)

MMR 在相关性多样性之间做权衡:

def mmr(query_vec, candidates, selected, lambda_param=0.7):
 mmr_scores = []
 for chunk, score in candidates:
 relevance = score
 redundancy = max(
 cosine_sim(chunk.vec, sel.vec) for sel in selected
 ) if selected else 0
 mmr = lambda_param * relevance - (1 - lambda_param) * redundancy
 mmr_scores.append(mmr)
 return candidates[argmax(mmr_scores)]

λ 调参建议

  • λ=0.9:相关性优先,多样性几乎不考虑
  • λ=0.7:平衡(生产默认)
  • λ=0.5:多样性优先,适合文档检索而非问答

5.3 Cross-Encoder Rerank(更强的方案)

比向量检索更精确的二次排序:

向量检索(Bi Encoder): question → q_vec → 一遍内积排序 → Top-50
Cross Encoder Rerank : question + doc → 一起过 Transformer → 更精确的 0~1 得分 → Top-5

原理:Bi-Encoder 分别 encode query 和 doc,Cross-Encoder 把 query+doc 拼接后一起 encode,能捕获更细粒度的交互信息。

Java 类比

  • Bi-Encoder = 索引时直接用分词权重(词频 TF),快但粗糙
  • Cross-Encoder Rerank = 用户搜索后再用 Learning to Rank 模型(如 LambdaMART)精排

 六、混合检索 — 稀疏 + 稠密双管齐下

6.1 单一检索的问题

检索方式

原理

擅长

不擅长

向量检索(稠密)

语义匹配

同义改写、口语化查询

精确术语、ID exact match

关键词检索(稀疏)

BM25 / TF-IDF

精确词匹配、专业术语

语义相关但词不同

6.2 混合检索 = 并行执行 + 融合

# Step 1: 并行执行
dense_results = vector_store.search(q_vec, top_k=20) # 语义检索
sparse_results = bm25_engine.search(query, top_k=20) # 关键词检索

# Step 2: RRF 融合(Reciprocal Rank Fusion)
# 每条结果根据排名给分,第一名10分,第二名9分... 最后求和
scores = {}
for rank, (chunk, _) in enumerate(dense_results):
 scores[chunk.id] = scores.get(chunk.id, 0) + 1 / (60 + rank)
for rank, chunk in enumerate(sparse_results):
 scores[chunk.id] = scores.get(chunk.id, 0) + 1 / (60 + rank)

final_ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)

RRF 公式:score = Σ 1 / (k + rank),k=60 是平滑因子。

Java 类比:搜索引擎的「综合排序」,向量检索 ≈ PageRank,BM25 ≈ TF-IDF,最后用机器学习模型融合两个信号。


 七、查询改写 — 让口语匹配文档

7.1 问题:用户问法和文档写法不一致

用户问:“怎么买服务器”
文档写:“CVM云服务器购买流程”

直接检索可能匹配不上。

7.2 HyDE(Hypothetical Document Embeddings)

# Step 1: 让 LLM 基于问题生成一个"假设答案"
hypo_doc = llm.generate(f"假设你是文档作者,请写一段回答:{question}")

# Step 2: 用假设答案去检索,而不是原问题
hypo_results = store.search(embed(hypo_doc), top_k=5)

# Step 3: 结合原始问题一起检
combined_results = fuse(hypo_results, vector_store.search(embed(question)))

原理:LLM 生成的假设答案在向量空间里更接近真实文档。


 八、评估 — RAG 系统质量度量

8.1 RAG 三元指标(RAGAs 框架)

指标

含义

评估方式

Faithfulness

回答是否忠实于检索内容

LLM 判断生成内容与上下文的一致性

Answer Relevancy

回答与问题的相关度

LLM 判断回答是否切题

Context Precision

Top-K 上下文的精准度

每条相关文档是否在 Top-K 中

8.2 端到端评估:RAGTriad

┌─────────────┐
 Question ─────►│ Retrieve │──── Chunks ────►│ Generate │──── Answer
 └─────────────┘ └──────────────┘
 │ │
 Precision@K │ Correctness / Hallucination
 Recall@K │ Faithfulness

核心链路:检索质量 × 生成质量 = 最终效果。

8.3 常见失败模式及诊断

失败现象

根因

解决方向

答案胡编(幻觉)

检索到的文档质量差或 Prompt 没说"不知道就说不知道"

提高检索精度,优化 Chunking

答案太短/没答到点

检索到的文档本身不含答案

扩充知识库,或用 HyDE 改写查询

相邻轮次答案矛盾

上下文窗口太小,历史被截断

增大窗口或接入历史摘要

精确术语查不到

向量检索无法 exact match

加 BM25 关键词检索(混合检索)


要点回顾

RAG = Ingest(切分→向量化→入库) + Query(检索→重排→生成)

Chunking: 固定500+重叠100是稳健起点
Embedding: bge-small-zh-v1.5 (512维, CPU可跑) 优先
向量检索: HNSW索引,余弦相似度(归一化后=内积)
Rerank: MMR平衡相关性与多样性,或Cross-Encoder二次精排
混合检索: 向量+BM25双路并行,RRF融合
评估: Faithfulness + Answer Relevancy + Context Precision

 思考题(3道)

  1. 知识库有 1000 万条文档,Chunk size 500 字,RAG 系统延迟高,你怀疑向量检索慢,你的第一步诊断是什么?用什么指标判断?
  2. 公司有一份 500 页的技术规范 PDF,你想让 LLM 能回答"这个规范里 X 流程怎么走",但每次问都说不知道。请列出至少 3 个可能的失败环节(从 Ingest 到 Query 的全链路)。
  3. "余弦相似度"和"向量内积"在 Embedding 归一化后等价,为什么?数学上给出一个简短推导。

(思考)...

(思考)...

(思考)...

(思考)...

(思考)...

(思考)...

(思考)...

(思考)...


思考题答案


第1题:10亿文档 + 高延迟,第一步诊断什么?

核心原则:先定位瓶颈在哪,再针对性优化。别猜,要量化。

诊断步骤
Step 1: 打日志分段计时,拆解各环节耗时
def ask(question):
 t0 = time.time()
 q_vec = embedder.embed(question) # Embedding
 t1 = time.time()

 raw_results = store.search(q_vec, top_k=20) # 向量检索
 t2 = time.time()

 reranked = reranker.rerank(q_vec, raw_results, top_k=3) # Rerank
 t3 = time.time()

 answer = llm_generate(question, reranked) # LLM 生成
 t4 = time.time()

 print(f"各环节耗时: embed={t1-t0:.0f}ms search={t2-t1:.0f}ms "
 f"rerank={t3-t2:.0f}ms llm={t4-t3:.0f}ms")
 return answer
关键指标

指标

含义

正常值

说明

Search QPS

每秒检索次数

-

超过索引承载上限会炸

Recall@K

Top-K 结果中相关文档的比例

>0.85

<0.7 说明检索质量差,LLM 自然答不上

P99 Latency

99%请求的延迟

<500ms

关注尾部延迟,不是平均值

Index Size / Memory

索引占用内存 vs 机器内存

<70%

超过会OOM或swap,延迟暴涨

实战技巧
怀疑检索慢 → 看 search time 是 1ms 还是 100ms
如果是 100ms+:
 → 检查向量维度(512 vs 1024)和索引类型(HNSW/IVF参数)
 → 检查是否发生磁盘交换(top / free 查看)
 → 检查 CPU 负载(向量化运算吃 CPU)
 
如果是 LLM 占了 90% 时间:
 → 优化 Prompt 长度(减少 context tokens)
 → 换更快的模型或加缓存

Java 类比:数据库慢查询,先看 EXPLAIN 执行计划。RAG 的 EXPLAIN 就是分段日志:Embedding 是"解析 SQL",向量检索是"索引扫描",Rerank 是"排序",LLM 是"结果返回"。


第2题:500页PDF,LLM总说不知道,列出3个以上失败环节

全链路逐一排查:

 环节1:Ingest — 文档根本没成功入库

症状:搜索任何关键词都返回空

# 自查
store_size = len(vector_store)
print(f"知识库 chunk 数量: {store_size}")

# 如果是 0 → Ingest 环节出问题
# 可能原因:
# 1. PDF 解析失败(PyMuPDF 未安装)
# 2. 空文档(全是图片没有文字)
# 3. Chunk 后全部被过滤(内容太短)
 环节2:Ingest — Chunking 切太碎,上下文丢失

症状:知识库有数据,但查不到

500页 PDF ≈ 20万字,按 chunk_size=500 切 → 400 个 chunk。

如果"X流程怎么走"这个答案跨了 3 页,而每个 chunk 只有 500 字:

Chunk 47: "第一步:登录系统...(在第47页末尾被切断)"
Chunk 48: "进入流程配置页面...(第48页开头,丢失了上下文)"

检索时 Chunk 47 和 Chunk 48 单独看都不完整,LLM 拿到的是残缺的流程描述。

修复方向

  • 增大 chunk_size 到 800~1000
  • 重叠从 50 提到 100
  • 或者用语义切分,按"步骤一/步骤二/步骤三"分段
 环节3:Embedding — 模型和文档不匹配

症状:知识库有数据,但语义检索全偏

500页技术规范里的术语:

  • “QoS 保障机制”
  • “SLA 99.95%”
  • “熔断器 pattern”

这些专业术语和口语化问题"这个规范里 X 流程怎么走"在向量空间里可能距离很远,因为:

  1. Embedding 模型训练数据以通用文本为主,没见过这个领域术语
  2. 用户问的是口语"怎么走",文档写的是"执行流程见 4.2.1 章节" → 语义表达方式不一致

修复方向

  • HyDE 查询改写
  • 或者用该领域的专业 Embedding 模型(如 acge text embedding 支持中文 + 专业领域)
 环节4:Query — 向量检索的 Top-K 设太小

症状:能检索到部分相关 chunk,但不够

# 问题代码:top_k=2 → 只返回2个chunk,不够回答复杂流程
results = store.search(q_vec, top_k=2)

# 修复:流程类问题至少 top_k=5~10
results = store.search(q_vec, top_k=10)

500页的复杂流程可能需要多个相关段落才能完整回答。

 环节5:LLM Prompt — 没有强制约束生成

症状:检索到了正确内容,但 LLM 仍然说不知道

# 问题 Prompt:LLM 有"不说不知道"的自由
bad_prompt = f"回答以下问题:{question}\n\n参考:{context}"

# 修复 Prompt:明确指令
good_prompt = f"""你是一个问答助手。基于以下参考内容回答问题。
如果参考内容包含答案,请给出具体步骤。
如果参考内容不包含答案,明确说"根据提供的文档,无法回答此问题"。

参考内容:
{context}

问题:{question}
回答:"""

LLM 有幻觉倾向,如果 Prompt 不够强制,它可能自己"发挥"。

正确排查顺序
1. 打印 store_size → 确认文档入库了吗?
2. 打印检索结果 → 确认能检索到东西吗?
3. 打印检索结果 snippet → 确认检索到的和问题相关吗?
4. 打印 Prompt 内容和 LLM 原始输出 → 确认是生成问题还是检索问题?

第3题:证明余弦相似度 = 向量内积(归一化后)

数学推导

余弦相似度定义

cos(θ) = (A · B) / (|A| × |B|)

其中分子是向量内积 A · B = Σ aᵢ × bᵢ,分母是两个向量的 L2 范数相乘。

向量内积dot(A, B) = Σ aᵢ × bᵢ

归一化操作:对向量 A 做 L2 归一化:

 = A / |A| (每个维度除以向量的长度)

|Â| = √(Â · Â) = √( (A/|A|) · (A/|A|) )
 = √( A·A / (|A| × |A|) )
 = √( |A|² / |A|² ) = 1

归一化后 |Â| = 1|B̂| = 1,所以:

cos(θ) = (A · B) / (|A| × |B|)
 = (|A|Â · |B|B̂) / (|A| × |B|) ← 把 A,B 替换成归一化形式
 = Â · B̂ ← |A| 和 |B| 约掉
 = dot(Â, B̂)

结论:当两个向量都是单位向量(L2 归一化)时,余弦相似度 = 向量内积。

实际工程意义
# 归一化前:需要计算 cos(θ)
cos_sim = dot(a, b) / (norm(a) * norm(b)) # 2次开方 + 1次除法

# 归一化后:直接内积
a_norm = a / norm(a)
b_norm = b / norm(b)
cos_sim = dot(a_norm, b_norm) # 零开销

性能差异:在 100 万次检索中:

  • 余弦相似度:每条做 512次乘法 + 511次加法 + 2次开方 + 1次除法
  • 归一化内积:每条只做 512次乘法 + 511次加法

1 次开方 ≈ 10 次乘加,所以归一化内积快 ~10%。

Java 类比:数据库索引列如果建立了 B+Tree,查询 WHERE age = 30 直接定位。但如果建了函数索引 md5(name),每次比较都要重新计算 MD5。归一化 = 把函数索引变成普通索引,查询时直接比。


三道题知识点回顾

Q1: 性能诊断 → 分段打时 + Recall@K + P99延迟
Q2: 全链路排查 → 入库/切分/Embedding/检索/Prompt
Q3: 归一化数学 → |A|=1 时 cos(θ)=A·B

思维导图

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐