Asset Library 流程说明

Asset Library · 当前代码流程

从一份素材,
到一次搜索。

入库时,从 URL 下载图片或视频,生成可检索的文字和向量。查询时,原 query 同时走关键词和语义两路召回,合并同一文件的副本后由 Jev 打分精排,去掉低分结果,最后按 limit 返回。

代码基线 feat/asset-library711d6d6 ↗核查日期 2026-09-28按仓库代码说明,非线上运行监控

先看系统分工

原文件、业务记录、搜索索引各有位置

浏览器通过 Pages 网关访问 Node.js 服务。服务核验来源身份,再操作私有文件、PostgreSQL(图中简称 PG)、Qdrant、Vertex AI 和 Jev。关键词向量记录词及其权重;语义向量(embedding)把文字含义表示为一组数字,用来寻找意思接近的内容。

80 MiB默认单文件上限下载配置可调整
1536语义向量维度素材与查询使用同一维度
20 / 路默认召回候选上限指定业务类别时;不指定时每类每路 10
5 条默认返回上限与召回数量分别配置
组件保存或处理什么在流程中的作用
服务私有文件目录处理期间的完整原文件、模型响应缓存为完整媒体分析提供内容。登记 URL 是不带查询参数的公开 HTTPS 地址时,ready 后删除本地副本,原文件请求核验权限后 302 到源地址;签名 URL 来源一直保留副本,由服务受保护地返回。
PostgreSQL素材、来源、映射、访问关系、用户,共五张业务表保存生成的和用户改过的标题描述、归属、共享范围、处理与清理状态,以及跨来源关联后的统一用户;结果返回前复核权限。
Qdrantkeyword_sparse、semantic_dense、权限与分类 payload关键词与语义召回。一条素材对应一条索引记录(point),保存两种向量及权限、分类等附带字段(payload);完整正文保存在 PostgreSQL。
Vertex AI入库媒体分析、素材与查询的文本 embedding;HyDE 只在 SEMANTIC_INPUT=hyde 时调用分析模型与 HyDE 模型独立配置,默认均为 gemini-3.8-flash;向量模型为 gemini-embedding-2。
Jev(经 ZenMux)搜索精排:给每条候选的描述和 tags 打相关性分唯一的精排模型,默认 typesafe/jev-1.13,不经过 Vertex AI。失败时本次不重排,保留 RRF 顺序。

模型名与数量是代码默认配置,实际环境可以覆盖。1536 维属于索引协议,修改时需处理已有向量兼容性。

路线一 · 素材入库

把原文件准备成可搜索的素材

先提交 file_id 与 URL,立即取得任务 upload_id 和素材短码 asset_id。后台下载、分析并写入双向量;调用方通过 uploads.get 轮询,ready 时取得正式素材。源地址可公开直连的素材,ready 后不再保留本地副本。

图可滚动查看;点击步骤展开输入、技术和输出。
本人既有任务他人或已注销新素材queued 或中断任务调用方轮询新下载文件复用已有完整原件处理异常重新 queued之后按 asset_id

01 提交 file_id + HTTPS URL
assets.register · Sozai 可带 asset_id

02 核验来源身份
签名断言或回源会话 · 统一用户

03 校验参数和 URL
暂不下载原文件

04 同来源文件是否已登记?

复用原 upload_id + asset_id

拒绝:NOT_FOUND

05 PG 四表事务保存任务
asset_id 短码 · queued · 指定短码被占用则 409

06 立即返回 upload_id + asset_id
调用方可以轮询 uploads.get

07 后台领取任务
PG 会话锁 · downloading

17 uploads.get 查询进度
ready 时返回素材 result

08 下载或复用完整原文件
公网 DNS · 重定向 · 流式下载

09 文件头 + ffprobe + SHA-256
私有文件与媒体信息

10 Gemini 分析完整媒体
analyzing · 附上传者标题 / 标签

11 PG 保存分析文字
音频描述并入 description

12 素材文本 embedding
embedding · 1536 维 · 内容版本核对

13 同一组文字计算 BM25
Intl 分词 · 词频与长度修正

14 写入同一 Qdrant point
稀疏 + 稠密 + 权限字段

15 PG 标记 ready

16 登记 URL 无查询参数时删除本地副本
媒体请求 302 到源地址 · 签名 URL 保留副本

18 failed:所有者 uploads.retry
复用任务,可替换 URL

19 注销或修改元数据
后台清理或重算双向量

