BM25:文档先算权重
入库时把标题、描述和 tags 转小写、分词、去停用词,统计词频 tf 和文档词数 dl,写入下面的文档侧权重。用户改过的标题、描述和 tags 优先。
- 默认
k1 = 1.2控制词频饱和,b = 0.75控制文档长度修正。 avgdl是平均文档词数,在新集合建立时统计并固定;只有空库才使用默认估计值 100。- 查询词去重,每词权重为 1。Qdrant 在查询时加一次 IDF,让少见词更有区分度。
Asset Library · 当前代码流程
入库时,从 URL 下载图片或视频,生成可检索的文字和向量。查询时,原 query 同时走关键词和语义两路召回,合并同一文件的副本后由 Jev 打分精排,去掉低分结果,最后按 limit 返回。
先看系统分工
浏览器通过 Pages 网关访问 Node.js 服务。服务核验来源身份,再操作私有文件、PostgreSQL(图中简称 PG)、Qdrant、Vertex AI 和 Jev。关键词向量记录词及其权重;语义向量(embedding)把文字含义表示为一组数字,用来寻找意思接近的内容。
| 组件 | 保存或处理什么 | 在流程中的作用 |
|---|---|---|
| 服务私有文件目录 | 处理期间的完整原文件、模型响应缓存 | 为完整媒体分析提供内容。登记 URL 是不带查询参数的公开 HTTPS 地址时,ready 后删除本地副本,原文件请求核验权限后 302 到源地址;签名 URL 来源一直保留副本,由服务受保护地返回。 |
| PostgreSQL | 素材、来源、映射、访问关系、用户,共五张业务表 | 保存生成的和用户改过的标题描述、归属、共享范围、处理与清理状态,以及跨来源关联后的统一用户;结果返回前复核权限。 |
| Qdrant | keyword_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 后不再保留本地副本。
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;
路线二 · 用户查询
两路都从原 query 出发:关键词路分词后查 BM25,语义路默认直接对 query 做 embedding(HyDE 可选),余弦低于 0.6 的不召回。指定业务类别时各路默认取 20 条;不指定时素材、创意源、成片各自召回,每类每路 10 条,每类融合后留前 10。同一文件的副本合并后交给 Jev 打分,低于 0.15 分的去掉,复查权限后才按 limit 截取。Jev 失败时保留 RRF 顺序。文本搜索不保存快照。
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 根据原始 query 与素材描述、tags 给每条候选打分,决定最终顺序,并去掉低分结果。
入库时把标题、描述和 tags 转小写、分词、去停用词,统计词频 tf 和文档词数 dl,写入下面的文档侧权重。用户改过的标题、描述和 tags 优先。
k1 = 1.2 控制词频饱和,b = 0.75 控制文档长度修正。avgdl 是平均文档词数,在新集合建立时统计并固定;只有空库才使用默认估计值 100。素材在命中的每一路贡献 1 / (60 + rank),rank 从 1 开始。贡献相加后按总分排序,同分按内部 ID 排。不指定业务类别时,素材、创意源、成片各自融合、各留前 10,再按融合分合并。
| 示例素材 | 关键词 / 语义名次 | 融合分 |
|---|---|---|
| A | 1 / 未命中 | 0.01639 |
| B | 2 / 2 | 0.03226 |
这个示例在 RRF 阶段是 B 排在 A 前面,后续 Jev 精排可以改变顺序;Jev 失败时就按这个顺序返回。这些数字仅解释公式,不是实际结果或相关性概率。
默认把原 query 套进 embedding.query 模板,生成 1536 维向量,直接查询 Qdrant 的 semantic_dense。余弦相似度低于 0.6 的点不召回,与库里内容无关的 query 就不会被最不像的素材填满名额。
SEMANTIC_INPUT=hyde 时,先由 Gemini 根据 query 写一段假想素材描述,再按文档 embedding 模板(标题、标签留空)编码。冷查询约多 2 秒,评测中召回没有可测提升,所以默认关闭。先回数据库过滤,再把同一文件(sha256 相同)的副本合并成一条(调用者自己有一份时用自己的),剩下的全部候选交给 Jev。每个候选单独判断:Jev 只看到原始 query 和这一条的 description、tags,给出 0–1 的分数。请求里没有图片、视频、URL、标题或 asset_id,批内用 c1、c2 这样的别名。
rerank_skipped: true,也不按 0.15 过滤。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 给出各阶段耗时。
状态与异常路径
登记返回持久任务 upload_id 和素材短码 asset_id。后台下载和处理完成后,uploads.get 才返回素材 result(asset_id、title、description、tags、urls、source_url 六个字段);搜索、列表及 get / lookup / resolve 只使用 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,关键词路没有门槛。仍没有搜索结果快照。
阅读与核对
每一步展开后都附有文件位置。下面三份文档分别说明架构、接口和运行配置,代码链接需要仓库访问权限。