嵌入向量最近邻
它取代什么,止步于何处。 正是这一接口,让一个 RDF 项目可以去掉为最近邻搜索而放在三元组存储旁边的 pgvector:?neighbour <space> ( ?seed k ?distance ) 在进程内作答,在工件所声明的度量下给出精确的 top-k,并且在原生构建与 wasm32 上逐字节相同。它是一次精确扫描——对每个候选打分,不剪枝,不使用近似索引——受调用方提供的 KnnGuard 约束,支持三种度量(余弦、负点积、平方欧氏距离),作用于 PURREMB 嵌入(向量嵌入)空间。PurRDF 不计算任何嵌入,也不运行任何 ANN 载荷:向量来自调用方填充的 PURREMB 工件,而 PurRDF 自己写出该载体(内存中用 EmbeddingBuilder,流式用 EmbeddingStreamWriter——仅限 Rust),并以失败即关闭(fail-closed)的方式打开它;产生这些向量的模型属于调用方。
purrdf-core 中的 PURREMB 层(参见确定性嵌入伴随文件与 docs/PURREMB.md)存储可按 RDF 1.2 寻址的向量、一个声明的距离度量,以及把第三方索引载荷绑定到其所基于的精确矩阵的防篡改守卫。它刻意不对任何东西排名——排名是查询操作,而 PURREMB 是工件格式。purrdf-sparql-eval 的 knn 模块正是这一查询操作,经由属性函数扩展点对外提供。
调用形态
PREFIX knn: <https://example.org/space/>
PREFIX d: <https://example.org/d/>
SELECT ?neighbour ?distance WHERE {
?neighbour knn:points ( d:a 3 ?distance )
}
四个展平后的位置——主语一侧一个,宾语一侧三个:
| 位置 | 名称 | 作用 |
|---|---|---|
| 0 | ?neighbour | 输出:其向量被检索到的 RDF 项 |
| 1 | ?query | 输入:以其向量作为搜索种子的项 |
| 2 | k | 输入:要检索多少个近邻 |
| 3 | ?distance | 输出:距离,类型为 xsd:double |
排名搜索模式是 fbbf:种子与 k 是该关系无法枚举的输入,而 ?neighbour 与 ?distance 各自可以是已绑定或自由的。k 接受任何由整数派生的数据类型(xsd:integer、xsd:int、xsd:long、xsd:nonNegativeInteger……);k = 0 是请求零个近邻的有效请求。在该空间中没有向量的种子得到的是空答案,而不是中止——否则,每个让种子遍历部分已嵌入项的查询都会失败终止。第二种模式 bbff 绑定近邻与种子,而让 k 保持自由:它询问空间是否持有该近邻,不对其排名。绑定 k 总是选择排名搜索,即使近邻也已绑定。
PurRDF 不为此生成任何 IRI
谓词属于调用方,它是查询为空间命名的方式:宿主为每个希望可查询的 (artifact, target set, vector space) 三元组构建一个 EmbeddingSpace,并在其自身词汇所用的任意 IRI 之下,为其注册一个 EmbeddingKnnRelation。没有默认 IRI,也没有后备空间。每个空间注册一个关系——而不是用一个关系把空间 IRI 作为参数——正是这一点让规划器能从实际将被搜索的空间读出一个如实的行数上界。
use std::sync::Arc;
use purrdf::sparql::{EmbeddingKnnRelation, EmbeddingSpace, KnnGuard, PropertyFunctionRegistry};
// 守卫是调用方提供的配置,没有默认值:一次调用可扫描的
// 最大空间,以及它可请求的最大 `k`。
let guard = KnnGuard::new(/* max_candidates */ 100_000, /* max_neighbours */ 64)?;
// `bindings` 为每个 PURREMB 行指明其所代表的 RDF 项。没有项的行
// 会在构造时被拒绝,而不是被跳过——在一个被静默缩小的候选集上
// 求 top-k,得到的只是某个子集的 top-k。
let space = EmbeddingSpace::from_artifact(
&purremb_bytes, target_set_id, vector_space_id, bindings, guard,
)?;
let mut registry = PropertyFunctionRegistry::new();
registry.register(
"https://example.org/space/points".to_owned(),
Arc::new(EmbeddingKnnRelation::new(Arc::new(space))),
);
随后,注册表经由一个 ExtensionEnv(ExtensionEnv::over_relations,并用 QueryOptions::with_env 交给查询)到达引擎,与全文检索一页中的做法完全相同。这是一个 Rust 宿主扩展点。
精确搜索,按所声明的度量排序
搜索是精确的:每个候选行都在该空间所属族契约声明的度量(FamilyView::metric())下打分——余弦与平方欧氏距离在真实数据上结果不同,而对两者使用同一个内核的接口会在把二者区分开的测试中失败——返回的 k 个就是真正最近的 k 个。任何地方都不做候选剪枝。正因如此,引擎的行数上限下推在这里才是可靠的——输出顺序_就是_排名顺序,因此对每个 n ≤ k,前 n 行就是最近的 n 个——也正因如此,「排序正确」是本模块经过测试的属性,而不是某个调优参数的属性。
排名顺序为 (distance ASC, row ASC)。行号互不相同,因此该顺序是严格的全序,而平局裁决也有意义:PURREMB 目标集按 TargetId(规范标识的摘要)排序并去重,因此行号升序_就是_规范内容的升序。以相反插入顺序、由相同内容构建出的两个工件,作答逐字节相同,包括序列化后的 JSON。
binary64,按固定顺序
工作区中的全文排名检索接口不使用浮点运算,因为 BM25 需要对数,而 IEEE-754 并不要求 ln 正确舍入。这一理由在此并不适用。k 近邻内核不需要任何超越函数——减法、乘法、加法、除法与平方根都是正确舍入的——因此 binary64 在这里给出_同样的_跨目标保证。docs/PURREMB.md 以规范性条文固定了工件自身的归约(「binary64,就近舍入、平局取偶,按书写顺序,不使用融合乘加」),L2 范数就是其中之一,因此 k 近邻接口使用 purrdf_core 中规范性的范数归约,而不是它的一份副本。定点数会引入一个该格式本身并不具有的量化步骤。
两个残余风险在结构上被消除。每一次点积求和与平方欧氏距离求和都遵循同一条固定的法则 purrdf_core::distance::Exact(binary64-lane16-tree-v1):在所有完整的十六元素分块上使用十六条 binary64 通道,随后是一棵固定的两两归约树,最后按下标升序处理剩余项,且绝不拆分到多个工作线程。它在每个目标、每条分派路径上都是同一顺序,而正是相互独立的通道让它无需允许任何重新排序就能向量化(一个测试对某个向量做归约:按通道树求和得 1,按顺序求和得 0,并固定采用哪一种)。每个乘积在被累加之前都先绑定到一个具名局部变量,因此不会形成融合乘加;若某线程的浮点环境会把次正规数冲刷为零,该线程会以具名错误被拒绝。距离以 xsd:double 输出,其规范词法形式能往返还原精确的比特——舍入后的十进制表示会把两个相邻的 double 打印成同一个值,恰好掩盖本节所讨论的那种分歧。
有一个限制在此明说而不隐藏:余弦自距离不一定恰好为零(dot(v, v) 与 |v|·|v| 是对同一个实数的两次舍入)。不要假定自距离为零;排名遵循算出的距离,并按规范行号裁决平局。
一次搜索的代价
governor(执行调控器)早先的计费点按驱动的调用次数与接受的行数为宿主关系定价。对于生成器,代价并不在这两处:在一百万个向量上扫描并返回五行,本会被定价得像一次六行的表扫描。因此,该关系在每次拉取之后都通过 PfCursor::take_work 报告其内部工作量,而引擎对报告的每一单位工作量消耗一单位燃料——于是搜索的计费随空间大小而变化,而不是随返回的行数,而 KnnGuard 正是让计费与规划器的行数上界都保持如实的东西。
设计记录——四项并不显而易见的决定以及各自是由什么确定的——见 docs/design/purrdf-embedding-knn.md。