实线表示执行顺序;分支文字说明条件。图内数字对应步骤详情。模型名和可配置数量以当前代码默认值说明。
查看 flowchart TD 源码
flowchart TD
    I01["01 提交 file_id + HTTPS URL<br/>assets.register · Sozai 可带 asset_id"] --> I02["02 核验来源身份<br/>签名断言或回源会话 · 统一用户"]
    I02 --> I03["03 校验参数和 URL<br/>暂不下载原文件"]
    I03 --> I04{"04 同来源文件是否已登记?"}
    I04 -->|本人既有任务| IDUP["复用原 upload_id + asset_id"]
    I04 -->|他人或已注销| DECLINE["拒绝:NOT_FOUND"]
    I04 -->|新素材| I05["05 PG 四表事务保存任务<br/>asset_id 短码 · queued · 指定短码被占用则 409"]
    IDUP --> I06["06 立即返回 upload_id + asset_id<br/>调用方可以轮询 uploads.get"]
    I05 --> I06
    I06 -->|queued 或中断任务| I07["07 后台领取任务<br/>PG 会话锁 · downloading"]
    I06 -.->|调用方轮询| I17["17 uploads.get 查询进度<br/>ready 时返回素材 result"]
    I07 --> I08["08 下载或复用完整原文件<br/>公网 DNS · 重定向 · 流式下载"]
    I08 -->|新下载文件| I09["09 文件头 + ffprobe + SHA-256<br/>私有文件与媒体信息"]
    I08 -->|复用已有完整原件| I10
    I09 --> I10["10 Gemini 分析完整媒体<br/>analyzing · 附上传者标题 / 标签"]
    I10 --> I11["11 PG 保存分析文字<br/>音频描述并入 description"]
    I11 --> I12["12 素材文本 embedding<br/>embedding · 1536 维 · 内容版本核对"]
    I12 --> I13["13 同一组文字计算 BM25<br/>Intl 分词 · 词频与长度修正"]
    I13 --> I14["14 写入同一 Qdrant point<br/>稀疏 + 稠密 + 权限字段"]
    I14 --> I15["15 PG 标记 ready"]
    I15 --> I16["16 登记 URL 无查询参数时删除本地副本<br/>媒体请求 302 到源地址 · 签名 URL 保留副本"]
    I16 --> I17
    I10 -.->|处理异常| I18["18 failed:所有者 uploads.retry<br/>复用任务,可替换 URL"]
    I08 -.-> I18
    I09 -.-> I18
    I12 -.-> I18
    I14 -.-> I18
    I18 -.->|重新 queued| I07
    I17 -.->|之后按 asset_id| I19["19 注销或修改元数据<br/>后台清理或重算双向量"]
    classDef focus fill:#EEF2F7,stroke:#1B365D,stroke-width:2px,color:#141413;
    classDef auxiliary fill:#EAE9E2,stroke:#6b6a64,color:#3d3d3a;
    class I06,I15 focus;
    class IDUP,DECLINE,I18,I19 auxiliary;

路线二 · 用户查询

关键词与语义两路召回,Jev 决定最终顺序

两路都从原 query 出发:关键词路分词后查 BM25,语义路默认直接对 query 做 embedding(HyDE 可选),余弦低于 0.6 的不召回。指定业务类别时各路默认取 20 条;不指定时素材、创意源、成片各自召回,每类每路 10 条,每类融合后留前 10。同一文件的副本合并后交给 Jev 打分,低于 0.15 分的去掉,复查权限后才按 limit 截取。Jev 失败时保留 RRF 顺序。文本搜索不保存快照。

图可滚动查看;点击步骤展开输入、技术和输出。
是否有候选无候选成功Jev 失败

01 提交 query + filters + limit

02 每个请求重新核验身份
统一用户 + 当前团队 + 创意源

03 校验参数 · 文本 limit 1–20
文本搜索不接受 cursor

04 query 为空?

PG 浏览 ready 素材 · limit 1–100
返回列表 + 无状态下一页游标

19 查看原图 / 视频
/api/media · 本地副本或 302 到源地址

05 从原 query 启动两路
单类并行 · 全部类型一次 batch

06 原 query → Intl 分词
去重 · 每词权重 1

07 BM25 关键词召回
Qdrant · 单类 20 / 每类 10 · 无下限

08 查询 embedding
默认原 query · HyDE 可选 · 1536 维

