Skip to content

Embedding从对比学习到生产检索完整原理 ​

Embedding 是把文本、图片、音频、商品或用户等对象映射为固定维度向量,使“对当前任务相似”的对象在向量空间中更接近。向量不是对象的完整副本,也不是天然携带全部事实;它是模型根据训练目标压缩出的表示。

真正掌握 Embedding 不能只会调用接口并把数组写进向量库,还要讲清:

  • 模型为什么会把语义相近文本映射得更近?
  • 正样本、负样本、对比学习和 In-batch Negative 在做什么?
  • Token 级隐藏状态怎样变成一个文本级向量?
  • Mean、CLS、Last Token Pooling 为什么不能随便替换?
  • 余弦、点积和欧氏距离有什么关系?
  • 为什么相似度高仍可能没有答案?
  • 为什么换模型、Pooling、归一化或维度通常要重建索引?
  • 批量 Embedding 部分失败怎样重试且不重复写脏数据?
  • 召回质量应该用什么数据和指标证明?

本页重点讲“内容怎样变成可检索向量”和完整生产契约;HNSW、IVF、PQ、分片和向量库运维在 向量数据库选型与原理 深入。

一、学习目标 ​

学完后,你应该能够:

  • 区分 Token Embedding、上下文隐藏状态和文本检索 Embedding。
  • 解释双塔模型和对比学习怎样训练语义空间。
  • 解释正样本、随机负样本、困难负样本和假负样本。
  • 写出 Masked Mean Pooling 和 L2 归一化过程。
  • 推导归一化向量上点积、余弦和欧氏距离的关系。
  • 解释文档与查询为什么要使用兼容模型、指令和版本。
  • 估算向量原始存储量,并理解维度不是越高越好。
  • 设计 Embedding 批处理的身份、校验、重试、发布和回滚。
  • 使用 Recall@K、MRR、nDCG 和分组指标评估召回。
  • 排查召回归零、召回退化、维度错误、NaN、延迟和成本异常。
  • 运行本页标准库 Demo 并验证 Pooling、归一化和相似度公式。

二、先区分三种经常混淆的表示 ​

名称形状方向是否依赖上下文主要用途
Token Embedding表[Vocab,D]查表时本身不看当前句子把Token ID变为模型初始向量
上下文隐藏状态[B,S,D]是,同一Token在不同句子中会变化Transformer内部预测和表示计算
文本检索Embedding[B,E]是,对整段文本编码和Pooling语义检索、聚类、去重、推荐

例如“苹果”在“吃一个苹果”和“苹果发布新设备”中,初始 Token Embedding 可能相同,但经过 Transformer 后上下文隐藏状态不同。文本 Embedding 模型再把整段序列压缩为一个 E 维向量,用于段落级相似度。

不能把聊天模型任意一层某个 Token 的隐藏状态直接当作高质量检索向量。检索模型通常使用专门的数据、Pooling 和对比学习目标训练。

三、Embedding解决什么,不解决什么 ​

3.1 适合解决 ​

  • 用户表达和文档字面不同,但含义相近。
  • 相似问题召回。
  • 文档、商品、图片或用户的语义近邻。
  • 聚类、去重和候选召回。

例如:

text
问题:缓存突然失效,大量请求打到数据库怎么办?
文档:Redis缓存雪崩与击穿治理

关键词不完全一致,语义模型可能仍把它们映射得较近。

3.2 不直接解决 ​

  • 精确订单状态、库存、金额等实时事实。
  • 租户、角色和部门权限。
  • 文档版本冲突。
  • “相似片段是否真正包含答案”。
  • 数据库事务和唯一约束。
  • 最终回答是否忠于证据。

因此生产 RAG 是:

text
Embedding语义候选
+ 关键词精确候选
+ Metadata与ACL过滤
+ Rerank
+ 证据阈值
+ Prompt预算
+ 引用和答案校验

四、文本怎样变成一个向量 ​

mermaid
flowchart TD
    A["原始文本"] --> B["按模型Tokenizer切成Token"]
    B --> C["截断、Padding与Attention Mask"]
    C --> D["Encoder计算每个Token隐藏状态"]
    D --> E["按模型约定执行Pooling"]
    E --> F["可选投影到目标维度"]
    F --> G["按模型约定做L2归一化"]
    G --> H["得到固定维度文本向量"]

