RAG 工程与检索系统设计

第9章 Embedding:文本如何变成可检索的向量

作者 杨艺韬 · 13,727 字 · 发布于 · 更新于

Embedding 是一次有损压缩:它保住的是你在意的那条语义轴,丢掉的是别的一切。

本章要点

  • Embedding 模型的核心任务:把文本压缩成低维稠密向量,让语义相近的文本在向量空间中距离相近
  • 三大训练范式:对比学习(SimCSE/BGE 主流)、蒸馏(MiniLM 小模型常用)、指令微调(E5-mistral 新路径)
  • 选型六维度:语言覆盖 / 领域 / 输入长度 / 向量维度 / 推理成本 / License
  • 2024-2026 新趋势:Matryoshka 可截断向量(短向量在线+长向量精排)、late-interaction(ColBERT v2,多向量精度但大)、多向量 per chunk
  • 生产部署的关键是吞吐和稳定性——批处理 + 动态 batch + 熔断 / 降级

9.1 Embedding 要解决的本质问题

RAG 的检索阶段问一个核心问题:"库里哪些 chunk 和这个 query 最相关?" 把这个问题工程化需要一个度量——"相关性"必须是可计算的数字。

最朴素的度量是 BM25(第 12 章)——按词项重叠打分。但"新版企业版 SSO 功能"和"最新产品套餐 单点登录"这两句在词项上几乎不重叠、语义上完全等价。BM25 给低分、语义检索给高分——这是 Embedding 在 RAG 里的不可替代价值。

Embedding 模型把这种"语义距离"学成一个向量空间的几何距离:把一段文本映射到 R^d 空间中的一个点(d 通常 384-1536),让语义相近的文本在空间中靠近。检索时只需要算 query 向量和 chunk 向量的余弦相似度,取 top-k。

这个思路简单,但实现细节决定了效果:

  • 空间怎么训出来?(9.2)
  • 向量维度怎么选?(9.3)
  • 领域不适配怎么办?(9.4-9.5)
  • 推理效率怎么撑起生产流量?(9.6)

9.2 三大训练范式

对比学习:主流中的主流

SimCSE(Gao et al., arXiv:2104.08821)是这条路线的代表作之一。核心思路:让 (anchor, positive) 对的向量靠近,让 (anchor, negative) 对的向量远离。

训练数据形如 (query, relevant_doc, irrelevant_doc_1, ..., irrelevant_doc_k)。loss 用 InfoNCE:

loss = -log( exp(sim(q, pos)) / (exp(sim(q, pos)) + Σ exp(sim(q, neg_i))) )

BGE(BAAI)、E5(Microsoft)、GTE(Alibaba)、Voyage、Cohere v3 都是这条路线的成熟产品。

关键工程细节:

  • 难负例(hard negatives):纯随机负例学不到细粒度语义。要从 BM25 召回的"表面相似但真正无关"的候选里挑
  • in-batch negatives:同 batch 里其他样本的 positive 互为负例——免费的负例,batch_size 越大,每条样本见到的负例越多
  • 多阶段训练:先在大规模弱监督数据(网页对、搜索 log)预训练,再在高质量人工标注数据上微调

蒸馏:小模型的出路

大模型(如 BGE-large 335M)效果好但推理慢。蒸馏把大模型的向量空间"教给"小模型:

  • 学生模型 (如 MiniLM 33M) 的输出向量 MSE-loss 对齐教师 (BGE-large)
  • 参数量小一个数量级,推理快得多,代价是检索精度有损——损多少要在你自己的 gold set 上量,公开榜单的差距不能直接搬

生产场景的经典权衡是按集合分模型:大而冷的长尾文档库用大模型,高频的 FAQ 库用小模型建独立索引。注意每个库的 query 和 chunk 必须用同一个模型编码——两个模型的向量不在同一空间(§9.13)。

指令微调:让 Embedding 听懂任务

E5-mistral-7b(Wang et al., arXiv:2401.00368,Microsoft,ACL 2024)走了另一条路:用 LLM 做 embedding,通过 prompt 告诉模型"我要检索什么"。

Instruct: Given a web search query, retrieve relevant passages that answer the query
Query: how to train a llama

优点:同一个模型可以做检索、分类、聚类等多种任务——靠改 instruction 切换。Cohere embed-v3、Voyage 的 API 用 input_type 参数做了轻量版:Cohere 要求 v3 及以上必须传(search_query / search_document / classification / clustering 等),Voyage 会按 query / document 在输入前自动拼一句固定 prompt。

缺点:模型大(7B+)、推理慢、显存占用高。适合质量优先场景(专业知识库、科研文献);不适合通用高并发场景。

flowchart TB
    classDef cls fill:#def,stroke:#06c
    classDef dist fill:#fed,stroke:#c60
    classDef inst fill:#efe,stroke:#080
    D1[大规模对比学习]:::cls
    D2[小模型蒸馏]:::dist
    D3[LLM + 指令微调]:::inst
    D1 --> R1[BGE / E5 / GTE<br/>主流生产模型]
    D2 --> R2[MiniLM / TinyBERT<br/>高吞吐小模型]
    D3 --> R3[E5-mistral / Voyage-3<br/>质量优先场景]

9.3 选型六维度

给定一个 RAG 项目,选 Embedding 模型要看六个维度。

语言覆盖

  • 中文为主:BGE 系列(bge-m3、bge-large-zh-v1.5)、M3E
  • 英文为主:E5、text-embedding-3、Voyage、Cohere embed-v3
  • 多语言:bge-m3 / paraphrase-multilingual-mpnet / multilingual-e5

MTEB 是基准 leaderboard(huggingface.co/spaces/mteb/leaderboard)——针对语言和任务有独立排名。选前查自己目标语言的子榜。

领域

通用 embedding 在专业领域会水土不服:

  • 医疗:PubMedBERT、BioASQ 训练的模型
  • 法律:LegalBERT、清华 THUNLP 的 Lawformer
  • 代码:voyage-code-3、nomic-embed-code、jina-embeddings-v2-base-code
  • 金融:FinBERT 系列

如果业务在垂直领域且有标注数据,微调 比直接换模型效果好(9.5)。

输入长度

Embedding 模型的最大输入 token 数:

模型 最大 token 备注
bge-small-zh-v1.5 512 短文本优化
bge-m3 8192 长文本友好
text-embedding-3-small 8192 OpenAI,与 large 同限
text-embedding-3-large 8192 OpenAI
voyage-3 32000 长文档王者
jina-embeddings-v3 8192 多语言长文本

(限长以各家当期文档为准,会随版本变化;上表按 2026-09-25 的各家官方文档与模型卡核对。)

注意 "最大" ≠ "最佳"。限长只是上界,训练语料的长度分布才决定模型在哪一段最稳——远超那个分布的长 chunk 表现会下降。所以 bge-m3 的 8192 是"塞得进去",不是"塞满最好";具体到哪一段最好,属于第 6 章说的必须自己扫的那条曲线(§6.9)。

向量维度

常见维度:

  • 384:bge-small、MiniLM 系列
  • 768:bge-base、multilingual-e5-base
  • 1024:bge-large、bge-m3、jina-v3、voyage-3
  • 1536:text-embedding-3-small、ada-002
  • 3072:text-embedding-3-large

维度影响两件事:

  • 索引内存:1M chunk × 1024 dim × 4 bytes = 4 GB RAM。维度翻倍直接翻倍存储
  • 检索延迟:距离计算 O(d),维度越大越慢