09 语义召回 · 余弦 ≥ 0.6
Qdrant Cosine · 单类 20 / 每类 10

10 RRF 融合、去重
单类 ≤40 · 全部类型 ≤30

11 PG 过滤权限 / ready / 筛选
取合格候选的描述和 tags

12 合并同一文件的副本
sha256 相同 · 优先自己的那份

返回空结果,不调用 Jev

13 Jev 逐条打分精排
原 query + description / tags

14 校验完整 ID 排列
不能新增、遗漏或重复

不重排,保留 RRF 顺序
rerank_skipped: true

15 去掉 Jev 分数低于 0.15 的结果
未重排时没有分数,不过滤

16 再次回 PG 复核
权限 / ready / 筛选与最新字段

17 最后按 limit 截取
默认返回 Top 5

18 返回本次结果
不保存搜索快照 · next_cursor 为 null

实线表示执行顺序;分支文字说明条件。图内数字对应步骤详情。模型名和可配置数量以当前代码默认值说明。
查看 flowchart TD 源码
flowchart TD
    Q01["01 提交 query + filters + limit"] --> Q02["02 每个请求重新核验身份<br/>统一用户 + 当前团队 + 创意源"]
    Q02 --> Q03["03 校验参数 · 文本 limit 1–20<br/>文本搜索不接受 cursor"]
    Q03 --> Q04{"04 query 为空?"}
    Q04 -->|是| BROWSE["PG 浏览 ready 素材 · limit 1–100<br/>返回列表 + 无状态下一页游标"]
    BROWSE --> Q19["19 查看原图 / 视频<br/>/api/media · 本地副本或 302 到源地址"]
    Q04 -->|否| Q05["05 从原 query 启动两路<br/>单类并行 · 全部类型一次 batch"]
    Q05 --> Q06["06 原 query → Intl 分词<br/>去重 · 每词权重 1"]
    Q06 --> Q07["07 BM25 关键词召回<br/>Qdrant · 单类 20 / 每类 10 · 无下限"]
    Q05 --> Q08["08 查询 embedding<br/>默认原 query · HyDE 可选 · 1536 维"]
    Q08 --> Q09["09 语义召回 · 余弦 ≥ 0.6<br/>Qdrant Cosine · 单类 20 / 每类 10"]
    Q07 --> Q10["10 RRF 融合、去重<br/>单类 ≤40 · 全部类型 ≤30"]
    Q09 --> Q10
    Q10 --> Q11["11 PG 过滤权限 / ready / 筛选<br/>取合格候选的描述和 tags"]
    Q11 -->|有候选| Q12["12 合并同一文件的副本<br/>sha256 相同 · 优先自己的那份"]
    Q11 -->|无候选| NONE["返回空结果,不调用 Jev"]
    Q12 --> Q13["13 Jev 逐条打分精排<br/>原 query + description / tags"]
    Q13 -->|成功| Q14["14 校验完整 ID 排列<br/>不能新增、遗漏或重复"]
    Q13 -->|Jev 失败| SKIP["不重排,保留 RRF 顺序<br/>rerank_skipped: true"]
    SKIP --> Q14
    Q14 --> Q15["15 去掉 Jev 分数低于 0.15 的结果<br/>未重排时没有分数,不过滤"]
    Q15 --> Q16["16 再次回 PG 复核<br/>权限 / ready / 筛选与最新字段"]
    Q16 --> Q17["17 最后按 limit 截取<br/>默认返回 Top 5"]
    Q17 --> Q18["18 返回本次结果<br/>不保存搜索快照 · next_cursor 为 null"]
    Q18 --> Q19
    classDef focus fill:#EEF2F7,stroke:#1B365D,stroke-width:2px,color:#141413;
    classDef auxiliary fill:#EAE9E2,stroke:#6b6a64,color:#3d3d3a;
    class Q08,Q13 focus;
    class BROWSE,NONE,SKIP auxiliary;

两种向量,一份最终排名

BM25 与 RRF 整理候选,Jev 决定最终顺序

BM25 匹配关键词,语义路线按余弦相似度召回,RRF 合并两份排名。它们负责产生候选;同一文件的副本合并后,Jev 根据原始 query 与素材描述、tags 给每条候选打分,决定最终顺序,并去掉低分结果。

BM25:文档先算权重

入库时把标题、描述和 tags 转小写、分词、去停用词,统计词频 tf 和文档词数 dl,写入下面的文档侧权重。用户改过的标题、描述和 tags 优先。

