Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

全文检索

它能取代什么,以及止步于何处。 正是这一接口,让一个 RDF 项目可以舍弃它为带排名的文本检索而放在三元组存储旁边的 PostgreSQL tsvector/tsquery:问题变成了在已持有该图的 SPARQL 查询中的一次属性函数调用,在进程内、针对同一数据集执行,且答案在原生环境与 wasm32 上逐字节相同。它是 BM25 排名,而不是 Lucene——Unicode 大小写折叠与词边界切分、显式英语词干提取和带代价的词典分析,没有停用词表或查询方言(短语与邻近查询在 SPARQL 中由词项出现关系组合而成),k1 固定,各字段的长度归一化参数显式声明,索引在内存中、针对冻结数据集构建。

purrdf-text(在总括 crate 中为 purrdf::text)是 PurRDF 的确定性全文检索索引。它从冻结数据集中读取 RDF 1.2 字面量,按 Unicode 标准自身的规则进行分词,并用 BM25 分数回答带排名的检索查询——从 SPARQL 出发,经由求值器的属性函数扩展点,使用调用方提供的 IRI。

它是一个 purrdf-core 之外的兄弟 crate,而不是对内核的改动:为了接纳它,purrdf-core、purrdf-sparql-algebra 以及属性函数扩展点本身都没有任何改动。这正是本章中每个扩展所采取的形态。

两种关系,一个索引

索引在数据集上构建一次,由两个属性函数共享;它们是两个不同的类型,而不是带模式开关的同一个类型:

?doc <caller-iri>  ( "needle" ?score ?rank ?lang ?matched )   # 带排名的检索
?doc <caller-iri2> ( "term"   ?lang  ?position )              # 每次出现一行
  • 带排名的检索(TextSearchRelation)对每个匹配的文档输出一行。?score 是携带精确 BM25 值的 xsd:decimal,?rank 是文档在其语料分区内从 1 开始的位置(默认 GraphLanguage profile 使用 (graph, language) 分区),?lang 是该分区的语言标签,?matched 统计该文档包含多少个不同的检索词。
  • 词项出现(TermOccurrenceRelation)对单个词项的每次出现输出一行,并给出其 token 位置 ?position。

短语与邻近检索通过在 SPARQL 中组合来实现,而不是通过内嵌的查询方言——内嵌语法会再造出一种互不兼容的厂商语言,而这恰恰是载体不应做的:

# "quick" 紧接着 "brown"
SELECT ?doc WHERE {
  ?doc <https://example.org/pf/occurs> ( "quick" ?l ?p1 ) .
  ?doc <https://example.org/pf/occurs> ( "brown" ?l ?p2 ) .
  FILTER(?p2 = ?p1 + 1)
}

FILTER(ABS(?p2 - ?p1) <= 3) 即邻近检索;在带排名的关系上使用 FILTER(?matched = 2) 即合取检索。

从 Rust 接入

索引接受它应读取其字面量的谓词,以及一个图选择器——GraphSelector::Any、Default 或 Named(iri)——PurRDF 对两者都不提供默认值。未指名任何谓词的配置是一个带类型的 TextError::Config,而不是一次猜测。

use std::sync::Arc;
use purrdf::sparql::{ExtensionEnv, NativeSparqlEngine, PropertyFunctionRegistry, QueryOptions};
use purrdf::text::{
    GraphSelector, TermOccurrenceRelation, TextIndex, TextIndexConfig, TextSearchRelation,
};
use purrdf::{SparqlRequest, TermValue};

// 索引哪些字面量:由调用方指名谓词。
let config = TextIndexConfig::new(
    vec![TermValue::iri("https://example.org/note")],
    GraphSelector::Any,
    purrdf::text::Analyzer::empty_lexicon(), // 显式选择字素回退。
)?;
let index = Arc::new(TextIndex::from_dataset(&dataset, &config)?);

// 查询通过哪些 IRI 调用索引:同样由调用方指名。
let mut registry = PropertyFunctionRegistry::new();
registry.register(
    "https://example.org/pf/search".to_owned(),
    Arc::new(TextSearchRelation::new(Arc::clone(&index))),
);
registry.register(
    "https://example.org/pf/occurs".to_owned(),
    Arc::new(TermOccurrenceRelation::new(index)),
);
// 读取查询文本所处的环境:每个已注册的 IRI 都在谓语位置
// 被认领,并依据这个注册表解析。
let env = ExtensionEnv::over_relations(registry)?;

