Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SINDI

SINDI(Sparse INverted Dense Index)是 VSAG 面向 稀疏向量 的索引—— 例如 BM25、SPLADE 以及其他学习稀疏(learned sparse)编码器产出的向量。与稠密索引 (HGraph、IVF)不同,SINDI 直接在“词项-权重”对上工作,是 VSAG 中接受 dtype: "sparse" 的索引之一。

工作原理

  1. 基于窗口的倒排表。 文档按固定窗口大小(window_size)分组,每个窗口独立维护一套 倒排表——即“词项 → (doc_id, value) 列表”的映射。
  2. 可选的剪枝与量化。 构建时可通过 doc_prune_ratio 按文档粒度丢弃权重最低的词项; use_quantization: true 使用 SQ8,use_quantization: "fp16" 使用半精度值。
  3. 打分。 检索时,SINDI 遍历查询向量的非零项,按窗口访问对应的倒排表,使用大小为 n_candidate 的大顶堆聚合得分,最后取 top-k。启用 use_reorder 时,候选会在正排 存储上重打分。默认正排存储保留 FP32 值;设置 rerank_type: "fp16" 时以半精度 保存 value、打分时转为 FP32;设置 rerank_type: "dmq8" 时使用压缩的 DMQ 正排, 进一步降低重排内存。

返回的距离为 1 - inner_product,使结果与稠密索引一样按升序排序。

需要同时支持内存与磁盘 I/O 时,请选择 SINDI_V2。

快速开始

#include <vsag/vsag.h>

std::string params = R"({
    "dtype": "sparse",
    "metric_type": "ip",
    "dim": 1024,
    "index_param": {
        "term_id_limit": 30000,
        "window_size": 50000,
        "doc_prune_ratio": 0.0,
        "use_quantization": false,
        "use_reorder": false,
        "remap_term_ids": false
    }
})";
auto index = vsag::Factory::CreateIndex("sindi", params).value();

// 使用 SparseVector 构建数据集。
auto base = vsag::Dataset::Make();
base->NumElements(n)
    ->SparseVectors(sparse_vectors)  // vsag::SparseVector*
    ->Ids(ids)
    ->Owner(false);
index->Build(base);

// 执行检索。
auto query = vsag::Dataset::Make();
query->NumElements(1)->SparseVectors(&query_vec)->Owner(false);
auto result = index->KnnSearch(
    query, /*topk=*/10,
    R"({"sindi": {"n_candidate": 100}})").value();

构建参数

构建参数放在 index_param 下。dtype 必须 为 "sparse",metric_type 必须 为 "ip"。

参数类型默认值说明
dimint—(必填)单条稀疏向量允许的最大非零项数量,不是 词表大小
term_id_limitint1000000词项 ID 的上界(应 ≥ 最大词项 ID + 1,最高 50 000 000)
window_sizeint50000每个窗口容纳的文档数(取值范围 10 000 – 60 000)
doc_prune_ratiofloat0.0构建阶段按文档丢弃权重最低词项的比例,取值范围为 [0.0, 1.0)
use_quantizationbool 或 stringfalsefalse 存 FP32,true 存 SQ8,"fp16" 存 FP16
use_reorderboolfalse是否保留一份正排存储,在 SINDI 粗排后对候选做精排
rerank_typestring"fp32"use_reorder 开启时使用的正排存储类型。fp32 保留精确值;fp16 以半精度保存、以 FP32 打分;dmq8 使用压缩的 8-bit DMQ 编码
dmq_shared_codebook_thresholdint1024rerank_type: "dmq8" 时,出现次数不超过该值的 term 共用一个 codebook;更高频的 term 保持独立 codebook。设为 0 可关闭共享
remap_term_idsboolfalse是否在建索引前重映射词项 ID,适用于词项 ID 很稀疏或存在大量空洞的词表
avg_doc_term_lengthint100仅用于内存估算
immutableboolfalse构建或加载紧凑的只读运行态;Build() 会逐窗口压缩以降低峰值内存,增量 Add() 会被拒绝

dim 与 term_id_limit 的区别。 对于稀疏向量 {0:0.1, 2:0.5, 177:0.8}, dim 为 3(三个非零项),而 term_id_limit 至少应为 178(最大词项 ID + 1)。 term_id_limit 要按词表大小估计,这是使用时最常见的坑。