tf × (k1 + 1) ──────────────────────────────── tf + k1 × (1 − b + b × dl / avgdl)
  • 默认 k1 = 1.2 控制词频饱和,b = 0.75 控制文档长度修正。
  • avgdl 是平均文档词数,在新集合建立时统计并固定;只有空库才使用默认估计值 100。
  • 查询词去重,每词权重为 1。Qdrant 在查询时加一次 IDF,让少见词更有区分度。

RRF:合并候选的初始顺序

素材在命中的每一路贡献 1 / (60 + rank),rank 从 1 开始。贡献相加后按总分排序,同分按内部 ID 排。不指定业务类别时,素材、创意源、成片各自融合、各留前 10,再按融合分合并。

融合分数 = Σ 1 / (60 + rank)
示例素材关键词 / 语义名次融合分
A1 / 未命中0.01639
B2 / 20.03226

这个示例在 RRF 阶段是 B 排在 A 前面,后续 Jev 精排可以改变顺序;Jev 失败时就按这个顺序返回。这些数字仅解释公式,不是实际结果或相关性概率。

语义路线:默认直接编码原 query,HyDE 可选

默认把原 query 套进 embedding.query 模板,生成 1536 维向量,直接查询 Qdrant 的 semantic_dense。余弦相似度低于 0.6 的点不召回,与库里内容无关的 query 就不会被最不像的素材填满名额。

  • 关键词路线仍使用原 query,保留精确词项,没有分数下限。
  • 服务端设 SEMANTIC_INPUT=hyde 时,先由 Gemini 根据 query 写一段假想素材描述,再按文档 embedding 模板(标题、标签留空)编码。冷查询约多 2 秒,评测中召回没有可测提升,所以默认关闭。
  • 假想描述只用于检索,不写回素材。0.6 这个下限是按默认的 query 模板标定的,开启 HyDE 或换 embedding 模型前要重测。

Jev 精排:逐条打分,失败就保留 RRF 顺序

先回数据库过滤,再把同一文件(sha256 相同)的副本合并成一条(调用者自己有一份时用自己的),剩下的全部候选交给 Jev。每个候选单独判断:Jev 只看到原始 query 和这一条的 description、tags,给出 0–1 的分数。请求里没有图片、视频、URL、标题或 asset_id,批内用 c1、c2 这样的别名。

  • 按分数从高到低排,同分保留 RRF 顺序。低于 0.15 分的结果不返回,所以与库里内容无关的 query 可能得到空结果。
  • 每批 10 条,最多 4 批同时进行。单次 8 秒超时,超时不重试;429、5xx 和网络错误最多重试 2 次。任一批最终失败,本次就不重排:按 RRF 顺序返回,响应带 rerank_skipped: true,也不按 0.15 过滤。
  • 模型只能给已有候选排序,找不回没有召回的素材。第二次权限与 ready 复查之后,才应用 limit。

候选数、门槛与返回数分开,模型缓存不等于搜索快照

RECALL_PER_ROUTE=20 控制单类检索每路的候选数,默认最多合并 40 条。不指定业务类别时,RECALL_PER_TYPE=10 控制每类每路的候选数,每类融合后留前 10,三类最多 30 条。语义路余弦低于 SEMANTIC_MIN_SCORE=0.6 的点不召回,同一文件的副本只留一份,所以实际候选常比上限少。全部合格候选都交给 Jev 精排,分数低于 RERANK_MIN_SCORE=0.15 的不返回。limit 最后限制返回数量:默认 SEARCH_LIMIT=5,文本搜索允许 1–20,空 query 浏览允许 1–100。

文本搜索每次都重新召回、复查并调用 Jev,返回 next_cursor=null,不提供结果快照或游标翻页。只有 embedding 和 HyDE 请求有经过校验的文件缓存,Jev 不缓存。空 query 的素材列表保留无状态游标,5 分钟过期。响应里的 duplicates_collapsed 和 rerank_dropped 记录合并和过滤掉的条数,timing 给出各阶段耗时。

状态与异常路径

先返回任务 ID,完成后取得正式素材

登记返回持久任务 upload_id 和素材短码 asset_id。后台下载和处理完成后,uploads.get 才返回素材 result(asset_id、title、description、tags、urls、source_url 六个字段);搜索、列表及 get / lookup / resolve 只使用 ready 的正式素材。