Matryoshka 向量(Kusupati et al., 2022,arXiv:2205.13147)让同一向量可以截断使用——完整维度最精,截短后仍可用,精度随维度递减。text-embedding-3(API 的 dimensions 参数)、nomic-embed-text-v1.5 都支持。生产常见用法:ANN 检索用短维度快速召回,top-k 精排用完整维度打分。

推理成本

API 侧是明码标价(下表为各家官方定价页 2026-09-25 的挂牌价,会变,用之前先核当期价):

模型 美元/1M tokens
text-embedding-3-small $0.02
text-embedding-3-large $0.13
voyage-3(Voyage 已列为上一代,同价的当期型号是 voyage-4) $0.06
Cohere Embed 4(纯文本;定价页已不再列 v3) $0.12

账单按 token 数 × 单价 算:100 万 chunk、每 chunk 平均 300 token 就是 3 亿 token,用 text-embedding-3-small 是 300 × $0.02 = $6,用 text-embedding-3-large 是 300 × $0.13 = $39。

自托管没有挂牌价,但可以直接算:单位成本 = 卡时单价 ÷ 实测吞吐(token/s)。吞吐必须自己测——同一张卡上 bge-small 和 bge-m3 差一个数量级,batch 大小、序列长度、是否开 FP16 也都影响它。测出吞吐再和上表比,才知道自托管划不划算。

API 便利但并发有限;自托管延迟低但运维成本。混合策略:日常索引走 API 按量付费,大批量全量重建走自托管 GPU pool。

License

开源可商用的 Embedding 模型:bge v1.5 系列与 bge-m3(MIT)、nomic-embed(Apache 2.0)、jina v2(Apache 2.0);jina-embeddings-v3 是 CC BY-NC 4.0,不可直接商用。闭源 API:OpenAI、Voyage、Cohere。

注意"开源可商用"≠"可微调再发布"。部分模型用了有限制的训练数据,商用前查清 license。

9.4 Query 和 Document 非对称编码

BGE、E5 这类模型在训练时区分 query 和 document,推理时要严格对应。但两家的约定不一样,不能互相照抄:

  • BGE(英文 v1.5 系列):只有查询加前缀 "Represent this sentence for searching relevant passages: ",文档不加(中文版的前缀是另一句中文,照模型卡抄)。bge-m3 则两侧都不加前缀。
  • E5 系列:两侧都要加——查询加 "query: "、文档加 "passage: "。漏掉 passage: 是这个模型家族最常见的踩坑,因为它和 BGE 的"文档不加"正好相反。
# 正确
q_vec = embed(query, mode="query")    # 带 prefix
d_vec = embed(chunk, mode="document")  # 按模型约定:BGE 不带,E5 带 "passage: "

# 错误——都用 document 模式
q_vec = embed(query, mode="document")  # 少了 prefix,匹配度下降

这个细节在 quickstart 教程里常被省略。生产代码必须严格区分 query/document mode。模型卡里会说明各自的 prompt/prefix 格式。

E5-mistral 走得更远——query 侧用完整的 instruction prompt 描述任务(上面 Instruct: ... Query: ... 的格式),document 侧则不加 instruction(模型卡原话是 no need to add instructions to the document side)。

9.5 领域微调:什么时候值得做

什么时候不用微调

  • 通用领域(百科、一般产品文档):开箱 bge-m3 / voyage-3 已足够
  • 标注数据太少:微调数据不够反而伤泛化
  • 团队没有 ML 工程师:维护微调模型的运维成本高

什么时候值得微调

  • 专业领域(法律条文、医学术语、代码)且标注数据充足
  • 开箱模型在本领域 gold set 上明显达不到业务能接受的 recall——这条线由你的业务定,不是某个通用阈值
  • 有长期 ML 团队能维护训练和发布流程

微调的两条路

路径 1:全参数微调。拿开源模型(bge-base / E5-base)当起点,在领域对比学习数据上继续训练。效果好但成本高(GPU 训练要跑多轮迭代),模型要完整部署。

路径 2:LoRA / QLoRA。只训练 adapter 参数、base 模型冻结——可训练参数量下降到全量微调的很小一部分,显存和训练时间跟着降;部署时 adapter 动态加载,可以针对不同子领域维护多个 adapter。2024 年后这条路主流化。

训练数据从哪来

  • 业务 log:用户点击、采纳的 (query, chunk) 是天然正样本
  • 人工标注:少量高质量标注校准方向
  • 合成数据:用 LLM 根据 chunk 生成问题,(generated_q, chunk) 作为正样本——LlamaIndex(llama_index.core.evaluation.DatasetGenerator)和 InPars 都有工具链
  • 难负例挖掘:BM25 或老版 embedding 召回的"高分但实际无关"的 chunk 作为负样本

几类来源要混用,比例在自己的 gold set 上调:纯合成容易过拟合合成模式,纯人工量不够。

9.6 推理部署:吞吐、延迟、稳定性

生产 embedding 服务的三个工程要点。

动态 batch

单次 embedding 调用固定 batch=1 会让 GPU 利用率很低。动态 batch 聚合多个请求:

  • 队列里等一小段时间(几毫秒到几十毫秒)积累请求,攒够一个 batch 或超时就 flush
  • batch 变大后 GPU 利用率显著上升,吞吐随之上升,直到打满显存或算力
  • 延迟代价:多等的那段排队时间加进 P50——换来的吞吐倍数要自己扫一条 batch-吞吐曲线才知道

单次调用 batch=1 时 GPU 大部分时间在等数据、利用率很低,这是动态 batch 收益的来源。

开源方案:Triton Inference Server、vLLM 的 embedding backend、TEI(Text Embeddings Inference,Hugging Face 官方)。

Prefix 的开销

query 侧的 instruction / prefix 每条都一样,看起来像是可以缓存复用的。但要注意:BGE、E5 这类 encoder 模型是双向 attention,prefix 的表示依赖后面的正文,不像自回归 LLM 那样能直接复用 KV cache。真正省下来的只有 tokenize 这一层,以及 instruction 很长时可以考虑换成更短的等价表述。别指望这里有大收益。

熔断和降级

外部 API embedding 服务偶尔抖动(OpenAI limit、网络、服务端故障)。熔断策略:

  • P99 延迟或错误率超阈值:自动切到 fallback 模型(自托管 bge-small)。前提是库里另有一份用 fallback 模型建的索引——query 和 chunk 必须同一模型编码,拿 bge-small 的 query 向量去查 OpenAI 向量建的索引是无效的(§9.13)
  • 业务继续运转,索引延迟略增但不中断
  • 恢复后自动切回主模型

在线查询侧比离线索引侧更需要熔断——离线可以等恢复后补,在线等不了。

多模型混合部署

不同场景选不同模型,一套服务对外:

  • 召回模型(bge-small 快):低延迟、覆盖面
  • 精排模型(bge-m3 准):top-k 精调
  • 领域模型(微调 adapter):专业查询

服务端按 request header 路由到不同 model。这种混合部署让单个 RAG 系统同时享受快和准。

9.7 Embedding 服务的容量规划

一个 RAG 项目在 Embedding 这一环的容量规划要算清楚两条需求:

离线索引吞吐。假设知识库 500 万 chunk、每月增量 50 万 chunk。

  • 全量重建耗时 = 500 万 ÷ 实测吞吐(chunk/s)。先在目标卡上把这个吞吐测出来,再决定几张卡
  • 每日增量 = 50 万 ÷ 30 ≈ 1.7 万 chunk/日,只是全量的 0.3%——离线卡按全量重建的窗口定,增量必然宽裕
  • 结论:离线侧的容量由「你能接受多长的重建窗口」倒推,不是由日常增量决定

