全文共 13,529 字 预计阅读 39 分钟
bg

bg16b.短视频-多模态数据管道

多模态数据怎么存储呢?

16A 有一条清晰的恒等式:广告收入 ∝ DAU × 人均时长内容链在这条式子里一个字都没出现。

那为什么还要投入巨大算力去理解每一个视频?因为它通过四条路径影响那条式子,而且其中两条是负向的风险,不是正向的收入。

# 路径 因果链 性质
1 分发精准度 理解视频内容 → 能匹配到对的人 → 用户看得久、完播率高 → 人均时长上升 → 库存上升 正向收入
2 审核合规 识别违规内容 → 拦在分发前 风险规避:漏放的代价可能是罚款、下架、牌照——不是"少赚",是"归零"
3 版权与查重 识别搬运、重复上传 → 保护原创、避免重复分发 风险 + 生态
4 转码与 CDN 成本 按内容特征选择合适的码率档位 → 同样画质更少带宽 → 降低成本 成本

注意路径 2 的性质,它决定了本篇的很多技术选择。 收入类需求可以接受"95% 够用了";而审核是不对称风险——漏放一条违规内容的代价,远大于多审一万条正常内容的成本。这个不对称会在两个地方留下痕迹:

  • 审核链路的延迟要求是分钟级甚至秒级(内容在审核完成前不能大范围分发),所以它不能走 T+1 批处理;
  • 审核链路必须可追溯、可重跑——事后要能回答"这条内容当时是被哪个版本的模型、按什么阈值判定为通过的"。这直接要求特征表和判定结果表都带模型版本号

自问:路径 1 的"理解视频内容",具体是理解成什么?存在哪?

存成两类东西,这是本篇建模部分的全部主题:

  • 离散标签:分类(美食/宠物/舞蹈)、实体(出现了哪个人物、哪个商品)、属性(是否有人脸、是否有字幕)。它们是结构化的STRING/ARRAY<STRING> 就能存。
  • embedding(向量):一串浮点数,用来表示"这个视频在内容空间里的位置"。它不是结构化的传统意义上的一列,它是本篇技术难题的来源。

一个创作者点了"发布",之后发生的事:

flowchart TD
    UP["创作者上传<br/>原始视频 1 个文件"] --> OSS1["对象存储<br/>原片 raw"]

    OSS1 --> TC["转码<br/>(CPU 或 GPU 硬编码)"]
    TC --> OSS2["对象存储<br/>多档位播放文件<br/>1080p / 720p / 480p ..."]
    OSS2 --> CDN["CDN 分发<br/>(16A 链路一)"]

    OSS1 --> KF["抽帧<br/>(decode + 采样)"]
    KF --> OSS3["对象存储<br/>关键帧图片 N 张"]

    OSS1 --> ASR["语音识别 ASR<br/>(GPU)"]
    OSS3 --> OCR["画面文字识别 OCR<br/>(GPU)"]
    OSS3 --> VIS["视觉模型<br/>分类 / 检测 / embedding<br/>(GPU)"]
    ASR --> TXT["文本模型<br/>标签 / embedding<br/>(GPU)"]

    OCR --> META["结构化元数据 + 标签<br/>(湖表)"]
    VIS --> META
    TXT --> META
    VIS --> EMB["embedding 表<br/>(湖表 / 向量索引)"]
    TXT --> EMB

    META --> AUD["审核判定"]
    META --> REC["推荐召回与排序"]
    EMB --> VDB["向量检索服务<br/>(查重 / 相似召回)"]
    EMB --> REC

先把图里三个画像未列的名词定义掉。

转码(transcoding):把上传的原始视频重新编码成若干个不同分辨率、不同码率的版本,让不同网络条件的用户各取所需(弱网取 480p,Wi-Fi 取 1080p)。它是整条链路里最耗算力的一步,因为要先把视频完整解码再重新编码。它属于音视频团队的职责,但它产生的数据(多个衍生文件 + 每个文件的规格)要进我们的表

关键帧 / 抽帧(keyframe extraction / frame sampling):视频本质是一串图片(帧)。视觉模型通常不直接吃整段视频,而是按某种规则抽出若干张代表性的图片(例如每秒 1 张,或只取编码意义上的关键帧),然后当图片处理。抽帧规则是一个必须记录进元数据的参数——因为"每秒 1 张"和"每 5 秒 1 张"抽出来的特征不可比。

ASR / OCR:ASR(Automatic Speech Recognition,语音识别)把音轨转成文字;OCR(Optical Character Recognition,光学字符识别)把画面里的文字(字幕、贴纸、商品标签)转成文字。它们的意义是把非结构化的音视频,转成我们最擅长处理的文本。 一旦变成文本,后面就能用文本模型、能建全文索引、能写 LIKE

多模态(multimodal):同一个对象同时有多种形式的数据——视频有画面、有声音、有标题文本、有用户评论。"多模态处理"就是把这几种形式的数据放在一起做理解。它对数据工程的直接含义是:一个逻辑实体的数据分散在多种物理形态里(二进制文件 + 文本 + 数值向量),而下游要求它们能被当成"一行"取出来。

数据形状:和行为链逐维度对比

维度 行为链(16A) 内容链(本篇)
单条大小 约 200 字节 原片 几 MB 到几百 MB;embedding 几 KB
条数/天(假设值) 2160 亿 千万级(上传量)
数据总量/天 约 43 TB 数百 TB 到 PB 级(原片 + 转码产物)
瓶颈资源 CPU(每秒条数)、网络 shuffle GPU(每秒能处理多少视频)、对象存储吞吐
数据会变吗 不会(只追加) :新模型上线要重算全库特征;标签会被人工修正
处理延迟要求 秒级(实时大盘、推荐特征) 分钟级(审核卡在分发前),但重算任务是天级
单位成本 一条几乎免费 一条要花掉可观的 GPU 秒数
处理失败的代价 丢一条心跳 = 少算 5 秒时长 丢一个视频 = 它进不了分发,创作者看不到播放量,会来投诉

从最后两行推出内容链最重要的一条工程原则:

内容链必须做到"任务级精确一次、可断点续跑",而且不能靠"整批重跑"来兜底。

因果链:单条处理成本高(GPU 秒数是真金白银)→ 整批重跑的代价与批量成正比,不可接受 → 必须能精确知道"哪些视频还没处理成功"→ 于是需要一张任务状态表(哪个视频、哪个处理阶段、哪个模型版本、成功/失败/重试次数),而不是"看输出目录里有没有文件"。

对比 16A:行为链丢几条心跳可以接受(误差是单向可控的),重跑一个小时的数据也就是几分钟的 CPU。同一家公司的两条链,容错策略完全相反。

量级:GPU 才是这里的"分区数"

以下全是为了算式可算而设的假设值,不是任何公司的真实数据。

假设:
  日上传视频数      = 3000 万条
  平均时长          = 30 秒
  抽帧规则          = 每秒 1 帧 → 平均 30 帧/视频
  单张图片过一次视觉模型 = 10 ms GPU 时间(含前处理)

每日需要的 GPU 计算量:
  3000 万 × 30 帧 = 9 亿张图
  9 亿 × 10 ms = 900 万 GPU·秒 = 2500 GPU·小时/天

需要多少卡(假设 GPU 利用率能做到 60%):
  2500 ÷ 24 ÷ 0.6 ≈ 174 张卡         ← 只是【一个】视觉模型
  链路里还有 ASR、OCR、embedding、审核模型……逐个乘上去

这个算式的意义在于它换了瓶颈的位置。 16A 里,"能不能扛住"取决于 Kafka 分区数和 Flink 并行度,而这两个东西加钱就能加。这里"能不能扛住"取决于你有几张 GPU,而 GPU 是:

  • ——单位算力成本远高于 CPU;
  • 紧张——通常是公司级的稀缺资源,需要排队和配额;
  • 不能像 CPU 那样细粒度切分(后文会讲到 K8s 里 GPU 的分配约束)。

所以内容链的核心工程指标不是"延迟"也不是"吞吐",而是 GPU 利用率。而"GPU 利用率低"的头号原因是数据加载跟不上——卡在那儿等数据。这就是为什么一个存储格式(Lance)会成为 AI 数据管道的话题