queued已登记,等待处理→downloading下载或复用原件→analyzing完整媒体分析→embedding生成并写入双向量→ready正式素材可返回
发生什么当前处理方式
登记参数或 URL 语法不合法登记前返回错误,不创建有效任务。源 URL 需要公开 HTTPS;asset_id 只有 Sozai 可传,且须是 4+4 短码;下载阶段还会核验 DNS 和重定向目标。Sozai 指定的 asset_id 已被别的素材占用时,登记事务回滚,返回 409 ASSET_ID_CONFLICT。
下载超限、超时、文件无法解析或模型/索引失败后台通常写 failed。所有者用 uploads.retry 重新排队,可附新 URL;复用 upload_id 与原素材身份。处理中已被注销的任务不写 failed,改为清理向量和本地文件。
并发重复登记同一来源文件进程内合并正在执行的同用户请求,PG 按“来源 + file_id”加事务锁,保证持久幂等。已有任务返回同一个 upload_id 和 asset_id,不重复创建素材,也不改写已登记的 URL、标题标签或 asset_id。他人名下或已注销的同一来源文件返回 NOT_FOUND。
进程中断或数据库连接异常可能留下未完成状态,后续可重新领取 queued / downloading / analyzing / embedding。failed 只由显式重试重新排队。
登记后注销(assets.unregister)同一事务软删素材、来源、映射、访问四行,任务状态改为 cancelled;正在处理的任务失去处理权后转去清理。Qdrant point 和本地文件由后台清理,失败按退避重试,间隔最长 60 秒。之后 uploads.get 返回 NOT_FOUND,同一 file_id 不能再次登记。
修改标题、描述、标签或团队(assets.update)写入用户覆盖值,不改写生成字段;ready 素材只按新文字重算双向量和权限字段,不重新下载或分析媒体。响应 index 为 done 或 pending,pending 由后台重试。处理中修改的,写向量时直接用最新文字。
同时进行的请求或身份核验过多超过 RPC_CONCURRENCY / AUTH_CONCURRENCY(默认各 64)立即返回 429 BUSY,不排队;登记和检索都适用。
查询 embedding、HyDE(开启时)或 Qdrant 召回失败整次文本搜索返回错误,不退回只用一路的结果。没有合格候选时正常返回空结果,不调用 Jev。
Jev 精排失败(超时、4xx、重试用尽或响应不合格)本次不重排:按 RRF 顺序返回,响应带 rerank_skipped: true,不按 0.15 分过滤,搜索不报错。排序与候选对不上时仍返回 MODEL_RESPONSE_INVALID。
query 与库里的内容都不相关语义路余弦低于 0.6 的点不召回,Jev 分数低于 0.15 的结果不返回,所以可能返回空结果;rerank_dropped 记录被下限去掉的条数。
精排调用期间撤权、软删除或状态改变Jev 返回(或跳过)后再次回 PostgreSQL 检查;剔除失效项,再从后续达到分数下限的候选中按 limit 取结果。
调用方给文本搜索传 cursor返回 INVALID_PARAMS。当前文本搜索只按 limit 返回本次结果,不保存搜索快照;空 query 浏览列表仍可使用无状态游标(limit 1–100,5 分钟过期)。
访问原图或视频再次核验登录和数据库可见性。本地有副本时由服务返回,支持 GET、HEAD、单个 Range。本地没有副本、登记 URL 又是不带查询参数的公开 HTTPS 地址时,302 跳转到该地址,Pages 网关只放行这种跳转;否则返回 MEDIA_UNAVAILABLE。直接媒体端点自身没有 ready 检查;正常客户端通过 ready 素材结果获取该地址。

入库与查询用不同输入

入库分析读取完整图片或视频,并附上上传者填写的标题和标签,只用来判定角色名;查询时 embedding(以及可选的 HyDE)只接收 query,Jev 精排只接收 query 和候选的描述、tags,候选 ID 换成批内别名。检索阶段不重新读取或观看媒体。

当前实现边界

显式筛选仍由调用方提供;query 里的文字不会自动变成 media_type 等结构化条件。精排只排序已召回候选,找不回没召回的素材。相关性门槛有两道:语义召回余弦 0.6、Jev 分数 0.15,关键词路没有门槛。仍没有搜索结果快照。

阅读与核对

流程依据来自当前代码

每一步展开后都附有文件位置。下面三份文档分别说明架构、接口和运行配置,代码链接需要仓库访问权限。

流程图