在线查询吞吐。假设日活 10 万、人均 5 次查询:

  • 日请求量 = 10 万 × 5 = 50 万次
  • 若集中在 8 个工作小时内,均值 QPS = 50 万 ÷ 28800 ≈ 17
  • 峰值按均值的 2-3 倍估:约 35-50 QPS
  • 每次查询未必只有一次 embedding 调用——query rewrite 生成 3 个子查询就是 3 倍。乘上去:100-150 QPS

这个乘法是最容易漏的一步:容量按"查询数"算会算少,要按"embedding 调用数"算。

有了目标 QPS,需要几张卡就是 目标 QPS ÷ 单卡实测 QPS,再为主备双活翻一倍。

如果查询 QPS 再上一个量级、或知识库上亿 chunk,需要多 GPU 水平扩展——走 Triton / TEI 的多实例部署。

9.8 评估 Embedding 的方法

选定候选模型后怎么验证?三层评估。

MTEB 排行榜

快速初筛。针对目标语言和任务(Retrieval / STS / Classification)看分数。但 MTEB 是公开数据集,不完全代表你的业务。

业务 gold set

200-1000 条业务真实 QA,每条标注 gold chunk。跑每个候选模型测 recall@10 / MRR。这是最重要的评估——排行榜再高,业务 gold set 上低就没用。

在线 A/B

候选模型上线 10% 流量,观察 CTR、refusal rate、转人工率。最准但最慢。候选模型必须先通过前两层才值得做 A/B。

实操顺序:MTEB 选 3-5 个候选 → gold set 选 1-2 个 top → A/B 验证选最终 1 个。

9.9 Embedding 的十个常见坑

生产 Embedding 服务里反复出现的坑,列出来避免踩:

  • 坑 1:tokenizer 不一致。训练和推理用不同 tokenizer(版本、vocab)导致 embedding 漂移。统一锁 tokenizer 版本
  • 坑 2:padding 策略不一致。训练时 right_pad 推理时 left_pad——pooling 层取 last_token 拿到的是 pad token。pooling 策略要和训练对齐(CLS / mean / last)
  • 坑 3:归一化遗忘。训练时对 embedding 做了 L2 归一化,推理忘了做——拿内积当余弦用时结果出错
  • 坑 4:FP16 精度损失。大向量维度(1024+)用 FP16 存储,距离计算累积误差。保留 FP32 存储,至少用 FP32 累加(BF16 尾数位比 FP16 更少,换它解决不了精度问题)
  • 坑 5:温度系数忘记标定。对比学习的 temperature 在推理时不相关,但若做 softmax 重排,得用标定温度
  • 坑 6:text truncation 静默。输入超限被 tokenizer 静默截断,后半段丢失。生产必须显式检查长度并告警
  • 坑 7:多语言混文。中英混文按哪种语言 tokenize 影响大。多语言模型选用对齐 tokenizer(xlm-roberta 系)
  • 坑 8:embedding 不归一化。向量库有的算 cosine 有的算 inner-product——不归一化结果不一致。入库前统一归一化
  • 坑 9:更新模型不回灌。换了新 Embedding 模型,老 chunk 没重 embed,新老向量空间不兼容。必须全量重 embed
  • 坑 10:数值不稳定。某些模型在 long input + 异常字符上产出 NaN 向量。入库前 validator 检查 all(finite(vec))

前期把 validator 写好、CI 跑好,能躲掉其中大部分。

9.10 2024-2026 的三条新路线

Embedding 领域不是静止的。最近三年出现的新路线:

Matryoshka:可截断向量

同一模型同一次推理产生一个 1024 维向量,但前 64/128/256/512 维各自可独立用作检索向量。实现靠训练时 loss 同时优化多个截断点。text-embedding-3、nomic-embed-text-v1.5 都支持这种截断。截断后要重新做 L2 归一化——截短的向量模长小于 1,直接拿内积当余弦就错了(nomic-embed-text-v1.5 模型卡的示例就是先截断、再 F.normalize)。

生产用法:ANN 召回用 128 维快速跑大集合,精排再用 1024 维打分。收益的来源是可推算的——距离计算的开销随维度线性下降,1024 截到 128 就是 8× 的计算量和存储量;实际端到端能省多少延迟,取决于这一段在总延迟里的占比,要看自己的 trace。精度损失同样要自己测:截得越短损失越大,甜点在哪条曲线上,只有你的数据说了算。

Late-interaction:ColBERT v2

ColBERT v2 不给 chunk 一个向量,给每个 token 一个向量。检索时 query 的每个 token 和 chunk 的每个 token 求 max-sim 再求和。

优点:token 级的细粒度匹配让精度高于 single-vector(ColBERTv2 论文在 BEIR 上给了对照数字,量级不要外推到你自己的语料)。缺点是存储膨胀,膨胀多少可以直接算:一个 200 token 的 chunk 用 128 维 per-token 向量是 200 × 128 × 4 = 100 KB,而单向量 1024 维只要 1024 × 4 = 4 KB——25 倍。chunk 越长膨胀越多。第 10 章 §10.15 会讲 ColBERTv2 的残差压缩怎么把这个倍数压下来。

应用:作为精排阶段的打分器,而非召回阶段的索引——精排只对 top-50 做,存储压力可控。

三条路线的选型

flowchart TB
    Q[需求] --> Q1{成本敏感?}
    Q1 -->|是| M1[Matryoshka<br/>分层检索]
    Q1 -->|否| Q2{精度优先?}
    Q2 -->|是| M2[ColBERT v2<br/>精排打分]
    Q2 -->|否| Q3{中等平衡?}
    Q3 -->|是| M3[多向量 per chunk<br/>框架组件]
    M1 --> U1[存储膨胀低<br/>延迟最优]
    M2 --> U2[存储膨胀高<br/>精度最高]
    M3 --> U3[存储中等<br/>配置简单]

生产实战:大部分项目选 Matryoshka——成本低、收益显著、无需大改架构。ColBERT v2 适合"成本不敏感但一定要拿到精度上限"的项目(法律、医疗、科研)。多向量 per chunk 是"中间档",用现成框架组件就能搭,不必换模型。

未来趋势预判

三条路线可能融合——一个模型同时支持:

  • Matryoshka 可截断主向量
  • 额外产生若干辅助向量(多向量)
  • 在特殊场景下退化成 late-interaction 做精排

已有模型部分实现了这种"多模式"架构:bge-m3 一次推理同时给出稠密向量、稀疏词权重和 ColBERT 式多向量(arXiv:2402.03216),jina-embeddings-v4 同时提供可 Matryoshka 截断的单向量和 late-interaction 多向量。这类能力会不会成为主流模型的标配,还要再看。RAG 工程需要跟进这种演进——但稳定生产系统不要追最新模型,等一个模型有足够长的社区验证再换。

多向量 per chunk

极简版 ColBERT——给每个 chunk 产生多个向量(比如 3-5 个,分别代表 chunk 的不同语义角度,常见做法是分别 embed 摘要、假设问题和原文)。存储膨胀适中(3-5 倍),精度介于 single-vector 和 ColBERT 之间。框架里现成的实现如 LangChain 的 MultiVectorRetriever(langchain-classic,libs/langchain/langchain_classic/retrievers/multi_vector.py),检索命中任一子向量后回查父文档。

9.11 Embedding 的量化与压缩:int8 与 binary 向量