内容链的指标:先补两个必须先定义的词

行为链的指标是 VV、时长、DAU(16A §3)。内容链的指标完全是另一套,而且大部分是质量指标而非规模指标。要读懂它们,必须先定义两个 ML 领域的基础词——它们在 §4.3 讲向量检索时会再次出现,是本篇的关键概念之一

假设有一个模型判断"这条视频是否违规"。把它的判断和真实情况交叉起来:

                        真实情况
                   违规          不违规
模型   判违规    ①  正确拦截    ②  误杀(错杀正常内容)
判定   判正常    ③  漏放        ④  正确通过

精确率(precision,也叫查准率):模型说"是"的那些里面,真的是的比例 = ① ÷ (① + ②)。 它回答:"你报的警,有多少是真的?" 精确率低 = 误杀多。

召回率(recall,也叫查全率):真的是"是"的那些里面,被模型找出来的比例 = ① ÷ (① + ③)。 它回答:"该报的警,你报了多少?" 召回率低 = 漏放多。

这两个指标天然互相拉扯。 把判定阈值调低(更容易判违规)→ 漏放变少(召回率↑),但误杀变多(精确率↓);调高则相反。任何"我们的准确率是 99%"的说法,不说明是哪一个指标、在什么阈值下,就是无意义的。

回到之前说的不对称风险:审核场景必须优先保召回率(宁可误杀也不能漏放),然后用人工复审去挽回被误杀的内容。而"人工复审量"直接等于成本——所以精确率虽然不是第一目标,但它决定了审核团队要养多少人。

有了这两个词,内容链的指标表才读得懂:

指标 定义 为什么它是这条链特有的
打标覆盖率 有分类标签的视频数 ÷ 总视频数 行为链的数据"天然完整"(用户一动就有事件);内容链的标签是算出来的,会因为模型失败、格式不支持、超时而缺失。覆盖率是内容链的头号健康指标
标签精确率 / 召回率 见上,需人工标注的评测集做基准 需要维护一个人工标注的评测集,这是内容链独有的成本
审核漏放率 / 误杀率 1 - 召回率 / 1 - 精确率 前者是合规风险,后者是创作者体验与人工成本
特征新鲜度 当前特征是由哪个模型版本产出的、距今多久 新模型上线后,全库有一段时间是"新旧特征混杂"的(处理)
单位处理成本 GPU·秒 / 视频,或 元 / 万条 内容链唯一一个直接以钱为单位的技术指标
GPU 利用率 GPU 真正在算的时间占比 核心工程指标
端到端可分发时延 上传完成 → 审核通过可分发 直接影响创作者体验和热点内容的时效性

建模:为什么必须拆成三层表

新手最自然的建模是一张宽表:video_id + 元数据 + 标签 + embedding + 各档位播放地址。

这张表活不过三个月。 三个理由,每一个都对应一次真实的返工。

理由一:三类数据的更新频率差 3 个数量级。

数据 什么时候变 频率
视频的固有属性(时长、分辨率、上传者、上传时间) 永不变 0
衍生物(转码档位、封面图) 新增档位、重新转码时 一个视频一生几次
特征(标签、embedding) 每次模型迭代,全库重算 一个模型一年迭代若干次 → 每次动的是全表

放一张表里,就意味着"模型升级"这个动作要重写整张宽表,包括那些永不改变的固有属性。而 embedding 列是这张表里最宽的部分——重写它的时候顺手把全部数据都重写了一遍

理由二:列宽差异让扫描成本失控。

一行的大致构成(假设 embedding 是 1024 维 float32):
  固有属性 + 标签       ≈ 1 KB
  embedding(1024 × 4 B)≈ 4 KB          ← 占了整行的 80%

只查"某分类下的视频数"这种最常见的分析,如果 embedding 和元数据在同一张表里——列式存储会救你(04 篇讲的投影下推,只读需要的 column chunk,不碰 embedding 列)。所以这一条在纯列存下不算致命。但它在另一个场景致命:SELECT * 类的取样、以及"按 video_id 随机取一行"的点查——那时 4 KB 的向量必须被读出来。§4.1 会展开。

理由三:生命周期与合规要求不同。 原片可能因版权/合规被删除,但已产出的匿名统计要保留;embedding 可能因模型下线而废弃。放一张表里,删一个东西要动所有东西。

于是分三层:

-- ① 资产表 dim_video_asset:固有属性,一个 video_id 一行,几乎不更新
CREATE TABLE dim_video_asset (
  video_id        BIGINT,
  author_id       BIGINT,
  upload_ts       TIMESTAMP,
  duration_ms     INT,
  raw_uri         STRING,      -- 原片在对象存储上的路径;【表里只存引用,不存二进制】
  raw_bytes       BIGINT,
  raw_md5         STRING,      -- 用于精确去重(完全相同的文件重复上传)
  width           INT,
  height          INT,
  status          STRING       -- 审核状态:pending / passed / rejected / removed
) PARTITIONED BY (upload_date STRING);

-- ② 衍生物表 dwd_video_rendition:一个 video_id 【多行】,每行一个转码档位或封面
CREATE TABLE dwd_video_rendition (
  video_id        BIGINT,
  rendition_type  STRING,      -- 'video_1080p' / 'video_720p' / 'cover' / 'keyframe'
  seq             INT,         -- 关键帧的序号;单值型(如封面)填 0
  uri             STRING,
  codec           STRING,      -- 'h264' / 'h265'
  bitrate_kbps    INT,
  bytes           BIGINT,
  produced_ts     TIMESTAMP
) PARTITIONED BY (upload_date STRING);

-- ③ 特征表 dwd_video_feature:一个 video_id × 一个模型版本 【一行】
CREATE TABLE dwd_video_feature (
  video_id        BIGINT,
  model_name      STRING,      -- 'vis_clip' / 'asr_tag' / 'audit_v3'
  model_version   STRING,      -- ★ 关键:特征必须带产出它的模型版本
  feature_ts      TIMESTAMP,   -- ★ 关键:什么时候算出来的
  labels          ARRAY<STRING>,
  label_scores    ARRAY<FLOAT>,
  embedding       ARRAY<FLOAT>,-- 1024 维;这一列是本篇 §4 全部技术难题的来源
  frame_sample_rule STRING     -- ★ 关键:抽帧规则,不同规则的特征不可比(§2.1)
) PARTITIONED BY (upload_date STRING);

三个带 ★ 的字段是内容链建模的核心,值得单独说。

model_version + feature_ts + frame_sample_rule 这三个字段,让特征表从"一份数据"变成了"带出处的数据"。有了它们,三件事才可能:

  1. 新旧模型平滑切换。新模型上线不是"覆盖旧特征",而是以新的 model_version 追加写入。全库重算期间,两个版本并存;下游读的时候显式指定要哪个版本。切换 = 改一个配置里的版本号,回滚也是
  2. 可追溯。§1 说的"这条内容当时是被哪个版本的模型判定通过的",靠这几个字段回答。
  3. 可比性检查。有人拿 v2 的 embedding 和 v3 的 embedding 算相似度——这是无意义的(两个版本的向量空间不同,§3.3 会解释为什么),而带上版本号,这类错误至少能在数据层被发现和拦截。

⚠️ 代价必须说清(video_id, model_version) 做主键意味着特征表的行数 = 视频数 × 保留的模型版本数。留 3 个版本就是 3 倍存储,而 embedding 是最占空间的列。所以必须配一条版本淘汰策略(例如"只保留当前生产版本 + 上一个版本 + 正在灰度的版本"),并且这条策略要写进表的注释里——否则它会在某次容量告警时才被人发现。

这是 15 篇 SCD Type 2 的同一个思路,但目的不同。 SCD Type 2 用有效期区间保存维度的历史事实(这个商品当时的价格确实是 9.9);这里用 model_version 保存的是同一事实的多个计算版本(同一个视频,v2 模型认为它是"美食",v3 认为是"探店"——两者不是先后关系,是不同算法的输出)。前者是"历史上是什么",后者是"用不同方法算出来是什么"。别把 model_version 建成有效期区间,因为多个版本是同时有效的。

embedding:

先看没有它的世界。要判断"这两个视频内容相似吗",只有标签可用:

视频 A 标签: ["美食", "火锅", "成都"]
视频 B 标签: ["美食", "川菜", "成都"]
视频 C 标签: ["宠物", "猫"]

用标签重合度判断:
  A ∩ B = {美食, 成都}  → 2 个重合
  A ∩ C = {}            → 0 个重合

A 和 B 更像,结论对。但这个方法有一个修不掉的缺陷:标签是离散的,"火锅"和"川菜"在集合运算里是两个完全无关的符号,重合度为 0。 系统不知道它们其实很接近。要让它知道,就得人工维护一张同义/上下位关系表——而这张表永远不完整,且需要对每个新概念手工扩展。

embedding 的思路:不要用符号表示内容,用坐标。

embedding(嵌入向量):一个把对象映射成 N 个浮点数构成的定长向量的结果。这个映射由模型学习得到,其唯一被保证的性质是:内容语义相近的对象,向量在空间中的距离也近。 业界别名:特征向量(feature vector)、稠密向量(dense vector)。本系列统一用 embedding向量

假设一个极简的 3 维 embedding(真实系统通常是 128~4096 维):

  视频 A(火锅)  = [0.91, 0.12, 0.05]
  视频 B(川菜)  = [0.88, 0.15, 0.04]     ← 和 A 的坐标很接近
  视频 C(猫)    = [0.03, 0.07, 0.95]     ← 和 A、B 都很远

现在"火锅"和"川菜"的相似性【不需要任何人工同义词表】就被表达出来了,
因为它体现在坐标的接近程度上,而不是符号是否相等。

这是 embedding 的全部价值:把"语义相似"这件事,从需要人工维护的符号关系,变成了可以直接计算的几何距离。

代价,三条,必须一起记住:

  1. 不可解释。第 7 个维度是什么含义?没有人知道,它不对应任何人类可命名的概念。你不能像看标签那样"看懂"一个向量。
  2. 跨模型不可比。不同模型(甚至同一模型的不同训练轮次)产出的向量,处在不同的空间里。把 v2 的向量和 v3 的向量算距离,得到的数字是没有意义的——这就是为什么必须带 model_version
  3. 存储与计算成本高。它把"3 个标签字符串"变成了"1024 个 float"。这一条是后文全部难题的来源。

工业界最常用的是余弦相似度:看两个向量的方向有多接近,忽略长度。

余弦相似度(cosine similarity):两个向量夹角的余弦值,= 两向量点积 ÷ 两向量模长之积。取值 [-1, 1],1 = 方向完全相同,0 = 正交(无关),-1 = 完全相反。 为什么用它而不用直线距离:模型输出的向量长度往往和"内容强度"(比如画面亮度、音量)相关,而我们只关心语义方向。忽略长度,就忽略了这类无关变化。(这是行业通行做法;具体用余弦还是欧氏距离,取决于模型训练时用的是哪一种——必须和模型侧对齐,用错会让检索结果系统性变差。)

# 目的:手算余弦相似度,验证 §3.3.1 的坐标示例
# 环境:Python 3 + numpy(numpy 是 Python 的数值计算库,提供数组和向量运算)
import numpy as np

# 三个视频的 embedding。np.array 把 Python 列表转成 numpy 数组,才能做向量运算
video_a = np.array([0.91, 0.12, 0.05])   # 火锅
video_b = np.array([0.88, 0.15, 0.04])   # 川菜
video_c = np.array([0.03, 0.07, 0.95])   # 猫

def cosine(u, v):
    # np.dot(u, v)     → 点积:对应位置相乘后求和,即 u1*v1 + u2*v2 + u3*v3
    # np.linalg.norm(u) → 模长(L2 范数):sqrt(u1² + u2² + u3²)
    # 除以两个模长,就把"长度"这个因素约掉了,只剩方向
    return float(np.dot(u, v) / (np.linalg.norm(u) * np.linalg.norm(v)))

print("A vs B (火锅 vs 川菜):", round(cosine(video_a, video_b), 4))
print("A vs C (火锅 vs 猫)  :", round(cosine(video_a, video_c), 4))
print("B vs C (川菜 vs 猫)  :", round(cosine(video_b, video_c), 4))

# 顺便验证"跨模型不可比"这件事:
# 假装 v3 模型把维度顺序换了(真实情况远比这复杂,这里只做最小演示)
video_a_v3 = np.array([0.05, 0.12, 0.91])   # 同一个视频,v3 模型的输出
print("A(v2) vs A(v3) 同一个视频跨版本:", round(cosine(video_a, video_a_v3), 4))

模拟输入:上面代码里硬编码的四个 3 维向量(video_a/b/cvideo_a_v3)。

运行结果

A vs B (火锅 vs 川菜): 0.9994
A vs C (火锅 vs 猫)  : 0.1105
B vs C (川菜 vs 猫)  : 0.1417
A(v2) vs A(v3) 同一个视频跨版本: 0.1385

为什么是这个结果:A 与 B 的三个分量几乎逐位相等,方向几乎重合,余弦值 0.9994——"火锅"和"川菜"的语义接近,被表达成了一个接近 1 的数字,全程没有用到任何同义词表,这就是 §3.3.1 说的全部价值。A 与 C 的主分量落在不同维度上,方向近乎正交,余弦值 0.11。

最后一行是重点A(v2)A(v3)同一个视频,但两个版本算出来的相似度只有 0.1385——比它和"猫"的相似度(0.1105)高不了多少。跨模型版本比较向量,得到的是垃圾。 这一行就是那个 model_version 字段的存在理由,也是"不带版本号的 embedding 表迟早会出事"的具体形态。

现在把 embedding 放回数据工程的语境。它是 ARRAY<FLOAT>,1024 个元素,一行 4 KB。

04 篇讲的列式存储,性能优势建立在三条链上:①只读需要的列 ②每列压得很小 ③跳过不需要的行块。embedding 列把 ② 和 ③ 全部打掉:

打掉 ②(压缩):Parquet 的高压缩率靠字典编码RLE(04 篇讲过这两招:字典编码把重复值换成下标,RLE 把连续重复压成"值 + 次数")。这两招都要求值是重复的、低基数的。而 embedding 是连续实数、几乎每个都不重复——字典编码退化(04 篇提到 parquet-mr 会在字典涨过阈值时自动回退到不用字典,embedding 列必然触发这个回退),RLE 完全无效。它基本上只能原样存,压缩率接近 1。

打掉 ③(跳块):跳块靠每个 row group 的 min/max 统计。一个 1024 维向量的 min/max 是什么?就算逐维统计,"某维度的值在 [0.1, 0.9] 之间"这个条件也无法帮你判断"哪些行的向量与查询向量相似"——相似性是 1024 维空间里的距离,不是任何单个维度上的区间。向量检索这类查询,Parquet 的统计信息一块都跳不掉。

【推断】 ③ 这一条是我从"min/max 统计只能表达单列区间"与"余弦相似度是全维度的联合函数"两个事实推出的,逻辑上我认为是确定的。但**"完全没有任何统计信息能帮上忙"这个说法过强**——学术界和工程上确实有为向量设计的、能起到剪枝作用的辅助结构(与 IVF 就是一种)。准确的表述是:Parquet 内建的 min/max 统计对向量相似性查询无效,所以需要另建向量索引。

还有第三个假设被打掉,而且这个最贵。 04 篇的整套设计是为扫描(scan)优化的——一次读一大片连续字节。而 AI 训练要的访问模式是随机读

训练时的随机读:模型训练要求每一轮(epoch)都以不同的随机顺序遍历样本。原因属于优化算法(如果每轮顺序相同,模型会记住顺序而不是学到规律),本篇不展开。数据工程侧的含义是硬性的:训练框架会不断发出"给我第 8,142,073 号样本"这样的单点请求,而且顺序是随机的。

Parquet 上做这件事,代价是结构性的(这一段是 04 篇四级结构的直接推论):

要取第 8,142,073 号样本:
  1. 打开文件 → 跳到尾部读 footer(一次随机读)
  2. 从 footer 里算出该行落在哪个 row group
  3. 读该 row group 里目标列的 column chunk
  4. 但 page 是【压缩和解压的最小单元】(04 篇)
     → 要拿到一行,必须把整个 page 解压出来
  5. 从解压结果里取出那一行,丢弃同 page 的其余几千行