4.1 Tokenization ​

输入必须使用与模型权重匹配的 Tokenizer。最大输入长度不足时,文本可能被截断;如果答案段落在截断部分,向量自然无法表达它。

4.2 Encoder ​

Encoder 使用 Transformer 等网络把 [B,S] Token ID 转成 [B,S,D] 上下文隐藏状态。每个有效 Token 都有一条 D 维表示。

4.3 Pooling ​

检索需要每段文本一个固定向量,因此要把 S 个 Token 表示聚合为一个向量。Pooling 策略是模型训练契约的一部分,不是部署人员随意选择的展示选项。

4.4 投影与归一化 ​

模型可能把隐藏维度投影到 E 维检索空间,再按训练方式做 L2 归一化。Provider 接口有时直接返回最终向量,调用方仍需确认它是否已归一化以及推荐的距离函数。

五、Pooling为什么决定文本向量含义 ​

5.1 Mean Pooling ​

对所有有效 Token 隐藏状态求平均:

text
sentenceVector
= Σ(tokenHidden × attentionMask)
  / Σ(attentionMask)

Padding 的 Mask 为 0,不能进入分子和分母。

5.2 CLS Pooling ​

取特殊分类 Token 的最终隐藏状态。前提是模型训练时明确让该位置承载整段表示。不是所有模型都有 CLS,也不是任意模型的第一个 Token 都能当高质量句向量。

5.3 Last Token Pooling ​

取最后一个有效 Token 的隐藏状态。部分 Decoder 方向 Embedding 模型使用这种策略,因为因果模型最后位置已读取此前全部 Token。必须根据 Attention Mask 找到最后有效位置,不能误取右侧 Padding。

5.4 为什么不能上线时随便换Pooling ​

模型通过特定训练目标把语义组织到特定 Pooling 输出中。将训练时 Mean Pooling 改为 CLS 或 Last Token,相当于更换向量生成函数,空间分布、分数和阈值都会变化,通常需要重新评估并重建索引。

5.5 Padding方向也会影响实现 ​

  • Right Padding:有效 Token 在左,Last Token 要按 Mask 找最后一个 1。
  • Left Padding:有效 Token 在右,最后数组位置可能是有效 Token,但仍应按模型约定处理。
  • Mean Pooling:两种 Padding 都必须排除 Mask 为 0 的位置。

如果把 Padding 向量平均进去,短文本受影响通常更明显。

六、L2归一化做了什么 ​

向量 v 的 L2 范数:

text
||v||₂ = sqrt(v1² + v2² + ... + vn²)

归一化:

text
v_normalized = v / ||v||₂

归一化后向量长度约为 1,主要保留方向信息。

6.1 零向量不能直接归一化 ​

若范数为 0,除法会产生错误或 NaN。生产入库必须拒绝零向量、NaN、Infinity 和错误维度,不能把异常结果写进索引。

6.2 是否归一化取决于模型契约 ​

不是所有模型和索引都要求调用方手动归一化。有的服务已返回单位向量,有的训练目标保留向量模长信息。必须遵循模型说明,并在索引和查询两端保持一致。

七、余弦、点积和欧氏距离怎样选择 ​

7.1 余弦相似度 ​

text
cos(a,b) = a·b / (||a|| × ||b||)

它主要比较方向,范围通常为 [-1,1]。文本模型实际分数分布由训练决定,不能把 0.8 当成跨模型通用阈值。

7.2 点积 ​

text
dot(a,b) = a·b

同时受方向和模长影响。如果 a、b 都已经 L2 归一化:

text
dot(a,b) = cosine(a,b)

7.3 欧氏距离 ​

text
L2(a,b) = sqrt(Σ(ai-bi)²)

距离越小越接近。对单位向量:

text
||a-b||² = 2 - 2cos(a,b)

因此单位向量上按余弦从高到低、点积从高到低、欧氏距离从低到高,在数学上产生相同排序方向;浮点误差和具体索引实现仍需考虑。

7.4 为什么向量库分数不能直接跨实现比较 ​

有的 API 返回 similarity,越大越好;有的返回 distance,越小越好;有的对距离做转换。迁移向量库时必须确认:

  • 索引使用 Cosine、Inner Product 还是 L2。
  • 数据和查询是否归一化。
  • 返回字段是距离还是相似度。
  • 阈值判断方向是否相反。

