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 的文本分开保存,防止引用展示人工前缀。
  • 使用稳定 docIdchunkIdcontentHash
  • 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,而不是只会把一段文本转换成浮点数组。