稀疏值格式

use_quantization 保留原有 bool 行为,同时新增一个字符串取值:

取值存储格式取舍
falseFP32词项权重精度最高,posting payload 最大
trueSQ8权重存储最小;首次构建时学习量化范围
"fp16"FP16权重字节数为 FP32 的一半,不需要 SQ8 范围校准

使用 false 或 true 构建的索引仍保留旧版序列化表示。旧版本 VSAG 无法解析使用新 "fp16" 格式的 SINDI 索引;部署 FP16 产物前应先升级读取端。

重排值格式

rerank_type 控制 use_reorder: true 创建的独立正排存储,与倒排表的 use_quantization 无关。fp16 保持查询 value 和累加为 FP32,只把文档 value 存成 FP16。由于 term ID 仍为 32-bit,在不计记录与块开销时,每个正排非零项的核心存储从 8 字节降到 6 字节。FP16 转换会增加计算指令,因此受内存带宽限制时延迟可能下降,短向量 或已在缓存中的数据也可能变慢,应使用代表性数据实测。

fp16 和 dmq8 都要求设置 use_reorder: true。旧版本 VSAG 无法读取 FP16 重排 payload。

不可变低内存构建

完成后的 SINDI 只读时,可以设置 immutable: true:

{
    "dtype": "sparse",
    "metric_type": "ip",
    "dim": 1024,
    "index_param": {
        "term_id_limit": 30000,
        "window_size": 50000,
        "use_quantization": "fp16",
        "remap_term_ids": true,
        "immutable": true
    }
}

Build() 会把每个完成的可变窗口压缩成不可变 payload,并在继续构建前释放临时窗口。该功能 提交时的 Sparse4M FP16 实测中,构建峰值内存从 27.03 GB 降到 6.08 GB(降低 77.51%), 构建时间则从约 330 秒增加到 599 秒。这些数字是特定负载的实测证据,不是容量保证。

不可变运行态支持 KNN、范围搜索以及旧版 Serialize/Deserialize。它不支持增量 Add 与 GetSparseVectorByInnerId。支持单 ID CalcDistanceById 和批量 CalcDistancesById(包括旧名 CalDistanceById),详见按 ID 计算距离。 mutable 和 immutable 运行态均支持 SerializeStreaming、DeserializeStreaming 与 Index::Load。 反序列化时,新建 SINDI 的 immutable 设置必须与存储格式一致。 新索引会记录有序倒排链格式版本,加载时可跳过归一化排序;缺少该标记的旧索引仍保持兼容, 并在加载时完成排序归一化。

Host 过滤

mutable 和 immutable SINDI 及 SINDI_V2 索引都可以按来源 host 对文档 分组,避免返回其他 host 的文档。host-aware Build() 或 mutable Add() 批次需要提供完整的 字符串 host 数组;没有 host 的文档使用空字符串:

base->NumElements(n)
    ->SparseVectors(sparse_vectors)
    ->Ids(ids)
    ->StringMetadata("host", base_hosts)
    ->Owner(false);
index->Build(base);

std::string query_host = "example.com";
query->NumElements(1)
    ->SparseVectors(&query_vec)
    ->StringMetadata("host", &query_host)
    ->Owner(false);

每个 Build() 或 mutable Add() 批次都会按 host 对 inner ID 分组,同时保持 external label 不变。host 查询统一扫描相关 posting window,并在该 host 的一个或多个 inner-ID 区间上执行 精确成员检查。多次 mutable Add() 可以为同一 host 追加互不连续的区间;删除标记和额外的 用户 Filter 会与 host 成员检查共同生效。

host 字符串按字节精确匹配,不做大小写折叠或 URL 归一化。VSAG 在内部为字符串分配紧凑 ID, 并将字符串到 ID 的字典随索引落盘;调用方不需要接触这些 ID。空字符串是缺失 host 分组。 mutable 索引一旦包含 host metadata,后续每次 Add() 都必须提供完整的 host 数组;已有 host-unaware 文档后不能再引入 host metadata。空字符串查询只检索缺失 host 的文档,不提供 host 时保留全索引 KNN 行为;未知 host 返回空结果。构建时没有 base host metadata 的索引会 忽略查询 host metadata,行为保持不变。旧的数值 host_id 输入会被拒绝。host 过滤当前仅适用于 KNN;范围搜索仍使用原有全索引路径。