2024 年后 embedding 领域另一条实用路线——向量本身的量化压缩。这和 §10.5 的 PQ 不是一回事:PQ 是索引内部的量化,存进库的原向量可以原样保留,码本只是索引用来加速距离估算的一层近似;而 int8 / binary 量化发生在embedding 输出阶段——存进库的向量本身就变了,内存和传输都省。两者正交,可以叠加。

三档精度的权衡

flowchart LR
    classDef fp fill:#fed,stroke:#c60
    classDef int fill:#def,stroke:#06c
    classDef bin fill:#efe,stroke:#080
    FP[float32<br/>4 bytes/dim<br/>精度基线]:::fp
    INT[int8<br/>1 byte/dim<br/>精度损失小]:::int
    BIN[binary<br/>0.125 byte/dim<br/>精度损失明显]:::bin
    FP -->|"4× 压缩"| INT
    INT -->|"8× 压缩"| BIN
    FP -->|"32× 压缩"| BIN

1024 维向量的存储:

  • float32:4096 bytes/向量。100 万向量 = 4 GB
  • int8:1024 bytes/向量。100 万向量 = 1 GB(4× 压缩)
  • binary:128 bytes/向量。100 万向量 = 128 MB(32× 压缩)

压缩比是算出来的、确定的;精度损失不是——它取决于模型、维度和你的语料,必须自己在 gold set 上量。经验上 int8 的损失小到常被 rerank 吸收掉,binary 的损失肉眼可见,必须配 rescoring(下面就讲)。

binary 把每个维度压成 1 bit——用 sign(x) 或阈值切分。距离从 cosine 变成 Hamming 距离(异或后数 1)——一条 64 位 popcount 指令处理 64 个维度,比逐维浮点乘加快得多。

为什么 recall 损失可控

int8 量化损失之所以通常很小,原因在分布:embedding 的各维取值接近高斯,极值稀有、动态范围不大。把 [-1, 1] 的 float32 映射到 [-128, 127] 的 int8,量化误差落在小数点后几位,对 cosine 的排序影响远小于对数值的影响——而检索只关心排序。(上一节说过,具体保留多少 recall 必须在自己的 gold set 上量,这里解释的是"为什么损失小",不是"损失是多少"。)

binary 更激进但仍能工作的原因:高维空间里方向比具体数值更重要。1024 维 float 向量的"方向"大部分由每个维度的符号决定,幅度是次要信息。binary 保留了最核心的符号信息。

两阶段检索模式

binary 最成功的生产用法是两阶段:

  1. 粗召回:用 binary 向量 + Hamming 距离在全库跑 top-500。快在两处——数据量小 32 倍(内存带宽),以及 popcount 比浮点乘加便宜
  2. 精排 rescoring:对 top-500 加载对应的 float32 向量、算真实 cosine、选 top-k

粗召回阶段扩大 K(如从 top-50 扩到 top-500)吸收 binary 的精度损失,精排阶段用 float32 把排序修正回来。整体 recall 接近纯 float32,延迟和内存占用接近纯 binary(float32 向量仍要存,只是可以放在磁盘等较便宜的存储上按需加载)。

支持矩阵

主流模型的量化支持(按 2026-09-25 的官方文档与模型卡;"✓"指官方给出了量化输出或量化用法,"客户端"指只能自己对 float 输出做后处理):

模型 int8 binary 备注
bge-m3 客户端 客户端 模型卡未提供量化输出
mxbai-embed-large-v1 ✓ ✓ 模型卡给出 quantize_embeddings 用法,官方 API 可直接返回 int8 / ubinary;不是单独的量化权重
Cohere embed-v3 及以上 ✓ ✓ API 参数 embedding_types(int8 / uint8 / binary / ubinary)
nomic-embed-text-v1.5 客户端 ✓ 官方博客称支持 binary,可与 Matryoshka 叠加
text-embedding-3 客户端 客户端 API 只返回 float
voyage-3.5 / voyage-3-large 及 voyage-4 系列 ✓ ✓ API 参数 output_dtype;voyage-3 不支持

无原生支持的模型也能客户端量化——但会损失一些精度,因为训练时没考虑量化感知。

量化感知训练 vs 事后量化

两种获得量化向量的路径:

  • 事后量化(post-hoc quantization):用 float32 模型跑推理,对输出向量做量化。简单、零改动
  • 量化感知训练(quantization-aware training, QAT):训练时就让模型输出 int8 / binary 向量,loss 反向传播包含量化误差。精度更好,但需要重训

生产场景里事后量化通常够用——int8 那点损失在 hybrid + rerank 链路里往往被吸收掉。要不要上 QAT,先把事后量化的损失量出来再说。

何时用量化

场景 推荐
千万级以上向量 + 内存紧张 binary 粗召 + float 精排
百万级 + 延迟敏感 int8
百万级以下 + 有 rerank 兜底 int8 或 float32(看成本)
高精度硬需求(法律/医疗) float32 + int8 备份路径

常见坑

  • 归一化顺序错:量化前要对 float 向量做 L2 归一化,否则 int8 动态范围失控
  • Hamming 距离和 cosine 不同量纲:不要直接拿 binary 分数当 cosine 分数用——要么都转成一致的单位,要么用 rank 融合(第 13 章 RRF)
  • 数据漂移后量化参数失效:int8 的缩放系数(scale/zero_point)按校准数据的分布定,分布变了量化误差会明显上升。定期校准
  • 多版本混用:int8 向量和 float32 向量不能直接比——要么统一升到 float32 算,要么分别维护索引
  • 粗召回 K 设太小:粗召回 top-50 + 精排 top-5——binary 的精度损失让本该进 top-5 的真相关 chunk 在粗召回阶段就漏掉。粗召回 K 常取最终 k 的 10 倍左右起步,再按 gold set 调

和 Matryoshka 的叠加

Matryoshka 降维度,量化降每维度精度——两者正交、可叠加:

原始: 1024 dim × 4 bytes = 4096 bytes/向量
+ Matryoshka 截到 256 dim: 256 × 4 = 1024 bytes (4× 压缩)
+ int8 量化: 256 × 1 = 256 bytes (16× 压缩)
+ binary: 256 / 8 = 32 bytes (128× 压缩)

128× 总压缩——1 亿向量从约 400 GB(1e8 × 4096 bytes)降到 3.2 GB(1e8 × 32 bytes),单机可装。recall 用两阶段 rescoring 补回来。

9.12 多模态 Embedding:文本之外的检索

前 11 节的 Embedding 都假设输入是文本。但真实企业文档里,相当一部分有效信息不在正文里——截图、示意图、产品照、嵌入 PDF 的表格、产品视频里的演示片段、录音会议纪要。占比多少要拿自己的文档库统计,不同行业差别极大。这些非文本内容用传统 text embedding 会漏召,用 OCR+文本 embedding 又损失结构——多模态 Embedding 是这个空白的答案。

多模态 Embedding 的三条主线

flowchart TB
    classDef text fill:#def,stroke:#06c
    classDef vis fill:#fed,stroke:#c60
    classDef audio fill:#efe,stroke:#080
    classDef uni fill:#fef,stroke:#808
    T[文本 embedding]:::text
    V[图像-文本联合]:::vis
    A[音频-文本联合]:::audio
    U[统一多模态]:::uni
    T --> T1[BGE / E5 / Voyage<br/>text-to-text]
    V --> V1[CLIP / SigLIP<br/>image ↔ text 同空间]
    A --> A1[CLAP / Whisper embed<br/>audio ↔ text]
    U --> U1[Cohere Embed v3 multimodal<br/>Voyage-multimodal-3<br/>同一模型多模态]
  • 文本 embedding:前面 11 节
  • 图像-文本联合(CLIP 家族):图像和文本映射到同一向量空间——用文本 query 能召回图像,反之亦然
  • 音频-文本联合(CLAP、LAION-CLAP):类似 CLIP,但替换成音频
  • 统一多模态:2024 年后兴起的"一个模型处理所有模态"——Cohere Embed v3 multimodal、Voyage-multimodal-3、Jina-clip-v2

