目录 展开 收起
Embedding 服务接收哪些文本、怎样分块、何时替换旧索引,会直接影响检索结果和失败后的状态。本文对照 ChatCrystal 的固定实现拆开这些责任,再用一个不调用模型的练习验证分块与向量边界。
先确定被检索的文本
本文对应 embedding.ts。服务将标题、摘要、关键结论、标签和代码片段描述去重后拼接。普通导入对话的笔记不会因此把原始对话和全部代码正文都放进向量输入。
agent-writeback 和 manual-note 两种记忆来源还有补充规则:包含代码正文,以及根因、处理方式、易错点、可复用模式、决策、错误特征和文件路径等已有字段。每段代码正文先压缩连续空白,再截取最多 1000 个 JavaScript 字符单位。这个区别决定“搜索代码内容”在哪些笔记上可能成立;不能把所有代码正文一概视为不参与向量输入。
先列出一份具体的输入清单,再讨论模型选择。如果关键信息没有进入输入,换一个 Embedding 模型也不能直接找回被删掉的内容。模型配置与供应商能力见Embedding 配置。
500 是合并目标,不是硬上限
CHUNK_SIZE = 500 的分块器按空行分段,尝试把相邻段落合并。它使用 JavaScript 的 .length,计算的是 UTF-16 代码单位,不是 token 数,也不保证每个块都小于 500。一个没有空行的 700 单位段落仍然成为一个块;这个算法也没有重叠窗口。
下面是当前分块逻辑的独立 JavaScript 版本,变量名简化但保留判断顺序。保存为临时目录中的 lesson.mjs,用 Node 24+ 运行;无需安装依赖,不读写知识库。
import assert from 'node:assert/strict';
function chunkText(text) {
const size = 500;
if (text.length <= size) return [text];
const chunks = [];
let current = '';
for (const paragraph of text.split(/\n\n+/)) {
if (current.length + paragraph.length + 2 > size && current.length > 0) {
chunks.push(current.trim());
current = paragraph;
} else {
current += (current ? '\n\n' : '') + paragraph;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
assert.deepEqual(
chunkText('a'.repeat(700)).map((s) => s.length),
[700]
);
assert.deepEqual(
chunkText('a'.repeat(300) + '\n\n' + 'b'.repeat(300)).map((s) => s.length),
[300, 300]
);
assert.equal('😀'.length, 2);
console.log('chunk boundaries pass');
把第一条断言改为期望 [500, 200],程序会失败;恢复 [700] 后通过。这证明当前实现没有硬切长段落,不能证明它符合任意模型的 token 上限。若要设计严格上限,需要针对选定模型增加计数、长段落拆分和对应反例;这是改进方向,不是已经实现的功能。
先生成全部向量,再进入更新
当前服务按顺序调用 AI SDK 的 embed,把本次所有块的结果准备好后,才开始 Vectra 更新。任一模型调用提前失败,就不会进入这次索引替换。供应商工厂在 providers.ts 中通过 embeddingModel(model) 创建模型;生成模型配置与向量模型配置分开。
设计自己的服务时,可以在提交之前增加明确的向量契约。下面继续追加到同一个文件:它是教学设计,不是当前应用已有的向量校验器。embedOne 是注入的假模型,commit 是提交回调。
async function stageVectors(texts, embedOne, commit) {
const vectors = [];
let dimension;
for (const text of texts) {
const vector = await embedOne(text);
const norm = Array.isArray(vector) ? Math.hypot(...vector) : NaN;
if (
!Array.isArray(vector) ||
vector.length === 0 ||
!vector.every(Number.isFinite) ||
!Number.isFinite(norm) ||
norm === 0
) {
throw new Error('invalid vector');
}
dimension ??= vector.length;
if (vector.length !== dimension) throw new Error('dimension mismatch');
vectors.push(vector);
}
await commit(vectors);
}
let commits = 0;
await assert.rejects(
stageVectors(
['first', 'second'],
async (text) => (text === 'first' ? [1, 0] : [1, 0, 0]),
async () => {
commits++;
}
),
/dimension mismatch/
);
assert.equal(commits, 0);
await assert.rejects(
stageVectors(
['zero'],
async () => [0, 0],
async () => {
commits++;
}
),
/invalid vector/
);
await stageVectors(
['valid'],
async () => [1, 0],
async () => {
commits++;
}
);
assert.equal(commits, 1);
console.log('staging boundaries pass');
这组固定二维、三维向量只验证提交回调和校验顺序,不代表真实语言语义。练习还没有定义空输入、模型身份记录、并发写入或跨文件事务;真实服务需要补齐这些契约。
维度一致仍然不够
即使两个模型输出相同维度,也不能直接混用它们的向量空间。当前实现从配置读取模型,却没有在每条索引记录中保存可用于验证兼容性的完整模型指纹。切换模型时需要把旧索引兼容性作为单独问题处理,不能只比较数组长度。
实际提交跨越 SQLite 状态和 Vectra 文件:先在数据库事务中替换块映射并标记 syncing,保存数据库,再删除旧向量、结束 Vectra 更新,最后标记 done 并保存。它不是覆盖两个存储的原子事务。失败清理和恢复路径见索引恢复;本文的“先准备再提交”只说明模型调用阶段的位置。
接下来可用固定向量的本地搜索练习观察持久化、过滤和分数,或查阅官方 embed 接口。验收服务时分别检查输入覆盖、分块边界、向量有效性、模型兼容性与提交失败,避免用一次搜索成功代替整条链路的证据。