发布时间过滤

mutable 和 immutable SINDI 无论是否开启 rerank,都可以按文档发布时间过滤 KNN 查询。构建时通过 int64 metadata 为每篇文档附加一个 Unix 秒时间戳;0 表示该文档没有发布时间:

int64_t base_times[] = {0, 1777593600, 1777680000};
base->Int64Metadata("publish_time_stamp", base_times);

int64_t query_time = 1777593600;
query->Int64Metadata("publish_time_stamp", &query_time)->Owner(false);

int64_t time_begin = 1777593600;
int64_t time_end = 1777766399;
query->Int64Metadata("publish_time_stamp_begin", &time_begin)
    ->Int64Metadata("publish_time_stamp_end", &time_end)
    ->Owner(false);

SINDI 使用 timestamp / 86400 把每个正时间戳归一化为 UTC epoch day。单值查询匹配同一 UTC 日的 所有文档;范围查询把两个端点归一化后按闭区间匹配。两个范围端点必须同时提供、满足 begin <= end,并且不能与单值 selector 同时使用。query 值必须大于 0;base 值必须非负, 负数会被拒绝。换算后的 epoch day 超过 INT32_MAX 时同样报错。

base 值为 0 的文档在不提供时间 selector 时仍可检索,仅按 host 查询时也会包含 host 匹配的 无时间文档;任何时间查询都不会命中这些文档。每篇 base 文档仍须在数组中占一项。没有使用 publish_time_stamp 构建的索引收到任一时间 selector 时会报错。

时间构建先按发布时间排序,再按 window_size 个文档切分逻辑分区,并在每个分区内按 host 分组; 无时间文档合并为末尾的单独分区。查询先选择 UTC day 范围相交的分区,再在候选进入 heap 前执行 精确的逐文档 day 与可选 host 检查。时间、host、删除标记和用户 Filter 使用 AND 语义。插入失败 或跳过的文档可能让相邻逻辑分区共享同一个物理 window,精确 day 检查会保证边界结果正确。

带时间的索引为 build-once。mutable 索引可以通过 Build() 或空索引上的第一次 Add() 提供 publish_time_stamp,但之后的每次 Add() 都会被拒绝。仅使用 host 的 mutable 索引仍保留原有 增量 Add() 能力。发布时间过滤支持 mutable、immutable 以及 use_reorder 的任意设置,仅作用于 KNN,并由普通与 streaming 序列化共同保存;恢复后的 mutable 时间索引仍保持 build-once。

不提供时间 selector 时会扫描全部时间分区与无时间分区;仅提供 host 时会从每个分区选择该 host。 精确时间过滤会关闭所选 window 的 term 级 posting 剪枝,因此时间查询可能比仅 host 查询扫描更多 posting,较宽的范围也会选择更多 window。在启用时间的索引上,仅 host 查询遇到跨分区或 host 边界的 window 时也可能需要完整扫描。

检索参数

检索参数放在 sindi 子对象下:

参数类型默认值说明
n_candidateint0候选堆大小。为 0 时自动取 SPARSE_AMPLIFICATION_FACTOR · topk(500 倍);若显式设置,须满足 1 ≤ n_candidate ≤ SPARSE_AMPLIFICATION_FACTOR · topk
filter_callback_limituint640单次带过滤检索最多调用用户 Filter::CheckValid 回调的次数;0 表示不限制。删除 ID 过滤器等内部检查不计数。达到正数上限时,在正常处理最后一次回调结果后停止候选及后续窗口扫描,并返回已经通过过滤的候选,因此结果可能不完整。该限制同时适用于 KNN、范围检索以及常规 API 和 SearchWithRequest
query_prune_ratiofloat0.0查询时丢弃权重最低查询项的比例,取值范围为 [0.0, 1.0)
term_prune_ratiofloat0.0每条倒排链中按 value 丢弃低权 posting 的比例,取值范围为 [0.0, 1.0)
term_retain_thresholduint640单个 term 在所有 window 中最多扫描的 posting 总数;0 表示关闭此限制,正数使每个 window 的非空 posting list 最多扫描 max(1, floor(threshold / window_count)) 个

合并 ratio 与 threshold 限制后,每条非空倒排链至少扫描一个 posting。