CLIP 的工作原理和局限

CLIP(OpenAI 2021)和它的变体(SigLIP、EVA-CLIP)是多模态 embedding 的基础。核心思路:

  1. 训练时用大量 (image, caption) 对,用对比学习拉近同一对的距离,拉远不同对
  2. 推理时图像过 image encoder,文本过 text encoder,各自输出同维度向量
  3. cosine 距离直接跨模态对比

生产里 CLIP 的几个实际局限:

  • 文本端能力弱:CLIP 的 text encoder 输入上限只有 77 个 token,比 BGE/E5 短得多,长句理解差。"文本 + 文本"的 RAG 不要用 CLIP 当通用 embedding
  • 领域泛化有限:CLIP 预训练数据偏自然图像,在企业文档里的流程图、截图、PDF 版式上表现一般
  • 分辨率限制:典型 224×224 或 336×336,小字识别不行

生产补救:

  • 文本 embedding 继续用 BGE/Voyage——只把图像走 CLIP 独立索引
  • 业务领域多的话,fine-tune CLIP 或用领域模型(MedCLIP 医疗、FashionCLIP 时尚)

统一多模态:2024 年后的方向

Cohere Embed v3 multimodal、Voyage-multimodal-3、Jina-clip-v2 的核心卖点:一个 embedding 模型同时支持文本、图像、PDF 版面——不用分别维护 text 和 image 索引。

# 统一多模态 API 的示意(伪代码)
text_vec = embed("企业版 SSO 配置界面")
image_vec = embed(png_bytes)     # 同一个 embed 函数
pdf_vec = embed(page_image)       # PDF 页面:Voyage 多模态接口只收文本/图像/视频,要先按页转截图;
                                  # Cohere Embed 4 则可直接收 PDF 这类混合模态文档

# 都在同一向量空间、可直接对比
similarity = cosine(text_vec, image_vec)

统一多模态对 RAG 工程的简化明显:

  • 单一向量索引,不需要区分 chunk 是图还是文
  • 检索跨模态自然,"找和这张产品图类似的文档"一次查询搞定
  • 只维护一个 embedding 服务,成本 / 运维都省

但统一多模态模型在纯文本 retrieval 上未必比得过专用文本 embedding(bge-m3 等),要在自己的 gold set 上比——权衡是"一处简单 vs 两处最优"。

多模态 RAG 的工程模式

生产多模态 RAG 的典型架构:

  • 解析阶段(第 5 章):PDF 解析出文本 + 图像 + 表格,分别产生独立 chunk
  • 嵌入阶段:
    • 文本 chunk → text embedding
    • 图像 chunk → CLIP / 统一模型 embedding
    • 表格 chunk → 转成 markdown 后 text embedding(结构化保留)
    • 音频 chunk → CLAP embedding 或先 ASR 转文本
  • 索引阶段:所有 embedding 进同一向量库(同维度或按类型分 collection)
  • 检索阶段:query 先判断类型(文本 query 还是图像 query),选对应 embedding 路径

图像 chunk 的 metadata 要保留文档来源 + 所在页码 + 描述文本——这样答案可以显示"见《产品手册》第 5 页的架构图",而不是只给个图片 URL。

多模态 RAG 的评估挑战

多模态评估比纯文本复杂:

  • 跨模态相关性:"这张图是否回答了这个问题"——需要人工标注,LLM-as-judge 在视觉任务上稳定性差
  • 图像质量是信号:同一内容的清晰图 vs 模糊图在召回时应有差异——单纯 embedding 不直接体现
  • 答案引用的视觉指代:答案说"如图所示",UI 要能跳转展示——测试 retrieval 不够,还要测 UI 对齐

生产方案:文本 RAG 的 gold set 保持独立测试,图像 RAG 建独立 gold set(问题 + 期望图像),分别评估,避免相互干扰。统一报告里分 modality 拆指标。

什么时候该上多模态 RAG

不是所有 RAG 都需要:

场景 是否值得上多模态
纯文本 FAQ / 文档问答 不需要
产品手册含大量示意图 值得
代码库 RAG(含 diagrams) 中等、取决于 diagram 重要性
电商 / 时尚 / 视觉检索产品 必需
医疗影像 / 法律证据图 必需、且需要领域专用模型

判断标准:先统计自己文档库里有多少有效信息藏在图像 / 表格 / 音视频里,占比可观才值得上多模态。否则 OCR + 文本 RAG 成本效益更高。

多模态的成本

多模态 Embedding 比纯文本贵,量级差多少要按各家当期定价页算:

  • Image embedding:CLIP 类模型按图计费或自托管 GPU
  • Audio embedding:CLAP 按音频时长计
  • 统一多模态:Voyage/Cohere 按 token + image token 混合计费

算法是一样的——图/音频数量 × 单价,加上离线批量算的 GPU 卡时。真正会让你意外的是数量:一份 PDF 里的图往往比你预估的多。

生产考虑离线索引时批量计算(GPU 利用率高,单位成本明显低于逐条实时算)——在线检索只做 query 侧 embedding,就和纯文本 RAG 成本相当。

9.13 Embedding 模型升级的迁移工程

§9.9 的坑 9 提到"换新 embedding 模型、老 chunk 没重 embed、新老向量空间不兼容"——这是 embedding 升级最危险的陷阱。生产系统迟早会遇到 embedding 升级(模型厂商出新版、自托管模型迭代、业务换垂直模型)——这一整节讲清楚升级的工程路径,避免踩第二次坑。

升级的三种触发

flowchart LR
    classDef trig fill:#fed,stroke:#c60
    T[升级触发]
    T --> T1[模型厂商换版<br/>OpenAI / Voyage / Cohere]:::trig
    T --> T2[自托管 base 模型更新<br/>bge-large-zh-v1.5 → bge-m3]:::trig
    T --> T3[业务换垂直模型<br/>通用 → 领域专用]:::trig

每种触发性质不同:

  • 厂商换版:被动,必须跟(老版本可能下架)
  • 自托管更新:主动,按需跟进
  • 垂直换模型:主动,ROI 驱动

核心挑战:向量空间不兼容

Embedding 升级最严重的问题:新老模型的向量不在同一空间。直觉检验:

# 新老模型对同一文本的 embedding
v_old = old_model("企业版 SSO")  # 例 [0.12, -0.34, ...]
v_new = new_model("企业版 SSO")  # 例 [0.21, 0.05, ...]

# 两个向量之间的 cosine 没有意义:维度可能都不同,
# 就算维度相同,各维的含义也互不对应

这意味着:

  • 新 query 用新模型 embed,拿去老索引检索 → 距离完全错位,recall 崩溃
  • 老 chunk 用老模型 embed,新 query 用新模型 → 同样不行
  • 必须全量重 embed所有 chunk——不能混用

三种迁移策略

flowchart LR
    classDef strat fill:#def,stroke:#06c
    classDef risk fill:#fdd,stroke:#c00
    classDef safe fill:#dfd,stroke:#080
    S1[停机重建<br/>最简单]:::strat --> S1R[停服几小时]:::risk
    S2[蓝绿双索引<br/>推荐]:::strat --> S2R[存储翻倍 + 代码切换]:::safe
    S3[渐进双写<br/>最稳]:::strat --> S3R[最复杂 + 最长迁移周期]:::safe