八、Embedding空间是怎样训练出来的 ​

8.1 双塔或双编码器方向 ​

检索模型常分别编码 Query 和 Document:

text
q = QueryEncoder(query)
d = DocumentEncoder(document)
score = similarity(q,d)

两边可以共享权重,也可以有不同指令或适配。目标是让正确 Query-Document 对分数高,让不相关对分数低。

mermaid
flowchart TD
    A["Query文本"] --> B["Query Encoder与Pooling"]
    C["Document文本"] --> D["Document Encoder与Pooling"]
    B --> E["Query向量"]
    D --> F["Document向量"]
    E --> G["计算正负样本相似度"]
    F --> G
    G --> H["对比Loss"]
    H --> I["反向传播更新编码器"]

8.2 正样本 ​

正样本表示业务上应该匹配,例如:

text
Query:patient_id字段表示什么?
Document:patient_id是患者唯一标识。

正样本质量决定模型学到什么“相似”。标题重复、模板噪声或错误配对会污染空间。

8.3 负样本 ​

负样本告诉模型哪些内容不应匹配:

  • 随机负样本:通常容易区分,训练信号有限。
  • 困难负样本:主题相近但答案不对,例如 patient_id 与 visit_id。
  • In-batch Negative:同一 Batch 中其他文档作为当前 Query 的负样本。

8.4 假负样本为什么危险 ​

Batch 中另一个文档可能也正确回答当前问题,却被当作负样本。训练会错误地把真正相关内容推远。需要去重、同义答案标注、跨语言对应和多正样本设计。

8.5 对比Loss的直觉 ​

一个常见方向是对 Query 与 Batch 内文档分数做 Softmax,让正确文档的概率更高:

text
P(correctDoc | query)
= exp(score(q,dPositive)/temperature)
  / Σj exp(score(q,dj)/temperature)

Loss 是正确文档负对数概率。训练会提升正对分数、降低负对相对分数。Temperature 控制分布锐度,过小可能让梯度集中在少数样本,具体值必须随训练方案确定。

8.6 为什么“语义相似”取决于训练任务 ​

同一段文本可以按主题、答案、用户意图、商品购买关系或代码功能定义相似。通用语义模型未必适合医疗字段、法律条款和错误码精确检索,必须用真实业务评估。

九、Query和Document是否必须完全同样处理 ​

必须处在兼容的向量空间,但不等于输入字符串完全相同。

部分模型要求:

text
query: 用户问题
passage: 文档内容

或使用不同任务指令。训练时模型已经学习这些角色前缀。如果入库忘记 Document 指令、查询忘记 Query 指令,虽然模型名相同,召回也可能明显退化。

需要版本化的不只是模型名:

text
embeddingRevision
= provider
+ modelVersion
+ tokenizerVersion
+ queryInstructionVersion
+ documentInstructionVersion
+ pooling
+ normalization
+ outputDimension
+ preprocessingVersion

十、维度越高越好吗 ​

不一定。更高维度可能提供更大表示容量,但效果取决于训练,不是空维度数量。

10.1 原始向量存储估算 ​

忽略索引、Metadata、副本和对齐时:

text
raw bytes = vectorCount × dimension × bytesPerElement

例如一千万条、1024 维、Float32:

text
10,000,000 × 1024 × 4
= 40,960,000,000 bytes
≈ 38.15 GiB

真实存储还包括:

  • ANN 索引图或聚类结构。
  • 主键与 Metadata。
  • 删除标记和版本。
  • 副本、备份和 WAL。
  • 内存对齐与缓存。

10.2 维度影响 ​

  • 单条存储和网络响应。
  • 相似度计算量。
  • 索引内存。
  • 批量 Embedding 输出大小。
  • 重建索引时间。

模型支持降维输出时,也必须在目标维度重新评估,不能默认截短后质量不变。

十一、为什么相似度高不代表能够回答 ​

问题:

text
Redis缓存击穿怎样处理?

高相似片段:

text
Redis缓存雪崩是大量Key同时失效造成数据库压力。

主题相似,但没有回答击穿治理。Embedding 优化的是训练定义的相似关系,不直接验证答案覆盖。

