RAG 技术深度解析:从 Embedding 原理到工程实践
什么是 RAG?
RAG(Retrieval-Augmented Generation,检索增强生成)是一种将信息检索与大语言模型生成相结合的技术架构。核心思想:在让 LLM 生成回答之前,先从外部知识库中检索相关信息,将其作为上下文注入到 prompt 中,从而提升回答的准确性和时效性。
基本工作流程
1
用户提问 → 向量化查询 → 检索相关文档 → 拼接为 Prompt → LLM 生成回答
三个阶段:
- 索引阶段(Indexing):将文档切分为 chunks → Embedding 模型转为向量 → 存入向量数据库
- 检索阶段(Retrieval):用户问题转为向量 → 向量库中相似性搜索 → 取回最相关的片段
- 生成阶段(Generation):检索到的片段 + 用户问题拼成 prompt → LLM 生成回答
RAG 解决的核心问题
| 问题 | 说明 |
|---|---|
| 知识时效性 | LLM 训练数据有截止日期,RAG 可接入实时更新的知识库 |
| 幻觉(Hallucination) | 基于真实文档生成,减少”编造” |
| 领域专业性 | 无需微调,即可回答企业内部、法律、医疗等专业问题 |
| 可追溯性 | 回答可附带来源引用,便于验证 |
RAG vs Fine-tuning:RAG 适合”注入知识”(让模型知道新东西),Fine-tuning 适合”改变行为”(让模型用特定风格/格式回答)。两者不冲突,可以组合使用。
Retrieval 阶段:远不止字符串匹配
RAG 的检索阶段不是简单的关键词搜索,现代 RAG 系统的检索技术形成了一个从简单到复杂的光谱:
1
字符串匹配 → 关键词检索(BM25) → 向量语义检索 → 混合检索 → Agentic 检索
BM25 关键词检索(Sparse Retrieval)
BM25 不是简单的字符串匹配,它考虑了 TF(词频)、IDF(逆文档频率)和文档长度归一化:
1
2
3
# 查询:"Python 内存泄漏"
# ✅ 命中:"Python 程序中常见的内存泄漏原因"(关键词重叠)
# ❌ 漏掉:"程序运行越来越慢,占用资源不断增长"(语义相关但无关键词重叠)
BM25 在精确术语匹配场景下依然很强,比如搜索错误码 ERR_CONNECTION_REFUSED,向量检索反而可能不如它。
向量语义检索(Dense Retrieval)
1
2
3
query = embed("如何提升代码质量") # → [0.12, -0.34, 0.56, ...]
doc_1 = embed("代码审查的最佳实践") # → [0.11, -0.31, 0.58, ...] ✅ 相似度高
doc_2 = embed("今天天气不错") # → [-0.78, 0.42, -0.15, ...] ❌ 相似度低
没有任何共同关键词,但语义检索能找到 doc_1。核心优势:理解语义,而非匹配字符。
Bi-Encoder 与 Cross-Encoder
在深入混合检索和 Reranking 之前,需要先理解两种核心的文本匹配架构:
Bi-Encoder(双塔模型)— 用于初检索
Bi-Encoder 将 query 和 document 分别独立编码为向量,然后用余弦相似度等指标比较:
1
2
3
4
5
6
7
Bi-Encoder 工作方式:
"如何优化数据库" ──→ [Encoder] ──→ vec_q = [0.2, -0.4, ...]
↓ 余弦相似度
"MySQL索引调优指南" ──→ [Encoder] ──→ vec_d = [0.3, -0.3, ...]
关键:query 和 doc 的编码是独立的,互不影响
优点:document 的向量可以预计算并缓存。查询时只需编码 query(一次推理),然后在预计算好的百万向量中搜索,毫秒级返回。这是向量检索能做到大规模实时检索的根本原因。
缺点:因为 query 和 doc 是独立编码的,模型无法看到两者之间的细粒度交互,精度有限。
Cross-Encoder(交叉编码器)— 用于重排序
Cross-Encoder 将 query 和 document 拼接在一起作为输入,让 Transformer 的 Self-Attention 同时看到两段文本:
1
2
3
4
5
6
7
8
9
Cross-Encoder 工作方式:
输入:"[CLS] 如何优化数据库 [SEP] MySQL索引调优指南 [SEP]"
↓
整个 Transformer 编码(query 和 doc 的每个词互相 attend)
↓
输出:相关性得分 0.95
关键:query 和 doc 在 Attention 层中深度交互
优点:精度远高于 Bi-Encoder,因为模型能捕捉 query 和 doc 之间的细粒度语义关系。
缺点:无法预计算。每个 query-doc 对都需要一次完整的 Transformer 前向推理。如果有 100 万篇文档,就需要推理 100 万次——根本不可行。
两者配合:漏斗架构
1
2
3
4
5
百万文档
↓ Bi-Encoder 初检索(毫秒级)
Top-50 候选
↓ Cross-Encoder 精排(几百毫秒)
Top-5 最终结果
| Bi-Encoder | Cross-Encoder | |
|---|---|---|
| 编码方式 | query 和 doc 分别编码 | query 和 doc 拼接编码 |
| 能否预计算 | ✅ doc 向量可预存 | ❌ 每对都要实时算 |
| 速度 | 极快(毫秒级搜百万) | 慢(每对约 10ms) |
| 精度 | 中等 | 高 |
| 用途 | 初检索(召回阶段) | 重排序(精排阶段) |
混合检索 + Reranking(生产环境主流)
1
2
3
4
5
用户查询
├── BM25 关键词检索 ──→ 候选集 A(擅长精确匹配)
├── Bi-Encoder 向量检索 ──→ 候选集 B(擅长语义理解)
├── RRF 融合 ──→ Top-50 候选
└── Cross-Encoder Reranking ──→ Top-5 最终结果
| 场景 | BM25 | 向量检索 |
|---|---|---|
搜索错误码 NullPointerException | ✅ 精确命中 | ⚠️ 可能匹配到其他异常 |
| 搜索”程序跑着跑着就崩了” | ❌ 无关键词 | ✅ 语义匹配到 crash 相关文档 |
Reranking 的具体实现
Reranking 使用 Cross-Encoder 对初检索结果进行精细打分和重新排序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from sentence_transformers import CrossEncoder
# 加载 Cross-Encoder 重排模型
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
def rerank(query: str, candidates: list[str], top_k: int = 5):
# 构造 [query, doc] 对
pairs = [[query, doc] for doc in candidates]
# Cross-Encoder 为每对打分(query 和 doc 拼接后一起编码)
scores = reranker.predict(pairs)
# 按得分降序排列
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return ranked[:top_k]
# 示例
query = "如何解决Python内存泄漏"
candidates = [
"Python垃圾回收机制与内存管理", # 高度相关
"Python Web框架性能对比", # 部分相关
"Java JVM内存调优指南", # 不同语言
"使用tracemalloc追踪Python内存分配", # 高度相关
]
results = rerank(query, candidates, top_k=2)
# → [("使用tracemalloc追踪Python内存分配", 0.97),
# ("Python垃圾回收机制与内存管理", 0.93)]
Reranking 为什么能提升效果?因为初检索(Bi-Encoder)相当于”快速扫一眼标题”,而 Reranking(Cross-Encoder)相当于”仔细读一遍内容再判断相关性”。
常用的 Reranker 模型:
| 模型 | 特点 |
|---|---|
BAAI/bge-reranker-v2-m3 | 多语言,效果好,开源 |
cross-encoder/ms-marco-MiniLM-L-12-v2 | 英文,轻量快速 |
| Cohere Rerank API | 云服务,开箱即用 |
| Jina Reranker v2 | 多语言,支持长文本 |
Embedding 的实现原理
核心直觉:把文字变成数字
Embedding 就是把文本映射到数学空间中的函数。目标:语义相近的词/句子,在向量空间中距离也近。
1
2
3
"猫" → [0.21, -0.45, 0.78, 0.12, ...] # 一个高维向量
"狗" → [0.19, -0.42, 0.81, 0.09, ...] # 和"猫"很接近
"汽车" → [-0.56, 0.33, -0.12, 0.67, ...] # 和"猫"很远
演进历程
One-Hot 编码(最原始)
1
2
3
"猫" → [1, 0, 0, 0]
"狗" → [0, 1, 0, 0]
# 问题:任意两个词距离都相同,完全没有语义信息
Word2Vec(2013,里程碑)
核心思想:一个词的含义由它周围的词决定。
Word2Vec 本质是一个浅层神经网络。以 Skip-gram 为例:给定中心词”猫”,训练网络去预测周围的词(”可爱”“喵”“宠物”)。因为”猫”和”狗”出现在相似的上下文中,训练后它们的向量自然就接近——不是被”告知”相似,而是梯度下降自然把它们推到了相近的位置。
1
2
输入层(one-hot) → 隐藏层(embedding!) → 输出层(softmax预测周围词)
V × 1 V × d 权重矩阵 V × 1
训练完成后,隐藏层的权重矩阵 W 就是所有词的 Embedding 查找表。每行对应一个词的向量,使用时直接查表,O(1) 操作。
著名的向量算术:vec("国王") - vec("男人") + vec("女人") ≈ vec("女王")
但 Word2Vec 有局限:一个词只有一个固定向量,无法处理一词多义。”苹果很好吃”和”苹果发布新品”中的”苹果”会得到相同向量。
Transformer-based Embedding(现代主流)
Self-Attention 让每个词的向量取决于整句话的上下文:
1
2
embed("苹果很好吃") # "苹果" → 偏向水果语义的向量
embed("苹果发布新品") # "苹果" → 偏向科技公司语义的向量
流程:输入文本 → Token Embedding + Position Embedding → 多层 Transformer 编码器 → Pooling → 句子向量
相似度度量
有了向量之后,衡量相似度就是纯数学问题:
余弦相似度(最常用):衡量方向是否一致,忽略长度
1
cos(θ) = (A · B) / (‖A‖ × ‖B‖) 范围 [-1, 1]
为什么余弦最适合文本? 文本长度不同会导致向量大小不同,但语义方向是稳定的。余弦相似度忽略大小只看方向,恰好符合需求。
Embedding 模型的本质:计算函数,不是关联度表
一个常见的误解是认为 Embedding 模型存了一张”词与词的关联度表”。实际上:
| Word2Vec | Transformer Embedding | |
|---|---|---|
| 存储内容 | W 矩阵(词→向量 查找表) | 神经网络权重(多层 Transformer) |
| 使用方式 | 查表,O(1) | 前向推理,需要计算 |
| 同词不同语境 | 返回相同向量 ❌ | 返回不同向量 ✅ |
| 没见过的词 | 无法处理 ❌ | 子词拆分后可处理 ✅ |
Transformer Embedding 模型存的是网络权重(计算函数),每次使用都需要运行前向推理。使用 Embedding API 时,模型在服务端 GPU 上实时计算。
工程优化:预计算 + 缓存
1
2
离线阶段:100万篇文档 → Embedding 模型逐篇计算 → 向量存入数据库(只做一次)
在线阶段:用户提问 → Embedding 模型计算 1 个向量 → 向量数据库搜索 → top-K
这就是向量数据库存在的意义:文档向量只需算一次然后存起来。
大语言模型 ≠ Embedding 相似度搜索
另一个常见误解:大模型是不是把输入转换为 Embedding,然后找到最相近的下一个词?
不是。 Embedding 模型和 LLM 是完全不同的东西:
| Embedding 模型 | LLM 大语言模型 | |
|---|---|---|
| 目的 | 文本 → 向量 | 文本 → 文本 |
| 架构 | Encoder(编码器) | Decoder(解码器) |
| 输出 | 一个向量 | 下一个词的概率分布 |
| 能力 | 不会说话 | 能生成内容 |
LLM 不是在向量空间里”搜索相似的词”,而是经过几十层 Transformer 运算后,输出整个词表上的概率分布,然后从中采样:
1
2
3
4
5
6
7
8
输入:"中国的首都是"
→ 96 层 Transformer 计算
→ 输出概率分布:
"北京": 0.92 ← 最高概率
"上海": 0.03
"南京": 0.02
...
→ 采样得到 "北京"
Embedding 层在 LLM 内部只是输入层,占参数量极小(~1-2%),真正的”智能”来自后面的 Transformer 层。类比:Embedding 层相当于”视网膜”(信号转换),Transformer 层相当于”大脑皮层”(理解和思考)。
模型更新与向量空间兼容性
模型更新后向量会变吗?
完全不同,不可混用。 每个模型版本有不同的网络权重,同一段文本经过不同模型计算出的向量不在同一个空间:
1
2
3
# 同一句话,不同模型的输出完全不同
text_embedding_ada_002("深度学习") → [0.21, -0.45, 0.78, ...] # 1536 维
text_embedding_3_large("深度学习") → [-0.34, 0.15, 0.52, ...] # 3072 维
这意味着升级 Embedding 模型 = 必须重新编码所有文档。
生产环境的应对策略
- 锁定模型版本:不轻易升级,除非有明确的质量收益
- 版本化存储:新旧 collection 隔离,灰度切换验证
- 自托管开源模型:如 BGE、E5,版本完全由自己控制
- 元数据管理:记录每个向量的模型版本
为什么 Embedding 模型无法统一?
- 数学本质:向量空间有旋转不变性,每次训练都产生不同空间(像不同的地图坐标系)
- 设计目标不同:不同场景需要不同的优化方向(见下表)
- 技术快速迭代:统一标准会冻结进步
- 商业竞争:各厂商有动力做差异化
主流 Embedding 模型对比
不同模型针对不同场景做了专门优化,没有”万能 Embedding”:
| 模型 | 厂商 | 维度 | 最大 Token | 侧重点 | 适用场景 |
|---|---|---|---|---|---|
text-embedding-3-large | OpenAI | 3072 | 8191 | 通用语义检索 | 开放域问答、文档搜索 |
text-embedding-3-small | OpenAI | 1536 | 8191 | 性价比 | 成本敏感的通用场景 |
voyage-code-3 | Voyage AI | 1024 | 16000 | 代码理解 | 代码搜索、技术文档检索 |
voyage-law-2 | Voyage AI | 1024 | 16000 | 法律领域 | 法律条文、判例检索 |
BAAI/bge-large-zh-v1.5 | 智源 | 1024 | 512 | 中文优化 | 中文文档、中文问答 |
BAAI/bge-m3 | 智源 | 1024 | 8192 | 多语言+多粒度 | 跨语言检索(中↔英↔日等) |
intfloat/e5-mistral-7b | Microsoft | 4096 | 32768 | 长文本 | 整篇论文、长文档检索 |
jina-embeddings-v3 | Jina AI | 1024 | 8192 | 多任务 | 检索+分类+聚类一模多用 |
nomic-embed-text-v2-moe | Nomic | 768 | 8192 | 轻量高效 | 资源受限的本地部署 |
Cohere embed-v4 | Cohere | 1024 | 512 | 多模态 | 文本+图片混合检索 |
选型决策树:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
你的主要语言?
├── 中文为主 → bge-large-zh 或 bge-m3
├── 英文为主 → text-embedding-3-large 或 e5-mistral
└── 多语言混合 → bge-m3 或 jina-embeddings-v3
你的内容类型?
├── 代码/技术文档 → voyage-code-3
├── 法律文书 → voyage-law-2
├── 超长文档(>4K tokens)→ e5-mistral-7b 或 jina-v3
└── 通用文本 → text-embedding-3-large 或 bge-m3
你的部署方式?
├── 云 API(省心) → OpenAI / Cohere / Voyage
└── 本地部署(可控) → bge 系列 / e5 系列 / nomic
各模型不仅向量空间不同,连维度都不统一(768/1024/1536/3072/4096),根本无法互通。选定一个模型后,所有文档和查询都必须用同一个模型编码。
Embedding 模型需要持续改进
语言是活的,Embedding 模型面临:
- 新词新概念:”ChatGPT”“内卷”“Agentic RAG”——训练时不存在的词
- 词义漂移:”PUA”从”搭讪艺术家”变为”精神控制”;”元宇宙”从科幻术语变为科技热词再变为”概念炒作”
- 领域知识演化:新药名、新法规、新技术术语
应对方式包括子词分词(BPE)缓解新词问题、领域微调、定期升级模型版本。而 RAG 本身就是一种弥补——把”知识更新”和”模型更新”解耦,更新知识库即可覆盖大部分场景。
RAG 完整实现路径
Step 1:文档解析(Document Loading)
1
2
3
4
5
6
7
8
9
from langchain_community.document_loaders import (
PyPDFLoader, # PDF
Docx2txtLoader, # Word
UnstructuredHTMLLoader,# HTML
TextLoader, # 纯文本
)
loader = PyPDFLoader("company_report.pdf")
pages = loader.load() # 每页一个 Document,含 page_content 和 metadata
PDF 解析坑最多:扫描件需 OCR、表格需专门提取、多栏排版可能错乱。数据质量在 RAG 中的重要性远超模型选择。
Step 2:文本清洗(Text Cleaning)
1
2
3
4
5
6
7
import re
def clean_text(text: str) -> str:
text = re.sub(r'(?<!\n)\n(?!\n)', ' ', text) # 单换行 → 空格
text = re.sub(r' +', ' ', text) # 去多余空白
text = re.sub(r'\n{3,}', '\n\n', text) # 去多余空行
return text.strip()
Step 3:文本分块(Chunking)— 最关键的环节之一
为什么需要分块:
- Embedding 模型有长度限制:bge-large 最大 512 tokens,text-embedding-3 最大 8191 tokens
- 文档太长会”稀释”语义:一整篇 50 页文档压缩成一个向量,等于什么都没表达
- LLM 上下文窗口有限:检索到的 chunk 要拼入 prompt,chunk 太大则能放的条数就少
策略 1:固定大小分块
最简单粗暴的方式,按字符数或 token 数直接切分:
1
2
3
4
5
6
7
8
from langchain.text_splitter import CharacterTextSplitter
splitter = CharacterTextSplitter(
chunk_size=500, # 每块最大 500 字符
chunk_overlap=100, # 相邻块重叠 100 字符
separator="\n" # 优先在换行处切分
)
chunks = splitter.split_text(long_text)
重叠区域(overlap)的作用:
1
2
3
4
5
6
7
8
9
10
11
原文:[========A========][========B========][========C========]
chunk_size=500, overlap=100:
chunk1: [========A========]
chunk2: [=A_tail=B===============]
chunk3: [=B_tail=C===============]
重叠区保证跨块边界的信息不丢失
比如一个关键结论刚好在 A 和 B 的交界处,
没有 overlap 就会被切断,两个 chunk 各拿到一半
优点:实现简单,速度快,行为可预测。
缺点:完全不考虑语义,可能在句子中间、甚至词中间切断。一段完整的论述可能被分到两个 chunk 中,各自都不完整。
策略 2:递归字符分块(通用推荐)
按优先级尝试不同的分隔符,尽量在语义边界处切分:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
separators=[
"\n\n", # 1. 最优先:按段落分(两个换行 = 段落分隔)
"\n", # 2. 其次:按行分
"。", # 3. 再次:按中文句号分
".", # 4. 按英文句号分
"!", "?", "!", "?", # 5. 按感叹号/问号分
";", ";", # 6. 按分号分
" ", # 7. 按空格分
"" # 8. 兜底:逐字符分
]
)
工作原理:先尝试用 \n\n 分,如果分出来的块仍然超过 chunk_size,就降级用 \n 分,依此类推。这样保证了段落完整 > 句子完整 > 词完整的优先级。
优点:在简单和智能之间取得了很好的平衡。大多数情况下能在合理的语义边界处切分。
缺点:依赖分隔符启发式规则。对于没有明显段落分隔的文本(如 OCR 结果、日志)效果退化。中文文本需要自定义 separators(默认是英文优化的)。
策略 3:语义分块(基于 Embedding)
用 Embedding 模型检测话题转换点,在语义断裂处切分:
1
2
3
4
5
6
7
8
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
splitter = SemanticChunker(
embeddings=OpenAIEmbeddings(),
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=75
)
工作原理:
1
2
3
4
5
6
7
8
9
10
11
1. 按句子切分文本
2. 计算每对相邻句子的 Embedding 余弦相似度
句子1 ↔ 句子2: 0.92(谈同一话题)
句子2 ↔ 句子3: 0.89(谈同一话题)
句子3 ↔ 句子4: 0.45 ← 相似度骤降!话题转换!
句子4 ↔ 句子5: 0.87(新话题内部)
3. 在相似度骤降处切分
→ chunk1 = [句子1, 句子2, 句子3](话题 A)
→ chunk2 = [句子4, 句子5, ...] (话题 B)
断裂点检测有三种模式:
percentile:相似度低于全局 P25 分位数时切分standard_deviation:相似度低于均值减一个标准差时切分interquartile:使用四分位距检测异常低值
优点:真正基于语义切分,每个 chunk 内部话题高度一致,检索时噪声最少。
缺点:需要对每个句子调用 Embedding 模型,分块速度慢得多(尤其是大文档)。chunk 大小不固定,可能出现极小或极大的 chunk。依赖 Embedding 模型质量。
策略 4:按文档结构分块
利用文档自身的标题、章节等结构信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from langchain.text_splitter import MarkdownHeaderTextSplitter
splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
)
chunks = splitter.split_text(markdown_text)
# 每个 chunk 自动继承父标题作为 metadata:
# chunk.content = "具体内容..."
# chunk.metadata = {"h1": "用户手册", "h2": "安装指南", "h3": "Windows"}
对 HTML 文档也有类似的处理:按 <h1>、<h2>、<section> 等标签切分。
优点:充分利用作者的原始组织意图,每个 chunk 天然是一个完整的小节。metadata 中的标题层级可以用于过滤和检索增强(如搜索时加上”出自安装指南章节”的约束)。
缺点:依赖文档格式规范。不规范的文档(没有标题、标题层级混乱)则无法使用。
策略 5:父子 chunk(Parent-Child / Small-to-Big)
一种进阶策略:用小 chunk 检索,但返回大 chunk 给 LLM:
1
2
3
4
5
6
原始文档
↓ 先切成大块(Parent chunk, 2000字)
↓ 每个大块再切成小块(Child chunk, 200字)
检索时:用 child chunk 的向量做搜索(小块语义集中,检索准)
返回时:把命中的 child 对应的 parent chunk 返回给 LLM(大块上下文完整)
优点:兼顾检索精度(小 chunk)和上下文完整性(大 chunk)。
缺点:实现复杂度高,需要维护 parent-child 映射关系。
各策略全面对比
| 策略 | 适用场景 | 分块质量 | 速度 | 实现复杂度 | 推荐度 |
|---|---|---|---|---|---|
| 固定大小 | 快速原型、日志类文本 | 低 | 极快 | 最低 | ⭐⭐ |
| 递归字符 | 大多数通用场景 | 中高 | 快 | 低 | ⭐⭐⭐⭐⭐ |
| 语义分块 | 高质量检索需求 | 高 | 慢 | 中 | ⭐⭐⭐⭐ |
| 结构分块 | Markdown/HTML/PDF有目录 | 高 | 快 | 中 | ⭐⭐⭐⭐ |
| 父子 chunk | 对上下文完整性要求高 | 最高 | 中 | 高 | ⭐⭐⭐⭐ |
关键参数选择
chunk_size:300-800 字符是常见范围- < 200:chunk 太碎,缺乏上下文,LLM 看到后可能无法理解
- 300-500:适合短问答、FAQ 类场景
- 500-800:适合段落级的文档检索(推荐起步值)
- > 1000:适合长文档摘要场景,但检索噪声增大
chunk_overlap:chunk_size 的 10%-20%- 保证跨块边界的信息不丢失
- 过大会导致大量重复内容,浪费存储和检索资源
Step 4:批量向量化(Batch Embedding)
API 方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from openai import OpenAI
client = OpenAI()
def batch_embed(texts, model="text-embedding-3-small", batch_size=100):
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
response = client.embeddings.create(input=batch, model=model)
batch_embeddings = [
item.embedding
for item in sorted(response.data, key=lambda x: x.index)
]
all_embeddings.extend(batch_embeddings)
return all_embeddings
生产级需处理:并发控制、速率限制(429 重试)、指数退避、断点续传。
本地模型方式
1
2
3
4
5
6
7
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
embeddings = model.encode(
texts, batch_size=64, show_progress_bar=True,
normalize_embeddings=True, device="cuda"
)
Step 5:存入向量数据库
主流选择:
| 数据库 | 特点 |
|---|---|
| Chroma | 轻量级,适合原型和小规模 |
| FAISS | Meta 开源,高性能向量检索库 |
| Milvus | 开源,支持私有部署,适合大规模 |
| Pinecone | 全托管云服务,开箱即用 |
| pgvector | PostgreSQL 插件,适合已有 PG 的团队 |
1
2
3
4
5
6
7
8
9
10
11
12
13
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
name="company_docs",
metadata={"hnsw:space": "cosine"}
)
collection.add(
ids=[f"doc_{i}" for i in range(len(chunks))],
embeddings=embeddings,
documents=[c.page_content for c in chunks],
metadatas=[{"source": c.metadata["source"], "page": c.metadata.get("page", 0)} for c in chunks]
)
FAISS 的 IVF 索引原理:先用 K-Means 把所有向量聚成 N 个簇,查询时只在最近的几个簇内搜索,速度提升 10-100 倍。PQ(Product Quantization)进一步压缩向量,支持亿级检索。
Step 6:在线检索
1
2
3
4
5
6
7
8
9
10
11
# 混合检索 + RRF 融合
class HybridRetriever:
def retrieve(self, query, top_k=5):
# BM25 关键词检索 → 候选集 A
bm25_results = self.bm25.get_top_n(query, n=20)
# 向量语义检索 → 候选集 B
vec_results = self.collection.query(query_embeddings=[embed(query)], n_results=20)
# RRF 融合(只用排名,不依赖分数)
fused = self.rrf_merge(bm25_results, vec_results)
# Cross-Encoder 重排序
return self.rerank(query, fused[:20])[:top_k]
RRF(Reciprocal Rank Fusion):score(d) = Σ 1/(k + rank),在两个列表中都排在前面的文档得分最高。
Step 7:Prompt 构造 + LLM 生成
1
2
3
4
5
6
7
8
9
prompt = f"""基于以下参考资料回答用户问题。
规则:只基于资料回答,不编造;标注来源;资料不足时明确说明。
参考资料:
{context}
用户问题:{query}"""
response = llm.chat(messages=[{"role": "user", "content": prompt}], temperature=0.1)
Step 8:评估
1
2
3
检索质量:Recall@K、MRR、NDCG
生成质量:Faithfulness(忠实度)、Relevance(相关性)
端到端: RAGAS 框架自动评估
总结:影响 RAG 效果的因素排名
1
2
数据质量(清洗+解析) > Chunking 策略 > Embedding 模型选择
> 检索策略(混合+重排) > Prompt 设计 > LLM 选择
很多人把精力放在最后一步选 LLM,其实前面几步的 ROI 更高。一个最小可用的 RAG 系统可以在 50 行 Python 内搭建完成——先跑通再优化,不要一开始就追求完美架构。