先用几条可手算的向量理解索引,比随机生成高维数组更容易发现错误。本文在临时目录运行 Vectra 0.15.0,验证排序、过滤与持久化,再说明这些结果与 ChatCrystal 的笔记搜索有什么区别。它不是语义质量或性能测试。
LocalIndex 实际做了什么
ChatCrystal 的 依赖清单声明 vectra: ^0.15.0,该提交的 锁文件解析为 0.15.0。本文的练习固定使用这个版本,不能从版本范围推导未来安装结果。
该版本的 LocalIndex.queryItems 加载索引,在元数据过滤后遍历候选项计算余弦相似度,用容量为 K 的最小堆保留靠前结果,再排序返回。这个查询路径没有使用 HNSW 近似索引;本文也没有可复查的百万级毫秒延迟测量。默认 JSON 存储包含 index.json,可按配置另存元数据,并不固定生成 items/ 目录。库也有自己的 npm 依赖,“不需要独立数据库服务”不能写成“零依赖”。公开实现与使用说明见 Vectra 仓库。
余弦分数可能为负数。在有效非零向量上,同向、正交、反向分别对应 1、0、-1;它不是概率,也不是“越接近 1 就越确定答案正确”。
跑一个确定性的最小索引
在新的临时目录中安装依赖,使用 Node 24+。以下命令只安装教学依赖;程序自行创建临时索引,结束后仅清理这个新目录。
npm init -y
npm install --ignore-scripts --no-audit --no-fund vectra@0.15.0
node lesson.mjs
先保存下面的 lesson.mjs 再执行最后一行。三个向量是构造数据,query 字符串不启用 BM25,不能把这个结果当成自然语言检索。
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { LocalIndex } from 'vectra';
const folder = await mkdtemp(join(tmpdir(), 'vectra-lesson-'));
try {
const index = new LocalIndex(folder);
await index.createIndex();
await index.batchInsertItems([
{ id: 'same', vector: [1, 0, 0], metadata: { project: 'alpha' } },
{ id: 'orthogonal', vector: [0, 1, 0], metadata: { project: 'beta' } },
{ id: 'opposite', vector: [-1, 0, 0], metadata: { project: 'alpha' } },
]);
const reopened = new LocalIndex(folder);
const all = await reopened.queryItems([1, 0, 0], '', 3);
assert.deepEqual(
all.map((r) => r.item.id),
['same', 'orthogonal', 'opposite']
);
assert.deepEqual(
all.map((r) => r.score),
[1, 0, -1]
);
const filtered = await reopened.queryItems([1, 0, 0], '', 3, { project: 'alpha' });
assert.deepEqual(
filtered.map((r) => r.item.id),
['same', 'opposite']
);
await reopened.beginUpdate();
await reopened.deleteItem('same');
reopened.cancelUpdate();
assert.equal((await new LocalIndex(folder).getItem('same')).id, 'same');
console.log('scores, filter, reopen, and cancel pass');
} finally {
await rm(folder, { recursive: true, force: true });
}
把分数断言改成 [1, 0, 0],运行会失败;恢复 [1, 0, -1] 后通过。把过滤条件改成不存在的项目,期望的两条结果也会失败。这样能确认断言真的检查目标行为,而不只是打印一组看似合理的数组。
过滤与取消更新的边界
这个版本的元数据选择器还支持 $and、$or 和若干比较操作,例如数值 $gte。因此“只能做等值过滤”不准确。但不能据此承诺它与完整数据库查询语言等价;对准备使用的操作符、字段类型与缺失字段,应单独构造例子核对。
beginUpdate 创建待提交的更新状态,endUpdate 写入文件并成为当前状态,cancelUpdate 丢弃待更新状态。上面的练习只证明取消删除后,重新打开仍能读取原项;它没有测试断电、文件写到一半、两个进程同时写入或多存储事务。
向量维度和模型兼容性应在服务边界验证。这个版本的相似度辅助代码会使用数组的重叠部分计算点积,不能把“没有报错”理解为两个维度不同的向量可以比较。非零、有限数值、维度一致和模型身份检查见Embedding 服务设计。
应用分数与原始余弦分开看
ChatCrystal 的 semanticSearch在 Vectra 候选项之上继续处理:只接纳数据库中状态为 done 的笔记,将多个块按笔记 ID 合并,必要时扩大候选数量,再做应用层词面匹配和分数筛选。
当前词面匹配可把对应结果的分数抬到至少 0.82,其他低于 0.58 的结果被过滤。这是该版本的应用规则,不是 Vectra 的通用阈值,也不表示 82% 或 58% 的正确率。关系扩展还可以加入折扣后的邻居分数。实际搜索入口与范围见笔记搜索。
备份和恢复需要同时考虑知识库数据库、向量索引与写入活动。本文不提供对正在写入的数据目录直接复制的“一致性保证”;相应状态检查见索引恢复。完成练习后,你得到的是一份可重复的索引行为证据;真实语义质量仍需同模型、同数据、明确查询与预期答案的独立评估。