策略 1:停机重建。停服务、全量重 embed、重建索引、切换、启服务。

  • 优点:代码路径不变、简单
  • 缺点:停机时长 = chunk 数 ÷ 实测吞吐(§9.7)加上建索引时间,百万级 chunk 往往以小时计,用户感受明显
  • 适合:小项目、非关键业务、低活跃时段

策略 2:蓝绿双索引。老索引(蓝)继续服务,新模型并行建新索引(绿)。建完后切换路由到绿索引,蓝索引保留 N 天作回滚。

  • 优点:零停机、回滚快
  • 缺点:存储翻倍一段时间,应用层需要支持 index 切换
  • 适合:中大型项目,推荐方案

策略 3:渐进双写。新增 chunk 同时用新老模型 embed 写两份,历史 chunk 后台逐步重 embed。全部完成后切换。

  • 优点:迁移周期最长但对系统冲击最小
  • 缺点:工程复杂,期间可能部分不一致
  • 适合:超大规模项目,不能有任何停机窗口

多数项目选策略 2(蓝绿双索引)——兼顾简单和零停机。

迁移的验证 checklist

切换前必须验证的项:

  • 向量格式兼容:新模型维度、数值范围和向量库 schema 匹配
  • gold set recall 不退化:新索引在历史 gold set 上 recall 至少不低于老版
  • 新能力测试:新模型的优势(如长 context)在对应 gold set 上体现
  • 延迟测试:新模型推理延迟在 SLA 内
  • 成本估算:新模型 + 新索引的月成本和预算对比
  • badcase 回归:历史 badcase 在新索引上仍然修复(不回退)
  • 权限和 metadata 完整:全量重 embed 不能丢任何 chunk 的元信息

任一不过关 → 不切换。带着问题上线,事后恢复的代价远高于事前预防。

Shadow 验证阶段

切换前,让新索引先跑 shadow 流量:

# 线上请求 shadow 到新索引
async def retrieve(query):
    # 主路径:老索引
    primary_result = await old_index.search(query)
    
    # shadow 路径:新索引(异步、不阻塞用户)
    # 生产代码要保存 task 引用(如放进一个 set),否则可能在执行完前被垃圾回收
    asyncio.create_task(shadow_check(query, primary_result))
    
    return primary_result

async def shadow_check(query, primary_result):
    new_result = await new_index.search(query)
    # 对比 recall 交集、记录 delta
    log_delta(query, primary_result, new_result)

Shadow 一周,积累足够多真实 query 的对比数据——看新索引在真实流量下是否达标。只用 gold set 不够,用真实流量才稳。

回滚策略

切换后发现问题,能分钟级回滚是必备:

  • 应用配置里 active_index = "green" 改回 "blue" 即可
  • 不涉及数据库变更,纯流量切换
  • 演练过,回滚才能真正做到分钟级

蓝索引保留期建议 至少 2 周——某些问题只在长尾流量里暴露,短期内发现不了。2 周后确认稳定再清理蓝索引释放存储。

升级的成本

把成本拆成四项分别算,别一起拍脑袋:

  • 重 embed = chunk 数 × 每 chunk 平均 token 数 × 当期单价。百万 chunk 量级代进去通常是个位数到几十美元(§9.3 的算例:3 亿 token 用 text-embedding-3-small 是 $6,用 text-embedding-3-large 是 $39)——这一项几乎总是比人们预期的低得多(第 11 章 §11.11 有同样的提醒)
  • 新索引构建 = 内存 + 几十分钟机器时间
  • 存储翻倍(蓝绿期) = 蓝绿并存的周数 × 双倍存储费
  • 人工 = 验证 + shadow + 切换,2-3 人周

四项里人工是大头,而且大出一两个数量级——算完你会发现纠结 embedding 单价没有意义,真正该优化的是把验证和切换流程做成可复用的(§9.13 前面的 checklist 和 shadow 脚本就是为这个)。相对 RAG 整体基础设施成本,一次升级是小数目;不做的代价则是一次说不清根因的 recall 漂移事故。

渐进式升级:Matryoshka 和快照

两种可以减少升级风险的新趋势:

  • 厂商保证兼容的模型系列:例如 Voyage 文档写明 voyage-4 系列(voyage-4-large / voyage-4 / voyage-4-lite)生成的向量彼此兼容,在系列内换型号不必重 embed。Matryoshka(§9.10)本身不提供这种保证——两个不同模型截到同一维度,向量依然不在同一空间
  • Embedding 空间对齐(学术):训一个映射模型 f: V_old → V_new,近似把老向量迁到新空间——避免重 embed。目前仍在研究阶段,生产上还不稳

两者都是减负,但不替代完整重建——跨模型换代,追求正确性还是要全量重 embed。

常见迁移反模式

  • 增量 embed 新 chunk、不动老 chunk:新老混用,新 query embed 后检索结果漂移,recall 崩
  • 没做 shadow 验证:直接切换,出问题时线上用户已经遭殃
  • 不保留老索引:切换后立刻删老,回滚不可能
  • Gold set 和训练数据混:新模型在 gold 上分高、上线垮——gold 污染了
  • 升级时机错:业务高峰、版本迭代高频期间升级——错上加错

升级频率建议

  • 商用 API:跟厂商主版本,老版本要下架时必须跟
  • 自托管开源:定期评估,看是否有明显更优选择
  • 业务模型:有明确 badcase 时才升,不盲目追新

过频升级是工程浪费——每次都要重建索引、验证,还可能出事故——通常只有在明确问题驱动时 ROI 才好。

9.14 Embedding 的调试与可解释性

前 13 节讨论了 embedding 的训练、选型、部署、升级——但遇到问题怎么定位是单独一门工程手艺。embedding 是 1024 维黑盒,"这两条为什么检索不到"、"这个 chunk 的向量为什么异常"——没有专门的调试工具,工程师只能靠猜。这节把 embedding 调试的常用方法整理出来,让黑盒变灰盒。

调试的典型场景

flowchart TB
    classDef scene fill:#fed,stroke:#c60
    S[典型调试场景]
    S --> S1[召回不到已知相关 chunk]:::scene
    S --> S2[召回一堆不相关 chunk]:::scene
    S --> S3[相似度分布异常]:::scene
    S --> S4[某类 query 质量特别差]:::scene
    S --> S5[新 chunk 进索引后被召回奇怪]:::scene

每种场景都有不同的调试切入点。

场景 1:召回不到已知相关 chunk

用户问 "企业版 SSO 配置",系统里明明有一份讲这个的文档,但没被召回到 top-10。排查:

def debug_not_found(query, expected_chunk_id):
    # 1. 查 chunk 是否在索引里
    chunk = vector_db.get(expected_chunk_id)
    if not chunk:
        return "chunk not indexed"
    
    # 2. 算 query 和 chunk 的 cosine 相似度
    q_vec = embed(query)
    similarity = cosine(q_vec, chunk.vec)
    print(f"Similarity: {similarity}")  # 明显低于该模型上同类相关对的基线,说明 embedding 把两者分开了
    
    # 3. chunk 在 top-K 的排名
    results = vector_db.search(q_vec, top_k=100)
    ranks = [r.id for r in results].index(expected_chunk_id) if expected_chunk_id in [r.id for r in results] else None
    print(f"Rank in top-100: {ranks}")
    
    # 4. 找比 chunk 更相似的"抢位"chunk
    for r in results[:20]:
        if r.id != expected_chunk_id:
            print(f"Rank {r.rank}: {r.text[:100]} (sim={r.score})")

