本文面向:ChatCrystal 语义搜索返回空结果或报错,怀疑向量索引损坏的开发者。
预计阅读时间:7 分钟
症状
语义搜索出了问题,具体表现为以下几种:
- 搜索页面输入关键词后返回空结果,但笔记明明存在
- 搜索时报错
500 Internal Server Error,服务端日志出现 vectra 相关异常 crystal search "xxx"命令无结果,但crystal notes list能看到笔记- 新笔记的
embedding_status一直停在pending或syncing,不会变成done
这些问题很可能指向同一个原因:vectra 向量索引文件损坏了。
vectra 是什么
vectra 是一个纯 JavaScript 实现的本地向量搜索引擎。ChatCrystal 用它存储笔记的 embedding 向量,支撑语义搜索功能。它不需要外部服务,数据全部存在本地磁盘的文件里。
关键特性:
- 纯 JS,无原生依赖,跨平台
- 基于文件系统的本地索引(非 SQLite)
- 支持元数据过滤(ChatCrystal 用
noteId标记每个向量属于哪条笔记) - 提供
beginUpdate()/endUpdate()事务式写入
正因为索引是独立于 SQLite 的文件系统结构,它有自己的损坏风险。
索引文件在哪
vectra 索引存储在数据目录下的 vectra-index/ 文件夹:
perl
~/.chatcrystal/data/vectra-index/ # 默认位置
%APPDATA%/ChatCrystal/data/vectra-index/ # Electron 打包版
这个目录由 vectra 的 LocalIndex 类管理,内部包含向量数据和索引元文件。ChatCrystal 在内存中维护一个单例 _index,首次访问时创建 LocalIndex 实例并调用 createIndex() 初始化。
对应的源码路径是 server/src/services/vector-index.ts:
typescript
const INDEX_PATH = resolve(appConfig.dataDir, 'vectra-index');
let _index: LocalIndex | null = null;
export async function getIndex(): Promise<LocalIndex> {
if (_index) return _index;
_index = new LocalIndex(INDEX_PATH);
if (!(await _index.isIndexCreated())) {
await _index.createIndex();
}
return _index;
}
常见损坏场景
1. 进程中断
embedding 生成是一个多步操作:先调用 Embedding 模型拿到向量,然后通过 beginUpdate() 写入索引,再更新 SQLite,最后 endUpdate() 提交。如果在这中间被 kill 掉(Ctrl+C、系统崩溃、Electron 窗口强关),索引可能处于半写入状态。
typescript
// embedding.ts 中的写入流程
await index.beginUpdate();
// ... 插入新向量 ...
// ... 更新 SQLite ...
// ... 删除旧向量 ...
await index.endUpdate(); // 如果没走到这里,索引不完整
2. 磁盘空间不足
vectra 写入索引文件时,如果磁盘满了,写入会失败。部分写入的文件会导致索引结构不一致。
3. 并发写入冲突
ChatCrystal 的任务队列(p-queue)已经把并发限制为 1,正常情况下不会出现并发写入。但如果你同时运行了多个 Crystal 实例(比如 Electron 版和 CLI 版共用同一个数据目录),两个进程可能同时写索引,导致文件冲突。
4. 手动删除了部分索引文件
有些人清理磁盘时会误删 vectra-index/ 目录下的部分文件。vectra 的索引是一个整体结构,删掉任何一个文件都可能导致整个索引不可用。
embedding_status 状态机
理解索引问题的关键是理解 embedding_status 的状态流转:
bash
pending → syncing → done
→ failed
| 状态 | 含义 |
|---|---|
pending |
笔记已生成但还没有 embedding |
syncing |
向量已写入 vectra,等待确认提交 |
done |
embedding 完全就绪,可用于搜索 |
failed |
生成过程中出错,需要重试 |
如果一条笔记卡在 syncing 状态,说明向量可能已经写入了 vectra 但 endUpdate() 没有成功执行。下次搜索时 ChatCrystal 会尝试自动恢复(见下文),但不一定能成功。
自动恢复机制
ChatCrystal 内置了两层自动恢复机制,大多数情况下你不需要手动干预。
搜索前自动清理
每次执行语义搜索时,semanticSearch() 会先调用 preflightSemanticSearchVectorCleanup(),处理最多 25 条待清理任务:
typescript
export async function preflightSemanticSearchVectorCleanup(): Promise<void> {
try {
await processPending({ limit: 25 });
} catch {
// 搜索不应因为清理失败而中断
}
}
这个清理任务的来源是 vector_cleanup_tasks 表。当笔记被删除或需要重新生成 embedding 时,系统会往这个表里插入一条待清理记录,由下次搜索触发执行。
syncing 状态自动修复
当 generateEmbeddings() 发现笔记处于 syncing 状态时,会先检查 vectra 中的向量是否完整。如果完整,直接将状态标记为 done,跳过重新生成:
typescript
if (await maybeFinalizeCommittedSyncingNote(db, index, noteId, noteStatus, currentDbVectraIds)) {
return chunks.length; // 已经修好了,不用重做
}
清理失败不阻塞搜索
两个关键设计:
preflightSemanticSearchVectorCleanup的异常被 catch 吞掉,搜索照常进行vector_cleanup_tasks记录失败后保留pending状态,下次搜索会重试
这意味着即使索引有部分损坏,搜索功能不会完全瘫痪。
手动修复方案
如果自动恢复不起作用,或者索引损坏严重,手动修复是最可靠的方式。
方案一:删除索引重建(推荐)
最简单粗暴但最有效的方法。删除整个索引目录,然后重新生成所有 embedding:
bash
# 1. 停止服务
crystal serve stop
# 2. 删除索引目录
rm -rf ~/.chatcrystal/data/vectra-index/
# 3. 重启服务
crystal serve
# 4. 重新生成所有 embedding
curl -X POST http://localhost:3721/api/embeddings/batch
这个接口会找出所有 embedding_status 不是 done 的笔记,重新加入队列生成 embedding。
注意:
crystal summarize --all只会处理状态为imported、error或summarizing的对话,不会为已有的笔记重新生成 embedding。如果你的笔记已经存在只想重建索引,必须使用上面的 batch API。
方案二:重建单条笔记的 embedding
如果只有个别笔记的 embedding 有问题,可以单独重建:
bash
# 通过 API 重建指定笔记的 embedding
curl -X POST http://localhost:3721/api/notes/123/embed
方案三:代码级清除
如果你在开发或调试,可以直接调用 clearEmbeddingIndex():
typescript
import { clearEmbeddingIndex } from './services/vector-index.js';
clearEmbeddingIndex(); // 删除整个索引目录 + 清空内存缓存
这个函数会:
- 将内存中的
_index单例设为null - 用
rmSync递归删除vectra-index/目录 - 下次
getIndex()调用时自动重建空索引
beginUpdate / endUpdate 事务模式
vectra 的写入不是原子的。beginUpdate() 开启一个写入批次,所有 insertItem() / deleteItem() 操作在 endUpdate() 之前都不会持久化到磁盘。
ChatCrystal 在所有使用这个模式的地方都有错误处理:
typescript
let updateOpen = false;
try {
await index.beginUpdate();
updateOpen = true;
// ... 写入操作 ...
await index.endUpdate();
updateOpen = false;
} catch (error) {
if (updateOpen) {
try {
index.cancelUpdate(); // 回滚未提交的变更
} catch {
// 忽略取消失败,优先抛出原始错误
}
}
throw error;
}
cancelUpdate() 会丢弃未提交的变更。但如果你的进程在 beginUpdate() 之后、cancelUpdate() 之前被强杀,vectra 内部可能残留中间状态。这就是索引损坏的主要来源。
预防措施
1. 不要共用数据目录
Electron 版和 CLI 版如果同时运行,确保它们使用不同的数据目录,或者同一时间只有一个进程在运行。
2. 保持磁盘空间充足
embedding 向量会占用一定磁盘空间(每个向量约 6KB,取决于模型维度)。定期检查数据目录所在磁盘的剩余空间。
3. 正常关闭服务
用 crystal serve stop 或 Electron 的退出菜单关闭,不要直接 kill 进程。这给正在执行的 embedding 任务一个完成的机会。
4. 定期检查 embedding 状态
bash
# 查看数据库统计(对话数、笔记数、标签数)
crystal status
如果发现大量笔记处于 failed 或 syncing 状态,及时用 POST /api/embeddings/batch 重新队列。
排查流程总结
bash
搜索无结果
↓
crystal status → 确认笔记存在
↓
curl GET /api/notes?embedding_status=pending → 检查是否有待处理的笔记
↓
有 pending/failed/syncing?
├── 是 → curl -X POST /api/embeddings/batch → 等待完成 → 重试搜索
│ ↓
│ 仍然失败?
│ ├── 是 → rm -rf vectra-index/ → 重启 → /api/embeddings/batch
│ └── 否 → 搞定
└── 全是 done 但仍无结果
↓
Embedding 模型配置是否正确?→ 见「LLM 和 Embedding 不能混用」
下一步
- LLM 和 Embedding 不能混用 --- 确保 Embedding 模型配置正确
- Embedding 模型选型与配置 --- 不同模型的维度和性能对比
- Ollama 本地部署:零成本跑通全流程 --- 用本地模型跑 embedding