向量数据库与 RAG 数据管道:嵌入生成、索引与检索服务

从数据工程视角拆解 RAG 链路:分块策略与嵌入模型选型、HNSW 与 IVF 索引原理、量化压缩对召回率的影响、Milvus/Qdrant/pgvector 选型对比、增量写入与重建管道、混合检索与重排序、召回率与延迟的可观测指标,以及生产环境最常见的一致性、分块与过滤踩坑清单。

引言

RAG(Retrieval-Augmented Generation)看起来是应用层的事,但真正决定效果的往往是它下面那条数据管道:文档怎么切、嵌入怎么算、索引怎么建、检索怎么调。模型换一代提升有限,而分块与索引一旦建错,再强的 LLM 也救不回来。

RAG 的上限由检索决定,检索的上限由数据管道决定;向量数据库只是这条管道末端的存储与检索层。

本文不讨论提示词技巧,而是把 RAG 当作一条数据工程链路来拆:从嵌入生成、索引原理、数据库选型,到增量写入、混合检索、评估监控与生产踩坑。


一、RAG 数据管道全景

1.1 离线索引管道

离线管道的职责是把原始文档变成"可检索的向量 + 元数据":

# 离线索引管道
# 源文档(HTML/PDF/Markdown) → 解析清洗 → 分块 → 嵌入
# → 向量 + 元数据(来源/时间/权限/标签) → 写入向量库
# 关键: 分块策略与嵌入模型必须版本化, 可重放

这条管道是典型的批处理任务,应当被编排调度、可重跑、有血缘。一旦分块规则或嵌入模型变更,全量重建不可避免。

1.2 在线检索链路

在线链路通常是三步:召回 → 融合 → 重排。

阶段目标典型手段延迟预算
召回高召回率、低延迟向量 ANN、BM25、过滤10-50ms
融合合并多路结果RRF、加权分数5-20ms
重排提升精度Cross-Encoder、LLM50-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.51024中文中文检索强,社区常用
bge-m31024多语言支持稠密+稀疏+多向量
text-embedding-3-large3072多语言可降维,成本较高
gte-large1024中英长文本友好
jina-embeddings-v31024多语言支持任务前缀

经验法则:先用榜单上靠前的中文模型跑通基线,再用业务数据做小规模评测,不要迷信通用榜单。

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(乘积量化)**把向量切段分别聚类编码,压缩比更高但精度损失更大。

方案压缩比召回损失适用
Flat1x0小规模、要精确
SQ84x小内存敏感
PQ8-64x中亿级向量
IVF+PQ高中高超大规模

3.5 召回率与延迟的取舍

没有任何 ANN 索引能同时做到"召回 100% 且毫秒级"。工程上的做法是先定召回率目标(如 recall@10 ≥ 0.95),再在该约束下压延迟,通过调 nprobe/ef_search 找到工作点,并把这个工作点写进监控。


四、向量数据库选型

选型不是比谁快,而是比谁能嵌进你现有的数据栈。

产品类型优势劣势适用
Milvus专用分布式规模大、功能全组件多、运维重亿级、独立向量平台
Qdrant专用过滤强、Rust 高效生态较新中大规模、重过滤
pgvectorPG 扩展与业务库同源、事务一致规模受限千万级、已有 PG
Weaviate专用混合检索内置资源占用高快速搭建
Elasticsearch搜索引擎全文+向量一体向量性能非最优已有 ES 栈
Doris/StarRocksOLAPSQL 一体、可 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 当作数据工程问题来对待,效果与稳定性都会好得多。


参考与延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「data-engineering」更多文章

  1. 数据归档与生命周期:冷热分层、保留策略与合规删除
  2. 流处理精确一次与状态后端:Checkpoint、两阶段提交与恢复
  3. 数据湖运维:小文件合并、压缩与元数据维护