引言
RAG(Retrieval-Augmented Generation)看起来是应用层的事,但真正决定效果的往往是它下面那条数据管道:文档怎么切、嵌入怎么算、索引怎么建、检索怎么调。模型换一代提升有限,而分块与索引一旦建错,再强的 LLM 也救不回来。
RAG 的上限由检索决定,检索的上限由数据管道决定;向量数据库只是这条管道末端的存储与检索层。
本文不讨论提示词技巧,而是把 RAG 当作一条数据工程链路来拆:从嵌入生成、索引原理、数据库选型,到增量写入、混合检索、评估监控与生产踩坑。
一、RAG 数据管道全景
1.1 离线索引管道
离线管道的职责是把原始文档变成"可检索的向量 + 元数据":
# 离线索引管道
# 源文档(HTML/PDF/Markdown) → 解析清洗 → 分块 → 嵌入
# → 向量 + 元数据(来源/时间/权限/标签) → 写入向量库
# 关键: 分块策略与嵌入模型必须版本化, 可重放
这条管道是典型的批处理任务,应当被编排调度、可重跑、有血缘。一旦分块规则或嵌入模型变更,全量重建不可避免。
1.2 在线检索链路
在线链路通常是三步:召回 → 融合 → 重排。
| 阶段 | 目标 | 典型手段 | 延迟预算 |
|---|---|---|---|
| 召回 | 高召回率、低延迟 | 向量 ANN、BM25、过滤 | 10-50ms |
| 融合 | 合并多路结果 | RRF、加权分数 | 5-20ms |
| 重排 | 提升精度 | Cross-Encoder、LLM | 50-300ms |
召回阶段追求"不漏",重排阶段追求"排得准",两者的优化方向恰好相反,必须分阶段设计。
1.3 数据契约与元数据
向量库里的每一条记录都应携带元数据:来源文档 ID、分块序号、更新时间、权限标签、语言。没有元数据的向量是"不可运维"的——你无法做权限过滤、无法做增量更新、无法追溯答案出处。
{
"id": "doc_1024#chunk_7",
"vector": [0.013, -0.221, 0.087],
"text": "Iceberg 通过快照实现时间旅行...",
"metadata": {
"doc_id": "doc_1024",
"chunk_index": 7,
"source": "s3://kb/lakehouse.md",
"updated_at": "2026-09-28T10:00:00Z",
"acl": ["team-data"],
"embed_model": "bge-large-zh-v1.5"
}
}
二、嵌入生成
2.1 模型选型
嵌入模型决定向量空间的质量。选型时同时看三个维度:中文效果、维度成本、推理吞吐。
| 模型 | 维度 | 语言 | 特点 |
|---|---|---|---|
| bge-large-zh-v1.5 | 1024 | 中文 | 中文检索强,社区常用 |
| bge-m3 | 1024 | 多语言 | 支持稠密+稀疏+多向量 |
| text-embedding-3-large | 3072 | 多语言 | 可降维,成本较高 |
| gte-large | 1024 | 中英 | 长文本友好 |
| jina-embeddings-v3 | 1024 | 多语言 | 支持任务前缀 |
经验法则:先用榜单上靠前的中文模型跑通基线,再用业务数据做小规模评测,不要迷信通用榜单。
2.2 分块策略
分块是 RAG 中最容易被低估的一步。块太大,噪声多、召回不精准;块太小,语义不完整、上下文缺失。
# 分块策略组合
# 1. 固定长度: 简单, 但切断语义
# 2. 递归分割: 按 段落/句号/换行 优先级切, 保语义
# 3. 结构感知: 按 Markdown 标题/代码块切, 保留层级
# 4. 语义分块: 相邻句向量相似度骤降处切
# 5. 父子分块: 子块检索, 父块喂给 LLM
实践中推荐 结构感知 + 重叠:按标题切大块,块间保留 10%-15% 重叠,避免答案正好落在切缝上。
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 目标块大小(字符)
chunk_overlap=64, # 重叠, 防止语义断裂
separators=["\n## ", "\n### ", "\n\n", "\n", "。", " "],
length_function=len,
)
chunks = splitter.split_text(markdown_doc)
2.3 批处理与吞吐
嵌入推理是 GPU 密集任务,吞吐取决于批大小与序列长度。生产管道要处理百万级分块,必须做批处理与并发控制。
import asyncio
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url="http://embed-svc:8000/v1", api_key="x")
async def embed_batch(texts, sem):
async with sem:
r = await client.embeddings.create(model="bge-m3", input=texts)
return [d.embedding for d in r.data]
async def embed_all(chunks, batch=64, concurrency=8):
sem = asyncio.Semaphore(concurrency)
tasks = [embed_batch(chunks[i:i+batch], sem)
for i in range(0, len(chunks), batch)]
out = await asyncio.gather(*tasks)
return [v for part in out for v in part]
2.4 维度与归一化
- 归一化:若用余弦相似度,务必在写入前 L2 归一化,把余弦退化为点积,索引更快。
- 降维:高维模型(如 3072)可做 PCA/Matryoshka 截断到 1024,存储与检索成本显著下降,召回损失通常可接受。
- 一致性:查询向量与文档向量必须用同一模型、同一归一化方式,否则相似度无意义。
三、向量索引原理
3.1 暴力检索与它的边界
精确检索(Flat)逐个计算距离,召回率 100%。在百万级以下、延迟不敏感的场景,Flat 往往是最省心的选择。
# Flat 检索复杂度: O(N * D)
# 100 万条 * 1024 维 → 单次查询约 10 亿次浮点运算
# GPU 上可做到毫秒级, CPU 上通常百毫秒级
3.2 IVF:倒排文件索引
IVF 先用 k-means 把向量聚成 nlist 个簇,检索时只扫最近的 nprobe 个簇,把复杂度降到 O(N/nlist * nprobe)。
# FAISS IVF 索引构建
import faiss
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFFlat(quantizer, d, nlist=4096, faiss.METRIC_INNER_PRODUCT)
index.train(vectors) # 必须先训练聚类中心
index.add(vectors)
index.nprobe = 16 # 查询时扫描的簇数, 越大越准越慢
| 参数 | 增大时 | 权衡 |
|---|---|---|
| nlist | 簇更细,训练更久 | 通常取 sqrt(N) |
| nprobe | 召回↑,延迟↑ | 从 1% 的 nlist 起步调 |
3.3 HNSW:分层可导航小世界
HNSW 用多层图结构做贪心搜索,是目前综合性能最好的索引:查询快、召回高,代价是内存占用大。
# HNSW 关键参数
# M 每节点邻居数, 越大越准越占内存(常用 16-64)
# ef_construction 建图时的候选队列, 越大图质量越好(常用 200-500)
# ef_search 查询时的候选队列, 越大召回越高(常用 64-256)
index = faiss.IndexHNSWFlat(d, M=32)
index.hnsw.efConstruction = 256
index.add(vectors)
index.hnsw.efSearch = 128 # 运行时按召回率需求调
3.4 量化压缩:PQ 与 SQ
量化用精度换内存。**SQ(标量量化)**把 float32 压到 int8,内存降 4 倍;**PQ(乘积量化)**把向量切段分别聚类编码,压缩比更高但精度损失更大。
| 方案 | 压缩比 | 召回损失 | 适用 |
|---|---|---|---|
| Flat | 1x | 0 | 小规模、要精确 |
| SQ8 | 4x | 小 | 内存敏感 |
| PQ | 8-64x | 中 | 亿级向量 |
| IVF+PQ | 高 | 中高 | 超大规模 |
3.5 召回率与延迟的取舍
没有任何 ANN 索引能同时做到"召回 100% 且毫秒级"。工程上的做法是先定召回率目标(如 recall@10 ≥ 0.95),再在该约束下压延迟,通过调 nprobe/ef_search 找到工作点,并把这个工作点写进监控。
四、向量数据库选型
选型不是比谁快,而是比谁能嵌进你现有的数据栈。
| 产品 | 类型 | 优势 | 劣势 | 适用 |
|---|---|---|---|---|
| Milvus | 专用分布式 | 规模大、功能全 | 组件多、运维重 | 亿级、独立向量平台 |
| Qdrant | 专用 | 过滤强、Rust 高效 | 生态较新 | 中大规模、重过滤 |
| pgvector | PG 扩展 | 与业务库同源、事务一致 | 规模受限 | 千万级、已有 PG |
| Weaviate | 专用 | 混合检索内置 | 资源占用高 | 快速搭建 |
| Elasticsearch | 搜索引擎 | 全文+向量一体 | 向量性能非最优 | 已有 ES 栈 |
| Doris/StarRocks | OLAP | SQL 一体、可 JOIN | 向量功能较新 | 分析+向量混合 |
选型决策树:
# 1. 已有 PostgreSQL 且量级 < 千万 → pgvector
# 2. 需要强过滤(权限/租户) → Qdrant / Milvus partition
# 3. 需要与全文检索融合 → ES / Qdrant 混合
# 4. 亿级以上、独立平台 → Milvus 集群
# 5. 想 SQL 化分析 → Doris/StarRocks 向量索引
五、写入与增量更新
5.1 全量重建 vs 增量写入
- 全量重建:新索引替换旧索引,简单可靠,但成本高、需双写切换。
- 增量写入:只写新增/变更分块,成本低,但要处理删除与更新。
# 增量更新: 以 doc_id 为单位做删除+重写
def upsert_document(collection, doc_id, new_chunks, vectors):
collection.delete(expr=f'doc_id == "{doc_id}"') # 先删旧块
collection.insert([
{"id": f"{doc_id}#{i}", "vector": v, "text": t,
"metadata": {"doc_id": doc_id, "chunk_index": i}}
for i, (t, v) in enumerate(zip(new_chunks, vectors))
])
5.2 幂等与版本
写入必须幂等:用确定性 ID(doc_id#chunk_index)而非随机 UUID,重跑不会产生重复。嵌入模型版本要写进元数据,模型升级时能精准定位需要重建的记录。
5.3 一致性窗口
向量库与源文档之间存在一致性窗口。常见做法是引入"索引版本号":查询时只检索当前已发布版本,重建期间旧版本继续服务,切换是原子的。这与湖仓的快照切换思路一致。
六、检索服务:混合检索与重排序
6.1 为什么需要混合检索
纯向量检索擅长语义匹配,但对精确关键词、编号、专有名词不敏感;BM25 恰好相反。生产系统几乎都用混合检索。
# 混合检索: 向量召回 + BM25 召回, RRF 融合
def hybrid_search(query, top_k=20, k=60):
vec_hits = vector_store.search(embed(query), limit=top_k)
bm25_hits = bm25_index.search(query, limit=top_k)
scores = {}
for rank, h in enumerate(vec_hits):
scores[h.id] = scores.get(h.id, 0) + 1 / (k + rank + 1)
for rank, h in enumerate(bm25_hits):
scores[h.id] = scores.get(h.id, 0) + 1 / (k + rank + 1)
return sorted(scores, key=scores.get, reverse=True)
RRF(Reciprocal Rank Fusion)只依赖排名不依赖分数,避免了不同检索器分数量纲不一致的问题,是最稳妥的融合方式。
6.2 元数据过滤
权限与租户过滤必须在召回阶段完成,而不是召回后再筛——否则可能召回一堆无权限内容,挤掉了有权限的结果。
# 过滤策略对比
# 后过滤: 先召回再筛 → 有效结果可能不足 top_k
# 预过滤: 过滤条件下推索引 → 结果稳定, 但要求库支持
# 推荐: Qdrant/Milvus 的 filter 参数走预过滤
6.3 重排序
重排用 Cross-Encoder 对"查询-文档对"联合编码,精度显著高于向量点积,但无法预先建索引,只能对召回结果做小批量打分。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-large")
pairs = [(query, doc.text) for doc in candidates]
scores = reranker.predict(pairs)
top = [c for _, c in sorted(zip(scores, candidates), reverse=True)][:5]
重排通常把 top-50 收敛到 top-5,是"最后一公里"精度的关键。
七、评估与可观测性
7.1 离线评估指标
| 指标 | 含义 | 目标 |
|---|---|---|
| Recall@k | 前 k 个结果含正确答案的比例 | 召回层核心 |
| MRR | 首个正确结果的排名倒数均值 | 排序质量 |
| NDCG@k | 考虑位置的排序质量 | 重排层核心 |
| Hit Rate | 至少命中一次的比例 | 粗粒度 |
离线评估需要标注问答对,可以从线上日志里抽样人工标注,滚动更新测试集。
7.2 在线可观测
# [ ] 检索延迟 P50/P95/P99, 分召回/重排两段
# [ ] 召回数量分布: 突然变少可能是索引退化
# [ ] 空结果率: 高说明分块或过滤过严
# [ ] 嵌入服务 QPS 与排队时长
# [ ] 索引大小与内存占用, 是否触发落盘
把检索质量当 SLO 来管:召回率、空结果率、重排命中率都要有告警阈值。
八、生产踩坑清单
8.1 一致性与版本类
- 查询与文档用了不同模型:相似度完全失真,且很难从结果上看出,务必在元数据里锁定模型版本。
- 未归一化就写入:改用余弦或内积后结果全变,写入前统一处理。
- 重建期间双版本并存:查询命中了半新半旧的索引,答案前后矛盾,需版本隔离。
8.2 分块与召回类
- 固定长度硬切:表格、代码块被拦腰截断,语义丢失,优先结构感知分块。
- 重叠过大:存储翻倍、检索重复,10%-15% 足够。
- 块过大:单块塞入过多主题,向量被"平均"掉,召回不准。
8.3 性能与成本类
- ef_search 默认值过高:延迟暴涨,应按召回目标调优而非拉满。
- 过滤条件未下推:全量召回再筛,延迟与精度双输。
- 忽略内存:HNSW 图结构常驻内存,规模上去后必须规划内存或改用磁盘索引。
总结
| 环节 | 关键决策 | 常见坑 |
|---|---|---|
| 分块 | 结构感知 + 重叠 | 硬切切断语义 |
| 嵌入 | 中文模型 + 归一化 | 查询/文档模型不一致 |
| 索引 | HNSW 为主、按规模选 | 参数默认值不当 |
| 选型 | 贴合现有栈 | 盲目追新、运维超载 |
| 写入 | 幂等 + 版本化 | 重复写入、更新残留 |
| 检索 | 混合 + 重排 | 过滤未下推 |
| 评估 | 召回率 + 延迟双指标 | 只看效果不看延迟 |
向量检索不是"接个库就完事",它是一条需要持续运营的数据管道:分块策略要版本化、嵌入模型要可追溯、索引参数要按指标调优、写入要幂等。把 RAG 当作数据工程问题来对待,效果与稳定性都会好得多。
参考与延伸阅读
- Milvus 官方文档:索引类型(HNSW/IVF/PQ)与一致性级别
- Qdrant 官方文档:过滤与混合检索实践
- FAISS Wiki:索引选型与量化压缩指南
- 数据查询引擎与向量化执行 — 向量化执行原理
- 特征存储 — 在线特征与向量服务的关系
- 数据可观测性 — 管道质量监控方法
- 数据管道编排 — 索引重建任务的调度
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。