有效数据: 1 行 (约 4 KB)
实际解压: 1 个 page (约 1 MB) 及其内含的数千行

读放大量级在千倍上(这个倍数取决于 page 大小与行宽,随配置变化)。 而"随机读一行就要解压一个 page"这件事本身是 Parquet 的设计取舍,不是 bug——它换来的正是扫描时极高的压缩率和吞吐(04 篇讲过这个取舍)。

于是矛盾成型了:训练需要高频随机点读,Parquet 为顺序批量扫描而生。GPU 在等数据,而数据加载正在做千倍的读放大——这就是"GPU 利用率低的头号原因是数据加载跟不上"的机制层解释,也是 Lance 这类格式出现的原因。

最后一个建模问题,它是分三层表的必然后果。

算法同学要的输入是样本

一个训练样本 = 视频的若干关键帧图片 + 该视频的 ASR 文本 + 人工标注的标签

这一"行"在我们的表里是分散的:图片在 dwd_video_rendition(多行)、文本和标签在 dwd_video_feature(一行)、基础属性在 dim_video_asset(一行),而图片的二进制内容根本不在任何表里,在对象存储上

-- 拼样本:把三张表拼成"一个视频一行",其中关键帧收成一个数组
-- 引擎:Spark SQL(COLLECT_LIST 是 Spark/Hive 的聚合函数,把多行收成一个数组)
WITH kf AS (
  -- 先把关键帧从"多行"折叠成"一行一个数组",避免 JOIN 时扇出
  -- (15 篇 §3.2 讲过 fan-out:不折叠就 JOIN,一个视频会变成 N 行,指标全被虚增)
  SELECT video_id,
         COLLECT_LIST(uri) AS frame_uris        -- 按 seq 排序在生产里要用 sort_array 保证顺序
  FROM dwd_video_rendition
  WHERE upload_date = '2026-07-29'
    AND rendition_type = 'keyframe'
  GROUP BY video_id
)
SELECT
  a.video_id,
  a.duration_ms,
  kf.frame_uris,                    -- 【注意】这里是【路径数组】,不是图片本身
  f.labels,
  f.embedding,
  f.model_version
FROM dim_video_asset a
JOIN kf              ON a.video_id = kf.video_id
JOIN dwd_video_feature f
     ON a.video_id = f.video_id
    AND f.model_name    = 'vis_clip'
    AND f.model_version = 'v3.2'     -- 显式锁版本(§3.2 的理由)
WHERE a.upload_date = '2026-07-29'
  AND a.status = 'passed';           -- 未过审的内容不能进训练集

模拟输入video_id = 778899,3 张关键帧):

行数 内容
dim_video_asset 1 778899, 30000ms, status=passed
dwd_video_rendition 5 3 行 keyframe + 1 行 video_720p + 1 行 cover
dwd_video_feature 2 model_version=v3.1 一行,v3.2 一行

运行结果(1 行):

video_id | duration_ms | frame_uris                          | labels        | model_version
778899   | 30000       | [s3://.../f0.jpg, .../f1.jpg,       | [美食, 火锅]  | v3.2
                          .../f2.jpg]

为什么是这个结果kf 子查询把 3 行关键帧折叠成一个 3 元素数组,所以 JOIN 后没有扇出(否则会变成 3 行,embedding 被复制 3 份);rendition_type = 'keyframe' 过滤掉了 video_720pcover 这两行;特征表有 2 个版本,model_version = 'v3.2' 只锁定了一行——如果不写这个条件,一个视频会 JOIN 出 2 行,而且两行的 embedding 处在不同空间里(验证过跨版本不可比),这个 bug 在数据层完全看不出来,只会表现为"模型效果莫名变差"。

但这行结果有一个根本性的不足:frame_uris 是路径,不是图片。

于是训练时真正发生的事:

训练框架读到一行 → 拿到 3 个 URI
                 → 对每个 URI 发一次对象存储 GET 请求
                 → 等网络往返
                 → 解码 JPEG
                 → 才能喂给 GPU

一个 batch 假设 256 个样本 × 3 帧 = 768 次独立的对象存储 GET

batch(批 / 批大小):训练时不是一个样本一个样本地喂给模型,而是一次喂进一组(例如 256 个),一组算完才更新一次模型参数。这么做的原因属于优化算法与 GPU 并行效率,本篇不展开。数据工程侧的含义只有一条:训练框架的每一次数据请求,实际是"一次要 256 个随机样本",而不是要 1 个。 所以上面那 768 次请求是一个 batch 的量,而一轮训练有成千上万个 batch。

对象存储的单次请求延迟通常在几十毫秒量级(03 篇讲过对象存储是 HTTP 接口、不是本地文件系统),而且每个请求都有固定开销,与读多少字节关系不大。768 次请求即使并发发出,也要吃掉可观的等待时间——而 GPU 在这段时间里空转。

这就是"多模态数据管道"这个词真正指的问题:

传统数仓交付的是结构化的行,而 AI 训练要的是一个样本的全部模态(含二进制本体)能被一次性、随机地、高吞吐地取出来。中间这道鸿沟——"表里存路径,本体在别处,取一个样本要发几百次请求"——就是内容链在存储层的核心难题。

15 篇压的是存储层的行级更新,16A 压的是流计算层的吞吐与分布。 本篇压的是三处,而且这三处都在"数据与算力之间的那道界面"上:

  • 存储格式:为扫描优化的格式,撞上要随机读的负载。
  • 调度:为 CPU 设计的资源模型,撞上不能细分的 GPU。
  • 索引:为一维有序键设计的索引思维,撞上高维空间的相似性查询。

必须先说清一件事:Parquet 没有做错任何事。 04 篇讲过它的取舍——用"page 是压缩最小单元 + footer 在尾部 + row group 级统计"换来了极高的扫描吞吐和压缩率。在 OLAP 扫描场景,这个取舍是对的,而且是本系列前面所有分析的基础。

失效发生在负载变了的时候。把两种负载并排放:

OLAP 分析(Parquet 的目标场景) AI 训练 / 多模态(本篇的场景)
访问模式 顺序扫描大批行的少数列 随机点读单行的全部列
一次要多少行 百万级 1 行(然后重复百万次,顺序随机)
要多少列 少数几列(投影下推的用武之地) 几乎全部(图片 + 文本 + 向量都要)
列的形状 低基数、高重复 → 压得动 高维浮点数组 + 二进制大对象 → 压不动
数据要变吗 追加为主 要频繁加列(新模型 = 新的一列特征)

于是三个具体失效点:

失效点一:随机读的读放大。 §3.3.3 已经推过——取 1 行要解压 1 个 page,读放大在千倍量级。

失效点二:加一列要重写整个文件。 Parquet 文件是一次性顺序写、footer 收尾的(04 篇 讲过为什么必须这样)。这意味着 schema 和各 column chunk 的 offset 在写完的那一刻就固定了。想给已有的一亿行加一列新特征,没有"在文件里插一列"的操作——只能把文件读出来、拼上新列、重新写一遍。

而"加一列"在内容链是高频动作:每上一个新模型就是一列新特征(§3.2)。

假设特征表 1 亿行,原有数据 400 GB(主要是 embedding 列)
加一列新的 512 维 float32 特征,新数据本身 = 1 亿 × 2 KB = 200 GB

Parquet 路径:  读 400 GB + 写 600 GB = 1000 GB 的 I/O,只为了新增 200 GB 有效数据
理想路径:      只写那 200 GB

这和 15 篇 §4.5 的 COW 写放大是同一个病根:文件不可变 + 元数据在文件内固定 → 任何局部变更都退化成全量重写。15 篇的解法是 MOR(把变更写成 delete file 延迟合并),这里的解法是把列拆成物理独立的存储单元,见下。

失效点三:二进制本体存不进去,只能存路径。 之前说的 768 次对象存储请求。理论上 Parquet 可以用 BINARY 列存图片本体,但那会让每个 page 变得极大、扫描和随机读都更糟——结果是工程上普遍选择"表里存 URI",然后承担 前文说的后果

Lance:它是什么,以及可核验到什么程度

Lance:一个开源的、面向多模态 AI 的湖仓格式。官方 README 的原话是:

【官方口径】"Lance is an open lakehouse format for multimodal AI."(面向多模态 AI 的开放湖仓格式);它包含 "a file format, table format, and catalog spec"(文件格式、表格式与 catalog 规范);宣称提供 "Lightning-fast random access: 100x faster than Parquet or Iceberg for random access without sacrificing scan performance."(随机访问比 Parquet 或 Iceberg 快 100 倍,且不牺牲扫描性能);"Store images, videos, audio, text, and embeddings in a single unified format."(在单一统一格式中存储图片、视频、音频、文本与 embedding);"Efficiently add columns with backfilled values without full table rewrites."(高效新增列且无需全表重写)。

把这四句话对回上一节的三个失效点

失效点 Lance 官方宣称对应的能力
① 随机读放大 "100x faster than Parquet or Iceberg for random access"
② 加列要全表重写 "add columns … without full table rewrites"
③ 二进制本体存不进去 "Store images, videos, audio, text, and embeddings in a single unified format"

三条一一对上了。这不是巧合——它就是照着这三个痛点设计的。

⚠️ 可信度边界,必须读

  1. "100x" 是项目方自述,README 的 benchmark 段落原话是 "For batched random access, Lance is 100x better than both parquet and raw files."(注意限定词是 batched random access,批量随机访问)。没有公开的硬件配置、数据集与测试方法说明,不能当作跨格式横向对比的中立基准。它能说明的是"设计目标在项目方的测试里达成了",不能说明"在你的负载上会快 100 倍"。
  2. 我没有核实到 Lance 的物理布局细节。 我查的 README 没有任何一句描述"Lance 的布局与 Parquet 的 row group 有何不同"。所以关于"它为什么能快 100 倍"的机制,我不做断言。任何"因为 Lance 没有 row group / 因为它按列独立存文件"之类的解释,都需要你去查 Lance 的 format spec 自行确认。
  3. 【推断】 从"能加列而不重写全表"这一条能力,可以反推出每一列的物理存储在很大程度上是相互独立的(否则改一列必然要动别的列的字节)。这是我从功能反推的结构性质,不是官方表述

Lance 的代价(这一段是通用判断,不是官方表述)

  • 生态成熟度。Parquet 有十几年的引擎支持(Spark/Flink/Trino/Hive/所有云仓);Lance 的读写支持范围小得多。你的 SQL 引擎能不能直接查它,是选型第一个要验证的问题。
  • 它优化的是随机读,那么按定义它在纯扫描型 OLAP 上不会比 Parquet 更有优势(官方说的是 "without sacrificing scan performance",即"不牺牲",不是"更快")。所以它不是 Parquet 的替代品,是另一个场景的专用件。
  • 多一种格式 = 多一套运维。同一份数据在数仓侧是 Parquet、在训练侧是 Lance,就要面对"两份数据怎么保持一致"的老问题。

所以选型判据是(这是我的判断,不是官方口径):

数据被【扫描聚合】的次数 >> 被【随机单点读取】的次数   → Parquet / Iceberg
数据被【随机单点读取】喂给训练/推理是主要用途          → 评估 Lance 这类 AI 专用格式
两者都重                                              → 分两份存,付双份存储成本,
                                                        并且必须有明确的"哪份是权威源"

最后一行不是和稀泥,它是真实的工程常态:数仓侧的 Parquet 表用于分析和血缘,训练侧的 Lance 数据集从它派生。关键是要明确单向派生关系——谁是源、谁是副本。双向同步一定会出一致性问题(13 篇讨论过这类问题的一般形态)。

调度:GPU 为什么不能像 CPU 那样分

09 篇讲的资源调度,模型是"一次分出一份 <CPU, 内存> 额度"。CPU 可以按毫核(millicore,K8s 里 cpu: 500m 表示半个核,即把一个核的算力按千分之一为单位切分)细分、可以超卖、可以让多个容器共享一个物理核。GPU 这三件事都不行,或者说都要额外条件。

可核验的部分:K8s 里 GPU 是怎么被表达的

Kubernetes 官方文档(Tasks / Manage GPUs / Scheduling GPUs 页,页面标注 FEATURE STATE: Kubernetes v1.26 [stable])的原话:

【官方口径】"Kubernetes includes stable support for managing AMD and NVIDIA GPUs … using device plugins."(K8s 通过 device plugin 管理 GPU);"Once you have installed the plugin, your cluster exposes a custom schedulable resource such as amd.com/gpu or nvidia.com/gpu."(插件装好后,集群暴露出形如 nvidia.com/gpu自定义可调度资源);"You can consume these GPUs from your containers by requesting the custom GPU resource, the same way you request cpu or memory. However, there are some limitations in how you specify the resource requirements for custom devices."

那个 "limitations" 的原文是三条:

【官方口径】"GPUs are only supposed to be specified in the limits section, which means:

  • You can specify GPU limits without specifying requests, because Kubernetes will use the limit as the request value by default.
  • You can specify GPU in both limits and requests but these two values must be equal.
  • You cannot specify GPU requests without specifying limits."

先补一个名词,画像里没列:

device plugin(设备插件):K8s 的一种扩展机制。K8s 内核只认识 cpumemory 两种内建资源;厂商通过实现 device plugin 接口,把 GPU、FPGA、RDMA 网卡这类专用硬件注册成集群的一种"自定义可调度资源",之后就能像申请 CPU 那样在 Pod 里申请它。官方原话:"Kubernetes implements device plugins to let Pods access specialized hardware features such as GPUs."

把这几条读出信息量来:

requests 必须等于 limits 这条约束,含义是 GPU 没有"超卖"这个概念。09 篇讲过 CPU 上 requests < limits 的用法:按 requests 调度(保证下限),允许突发到 limits(弹性上限)——集群因此可以超卖,靠"不是所有容器同时打满"来提高利用率。GPU 上这条路被官方约束直接封死了:申请多少就独占多少。

可信度边界:K8s 这个页面没有任何一句谈到"GPU 能不能共享/切分/申请小数份"。我没有在这一页核实到"不允许小数"的明文;页面里所有示例的数量都是整数 1【推断】 从 "requests 必须等于 limits" 与"没有提供任何共享语义"两点,加上厂商另行提供 MIG(硬件级切分)、时间片共享、vGPU 这类独立机制这一事实,我的读法是:K8s 内建的 GPU 调度模型就是"按整卡(或厂商预先切好的实例)分配、独占使用",细粒度共享必须靠 device plugin 或厂商方案额外提供。 这是推断,要用在生产选型上请查你所用 device plugin 与 GPU 厂商的文档。

由此推出的三条工程后果

后果一:GPU 利用率是一个必须主动经营的指标,不是自然结果。

因果链:GPU 独占分配 → 一个作业拿到卡之后,卡的利用率完全取决于这个作业自己 → 作业在做数据加载/预处理/等 I/O 的每一秒,卡都在空转,而账照付

而之前已经证明数据加载很容易成为瓶颈(768 次对象存储请求)。于是存储格式问题和这里的调度问题是同一个问题的两面:格式导致加载慢,加载慢导致卡空转,卡空转导致钱白花。

后果二:GPU 作业与 CPU 作业必须混合调度,而且这件事很难。

一条内容处理管道里,CPU 段和 GPU 段是交错的:

读对象存储(网络 I/O) → 解码视频(CPU 密集) → 抽帧(CPU) → 模型推理(GPU) → 写结果(网络 I/O)
       ▲                    ▲                            ▲
   GPU 空转             GPU 空转                    GPU 终于在干活

如果整条管道跑在同一个"占了一张 GPU"的容器里,GPU 在前三步和最后一步全程空转。要提高利用率,就必须把 CPU 段和 GPU 段拆成不同的、并行的、可以各自伸缩的阶段,让 GPU 段有一个持续填满的输入队列。

这个"把异构阶段拆开并各自伸缩"的需求,就是 Ray 在这条链路里的位置。

后果三:整卡分配让"小任务"变得极其浪费。 一个只需要 2 GB 显存的小模型,占掉一张 80 GB 的卡——利用率 2.5%。这是批量推理任务要尽可能攒大 batch 的经济原因,也是各种 GPU 共享方案存在的原因。