常见情况:

  • 同主题但不同问题。
  • 同实体但版本错误。
  • 含问题关键词但没有结论。
  • 多个 Chunk 各含半个答案。
  • 相反结论但语义词高度重合。

因此要分开评估:

text
正确Chunk是否进入候选
→ 是否被Rerank保留
→ 是否进入最终Prompt
→ 答案是否被引用证据支持

十二、Chunk怎样影响向量 ​

Embedding 把一段文本压缩为固定维度。一个 Chunk 混入多个主题时,表示可能成为多种信息的折中,任何一个主题都不够突出。

12.1 太短 ​

  • 指代对象缺失。
  • 标题和条件丢失。
  • 大量片段高度相似。
  • 召回后无法独立回答。

12.2 太长 ​

  • 多主题混合。
  • 输入被模型截断。
  • 精确答案信号被背景稀释。
  • 入库和查询成本增加。

12.3 商业切分原则 ​

  • 按标题、段落、条款、字段、表格行和代码结构切分。
  • 将必要标题路径加入 Embedding 文本。
  • 原文与用于 Embedding 的文本分开保存,防止引用展示人工前缀。
  • 使用稳定 docId、chunkId 和 contentHash。
  • Metadata 保存租户、角色、版本、来源和有效期。
  • 重叠不是越多越好,过度重叠会产生近重复候选。

十三、Embedding批处理完整过程 ​

mermaid
flowchart TD
    A["待处理Chunk"] --> B["按contentHash判断是否变化"]
    B --> C["按模型长度和批次限制组Batch"]
    C --> D["调用Embedding服务"]
    D --> E["校验响应数量、顺序和身份"]
    E --> F["校验维度、NaN、Infinity和零向量"]
    F --> G["按契约归一化"]
    G --> H["幂等写入候选索引"]
    H --> I["记录batch状态、成本与版本"]
    I --> J["全部完成后做计数与召回评估"]
    J --> K["发布新索引Alias"]

13.1 幂等身份 ​

推荐至少包含:

text
chunkId + contentHash + embeddingRevision

相同内容和相同 Revision 重试不应生成重复记录;内容或模型契约变化应产生新版本向量。

13.2 Batch为什么不能只记录序号 ​

重试后顺序可能改变。应保存 batchId、输入 Chunk ID 列表、请求摘要、Provider Request ID、尝试次数、状态和每条结果身份。若 Provider 只保证按输入顺序返回,也要先校验响应数量,再按请求快照绑定。

13.3 必须校验什么 ​

  • 返回向量数量等于请求数量。
  • 每条向量维度符合 Revision。
  • 所有元素是有限数值。
  • 范数符合预期,不是零向量。
  • 输入没有因最大长度被静默错误截断;记录 Token 数和截断策略。
  • Metadata、tenantId、chunkId 没有错位。

13.4 什么错误可以重试 ​

  • 429、瞬时 5xx、连接重置:尊重 Retry-After,指数退避加抖动,有限重试。
  • 401、无效模型、维度配置错误、输入永久超长:修复配置,不应盲目重试。
  • 批内单条非法:隔离到 Quarantine,不能让整个索引永远卡住,也不能跳过后无记录。

十四、模型升级为什么通常要重建索引 ​

模型 B 生成的向量坐标轴通常与模型 A 不可直接比较,即使维度相同也不代表同一空间。以下任意改变都可能需要新 Revision:

  • 模型或权重版本。
  • Tokenizer。
  • Query/Document 指令。
  • Pooling。
  • 归一化。
  • 输出维度。
  • 文本清洗和标题拼接方式。

14.1 不应原地覆盖 ​

正确方向:

mermaid
flowchart TD
    A["线上索引index_v17"] --> B["使用新Revision构建index_v18"]
    B --> C["校验数量、维度、ACL和失败批次"]
    C --> D["固定评估集比较Recall、MRR、延迟和成本"]
    D --> E{"门禁是否通过"}
    E -- "否" --> F["保留v17并修正方案"]
    E -- "是" --> G["Alias原子切换到v18"]
    G --> H["灰度观察线上指标"]
    H --> I{"是否异常"}
    I -- "是" --> J["Alias切回v17"]
    I -- "否" --> K["稳定后按保留策略清理旧版"]

查询服务必须使用与 Alias 指向索引匹配的 Query Embedding Revision。先切查询模型、后切文档索引,或反过来,都会形成跨空间查询窗口。

