RAG 检索增强生成:Embedding 模型选型、向量数据库对比、Chunk 策略与检索重排序
提出问题
RAG(Retrieval-Augmented Generation)是 2025-2026 年落地最广泛的大模型应用架构。原因很直接:LLM 的知识截止于训练数据,覆盖不了企业内部文档、实时数据或私有知识库。RAG 通过"检索 + 生成"的流水线,让 LLM 在回答前先检索相关文档,把检索结果作为上下文输入,弥补模型的知识短板。
但搭建一个可用的 RAG 系统不难,搭建一个高精度的 RAG 系统很难。面试官会从三个层面追问:Embedding 模型怎么选(维度、语言覆盖、长度限制)、向量数据库怎么对比(功能、性能、成本)、Chunk 策略和重排序怎么做(精度、延迟、工程成本)。这三个选择题环环相扣,选错了组合,上线后召回率低得没法用。
RAG 完整流水线
RAG 系统在单次查询中经历以下步骤:
用户输入 query
│
▼
┌─────────────────────────────────────┐
│ 1. Query 预处理 │
│ - 改写/扩展(Query Rewrite) │
│ - 去除停用词、标点处理 │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 2. 向量检索(Bi-Encoder) │
│ - Embedding 模型将 query 转向量 │
│ - 向量数据库 ANN 搜索 Top-100 │
│ - 耗时 ≈ 5-20ms │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 3. 关键词检索(BM25) │
│ - 倒排索引匹配精确关键词 │
│ - 捕获专有名词/代码变量名 │
│ - 耗时 ≈ 1-5ms │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 4. 融合排序(Hybrid Fusion) │
│ - 向量分 + BM25 分加权融合 │
│ - 去重后取 Top-50 │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 5. 重排序(Cross-Encoder Reranker) │
│ - 对 (query, doc) 逐对打分 │
│ - 取 Top-5 ~ Top-10 │
│ - 耗时 ≈ 50-200ms (瓶颈) │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 6. 组装 Prompt + LLM 生成 │
│ - 拼接检索结果 + 系统指令 │
│ - LLM 生成最终回答 │
│ - 耗时取决于模型(小模型 1-2s) │
└─────────────────────────────────────┘整条链路端到端延迟通常在 200ms-2s 之间,重排序是最大瓶颈——50 个候选对每对都要过 Cross-Encoder。如果对延迟敏感,可以跳过重排序,直接用融合后的 Top-5 送 LLM,召回率约降 5-10%,但延迟能降到 50ms 以内。
分析问题
Embedding 模型选型:选对模型比调参重要一百倍
Embedding 模型将文本映射到向量空间,语义相似的文本向量距离近。2025-2026 年主流模型对比如下:
| 模型 | 维度 | 最大输入长度 | 语言覆盖 | 适用场景 | MTEB (中文) | 推理成本 |
|---|---|---|---|---|---|---|
| BGE-large-zh-v1.5 | 1024 | 512 tokens | 中文优先 | 中文企业文档 | 64.2 | 低(开源) |
| OpenAI text-embedding-3-large | 3072 | 8191 tokens | 多语言 | 多语言通用 | 62.8 | $0.13/1M tokens |
| Cohere embed-v3 | 1024 | 512 tokens | 英文为主 | 英文场景 | 56.3 | $0.1/1M tokens |
| m3e-base | 768 | 512 tokens | 中文 | 国产轻量级 | 61.5 | 低(开源) |
| stella-base-zh-v3-1792d | 1792 | 1792 tokens | 中文 | 长文档中文 | 65.1 | 低(开源) |
选型考虑三个核心维度:
维度:1024 维是 2025 年的黄金标准。OpenAI 的 3072 维检索精度高但存储成本高——以 100 万条 1024 维向量为例,float32 存储约 4GB,3072 维则 12GB。如果使用 HNSW 索引,内存消耗还要再翻 2-3 倍。小规模语料(<10 万条)可以上 3072 维,但业内 80% 以上的场景用 1024 维就够了。
语言覆盖:中文场景推荐 BGE 或 stella。注意一个坑:BGE-large-zh-v1.5 虽然 MTEB 中文分数高,但它在英文代码片段上的表现很差——如果你需要检索混合了中英文代码的文档,建议用 m3e 或 stella。多语言场景直接上 OpenAI 或 Cohere。
最大输入长度:512 tokens 是常见限制。处理长文档时必须提前做 Chunk——如果文档长度超过模型限制,Embedding 会截断尾部,尾部信息直接丢失。我遇到过的一个案例:某公司把 2000 字的合同全文直接 Embedding,结果最后一段(违约责任条款)永远搜不到。解决方案:要么用 stella(1792 tokens)这种长文本模型,要么做 Chunk 策略。
生产经验:不要只看 MTEB 排行榜。在领域数据上做 A/B 测试,用你自己的 1000 条 query + 人工标注的 ground truth 文档,测 Recall@K 和 MRR。排行榜上的模型在通用数据上表现好,但可能在你领域数据上不如一个小众模型。我见过一个金融场景,BGE 在通用测试集上 Recall@10 = 0.92,但在实际的基金合同检索上 Recall@10 只有 0.73,换成领域微调的模型直接升到 0.88。
向量数据库对比:选型要看"能用"还是"好用"
| 方案 | 部署模式 | 索引类型 | 支持 GPU 加速 | 标量过滤 | 混合检索 | 适用规模 | 运维成本 |
|---|---|---|---|---|---|---|---|
| Milvus 2.5 | 分布式 | IVF、HNSW、DiskANN | ✅ GPU_IVF_FLAT、GPU_CAGRA | ✅ | ✅ | 亿级 | 高(6+ 组件) |
| Pinecone | 全托管 SaaS | 自动优化 | ✅ | ✅ | ✅ | 千万级 | 无($500-1000/月) |
| Weaviate | 单机/集群 | HNSW | ❌ | ✅ | ✅ | 百万级 | 中 |
| Qdrant | 单机/集群 | HNSW | ❌ | ✅ | ✅ | 千万级 | 低(Rust 单进程) |
| Chroma | 嵌入式 | HNSW | ❌ | ✅ | ❌ | 十万级 | 低(pip install) |
Milvus 功能最全,支持 GPU 加速索引(GPU_IVF_FLAT、GPU_CAGRA),在 v2.5 版本后支持 DiskANN,可以在亿级数据上用 SSD 显存混合索引。适合大厂生产环境,但运维成本高——需要部署 Coordinator、Proxy、DataNode、QueryNode 四个组件,还要配置 MinIO 和 Pulsar。我见过一个团队 G1 的 Coordinator 挂了,整个集群查询全部超时,排了 3 小时才定位到是 etcd 内存不足。
Pinecone 全托管,AWS Marketplace 一键部署,15 分钟跑通。适合小团队快速验证,但成本高——百万级向量月费约 $500-1000(按 pod 计费,一个 pod 约 $0.07/h)。数据量大时性价比不如自建 Milvus。另外注意:Pinecone 的索引有写入 QPS 限制,大量写入时容易触发 429 限流。
Qdrant 用 Rust 实现,单机性能极好。我在 4C8G 的机器上压过:100 万条 768 维向量,HNSW 索引,查询延迟 p99 = 8ms,写入吞吐 5000 docs/s。原生支持 Filter(标量过滤)和 Payload 存储,不需要额外挂数据库维护元数据。
选型准则:亿级 + 有运维团队 → Milvus;百万级 + 快速验证 → Qdrant(本地)或 Pinecone(SaaS);千万级 + 中文场景 + 预算有限 → Qdrant 本地部署,一台 16C32G 的机器扛 1000 万向量没问题。
Chunk 策略和检索重排序:精度提升的关键
Chunk 策略直接影响召回率。错误的切割方式会导致语义断裂,相关信息被切到不同 chunk 里,检索时谁也找不到谁。
三种主流方案实现对比:
from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter
from sentence_transformers import SentenceTransformer
# 方案一:固定大小切割(简单,但容易切碎语义)
fixed_splitter = TokenTextSplitter(chunk_size=256, chunk_overlap=50)
chunks = fixed_splitter.split_text(long_document)
# 问题:如果 256 token 正好切在一个句子的中间,后半句的语义就丢了
# 方案二:递归字符拆分(推荐,兼顾语义和性能)
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=100,
separators=["\n\n", "\n", "。", ";", ",", " ", ""]
# 先按段落切,再按句子切,最后按词切
# 保证每个 chunk 最大程度保留完整语义
)
chunks = recursive_splitter.split_text(long_document)
# 方案三:语义分割(精度最高,但计算量大,适合离线预处理)
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
def semantic_chunk(text, threshold=0.6):
"""按句子计算 Embedding,相似度低于阈值时切分"""
sentences = text.split("。") # 先按句号分割
embeddings = model.encode(sentences)
chunks = []
current_chunk = sentences[0]
for i in range(1, len(sentences)):
sim = cosine_similarity(embeddings[i-1:i], embeddings[i:i+1])[0][0]
if sim < threshold:
# 语义跳变,在这里切分
chunks.append(current_chunk)
current_chunk = sentences[i]
else:
current_chunk += "。" + sentences[i]
if current_chunk:
chunks.append(current_chunk)
return chunks实践经验:chunk_size 256-512 是 RAG 系统的黄金区间。chunk 太小(<100)导致上下文碎片化,LLM 无法理解完整语义——比如一个技术方案分成了 5 个 chunk,LLM 拿到第 3 个 chunk 时不知道前因后果。chunk 太大(>1024)浪费 token 预算,且过多的无关信息会稀释相关信息的权重——我压过一个案例,chunk_size=2048 时,LLM 经常忽略关键信息,输出"根据文档,这个信息不明确"。chunk_overlap 取 10-20%,保证边界信息不丢失。
检索重排序:为什么必须分两阶段?因为 Cross-Encoder 的精度高但速度慢,不能直接用在百万级检索上。
from sentence_transformers import CrossEncoder
import time
# 第一阶段:Bi-Encoder 粗筛(这是向量数据库做的事)
# 耗时:5-20ms,从百万级召回 Top-100
# 第二阶段:Cross-Encoder 精排
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
query = "RAG 中 Hybrid Search 的权重怎么调优?"
candidates = [
"RAG 的核心是检索加生成的流水线,通过外挂知识库弥补 LLM 的知识短板",
"Hybrid Search 结合向量检索和 BM25 关键词检索,权重调优取决于场景",
"Cross-Encoder 模型对每一对 query 和候选文档打分,精度比 Bi-Encoder 高 10-20%",
"在专有名词多的场景,BM25 权重可以提到 0.5,向量检索降到 0.5",
"向量检索对语义相似文档效果好,但高频词和同义词区分力不足"
]
# 重排序
pairs = [(query, doc) for doc in candidates]
start = time.time()
scores = reranker.predict(pairs)
elapsed = time.time() - start
# 按分数排序,取 Top-3
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:3]
print(f"重排序耗时: {elapsed*1000:.1f}ms")
print("Top-3 重排序结果:")
for i, idx in enumerate(top_indices):
print(f" {i+1}. 分数 {scores[idx]:.3f}: {candidates[idx][:40]}...")重排序的坑:Cross-Encoder 一次推理几十毫秒,如果要从 Top-100 精排到 Top-10,就要做 100 次推理,总计 2-5 秒。这在实时场景是不可接受的。解决方案:
- 只对 Top-20 做重排序,而不是 Top-100。实测从 Top-100 缩到 Top-20,Recall@10 只降 1-2%,但延迟从 5s 降到 1s
- 用更小的 Cross-Encoder 模型(如 bge-reranker-v2-m3 比 m1 快 3 倍)
- 对延迟极度敏感的场景,直接跳过重排序,用 Bi-Encoder 的 Top-5 送 LLM
Hybrid Search:向量检索 + BM25 关键词检索
单靠向量检索不够:Embedding 模型对高频词和同义词的区分力不足。比如搜索"苹果",向量检索会把"苹果公司供应链"和"苹果种植技术"都拉回来,因为它们语义上都和"苹果"相关。但用户可能只想要其中一个。Hybrid Search 的做法是同时跑向量检索和 BM25 关键词检索,加权融合得分。
真实的工程实现需要考虑归一化问题:
import numpy as np
from sklearn.preprocessing import MinMaxScaler
def hybrid_search(query,
vector_search_fn,
bm25_search_fn,
vector_weight=0.7,
keyword_weight=0.3,
top_k=100,
final_top_k=10):
"""
完整的 Hybrid Search 实现
Args:
vector_weight: 向量检索权重,默认 0.7
keyword_weight: BM25 权重,默认 0.3
top_k: 各检索通道召回数量
final_top_k: 最终返回数量
"""
# 1. 向量检索
vector_results = vector_search_fn(query, top_k=top_k)
# 格式: [(doc_id, score), ...]
# 2. BM25 关键词检索
keyword_results = bm25_search_fn(query, top_k=top_k)
# 3. 归一化:MinMax 归一化到 [0, 1]
all_docs = set()
doc_scores = {}
for doc_id, score in vector_results:
all_docs.add(doc_id)
doc_scores.setdefault(doc_id, {})['vector'] = score
for doc_id, score in keyword_results:
all_docs.add(doc_id)
doc_scores.setdefault(doc_id, {})['keyword'] = score
# 提取分数做归一化
vec_scores = np.array([v.get('vector', 0) for v in doc_scores.values()])
kw_scores = np.array([v.get('keyword', 0) for v in doc_scores.values()])
# 处理空值
vec_scores = np.nan_to_num(vec_scores, nan=0)
kw_scores = np.nan_to_num(kw_scores, nan=0)
# MinMax 归一化
vec_norm = (vec_scores - vec_scores.min()) / (vec_scores.max() - vec_scores.min() + 1e-8)
kw_norm = (kw_scores - kw_scores.min()) / (kw_scores.max() - kw_scores.min() + 1e-8)
# 4. 加权融合
hybrid = {}
for i, doc_id in enumerate(doc_scores.keys()):
hybrid[doc_id] = vector_weight * vec_norm[i] + keyword_weight * kw_norm[i]
# 5. 排序返回
return sorted(hybrid.items(), key=lambda x: x[1], reverse=True)[:final_top_k]权重调优:向量检索权重 0.6-0.8,BM25 权重 0.2-0.4 是通用推荐。但要根据场景调:
- 专有名词(产品名、代码变量名、人名)多的场景→ BM25 权重提到 0.5,因为 BM25 的精确匹配优于向量检索的语义近似
- 通用语义搜索场景 → 向量检索权重 0.8,BM25 0.2
- 代码搜索场景 → 建议 BM25 权重 0.6,因为代码中的变量名、函数名都是精确匹配
我踩过的坑:过早优化归一化。最开始我用的是 Z-score 归一化,但当一批得分全部集中在 0.8-0.9 时,Z-score 会把它们拉到 -1 到 1 之间,导致排序完全错乱。改用 MinMax 归一化后问题解决。另外,注意向量检索返回的分数不是余弦距离,而是一些数据库自定义的"距离"——Milvus 返回的是 L2 距离,Pinecone 返回的是余弦相似度,归一化时一定要搞清楚你的分数是什么含义。
总结:从 8 年 Java 后端视角看 RAG
对从 Java 后端转过来的你来说,RAG 系统本质上是一个倒排索引的升级版。传统的 ES 搜索是关键词匹配 + TF-IDF 打分,RAG 是语义匹配 + 向量检索。核心思路不变:先召回再排序,只不过召回方式从倒排索引变成了 ANN 搜索。
面试话术示例:
"RAG 的精度优化是一个系统工程。Embedding 选型上,我推荐 BGE 系列,用 1024 维向量,在领域数据上做 A/B 测试验证。向量数据库选 Qdrant 或 Milvus,看数据规模和运维能力。Chunk 策略用 RecursiveCharacterTextSplitter,512 长度 + 100 overlap。检索重排序用两阶段:Bi-Encoder 粗筛 + Cross-Encoder 精排。最后加 Hybrid Search 兜底,融合 BM25 关键词检索,召回率至少提升 10%。如果延迟敏感,我会跳过重排序,把 Hybrid 的 Top-5 直接送 LLM,牺牲 5% 召回率换 10 倍延迟。"
关键点清单:
- Embedding 选型:1024 维 + 语言覆盖 + 最大输入长度;不要只看 MTEB,做领域数据 A/B 测试
- 向量数据库:看规模、运维能力、混合检索支持;Qdrant 单机性价比高,Milvus 适合亿级
- Chunk 策略:256-512 tokens,10-20% overlap,递归拆分优先;注意语义切割不要断开关键信息
- 重排序:两阶段(Bi-Encoder → Cross-Encoder),精度提升 10-20%;注意延迟预算,可跳过的场景跳过
- Hybrid Search:向量 + BM25 加权融合;专有名词场景提 BM25 权重;归一化方法选 MinMax 而非 Z-score
- 完整链路:Query 预处理 → 向量检索 → BM25 检索 → Hybrid 融合 → 重排序 → LLM 生成,端到端 200ms-2s
参考:LangChain RAG 文档(text_splitter 部分)、BGE 和 m3e 模型 HuggingFace 页面、Milvus v2.5 官方文档(GPU 索引部分)、Qdrant 官方文档(HNSW 配置)、Sentence-Transformers 重排序文档