这个脚本能区分:

  • 根本没索引:chunk 不在 DB 里
  • 相似度过低:embedding 把 query 和 chunk 分开了
  • 被别的 chunk 抢位:有更相似但不相关的干扰项

不同原因对应不同修复——瞎猜浪费时间。

相似度可视化:UMAP / t-SNE

想看 chunk 的 embedding 空间整体分布——用降维可视化:

import random

import numpy as np
from umap import UMAP
import matplotlib.pyplot as plt

def visualize_embeddings(chunks, sample_n=1000):
    # 随机采样避免图太密
    sample = random.sample(chunks, sample_n)
    vectors = np.array([c.vec for c in sample])
    labels = [c.metadata["category"] for c in sample]
    
    # UMAP 降到 2D
    reducer = UMAP(n_components=2, random_state=42)
    coords = reducer.fit_transform(vectors)
    
    # 按 category 着色
    for cat in set(labels):
        mask = [l == cat for l in labels]
        plt.scatter(coords[mask, 0], coords[mask, 1], label=cat, alpha=0.5)
    plt.legend()
    plt.savefig("embedding_viz.png")

这种图能肉眼看出:

  • 聚类分布:同类 chunk 是否在空间里聚成团?
  • 异常点:孤立的 chunk 可能是异常(embedding 坏了 / 内容异常)
  • 类别重叠:两个"不同"类别的 chunk 混在一起,说明 embedding 分不出

2D 图不严格,但能发现大问题。做重大 embedding 迁移前(§9.13)必做一次可视化对比。

异常 embedding 的识别

有些 chunk 的 embedding 本身就有问题——识别方法:

def detect_anomalous_embeddings(chunks):
    vectors = np.array([c.vec for c in chunks])
    norms = np.linalg.norm(vectors, axis=1)
    
    # 1. norm 异常(应该都是 1 如果归一化了)
    wrong_norm = (norms < 0.95) | (norms > 1.05)
    
    # 2. 全 0 或全非常小
    dead = norms < 0.01
    
    # 3. NaN / Inf
    invalid = ~np.isfinite(vectors).all(axis=1)
    
    # 4. 离群(和均值距离极远)
    mean_vec = vectors.mean(axis=0)
    dist_to_mean = np.linalg.norm(vectors - mean_vec, axis=1)
    outlier = dist_to_mean > dist_to_mean.mean() + 3 * dist_to_mean.std()
    
    return {
        "wrong_norm": wrong_norm.sum(),
        "dead": dead.sum(),
        "invalid": invalid.sum(),
        "outlier": outlier.sum(),
    }

每周跑一次,发现异常及时处理。

相似度分布的健康检查

健康的 embedding 系统,cosine 相似度应该满足:

  • 跨 chunk 平均相似度:明显低于同 doc 邻近 chunk(太高说明 embedding 塌缩,所有 chunk 都相似)
  • 同 doc 邻近 chunk 相似度:高于跨 doc
  • top-10 检索结果的分数分布:递减,不是平坦

绝对数值不要抄——不同模型的相似度尺度差很远(有的模型任意两条文本都在 0.7 以上),所以先在自己的模型上测出基线,之后盯的是这条基线的漂移,不是某个通用阈值。

监控:

def cosine_stats(chunks, sample=10000):
    sample_chunks = random.sample(chunks, min(sample, len(chunks)))
    vectors = np.array([c.vec for c in sample_chunks])
    
    # 随机对的相似度
    pairs = random.sample(range(len(vectors)), 1000)
    sims = []
    for i in range(0, len(pairs), 2):
        sim = cosine(vectors[pairs[i]], vectors[pairs[i+1]])
        sims.append(sim)
    
    return {"mean": np.mean(sims), "std": np.std(sims)}

相对基线的分布漂移(比如均值一路上移)——embedding 开始塌缩,质量下降。

跨模型对比

评估一个新 embedding 候选时,不只跑 benchmark 分数——还要和现有模型对比同一份数据的相似度结构:

def compare_embeddings(chunks, model_a, model_b):
    for chunk in chunks[:100]:
        v_a = model_a.embed(chunk.text)
        v_b = model_b.embed(chunk.text)
        
        # 两模型眼中的"最相似 chunk" 是否一致?
        top_a = find_nearest(v_a, all_vectors_a, top_k=5)
        top_b = find_nearest(v_b, all_vectors_b, top_k=5)
        
        overlap = len(set(top_a) & set(top_b)) / 5
        print(f"Overlap: {overlap:.2f}")

Overlap 很低——两模型的"相关概念"差距大,升级要慎重。

具体 chunk 的语义解释

想知道 "为什么模型觉得 chunk A 和 chunk B 相似"——技术上很难解释黑盒,但有几个 proxy 方法:

  • attention 可视化(自托管模型可用):看两个 chunk 的 encoder attention 重点对应哪些 token
  • 删词实验:逐个删除 chunk 里的关键词,看相似度变化——下降最多的词是"关键"
  • 换词实验:把关键词替换成同义词,看相似度是否保留——保留说明 embedding 捕捉了语义

这些 proxy 不严格,但比完全黑盒好。

Embedding 测试的 gold set

除了检索评估 gold set,embedding 本身也要 gold set(下面的阈值只是示意,按上一小节的做法先在自己的模型上测出基线再定):

测试项 1:同义句应该接近
- "企业版支持 SSO"
- "Enterprise 版本包含单点登录"
→ 期望 cosine > 0.85

测试项 2:反义句应该远
- "企业版支持 SSO"
- "基础版不支持 SSO"
→ 期望 cosine < 0.6

测试项 3:不相关应该远
- "企业版支持 SSO"
- "天气预报今天晴天"
→ 期望 cosine < 0.3

几百条这样的测试对作为 embedding unit gold set,每次升级跑;换模型时阈值要跟着重新标定。

生产 debug 工具链

生产环境的 debug 工具配置:

  • cosine similarity explorer:UI 里输入两段文本,立刻看相似度
  • neighbor lookup:任一 chunk → 看其 top-20 邻居(调试检索异常的直观方式)
  • embedding health dashboard:norm / 分布 / 异常数
  • A/B compare UI:同 query 同时跑两个模型,看差异

这些工具不需要花哨——简单 Streamlit / Gradio 应用即可,能省下大量人工排查。

Debug 的思维方式

Embedding debug 的核心是:假设 → 验证 → 迭代。

  • 不要"瞎试参数"——每次改都要有假设
  • 不要"只看一个案例"——每个改动都要在一批样本上看 aggregate 效果
  • 不要"跳过基础"——先验证 norm / 编码 / 版本正确,再深入调优

这套思维方式不只 embedding——所有 ML 系统 debug 通用。

Debug 工具的长期投资

团队应该长期投入维护这套工具:

  • 初版:先做上面几个最小可用的工具
  • 之后按需扩展
  • Badcase 库持续积累

Debug 工具是团队生产力的乘数。尤其 embedding 升级 / 向量库迁移这种大事,工具准备好的团队推进得快得多。

9.15 Embedding 的隐私风险:从向量能反推文本吗

Embedding 看起来只是「1024 个数字、看不懂」,很多工程师因此认为存向量比存原文安全。这是个严重误解——学术界已经证明 embedding 可以反推出原文。

flowchart LR
    classDef danger fill:#fdd,stroke:#c00
    T[原文: 企业版价格 2 万]:::danger
    T -->|embed| V[向量 0.12 0.44 ...]:::danger
    V -->|inversion 攻击| R[恢复的文本<br/>企业版的定价是 20000 元]:::danger