14.2 回滚不只切Alias ​

缓存 Key 必须包含索引和 Embedding Revision;回滚后要处理旧缓存和在途请求。日志记录实际使用的 indexVersion,而不是只记录“当前配置”。

十五、向量检索与ANN的边界 ​

少量向量可以暴力计算所有距离,结果精确但规模大时成本高。向量数据库通常使用 ANN 近似最近邻索引减少候选搜索。

ANN 意味着:

  • 更快、更省资源。
  • 可能漏掉真实精确 TopK。
  • 索引参数影响 Recall、延迟和内存。
  • Metadata Filter 与 ANN 的结合方式会影响结果。

HNSW、IVF、PQ、Filter、分片和索引参数详见 向量数据库选型与原理。本页评估的 Recall@K 是“整个检索链路”的结果,既受 Embedding 模型影响,也受 ANN 配置和过滤影响。

十六、检索评估集怎样建立 ​

每条评估样本至少包含:

json
{
  "queryId": "q-1001",
  "query": "patient_id字段表示什么",
  "tenantId": "hospital-a",
  "roles": ["data_admin"],
  "relevantChunkIds": ["lis-field-patient-id-v3"],
  "relevanceGrades": {
    "lis-field-patient-id-v3": 3,
    "lis-table-overview-v3": 1
  },
  "tags": ["field", "exact_term", "medical"],
  "mustNotReturn": ["hospital-b-private-field"]
}

样本要覆盖:

  • 精确字段、错误码、编号。
  • 同义表达和口语问题。
  • 相近概念困难负样本。
  • 无答案问题。
  • 多文档共同回答。
  • 多语言和代码混合。
  • 租户、角色和密级隔离。
  • 历史线上召回失败。

十七、Recall@K、MRR和nDCG是什么 ​

17.1 Recall@K ​

在前 K 个候选中,相关文档被召回了多少。若每个问题只有一个标准 Chunk,常简化为“正确 Chunk 是否进入 TopK”。

text
Recall@K
= TopK中相关文档数 / 全部相关文档数

Recall@20 高不代表最终 Prompt 一定包含正确证据,因为后续 Rerank 和预算裁剪可能移除它。

17.2 MRR ​

关注第一个相关结果排名:

text
RR = 1 / 第一个相关结果名次
MRR = 所有Query的RR平均值

正确文档排第 1 得 1,排第 5 得 0.2。适合用户主要需要第一个正确答案的场景。

17.3 nDCG ​

nDCG 支持多个相关等级,让高相关文档排前面获得更高分,再与理想排序归一化。适合多个 Chunk 都有价值但相关程度不同的场景。

17.4 不能只看总平均 ​

必须按以下维度分组:

  • 业务域。
  • 语言。
  • Query长度。
  • 精确词与语义词。
  • 是否多跳。
  • 新旧文档。
  • 权限标签。
  • 困难负样本。

平均值可能掩盖医疗字段、支付制度等高风险场景退化。

十八、可运行Demo:Masked Mean Pooling与相似度 ​

下面只使用 Python 标准库,验证:

  • Padding 不参与 Mean Pooling。
  • L2 归一化后范数为 1。
  • 单位向量的点积等于余弦相似度。
  • 单位向量的平方欧氏距离等于 2-2cosine。
python
import math


Vector = list[float]


def dot(a: Vector, b: Vector) -> float:
    if len(a) != len(b):
        raise ValueError("向量维度不同")
    return sum(x * y for x, y in zip(a, b))


def l2_norm(v: Vector) -> float:
    return math.sqrt(dot(v, v))


def l2_normalize(v: Vector) -> Vector:
    norm = l2_norm(v)
    if norm == 0.0 or not math.isfinite(norm):
        raise ValueError("零向量或非法向量不能归一化")
    result = [value / norm for value in v]
    if not all(math.isfinite(value) for value in result):
        raise ValueError("归一化结果包含非法数值")
    return result


def cosine_similarity(a: Vector, b: Vector) -> float:
    denominator = l2_norm(a) * l2_norm(b)
    if denominator == 0.0:
        raise ValueError("零向量不能计算余弦相似度")
    return dot(a, b) / denominator


