多模态检索(混合搜索)
真实场景里,常常需要同时检索多种模态——商品同时挂 text + image embedding、视频片段同时有 audio + frame embedding、文档同时存 dense + sparse 表示。MultiModal 层提供一等公民的多 vector 字段 schema 和 Reciprocal-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 示例
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 |
| CRUD | pdb_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 |
#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:
- 顺序扫描 WAL,按
txid分组各条记录 - 一个
txid仅在尾部撕裂之前看到COMMIT才算生效 - 未完成的
txid全部回滚:orphan payload 字节截断,部分写入的子字段逐个删除 - 撕裂尾部(如有)截断,新的 WAL append 开启
端到端测试覆盖:在子进程里 os._exit(1)、人工破坏 WAL 尾部——两种场景都能干净恢复。
本版本未涵盖
MVP 故意收紧了功能面。下列项目在路线图上:
- 标量字段过滤(
category == 'shoes' AND price < 100) - 稀疏向量字段(BM25 / SPLADE)实现 dense + sparse 混合检索
- 后期交互的多向量字段(ColBERT / BGE-M3 的 MaxSim 聚合)
- WeightedRanker——RRF 之外的加权求和(含分数归一化)
如果你的场景需要,请在 GitHub 上提 issue 投票。