标志性工作是 Morris 等人的 Text Embeddings Reveal (Almost) As Much As Text(arXiv:2310.06816,2023)。他们把 inversion 建模成受控生成——生成一段文本,使它重新 embed 后逼近目标向量——并发现单步模型效果很差,但多步迭代修正再重嵌入的方法(作者开源为 Vec2Text)能把 32-token 的输入精确还原 92%。这里的数字要读准:是「32 token 的短输入被逐字还原的比例」,不是「任意长文本能恢复 92% 的内容」。同一篇论文还展示了从临床记录里恢复出患者全名。

2025 年有一篇复现研究(Seputis et al., arXiv:2507.07700)值得一并读:它成功复现了原结论,并补了两点工程上更有用的发现——攻击对输入长度敏感(长文本难度显著上升),以及高斯加噪和量化都能削弱这类攻击,其中量化更简单、适用面更广。

结论是:embedding 不是「脱敏后的原文」,它是原文的另一种表达,且可还原。

对 RAG 意味着什么

威胁在三种情况下是真实的:向量库被入侵,攻击者拿到 embedding 文件后反推出所有 chunk 内容,等同于原文泄漏;embedding 被导出或共享给第三方分析,以为匿名实际不匿名;用户数据被用于训练 embedding 模型,而模型被他人取得。

落到 RAG 的具体场景:企业内部索引被拖走等于机密外泄;和外部合作方共享「embedding 数据」并不因为它不是原文就安全;使用公有 embedding API 时,文本在 embed 那一刻本身就离开了你的环境。

不同数据的严重程度差别很大:

场景 威胁等级 原因
公开知识(维基百科 embed) 低 本来就是公开的
企业内部知识 RAG 高 Embedding 文件泄露 = 数据泄露
医疗 / 金融合规 极高 法规明确、赔偿罚款大
用户个人 memory embed 高 隐私泄露严重

不过也不必把它想成「拿到向量文件立刻看到明文」。攻击需要条件:知道用的是哪个 embedding 模型、能访问该模型做迭代,以及每条向量多轮迭代的 GPU 计算。所以现实形态是「花算力逐条恢复」,而不是即时解密——但对高价值数据来说,这个门槛并不高。

防御:按原文同级保护

第一层是存储和传输。向量库文件要做 at-rest 加密——Qdrant 开源版本身不做静态加密(1.19.1 源码里没有这项功能),要靠磁盘层加密,托管的 Qdrant Cloud 默认加密所有存储卷;Milvus 通过 cipher 插件支持存储层加密(pkg/util/paramtable/cipher_config.go);pgvector 跟着 Postgres 走,而社区版 PostgreSQL 没有内置 TDE,要靠文件系统 / 磁盘加密或提供 TDE 的商业发行版;Pinecone 托管服务默认加密。自托管则用 LUKS / dm-crypt 一类系统层加密,和对待任何敏感数据库一样。传输侧 TLS 是必需项而非可选项,内网同样不能明文(「内部可信」是个危险假设),高敏场景上 mTLS 双向认证。

第二层是访问控制:只有 RAG 服务本身能查询向量库,访问日志定期审计,任何大规模 export 都要触发告警——防的是撞库式批量下载。

第三层是少存、少发。如果 metadata 里同时存了原文,那就是双重暴露;可以只存 text_id、原文放独立的加密存储,或只存哈希、检索后再回查。对外 API 也应该只返回 chunk_id 列表而不返回向量——拿不到向量就谈不上反推。

第四层是让向量本身更难反推。前面那篇复现研究给了两个具体手段:加高斯噪声和量化,其中量化实现简单、适用面广,可以优先考虑。这条路的代价是检索精度会下降,工程上仍在成熟中,但已经不是纯理论。

用外部 embedding API 时还有一层额外风险:文本会离开你的环境到厂商服务器,是否存储、存多久取决于合同和 ToS,厂商被入侵你也受牵连。合规场景应尽量自托管;折中办法是先脱敏再 embed——把「张三的薪资 5 万」处理成「<人名>的薪资<金额>」,语义保留而隐私剥离。

这几层之间要权衡:完全加密就没法用向量检索,等于放弃 RAG;加噪会掉精度(掉多少取决于噪声强度,要在自己的 gold set 上扫一条曲线);脱敏后 embed 会丢失部分细节。多数企业 RAG 最终选的是自托管 + 加密存储 + 严格访问控制这个组合——既保住检索能力,数据也不出境。

合规与仍在演化的边界

法规对「embedding 算不算个人信息」尚无明确定义,但方向是清楚的。按 GDPR 第 4 条对 pseudonymisation 的定义,既然研究已证明可反推,趋势就是按伪匿名数据对待、与原文同级保护;HIPAA 保护的 PHI 若可从向量反推,同样落在保护范围内;中国 PIPL 下 embedding 若可反推识别个人,也受保护。稳妥做法是按比现行法规严一点的标准执行,等法规落定时你已经合规。

合规审查时要拿得出证据:加密算法与密钥管理的说明、谁查过哪些向量的访问日志、定期的第三方审计报告,以及 embedding 泄漏时的事故通知机制。这些是 baseline 而不是加分项。

还有一类相邻威胁叫 membership inference——攻击者不恢复原文,只判断某条数据是否存在于你的索引或训练集中。「某患者是否在这家医院」「某笔交易是否在库里」这类判断本身就是泄漏。防御思路是限制系统输出的信息量:检索结果不返回具体分数(只给存在与否)、限制查询频率以防拖库。

这仍是 2023 年之后才成型的研究领域,实践标准还在形成中,建议持续跟进 arXiv 上的相关工作,并和合规团队保持同步。但工程上的结论今天就可以落地:别把 embedding 当成看不见的数据,按原文等级对待它——存储加密、传输加密、访问控制、非必要不共享、审计留痕。这几件事加起来增加的复杂度有限,避免的却是一次真实的隐私事故。

9.16 跨专栏关联:Embedding 和预训练模型

Embedding 模型通常是预训练 encoder + 对比学习 finetune 的产物。encoder 骨架常用 BERT、RoBERTa、XLM-R,或最近的 Mistral、Gemma 这类 decoder LLM 改造而来。训练机制和原 LLM 预训练有重叠——了解 Transformer encoder 的 attention 和 pooling 层有助于理解 embedding 怎么产生。

《Rust 编译器与运行时揭秘》讨论 async 状态机时用的"把任意函数编译成状态机"的思路,在 Embedding 里有平行——把任意文本编码成固定维向量。两者都是"把多样输入压缩到固定形式"的工程抽象,只是一个在编译期、一个在推理期。

9.17 本章小结

  1. Embedding 把文本编码成向量让语义检索可计算——这是 RAG 检索的核心工具
  2. 三大训练范式:对比学习(主流)、蒸馏(小模型)、指令微调(LLM 级)
  3. 选型六维度:语言 / 领域 / 长度 / 维度 / 成本 / license
  4. Query / Document 非对称编码是必须注意的细节——生产代码严格区分 mode
  5. 微调 在专业领域值得——LoRA/QLoRA 成本低、效果显著
  6. 部署靠 动态 batch + 多模型路由 + 熔断降级(注意 encoder 的 prefix 不像自回归 LLM 那样能复用 KV cache,§9.6)
  7. 新趋势:Matryoshka / ColBERT v2 / 多向量——分层检索和精排

下一章讲向量索引——拿到几百万向量如何支持毫秒级 ANN 搜索——Flat、HNSW、IVF、PQ 的工程直觉。