let result = NativeSparqlEngine::new().query_with_options_view(
    &dataset,
    SparqlRequest {
        query: r#"SELECT ?doc ?score ?rank WHERE {
                    ?doc <https://example.org/pf/search> ( "quick brown" ?score ?rank ?lang ?matched )
                  } LIMIT 3"#,
        base_iri: None,
        substitutions: &[],
    },
    QueryOptions::new().with_env(&env),
)?;

已注册的 IRI 在谓词位置上被精确识别,因此无需任何解析器选项即可到达它。若查询指名的某个 IRI 已通过 ParserOptions 声明但未注册,则会以点名该 IRI 的方式硬失败;两者皆非的 IRI 仍是普通的三元组模式。

这是一个 Rust 宿主扩展点。索引及其关系是宿主闭包,因此不会跨越 Python、WebAssembly 或 C 边界——只有数据形态的属性函数(冻结表、以图为后端的表以及路径见证)才会。参见从其他宿主到达扩展。

每个分数都是精确的,且在每个目标上都相同

分数以十进制定点整数表示(i128,十二位小数)。评分和排序不使用浮点运算;crate 根模块声明 #![deny(clippy::float_arithmetic)]。

这是一项正确性要求,而不是偏好。BM25 需要自然对数,而 libm 的 ln 在不同实现之间可能相差一个最后位单位(ulp)——这足以颠倒两个分数几乎相同的文档的顺序,从而使同一数据上的同一查询,在原生构建中按一种顺序返回行,而在同一引擎的 wasm32-unknown-unknown 构建中按另一种顺序返回。这里的对数是一个固定长度、固定迭代次数的整数级数,从不使用收敛测试,因此其结果在每个目标上都是其输入的纯函数。排名——行顺序连同每个分数的十进制词法形式——由原生 Rust 测试按独立预期结果固定。make wasm 单独为 WASM 构建 crate;WASM 执行只覆盖实际目标路径与宿主接口。

k1 = 1.2 固定。RankingProfile::single_field() 选择一个权重为一、b = 0.75 的字段;显式的 RankingProfile 则声明每个字段的 b 与权重。这些参数绑定在不可变 profile 的指纹中。同一个已标识的 profile、索引与查询,在每个目标上都产生相同的排名与分数词法形式。

排序,以及复现它的惯用写法

默认的 GraphLanguage profile 按 (graph, language) 分区计算语料统计,因此分数是相对于某个语料的数值,?rank 是该分区内的位置,绝不是全局位置。行按 (partition key ASC, rank ASC) 顺序输出;在一个分区内,顺序为 (score DESC, document id ASC),而文档 id 是在按 (graph, subject, language) 排序之后分配的,因此平局决胜在各自独立构建的索引之间可复现。GraphFree 则按 (subject, language) 排列文档,并仅按语言分区。要在 GraphLanguage 中取得一个统一排名列表,应绑定 ?lang 并通过选择器限制 ?graph;在 GraphFree 中则只需绑定 ?lang。单分区索引也能给出一个统一排名列表。

在选定的同一分区内,有两个值得了解的推论:

  • 裸 LIMIT k 即 top-k。 输出顺序_就是_排名顺序,因此求值器的行上限许可适用,关系在输出 k 行后即停止。ORDER BY DESC(?score) LIMIT k 不会被下推——ORDER BY 加 LIMIT 没有 governor(执行调控器)可以许可的、经认证的下界——而且这里的 ORDER BY 本来就是多余的。
  • ORDER BY ?rank 是可复现的惯用写法。 分数以固定宽度的十进制数交给使用方,因此两行可能报告相同的 ?score,却带有不同的 ?rank;所以 ORDER BY DESC(?score) 可能与精确的内部顺序不一致,而 ?rank 不会。

RDF 1.2 一等支持

RdfDataset::quads() 只返回已断言的三元组表;注解位于单独的侧表中。索引读取这两层,因此仅由 :s :p :o {| :note "..." |} 携带的文本也可被检索,并以具体化节点作为文档主语。只读取已断言层的索引将索引零个注解字面量,并且什么也不报告——该 crate 的测试恰恰防范这一点。

明确的限制

  • 文档 id 是内容的函数,但_空节点标签_除外——标签是解析的产物:两个同构但标签不同的数据集会产生不同的索引指纹。
  • 索引在内存中,针对冻结数据集构建。verify_binding 检查查询所针对的索引是否基于眼前这个数据集构建,从而堵住一个静默通道:过时的索引输出的文档在连接回数据时得到零行。

评分设计记录——包括定点对数,以及被折入索引指纹的 Unicode 表版本——见 docs/design/purrdf-text-scoring.md。