同一 window 内的单 term posting 先按实际存储值降序排列,值相同时按内部 doc id 升序排列。ratio 和 threshold 同时生效时,实际扫描 floor(list_size · (1 - ratio)) 与 floor(threshold / window_count) 中的较小值。 SINDI 会根据构建阶段的 doc_prune_ratio 与检索阶段的 query_prune_ratio 自动选择堆插入策略。按当前 0.1 阈值,当两个比例都 <= 0.1 时,SINDI 使用 基于距离数组的入堆路径;只要任一比例大于 0.1,就使用基于 term-list 的入堆路径。 旧版 use_term_lists_heap_insert 检索参数会被忽略;请改用剪枝比例控制该行为。

auto result = index->KnnSearch(
    query, topk,
    R"({"sindi": {"n_candidate": 200, "filter_callback_limit": 10000, "query_prune_ratio": 0.1}})").value();

何时选择 SINDI

  • 使用 BM25、SPLADE、uniCOIL 等学习稀疏编码器的稀疏检索场景。
  • 稠密 + 稀疏的混合检索管线:SINDI 负责稀疏一路,HGraph / IVF 负责稠密 embedding。
  • 稀疏语料的内存受限部署:use_quantization: true 选择 SQ8,"fp16" 把 FP32 倒排 value 字节数减半;use_reorder: true 以正排内存换召回, rerank_type: "fp16" 以较小精度损失降低正排 value 开销,rerank_type: "dmq8" 可进一步压缩这部分存储。
  • 需要降低构建峰值内存的只读快照:使用 immutable: true,接受更慢的构建和不能增量写入。

SINDI 不支持 稠密向量,只支持内积相似度。范围检索与基于 ID 的过滤均已支持, 具体用法参见示例代码。 当 rerank_type 为 dmq8 时,码本由首次构建固定,因此模型建立后的增量 Add 和 UpdateVector 不受支持。

实践建议

  • 中文数据集的稀疏向量,推荐使用 BGE-M3 编码;英文数据集更常见的默认选择是 SPLADE。
  • BGE-M3 同时支持 sparse 和 dense 输出。当前 SINDI 负责稀疏一路,VSAG 未来计划 支持稀疏与稠密融合打分检索。
  • 稀疏向量不能完全替代 BM25 全文检索。实践中,BM25 + 稀疏向量 + 稠密向量的三路 召回通常优于任意两路组合。
  • 在索引层面,SINDI 也可以承载 BM25 风格打分:查询侧用逆文档频率作为词项权重, 文档侧用词频等特征计算出的词项权重作为向量值即可。

常用配置

  1. 扁平暴力搜索索引。倒排索引保留全部非零项(doc_prune_ratio: 0.0),不保留正排索引 重排(use_reorder: false),不开启量化(use_quantization: false)。这是最直接的 高召回基线。
  2. 剪枝高精索引。构建时剪掉大部分低权重词项(doc_prune_ratio: 0.4),保留正排索引 用于重排(use_reorder: true),并开启量化减少倒排索引内存 (use_quantization: true)。这是常见的精度与内存折中配置。
  3. 压缩正排重排索引。在上一种配置基础上,将 rerank_type: "fp16" 与 use_reorder: true 一起使用,在侧重精度的前提下降低正排内存;需要更高压缩率时 可选择 dmq8。
  4. 超大稀疏词表支持。对于词项 ID 在 uint32 范围内非常稀疏的场景,例如基于哈希的 分词器、外部词表 ID,或存在大量空白区间的词表,建议设置 remap_term_ids: true。 这样可以避免管理大量空倒排列表带来的内存浪费,也能降低触达 term_id_limit 上限的风险。
  5. 只读低内存构建。当构建峰值内存比构建速度更重要,且完成后不再增量写入时,增加 immutable: true,通常配合 use_quantization: "fp16" 与 remap_term_ids: true 使用。

标记删除

SINDI 支持 RemoveMode::MARK_REMOVE。调用 Remove(ids)(默认模式)会为给定的 id 打上删除标记:它们不再出现在检索结果中,GetNumElements() 相应减少, GetNumberRemoved() 返回累计删除数量。删除不存在或已删除的 id 不会有任何效果。 RemoveMode::FORCE_REMOVE 不支持,调用会返回错误。

被标记删除的文档在索引重建前仍占用内存,空间不会被物理回收。

相关文档