def squared_l2_distance(a: Vector, b: Vector) -> float:
    if len(a) != len(b):
        raise ValueError("向量维度不同")
    return sum((x - y) ** 2 for x, y in zip(a, b))


def masked_mean_pooling(
    token_embeddings: list[Vector], attention_mask: list[int]
) -> Vector:
    if not token_embeddings or len(token_embeddings) != len(attention_mask):
        raise ValueError("Token数量和Mask长度不一致")
    dimension = len(token_embeddings[0])
    if any(len(vector) != dimension for vector in token_embeddings):
        raise ValueError("Token向量维度不一致")

    total = [0.0] * dimension
    valid_count = 0
    for vector, mask in zip(token_embeddings, attention_mask):
        if mask not in (0, 1):
            raise ValueError("Mask只能是0或1")
        if mask == 1:
            valid_count += 1
            for index, value in enumerate(vector):
                total[index] += value

    if valid_count == 0:
        raise ValueError("没有有效Token")
    return [value / valid_count for value in total]


if __name__ == "__main__":
    # 前两个Token有效,后两个是Padding。Padding值故意设得很大,
    # 用于证明正确Mask后它们不会污染文本向量。
    token_embeddings = [
        [1.0, 0.0, 1.0],
        [0.0, 1.0, 1.0],
        [100.0, 100.0, 100.0],
        [100.0, 100.0, 100.0],
    ]
    pooled = masked_mean_pooling(token_embeddings, [1, 1, 0, 0])
    assert pooled == [0.5, 0.5, 1.0]

    a = l2_normalize(pooled)
    b = l2_normalize([0.4, 0.6, 1.0])
    cosine = cosine_similarity(a, b)
    inner_product = dot(a, b)
    l2_squared = squared_l2_distance(a, b)

    assert math.isclose(l2_norm(a), 1.0, abs_tol=1e-12)
    assert math.isclose(l2_norm(b), 1.0, abs_tol=1e-12)
    assert math.isclose(inner_product, cosine, abs_tol=1e-12)
    assert math.isclose(l2_squared, 2.0 - 2.0 * cosine, abs_tol=1e-12)

    print("masked pooled:", pooled)
    print("normalized a:", [round(value, 6) for value in a])
    print("cosine = dot:", round(cosine, 6))
    print("squared L2:", round(l2_squared, 6))

十九、可运行Demo:批量向量校验与幂等身份 ​

python
import hashlib
import math
from dataclasses import dataclass


@dataclass(frozen=True)
class ChunkInput:
    chunk_id: str
    content: str


def content_hash(content: str) -> str:
    normalized = " ".join(content.split())
    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()


def embedding_identity(chunk: ChunkInput, revision: str) -> str:
    raw = f"{chunk.chunk_id}|{content_hash(chunk.content)}|{revision}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def validate_batch(
    inputs: list[ChunkInput], vectors: list[list[float]], expected_dimension: int
) -> None:
    if len(inputs) != len(vectors):
        raise ValueError("响应向量数量与输入数量不一致")
    for chunk, vector in zip(inputs, vectors):
        if len(vector) != expected_dimension:
            raise ValueError(f"{chunk.chunk_id}向量维度错误")
        if not all(math.isfinite(value) for value in vector):
            raise ValueError(f"{chunk.chunk_id}包含NaN或Infinity")
        norm = math.sqrt(sum(value * value for value in vector))
        if norm == 0.0:
            raise ValueError(f"{chunk.chunk_id}是零向量")


if __name__ == "__main__":
    chunks = [
        ChunkInput("c-1", "patient_id 是患者唯一标识"),
        ChunkInput("c-2", "visit_id 是就诊唯一标识"),
    ]
    vectors = [[0.1, 0.2, 0.3], [0.2, 0.1, 0.4]]
    validate_batch(chunks, vectors, expected_dimension=3)

    revision = "embed-v3|mean|l2|dim-3|doc-instruction-v2"
    first = embedding_identity(chunks[0], revision)
    retry = embedding_identity(chunks[0], revision)
    changed = embedding_identity(
        ChunkInput("c-1", "patient_id 是患者脱敏后的唯一标识"), revision
    )

    assert first == retry
    assert first != changed
    print("idempotent identity:", first[:16])
    print("changed content identity:", changed[:16])

