Skip to content

多模态检索(混合搜索)

真实场景里,常常需要同时检索多种模态——商品同时挂 text + image embedding、视频片段同时有 audio + frame embedding、文档同时存 dense + sparse 表示。MultiModal 层提供一等公民的多 vector 字段 schemaReciprocal-Rank-Fusion (RRF) 混合检索,同时保留 PistaDB 零依赖、单机部署的特性。

核心能力

  • 多向量 schema——一条记录最多挂 16 个 named field,每个字段独立 dim / metric / index 算法。可以 text 用 HNSW、image 用 IVF 自由组合;每个字段内部就是一个标准 .pst 文件,8 种 index 全部可用。
  • 跨字段原子写入——mm.insert(id, vecs={...}) 要么所有 field + payload + catalog 全部成功落地,要么全部回滚。靠专属 MM 层 WAL 实现,os._exit / 断电后可完整恢复。
  • RRF 混合检索——每个字段并行做 k-NN,按 score = Σ 1 / (k + rank_i) 融合,rrf_k 可配(默认 60)。
  • 每条记录可挂二进制 payload——caption / URL / 标签由调用方自行序列化;读取时从内置缓冲区借用指针。
  • 完全向后兼容——不改 .pst 文件格式,不动任何现有 API。老的单模态 .pst 继续按原方式工作。

磁盘结构

多模态 bundle 是一个目录

mybundle.pmm/
├── pmm.manifest          (128 字节头 + schema)
├── pmm.catalog           (64 字节定长行,id → flags/mask/payload_off/label)
├── pmm.payload           (append-only blob 日志;checkpoint 时压缩)
├── pmm.wal               (canonical 多字段 WAL;CRC32 + 尾部撕裂安全)
└── fields/<name>.pst     (每个字段一个标准 PistaDB 文件)

每个字段的 <name>.pst 就是一个普通的 PistaDB 文件——你甚至可以直接用单模态的 pistadb_open() API 打开它。manifest、catalog、payload 日志、MM WAL 是仅有的新增磁盘工件。

Python 示例

python
import numpy as np
from pistadb import MultiModal, FieldSpec, Metric, Index, Params

mm = MultiModal.create("products.pmm", [
    FieldSpec("text",  dim=384, metric=Metric.COSINE, index_type=Index.HNSW),
    FieldSpec("image", dim=512, metric=Metric.COSINE, index_type=Index.IVF),
])
mm.train_field("image")     # IVF 在插入前需要训练

mm.insert(
    id=1,
    label="red leather wallet",
    payload=b'{"sku":"W-1042","price":49.99}',
    vecs={
        "text":  text_emb,     # numpy float32, shape (384,)
        "image": image_emb,    # numpy float32, shape (512,)
    },
)

# 并行搜两个模态再用 RRF 融合:
hits = mm.hybrid_search(
    {"text":  (query_text_emb,  20),     # 每个字段融合前的 top-k
     "image": (query_image_emb, 20)},
    top_k=10, rrf_k=60, parallel=True,
)
for h in hits:
    print(h.id, h.score, h.label)
    print("  payload:", mm.get_payload(h.id))

mm.checkpoint()             # 快照 + 压缩 + 截断 WAL
mm.close()

RRF 融合公式

对每个 field query,PistaDB 在对应的子索引上跑一次常规 k-NN。然后某个 id 的融合分数:

$$\text{score}(\text{id}) = \sum_{i \in \text{fields}} \frac{1}{k_{\text{rrf}} + \text{rank}_i(\text{id})}$$

其中 $\text{rank}_i(\text{id})$ 是 1-indexed(top hit rank=1)。某个 id 没出现在某 field 的 top-k 中,那个 field 贡献 0。默认 rrf_k=60 出自 RRF 原论文——值越大越拉平 top rank 的影响,越小则放大。

C API

完整接口在 src/pistadb_mm.h——14 个公开函数,延续既有 pistadb_batch.h / pistadb_txn.h / pistadb_cache.h 的 opaque-handle 模式:

分类函数
生命周期pdb_mm_create · pdb_mm_open · pdb_mm_close · pdb_mm_save · pdb_mm_checkpoint
CRUDpdb_mm_insert · pdb_mm_update · pdb_mm_delete · pdb_mm_get
搜索pdb_mm_hybrid_search
维护pdb_mm_train_field · pdb_mm_count · pdb_mm_schema · pdb_mm_last_error
c
#include "pistadb_mm.h"

PdbMmFieldSpec specs[2] = {
    { .name="text",  .dim=384, .metric=METRIC_COSINE, .index_type=INDEX_HNSW,
      .params=pistadb_default_params() },
    { .name="image", .dim=512, .metric=METRIC_COSINE, .index_type=INDEX_IVF,
      .params=pistadb_default_params() },
};
PdbMmSchema schema = { .n_fields = 2 };
schema.fields[0] = specs[0];
schema.fields[1] = specs[1];

PistaDBMM *mm = pdb_mm_create("products.pmm", &schema, NULL);

PdbMmVecEntry vecs[2] = {
    { .field_name = "text",  .vec = text_emb  },
    { .field_name = "image", .vec = image_emb },
};
PdbMmRecord rec = {
    .id          = 1,
    .label       = "red leather wallet",
    .payload     = jpg_bytes,
    .payload_len = jpg_len,
    .n_vecs      = 2,
    .vecs        = vecs,
};
pdb_mm_insert(mm, &rec);

PdbMmFieldQuery qs[2] = {
    { .field_name="text",  .vec=q_text,  .k=20 },
    { .field_name="image", .vec=q_image, .k=20 },
};
PdbMmQuery q = {
    .n_field_queries = 2,
    .field_queries   = qs,
    .top_k           = 10,
    .ranker          = { .kind = PDB_MM_RANKER_RRF, .rrf_k = 60 },
    .parallel        = 1,
};
PdbMmResult results[10];
int n_out = 0;
pdb_mm_hybrid_search(mm, &q, results, &n_out);

崩溃安全

MM 层 WAL 是唯一的 committed 写入真相来源。子 .pst 文件全部以 WAL 关闭模式打开——所有协调由 MM WAL 完成。每个逻辑操作是一组共享 txid 的记录:

BEGIN_INSERT(txid, id, label, payload_len, payload_crc, n_vecs)
PAYLOAD_WRITE(txid, off, len)
FIELD_INSERT(txid, field_idx, dim, vec[dim])    ×N
COMMIT(txid)

pdb_mm_open 时如果发现上次未净关闭,会执行 replay:

  1. 顺序扫描 WAL,按 txid 分组各条记录
  2. 一个 txid 在尾部撕裂之前看到 COMMIT 才算生效
  3. 未完成的 txid 全部回滚:orphan payload 字节截断,部分写入的子字段逐个删除
  4. 撕裂尾部(如有)截断,新的 WAL append 开启

端到端测试覆盖:在子进程里 os._exit(1)、人工破坏 WAL 尾部——两种场景都能干净恢复。

本版本未涵盖

MVP 故意收紧了功能面。下列项目在路线图上:

  • 标量字段过滤category == 'shoes' AND price < 100
  • 稀疏向量字段(BM25 / SPLADE)实现 dense + sparse 混合检索
  • 后期交互的多向量字段(ColBERT / BGE-M3 的 MaxSim 聚合)
  • WeightedRanker——RRF 之外的加权求和(含分数归一化)

如果你的场景需要,请在 GitHub 上提 issue 投票。

基于 MIT 协议发布