上面推断里提到的三个共享方案,一句话各自定位(它们都属于 K8s 之外的机制,我只给定位、不展开,细节请查厂商文档):MIG(Multi-Instance GPU)是 NVIDIA 部分型号提供的硬件级切分,把一张物理卡切成若干个互相隔离的实例,每个实例在 K8s 里表现为一个独立的可调度设备;时间片共享是让多个进程轮流使用同一张卡的计算单元,不提供显存隔离vGPU 是虚拟化层面的方案。三者的隔离强度、性能损耗与型号支持范围各不相同。

Ray:它是什么,以及和 Spark 的分工

Ray(官方 overview 页原话):

【官方口径】"Ray is an open-source unified framework for scaling AI and Python applications like machine learning."(用于扩展 AI 与 Python 应用的统一框架);Ray Core 是 "An open-source, Python, general purpose, distributed computing library"(通用分布式计算库),并且 "It's the foundation that Ray AI libraries and third-party integrations are built on.";Ray Data 是 "Scalable, framework-agnostic data loading and transformation across training, tuning, and prediction."(可扩展、框架无关的数据加载与转换)。

Ray Core 的编程模型只有三个概念,官方 Key Concepts 页的原话:

【官方口径】Task:"Ray enables arbitrary functions to execute asynchronously on separate worker processes. These asynchronous Ray functions are called tasks."(任意函数异步地在独立 worker 进程上执行) Actor:"Actors extend the Ray API from functions (tasks) to classes. An actor is essentially a stateful worker (or a service). When you instantiate a new actor, Ray creates a new worker and schedules methods of the actor on that specific worker."(把 API 从函数扩展到类;actor 是有状态的 worker,实例化时创建一个专属 worker,其方法都调度到该 worker 上) Object:"Tasks and actors create objects and compute on objects. Ray stores them anywhere in a Ray cluster, and you use object refs to refer to them."(任务与 actor 产出的值存在集群里,用 object ref 引用) 资源申请:"Ray enables tasks to specify their resource requirements in terms of CPUs, GPUs, and custom resources. The cluster scheduler uses these resource requests to distribute tasks across the cluster";且 "Like tasks, actors support CPU, GPU, and custom resource requirements."

Task vs Actor 的区别,一句话:task 对应函数、无状态、每次调度到任意空闲 worker;actor 对应类、有状态、独占一个 worker 并在其生命周期内保留可变状态。

为什么这个区别对本篇是关键的? 因为模型权重加载一次要几秒到几十秒。如果模型推理写成 task,每次调用都要重新把模型加载到显存——加载时间可能超过推理时间本身。写成 actor,模型在 actor 初始化时加载一次,之后所有请求复用同一个已加载的模型。

"actor 是承载模型推理的正确抽象"这个结论,是我从官方对 actor 的定义(有状态、独占 worker、方法调度到同一 worker)推出来的,不是官方原话。但这个推断非常直接:模型权重就是那个"状态"。

Ray 和 Spark 的分工(这一段是通用判断,不是官方口径):

Spark(05 篇) Ray
核心抽象 数据集上的算子map/join/groupBy),你描述"数据怎么变换" 任务与 actor,你描述"什么函数在哪跑、谁持有状态"
最擅长 大规模结构化数据的 shuffle 与聚合 异构资源(CPU+GPU)上的细粒度并行 + 有状态服务
对 GPU 的态度 支持,但资源模型以 executor 为单位、偏粗 每个 task/actor 各自声明所需 CPU/GPU,天然异构
Python 生态 通过 PySpark 桥接,跨语言边界有成本 原生 Python
在内容链的位置 元数据/特征的表级 ETL、拼样本 SQL(§3.4) 多模态处理管道本身:解码→抽帧→推理的异构流水线

一句话分工:Spark 处理"表",Ray 处理"文件与模型"。 内容链里两者都在,边界大致是"进了表之后归 Spark,进表之前归 Ray"。

下面是把 后果二(CPU 段与 GPU 段拆开)落地的最小示例。

# 目的:把"CPU 解码抽帧"和"GPU 模型推理"拆成两个可独立伸缩的阶段
# 环境:Ray(版本请以你集群实际安装的为准;本例只用 Ray Core 最基础的 API)
import ray

ray.init()   # 连接到 Ray 集群(本地调试时会在本机起一个单机集群)

# ── 阶段一:CPU 段。写成 task,因为它【无状态】——每次调用之间没有要保留的东西 ──
@ray.remote(num_cpus=2)          # 关键行:声明这个 task 要 2 个 CPU、不要 GPU
def decode_and_sample(video_uri):
    # 真实实现会调 ffmpeg / PyAV 解码并按规则抽帧(ffmpeg 是业界通用的音视频处理工具,
    # PyAV 是它的 Python 绑定);这里用假数据保持示例可读
    frames = [f"{video_uri}#frame{i}" for i in range(30)]   # 假装抽出 30 帧
    return frames

# ── 阶段二:GPU 段。写成 actor,因为模型权重是【必须保留的状态】 ──
@ray.remote(num_gpus=1)          # 关键行:声明这个 actor 独占 1 张 GPU(§4.2.1 的整卡语义)
class Embedder:
    def __init__(self):
        # 只在 actor 创建时执行一次。若写成 task,这一步每次调用都要重做
        self.model = "已加载的视觉模型权重"
        print("[actor] 模型加载完成,后续请求复用它")

    def embed(self, frames):
        # 真实实现会做 batch 前处理 + 模型 forward
        return {"n_frames": len(frames), "dim": 1024, "by": self.model}

# ── 编排:CPU 段并行铺开,GPU actor 只开有限个(因为卡就那么多)──
video_uris = [f"s3://bucket/video_{i}.mp4" for i in range(4)]

# .remote() 立即返回 object ref(不阻塞),4 个解码任务同时在不同 worker 上跑
frame_refs = [decode_and_sample.remote(u) for u in video_uris]

embedder = Embedder.remote()      # 创建 1 个 GPU actor(模型只加载这一次)

# 把上一阶段的 object ref 【直接】传给下一阶段:Ray 会自动等它就绪并传输数据
result_refs = [embedder.embed.remote(fr) for fr in frame_refs]

for r in ray.get(result_refs):    # ray.get 阻塞等待,把 object ref 换成真实值
    print(r)

模拟输入:4 个视频 URI(s3://bucket/video_0.mp4 ~ video_3.mp4),每个假装抽出 30 帧。

运行结果

[actor] 模型加载完成,后续请求复用它
{'n_frames': 30, 'dim': 1024, 'by': '已加载的视觉模型权重'}
{'n_frames': 30, 'dim': 1024, 'by': '已加载的视觉模型权重'}
{'n_frames': 30, 'dim': 1024, 'by': '已加载的视觉模型权重'}
{'n_frames': 30, 'dim': 1024, 'by': '已加载的视觉模型权重'}

为什么是这个结果 —— 三个关键点

  1. [actor] 模型加载完成 只打印了一次,而 embed 被调用了 4 次。这就是 actor 与 task 的区别在这条链路上的全部价值:模型加载这个昂贵的一次性动作被摊到了 4 次(生产里是几万次)调用上。若把 Embedder 写成 @ray.remote(num_gpus=1) def embed(...),这行日志会打印 4 次,GPU 大部分时间在加载权重而不是推理。
  2. decode_and_sample 声明 num_cpus=2 而 actor 声明 num_gpus=1 ——两个阶段的资源需求被分别声明,于是集群可以给它们分配不同的机器、各自伸缩。这正是 §4.2.2 后果二要的东西:CPU 段可以开 100 个并行去填满 GPU 段的输入。
  3. frame_refs 是直接传给 embedder.embed.remote() 的,中间没有 ray.get 这让解码和推理可以流水线重叠——第 2 个视频在解码时,第 1 个已经在 GPU 上算了。如果先 ray.get(frame_refs) 把全部结果收回来再送去推理,那就退化成"全部解码完 → 才开始推理"的串行两阶段,GPU 在整个解码期间空转。 这个细节是本例最容易写错、也最影响利用率的一处。

上面这段代码是概念示例,用于说明 task/actor 的分工与资源声明这三个机制。生产管道要处理批大小、背压、失败重试、actor 池扩缩、以及 Ray Data 提供的现成流水线原语(官方口径见上:Ray Data 就是做 "data loading and transformation" 的)。具体 API 签名与最佳实践请以你所用 Ray 版本的文档为准,不要照抄我这里的写法。

最后一处极限。它是 embedding 列必然引出的问题。

先说清"失效"在哪:查询语义变了

你熟悉的索引(MySQL 的 B+ 树、Iceberg 的 min/max 统计、10 篇讲的各种 OLAP 索引),全部服务于同一类查询:

等值:  WHERE video_id = 778899
范围:  WHERE upload_ts BETWEEN '2026-07-01' AND '2026-07-31'
前缀:  WHERE title LIKE '火锅%'

这三类查询有一个共同前提:存在一个全序(total order),并且查询条件能被翻译成这个序上的一段区间。 B+ 树能工作,就是因为它把数据按这个序排好,查询于是变成"定位区间端点 + 顺序读一段"。

向量检索的查询是:

给定查询向量 q,找出全库中与 q 余弦相似度最高的 K 条

top-K 就是"最相似的前 K 条"这个结果集的通称,下文一律这么叫。)