这个 Demo 证明同一 Chunk、同一内容和同一 Revision 在重试时身份稳定;内容变化后身份变化。真实系统还应把 batchId、索引版本、Provider Request ID 和状态写入持久化任务表。

二十、商业场景:医疗字段知识库 ​

目标:用户问“patient_id 是什么”,只召回当前医院、当前角色可见、当前版本的字段定义。

mermaid
flowchart TD
    A["字段目录和数据标准"] --> B["解析字段名、表名、系统和版本"]
    B --> C["按字段定义语义切分"]
    C --> D["保存租户、角色、密级与有效期"]
    D --> E["Document指令与Embedding"]
    E --> F["写入候选索引"]
    F --> G["数量、维度、ACL与固定Query评估"]
    G --> H["Alias发布"]
    I["用户问题"] --> J["认证上下文生成ACL Filter"]
    J --> K["Query指令与Embedding"]
    K --> L["ACL过滤加混合召回"]
    L --> M["Rerank与证据阈值"]
    M --> N["返回Chunk与真实引用"]

20.1 为什么字段名要进入Embedding文本 ​

字段定义正文可能只写“患者唯一标识”,用户却直接问 patient_id。用于 Embedding 的文本可以加入受控标题路径:

text
系统:LIS
表:lab_result
字段:patient_id
定义:患者唯一标识

但展示引用仍使用原始文档和真实位置,不应把人工拼接前缀伪装为原文。

20.2 为什么还要关键词召回 ​

patient_id、错误码和接口路径是精确符号,BM25/关键词往往比纯语义更可靠。商业系统通常做混合召回后 Rerank,而不是强迫一个 Embedding 解决所有精确匹配。

20.3 权限为什么必须在检索阶段生效 ​

如果先全库 TopK 再删除越权结果,越权向量对应的 Metadata 或内容可能已经进入应用内存、日志、Rerank 或模型。Filter 应由服务端认证上下文生成并尽量下推到候选检索阶段。

二十一、生产故障排查Runbook ​

21.1 发布后Recall突然接近零 ​

  1. 检查 Query 服务实际使用的 embeddingRevision 和索引版本。
  2. 检查是否出现模型 A 查询索引 B 的跨空间组合。
  3. 检查维度、归一化和距离函数是否一致。
  4. 检查 Query/Document 指令是否遗漏或互换。
  5. 检查 Alias 是否指向尚未完成的候选索引。
  6. 用固定 Query 暴力比较少量向量,区分模型问题和 ANN 索引问题。

21.2 只有短文本或某语言退化 ​

  • Padding 是否错误进入 Pooling,短文本受污染更大。
  • 文本是否超过模型长度并被截断。
  • Tokenizer 是否匹配权重。
  • 候选模型是否真正支持该语言和业务术语。
  • 清洗是否删除了字段名、数字、下划线或代码符号。
  • 评估集是否按语言、长度和精确符号分组。

21.3 批处理任务卡住或反复重跑 ​

  1. 查看任务表中各 batch 状态、尝试次数和最后错误。
  2. 区分限流、瞬时网络、永久非法输入和写索引失败。
  3. 检查是否只记录批序号而没有 Chunk 身份,导致无法精确恢复。
  4. 使用 chunkId + contentHash + embeddingRevision 幂等写入。
  5. 将永久失败样本隔离并告警,候选索引发布门禁必须知道缺失数量。

21.4 向量库报维度不一致 ​

  • 模型或输出维度切换但仍写旧索引。
  • Provider 默认维度变更。
  • 降维参数只在查询或文档一侧生效。
  • 空响应被错误解析成长度 0。
  • 多模型共用一个没有 Revision 隔离的索引。

不要通过补零或截断“修好”未知模型输出,应该阻断写入并修复版本契约。

21.5 出现NaN、Infinity或零向量 ​

阻断该条写入,记录脱敏后的输入身份、模型版本和 Provider Request ID;检查空文本、全 Padding、数值溢出、服务异常和错误 Pooling。不能把非法向量继续交给 ANN,结果可能不可预测。

21.6 延迟和成本突然升高 ​

检查:

  • 每批文本数量和 Token 总量。
  • Chunk 是否变长、重复率是否增加。
  • 批大小是否过小导致网络开销高,或过大触发超时。
  • 429 与重试次数。
  • 缓存是否因 Revision 变化失效。
  • 在线 Query Embedding 是否与离线批处理争抢同一容量。
  • 模型是否被路由到更大或跨区域 Provider。

