kevy4.0

全文检索(KIND text

text 是索引引擎的第三种 kind:像声明任何索引一样声明它,字段的原始字节就会被切成词元、进入逐 shard 的倒排段,并与每一次写入同步维护(零漂移,与 range / unique 同一套“构造即派生”的纪律)。没有单独的搜索服务器,没有摄取管线,也没有分析器配置——一个 verb 声明索引,此后的每一次写入都让它保持精确。

IDX.CREATE posts ON PREFIX post: FIELD body TYPE str KIND text
IDX.QUERY posts MATCH "rust 全文检索" LIMIT 10 [FIELDS title body]

快速上手(服务器)

行就是声明前缀下的 hash 键,被索引的值是一个声明过的字段——和 range 索引完全一样(indexes.md):

kevy-cli -p 6004 IDX.CREATE posts ON PREFIX post: FIELD body TYPE str KIND text
kevy-cli -p 6004 HSET post:1 title "intro" body "kevy is a pure-Rust key-value store"
kevy-cli -p 6004 HSET post:2 title "search" body "dictionary-free CJK full-text search"
kevy-cli -p 6004 IDX.QUERY posts MATCH "full-text rust" LIMIT 10

回复是一个扁平数组,内容是 key, score 对,最佳者在前。加上 FIELDS,可以在同一次调用里、在每个命中所属的 shard 上补上指定的 hash 字段(不需要第二次往返):

kevy-cli -p 6004 IDX.QUERY posts MATCH "search" LIMIT 10 FIELDS title

配套 verb 在 text 索引上的用法和其他 kind 一样:

MATCH 接受 LIMIT(≤ 1000)和 FIELDS;没有 CURSOR 形态(原因见“匹配与排名”)。

快速上手(embedded)

同一台引擎,在进程内。text kind 在 text 这个 cargo feature 后面(默认开启;编译掉之后,用 IndexKind::Textidx_create 会回答 KevyError::Unsupported):

use kevy_embedded::{Config, IndexKind, IndexValType, Store};

fn main() -> kevy_embedded::KevyResult<()> {
    let store = Store::open(Config::default())?;

    store.idx_create(b"posts", b"post:", b"body", IndexValType::Str,
                     IndexKind::Text)?;   // builds synchronously
    store.hset(b"post:1", &[
        (b"title".as_slice(), b"intro".as_slice()),
        (b"body".as_slice(),
         b"kevy is a pure-Rust key-value store".as_slice()),
    ])?;

    // BM25-ranked hits, best first: Vec<(key, score)>.
    let hits = store.idx_match(b"posts", b"rust store", 10)?;
    for (key, score) in &hits {
        println!("{} {score}", String::from_utf8_lossy(key));
    }
    Ok(())
}

idx_match 返回 KevyResult<Vec<(Vec<u8>, f64)>>;索引不存在时是 KevyError::NotFound。embedded 侧没有 FIELDS 补水——你人在进程内,字段用 hget 读。embedded 的构建是同步的:idx_create 返回即代表索引可服务(没有 -INDEXBUILDING 阶段要轮询)。

分词(无词典)

bigram 方案就是 CJK 故事的全部:没有词典要随包发布,没有词典会过期,混合书写系统的文档("Rust 入门")两半各按自己的规则入索引。代价是单字 CJK 查询的召回噪声(它们只能命中孤立单字的那些输出)——请用两个字以上来查。

匹配与排名

MATCH查询词元之间的 OR 语义,按 BM25 排名(k1=1.2、b=0.75、非负 idf 变体)。命中更多(且更罕见)查询词的文档排得更高;词频饱和;长文档被归一化压低。

两处声明在明处的近似:

没有短语查询、没有布尔语法、没有高亮——那是查询引擎的斜坡(刻意不在范围内)。如果你需要这些,你描述的是一个搜索引擎;kevy 的 text kind 停在“对声明过的字段做排序查找”。

混合检索(BM25 + KNN)

同一语料上的一个 text 索引和一个 ANN 索引,可以在服务端用倒数排名融合(reciprocal-rank fusion)合起来:

IDX.QUERY HYBRID posts MATCH "rust storage" embs KNN <f32-le-blob>
    [LIMIT n] [RRFK k] [EF ef] [FIELDS f…]

每个命中的融合分数是两份结果列表上的 Σ 1/(k + rank_i)RRFK 默认 60——标准的融合常数;用的是排名位次而非原始分数,所以 BM25 和距离之间不需要相互校准)。KNN 那一半见 vector-search.md。HYBRID 是服务器 verb;embedded 调用方自己跑 idx_match + idx_knn,在进程内融合。

生命周期与一致性

与每一种索引 kind 同一个信封(indexes.md):

性能

Top-K 求值用 MaxScore 剪枝(最罕见的词优先;常见词的列表一旦无法再把新文档抬进 top K,就只在候选上逐个探测)。倒排表是 doc-id 列表,做了两重 impact 排序:tf 桶降序,桶内按稀疏的 log2 dl 带升序——单词查询(没有第二个列表可供剪枝)会精确地停在第一个下界打不过第 K 名下限的带上,于是即便最常见的词,在 20 万文档上也约 0.1ms 应答(做全倒排扫描时约为 6ms)。只有一条 posting 的词元(Zipf 长尾)内联存放,不占堆。

实测信封(凭据在 bench 目录):

写入侧就是标准的索引税:每次写入、每命中一个索引,付一次 hash 字段读加一次段更新;空目录的代价是每次写入一个不被走到的分支。

定容

bytes ≈ Σ_token (token_len + 48) + postings × 64 + Σ_doc (key_len + text_len + 72)(文档保留原文,好让更新能精确移除自己的词元),由 IDX.VERIFY / IDX.LIST 实时报告(text kind 报 entries / bytes / postings / tokens)。bench/textgate.sh 拿这条公式跟真实 RSS 增长对钳。

重建走标准的 backfill 骨架(服务器上按 tick 增量推进,embedded 侧同步完成)。

另见