1024 维空间里没有全序。 没有任何一种把 1024 维向量排成一列的方式,能保证"排序上相邻的两个向量在空间中也相近"。(这不是本篇的猜测——18 篇讲空间索引时会展开"高维空间的排序为什么必然丢失邻近性",那里会用 Z-order 曲线把这件事讲透。这里只需接受结论:B+ 树这条路走不通,不是实现问题,是维度问题。

所以最朴素的正确做法是暴力扫描

暴力搜索 / Flat / 精确最近邻:把查询向量和库里每一个向量都算一次距离,取最大的 K 个。它的结果是绝对正确的。

代价(假设值,用于量级判断):

库里 10 亿个视频 × 1024 维 float32
一次查询要算 10 亿次余弦相似度,每次 1024 次乘加 ≈ 1 万亿次浮点运算
需要读的数据量 = 10 亿 × 4 KB = 4 TB     ← 每一次查询都要读全库

推荐系统要求这个查询在【几十毫秒】内返回,QPS 上万
→ 差了好几个数量级,完全不可行

ANN:用召回率换速度

于是有了近似最近邻检索。这里可以引 pgvector 的官方文档,它把这个取舍讲得最干净(pgvector 是 PostgreSQL 的向量扩展,而 PostgreSQL 在你的画像里是"用过",所以拿它当参照系最省事):

【官方口径】"By default, pgvector performs exact nearest neighbor search, which provides perfect recall."(默认做精确检索,召回率完美) "You can add an index to use approximate nearest neighbor search, which trades some recall for speed."(加索引后变成近似检索,用一部分召回率换速度) 并且明确提醒:"you will see different results for queries after adding an approximate index."(加了近似索引后,查询结果会变得不一样

ANN(Approximate Nearest Neighbor,近似最近邻):不保证找到真正的 top-K,只保证以高概率找到其中大部分。

这里的 recall(召回率)就是之前定义的那个召回率,只是"真实为正"的集合变成了"真正的 top-K":

recall@10 = (返回的 10 条里,属于真正 top-10 的条数) ÷ 10

recall@10 = 0.9  →  返回的 10 条里有 9 条是对的,1 条是"本来排不进前十但被选进来了"

这是本篇最需要你接受的一次观念转换:你习惯的索引是语义中立的——加了索引和不加索引,WHERE id = 5 的结果完全相同,索引只影响快慢。向量索引不是:加了它,查询结果本身就变了。

这也是它可以被接受的原因:推荐场景下,"第 11 相似的视频"被推给用户而不是"第 10 相似的",业务上几乎无差别。但在查重/版权场景下就要非常小心——"这条视频是否搬运了已有内容",漏掉那一条恰好就是要找的,代价是实质的。所以同一个向量库,推荐召回可以用 recall=0.9 的配置,版权查重必须用更高召回甚至精确检索。

两条主流路线的机制

路线一:IVF(倒排文件 / 聚类分桶)

pgvector 的 IVFFlat 文档原话:

【官方口径】"An IVFFlat index divides vectors into lists, and then searches a subset of those lists that are closest to the query vector."(把向量分成若干 list,查询时只搜索离查询向量最近的那部分 list);构建过程包含 "performing k-means"(做 k-means 聚类)。 参数指导:"lists" 的取值 "a good place to start is rows / 1000 for up to 1M rows and sqrt(rows) for over 1M rows";probes(搜索多少个 list)"higher is better for recall, lower is better for speed",起点建议 sqrt(lists);probes 加到等于 lists 时就等价于精确搜索,"at which point the planner won't use the index"。 相比 HNSW:"It has faster build times and uses less memory than HNSW, but has lower query performance."

机制:先用 k-means 把全库向量聚成 N 个簇,每簇记一个中心点。查询时只算查询向量与 N 个中心的距离,挑最近的 probes 个簇,只在这几个簇里做暴力扫描。

k-means(k 均值聚类):一种把 N 个点分成 k 组的经典算法。做法是:先随机取 k 个中心点,把每个点分给离它最近的中心,然后把每组的中心重新算成该组所有点的平均位置,反复迭代直到中心不再移动。产物就是 k 个中心点坐标 + 每个点属于哪一组。它是无监督的(不需要任何人工标注),这也是为什么它能直接跑在全库 embedding 上。本篇只需要知道它的产物是"k 个中心点",算法细节不展开。

10 亿向量,聚成 3 万个簇(≈ sqrt(10亿)),每簇约 3.3 万个向量

暴力:        算 10 亿次距离
IVF(probes=10): 算 3 万次(找簇)+ 10 × 3.3 万次(簇内扫描)≈ 36 万次
                → 计算量约为暴力的 1/2800(这是按上面假设值的算术结果)

漏检从哪来: 真正最相似的那个向量,可能落在【第 11 近的簇】里
            → 它没被搜到 → 召回率损失

这个"漏检从哪来"是 IVF 的召回率损失的全部来源,而它的形态很具体:靠近簇边界的向量最容易被漏掉。probes 调大 = 多搜几个簇 = 边界漏检变少 = 召回率升、速度降。这个旋钮的物理意义现在是清楚的,不是一个玄学参数。

路线二:HNSW(分层可导航小世界图)

pgvector 文档原话:

【官方口径】"An HNSW index creates a multilayer graph."(构建一个多层图);"It has better query performance than IVFFlat (in terms of speed-recall tradeoff), but has slower build times and uses more memory.";无需训练,"an index can be created without any data in the table." 参数:m 是 "the max number of connections per layer (16 by default)"(每层最大连接数),ef_construction 是 "the size of the dynamic candidate list for constructing the graph (64 by default)"(构图时的候选列表大小);查询侧 hnsw.ef_search 默认 40,且 "A higher value provides better recall at the cost of speed."

机制(这一段是对公开算法的通用描述,不是 pgvector 文档原话):把向量组织成一个图,每个向量是一个节点,与若干个"邻居"相连。查询时从某个入口点出发,每步都走向离查询向量更近的邻居,直到无法更近。多层结构的作用是"先在稀疏的高层大步跳跃、快速接近目标区域,再在稠密的底层精细搜索"。

【推断】 上面这段机制描述来自我对 HNSW 公开算法的理解,pgvector 文档只确认了"multilayer graph"这一点。如果你要实现或深度调优,请查 HNSW 原论文(Malkov & Yashunin)。但选型所需的信息已经在官方口径里了:比 IVF 的速度-召回权衡更好,代价是构建更慢、内存更多。

三方对比

维度 暴力 / Flat IVF HNSW
结果正确性 精确(官方口径:perfect recall) 近似,召回率由 probes 近似,召回率由 ef_search
查询速度 最慢(要读全库) 最快(官方口径:速度-召回权衡优于 IVFFlat)
构建时间 无需构建 需要 k-means 训练 → 中等 最慢(官方口径)
内存 低(不建索引) 较低(官方口径:less than HNSW) 最高(官方口径)
需要先有数据吗 不需要 需要(要跑 k-means;官方口径:"Create the index after the table has some data") 不需要(官方口径:可在空表上建)
主要旋钮 lists(分多少簇)、probes(搜多少簇) mef_construction(构图)、ef_search(查询)
何时不该 库大于百万量级且要求低延迟 数据频繁大批量变动导致簇中心失效时(需重建) 内存受限,或数据量极大导致图放不进内存

内容链的典型分工(这是我的判断,不是官方口径):

推荐召回(要快、允许近似)      → HNSW 或 IVF,配 recall ≈ 0.9~0.95
版权查重(漏一条代价实质)      → 高 probes 的 IVF 做粗筛 + 【精确重算】做精筛
                                 ★ 两阶段:ANN 只用来把候选集从 10 亿缩到几千,
                                   再对这几千个候选做精确距离计算并排序
离线全库两两相似(聚类/去重)    → 批处理,不用在线索引,Spark/Ray 分块暴力算

中间那条"ANN 粗筛 + 精确精筛"的两阶段模式,是把召回率损失控制在可接受范围内的通用手法,值得单独记住:近似只用于缩小候选集,最终排序用精确计算。 这样最终返回结果的排序是准的,只有"候选集里是否包含真正的答案"还带近似——而这一部分可以通过把粗筛的候选集开大来压低风险。

上游:我们依赖谁

上游 提供什么 出问题时的表现
上传服务 原片 + 上传元数据(作者、标题、话题) 原片损坏/格式不支持 → 整条处理链失败,需要有失败分类而不是笼统的 failed
音视频/转码团队 转码档位、封面、抽帧产物 抽帧规则变更 → 新旧特征不可比,但表面上看不出来
算法团队 模型本体与版本 新模型上线 → 全库重算,这是内容链最大的计划性负载
人工标注团队 评测集、人工修正的标签 评测集不更新 → 精确率/召回率指标失去意义
GPU 资源池 配额 配额被抢 → 端到端可分发时延拉长,热点内容错过时效窗口

下游:谁在吃,以及他们把什么约束加在我们头上

消费方 要什么 延迟 加在我们头上的约束
审核 违规判定结果 + 判定依据 分钟级(卡在分发前) 必须可追溯(哪个模型版本、什么阈值)→ §3.2 的三个 ★ 字段;必须高召回优先
推荐召回 内容 embedding、标签 分钟级(新内容要快速可分发) 需要在线向量检索服务(§4.3);embedding 必须与在线模型版本一致
推荐排序 / 特征平台 内容侧特征,与行为特征拼在一起 T+1 + 在线查询 离线在线口径必须一致(→ 20 篇的核心难题)
查重 / 版权 相似内容检索 分钟级 不能只用 ANN(§4.3.4);需要精确精筛
模型训练 样本集:多模态本体 + 标注 天级 随机高吞吐读(§3.4、§4.1);样本必须可复现(同一个数据集版本要能取到同一批样本)
创作者中心 内容标签、分发原因 小时级 标签要可解释、可对外展示——注意 embedding 不可解释(§3.3.1 代价 1),不能直接展示给创作者
成本团队 GPU·秒/视频、存储量、CDN 带宽 天级 处理任务必须打点记录资源消耗,否则单位成本这个指标算不出来
运营 / 内容池 分类分布、爆款内容特征 小时级 需要在标签维度上做高维下钻 → OLAP

两个观察:

  1. "模型训练"这个消费方的诉求与其他所有消费方都不同。 别人要的是"查询"(给条件、返回少量结果),它要的是"遍历"(把整个数据集以随机顺序反复读几十遍)。这个诉求上一节已经讲清了它对存储格式的全部要求——它是内容链里唯一一个把"随机读吞吐"当第一指标的消费方,也正因为它,Lance 这类格式才有存在的必要。
  2. 审核的"可追溯"和训练的"可复现"是同一类要求的两个方向。 审核要回答"过去那次判定是怎么来的",训练要回答"这次训练用的是哪批数据"。两者都要求数据集有版本——这正是 Iceberg 的快照和model_version 在解决的事。

边界与坑

  1. 把资产、衍生物、特征放一张宽表。模型迭代变成全表重写。
  2. 特征表不带 model_version。新旧模型无法并存、无法回滚、无法追溯;更糟的是跨版本向量比较不会报错,只会静默给出垃圾结果
  3. 拼样本时不锁模型版本。一个视频 JOIN 出多行,embedding 被复制且处在不同空间里。
  4. 关键帧多行直接 JOIN 而不先折叠成数组。扇出导致指标虚增。
  5. 忘记记录抽帧规则。不同规则产出的特征不可比,而这件事在数据里看不出来。
  6. 把 embedding 展示给创作者或用它做人工解释。它不可解释。
  7. 在 Parquet 上做训练时随机读。千倍量级的读放大,GPU 在等数据。
  8. 看到"100x faster"就选型。那是项目方自述、限定在 batched random access、无公开测试方法。
  9. 把 GPU 当 CPU 那样超卖。K8s 官方约束:GPU 的 requests 必须等于 limits
  10. 把模型推理写成 Ray task 而不是 actor。每次调用重新加载模型权重。
  11. 在 Ray 流水线里先 ray.get 收全再进下一阶段。退化成串行,GPU 在解码期间全程空转。
  12. 用 ANN 做版权查重。近似检索会漏掉那唯一一条要找的内容,必须两阶段。
  13. 拿 ANN 的召回率损失当"精度问题"忽略。它不是数值误差,是结果集本身变了
  14. IVF 建在数据大幅变动后不重建。k-means 的簇中心基于旧数据分布,分布漂移后召回率会静默下降。
  15. 不记录每个处理任务的 GPU 消耗。单位处理成本这个指标算不出来,而它是内容链唯一直接以钱为单位的指标。

小结

本篇的一条主线:内容链不直接产生收入,但它是分发精准度、审核合规、版权、成本这四条路径的乘数(§1);而"理解内容"的产物最终落成两样东西——离散标签和 embedding,后者是全篇技术难题的唯一来源(§3.3)。

为什么 embedding 这一列会引出三个极限

embedding 是高维稠密浮点数组
   ├─→ 压不动(字典/RLE 失效)+ min/max 统计对相似性查询无效
   │      └─→ 加上"训练要随机读单样本"这个访问模式
   │             └─→ Parquet 的三个失效点 → §4.1 存储格式
   │
   ├─→ 算它需要 GPU,而 GPU 独占分配、不可超卖
   │      └─→ 利用率取决于数据能不能填满它
   │             └─→ CPU 段与 GPU 段必须拆开并行 → §4.2 Ray 与调度
   │
   └─→ 查它是"高维空间求最近邻",1024 维空间无全序
          └─→ B+ 树思路整体失效,暴力扫描代价 4 TB/次
                 └─→ 只能用召回率换速度 → §4.3 ANN

三处极限的破法与代价

极限 根因 破法 代价
存储格式 为扫描优化的格式撞上随机读负载;加列要全表重写 Lance 这类 AI 专用格式 生态支持窄;不是 Parquet 的替代品而是第二份存储;"100x" 是自述数字
调度 GPU 独占分配(requests 必须等于 limits),CPU 段占着卡空转 Ray:task 跑无状态 CPU 段、actor 持有模型状态跑 GPU 段,各自声明资源、流水线重叠 多一套运行时;写错(用 task 装模型、提前 ray.get)反而更慢
索引 高维空间无全序,B+ 树思路失效;暴力扫描 4 TB/次 ANN(IVF 分簇 / HNSW 图) 查询结果本身变了——不是变慢变快的问题;版权类场景必须 ANN 粗筛 + 精确精筛

三个跨篇对照,帮你把批次二的三篇串起来:

  • 数据会不会变:15 篇会变(CDC / SCD / MOR);16A 不变(只追加);16B 会以"全库重算"的形态变——不是行级更新,是整列重生。
  • 瓶颈资源:15 篇是存储 I/O 与写放大;16A 是 CPU 与 shuffle;16B 是 GPU 与随机读吞吐
  • 误差可不可接受:15 篇不可(财务对账是最硬约束);16A 部分可(HLL 估计值可用于大盘、不可用于结算);16B 默认就是近似的(模型判定本身有精确率/召回率,ANN 又叠一层近似)——所以 16B 唯一的硬约束不是"准",而是"可追溯、可复现"
Back to Blog