二十二、常见误区与后果 ​

误区正确理解
Embedding是文本的无损压缩固定维向量只保留训练目标需要的表示
聊天模型隐藏状态可直接做检索检索通常需要专门训练、Pooling和评估
维度越高效果越好效果由训练决定,维度还增加存储与计算
模型名相同就一定兼容指令、Tokenizer、Pooling、归一化和Revision也必须一致
余弦0.8是通用高分分数分布和阈值依模型、领域和距离定义
相似度高就有答案可能只是同主题,需要Rerank和证据评估
换模型只改查询端文档和查询会落在不同空间,召回可能归零
向量检索后再做权限即可越权候选可能已进入内存和日志
Recall@20高说明RAG回答好正确Chunk还可能被重排或预算移除
批失败就全量从头跑应按稳定身份和batch状态精确恢复

二十三、面试标准回答 ​

23.1 Embedding是什么 ​

Embedding 是模型把文本等对象映射成固定维度向量,使训练任务定义的相似对象在空间中更接近。文本通常经过 Tokenizer、Encoder、Pooling、可选投影和归一化得到向量;它适合语义候选召回,但不负责权限、版本和事实正确性。

23.2 Embedding模型怎样训练 ​

常见双塔方向分别编码 Query 和 Document,用正样本表示应匹配的问答或文本对,用随机、In-batch 和困难负样本表示不应匹配的候选。对比 Loss 提高正对相似度、降低负对相对分数,通过反向传播更新编码器,使向量空间适合检索目标。

23.3 Mean Pooling为什么要乘Attention Mask ​

Batch 中 Padding 只是补齐长度,不是文本内容。Mean Pooling 应只累加 Mask 为 1 的有效 Token,并除以有效 Token 数;如果把 Padding 算进去,短文本向量会被占位表示污染。CLS、Last Token 等策略也必须与模型训练约定一致。

23.4 余弦、点积和欧氏距离有什么关系 ​

余弦比较方向,点积同时受方向和模长影响,欧氏距离比较空间距离。当文档和查询都 L2 归一化后,点积等于余弦,平方欧氏距离等于 2-2cosine,排序方向等价;未归一化时不能直接这样替换。

23.5 为什么更换Embedding模型要重建索引 ​

新模型通常产生不同坐标空间,即使维度相同也不可直接比较。模型、Tokenizer、Query/Document指令、Pooling、归一化、维度或预处理变化都应形成新 Revision,在独立候选索引全量或按规则重建,评估通过后原子切换 Alias。

23.6 怎样评估Embedding检索 ​

建立真实 Query 与相关 Chunk 标注集,覆盖同义表达、精确字段、困难负样本、无答案和权限样本;看 Recall@K 是否召回相关文档、MRR 看第一个相关结果排名、nDCG 看多级相关排序,并按业务域、语言、长度和风险分组,同时记录延迟、成本和越权候选为零。

更多简洁回答见 AI应用工程化面试题,本页负责完整原理。

二十四、关联知识点 ​

二十五、学习验收清单 ​

  • [ ] 能区分 Token Embedding、隐藏状态和文本检索向量。
  • [ ] 能画出文本到固定维向量的完整链路。
  • [ ] 能解释 Mean、CLS、Last Token Pooling 的边界。
  • [ ] 能手写 Masked Mean Pooling 并排除 Padding。
  • [ ] 能推导归一化向量上余弦、点积和L2的关系。
  • [ ] 能解释正样本、困难负样本、In-batch Negative和假负样本。
  • [ ] 能说明 Query/Document 指令为什么属于模型契约。
  • [ ] 能估算原始向量存储量,并列出真实额外开销。
  • [ ] 能设计 Embedding Revision 和幂等身份。
  • [ ] 能设计候选索引、评估、Alias发布与回滚。
  • [ ] 能解释 Recall@K、MRR、nDCG 分别衡量什么。
  • [ ] 能区分模型召回问题、ANN问题、ACL问题和Rerank问题。
  • [ ] 能根据日志排查跨模型空间、维度错误、NaN和批处理失败。

达到这些标准后,才算真正掌握 Embedding,而不是只会把一段文本转换成浮点数组。