vectra 向量索引文件损坏怎么办

本文面向:ChatCrystal 语义搜索返回空结果或报错,怀疑向量索引损坏的开发者。

预计阅读时间:7 分钟


症状

语义搜索出了问题,具体表现为以下几种:

  • 搜索页面输入关键词后返回空结果,但笔记明明存在
  • 搜索时报错 500 Internal Server Error,服务端日志出现 vectra 相关异常
  • crystal search "xxx" 命令无结果,但 crystal notes list 能看到笔记
  • 新笔记的 embedding_status 一直停在 pendingsyncing,不会变成 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;  // 已经修好了,不用重做
}

清理失败不阻塞搜索

两个关键设计:

  1. preflightSemanticSearchVectorCleanup 的异常被 catch 吞掉,搜索照常进行
  2. 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 只会处理状态为 importederrorsummarizing 的对话,不会为已有的笔记重新生成 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();  // 删除整个索引目录 + 清空内存缓存

这个函数会:

  1. 将内存中的 _index 单例设为 null
  2. rmSync 递归删除 vectra-index/ 目录
  3. 下次 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

如果发现大量笔记处于 failedsyncing 状态,及时用 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 不能混用」

下一步


项目地址:github.com/ZengLiangYi...

相关推荐
浩风祭月2 小时前
把项目环境配置全自动化:新人入职从两天变成两小时
ai编程·cursor
人月神话Lee2 小时前
【图像处理】卷积原理与卷积核——图像处理的核心引擎
ios·ai编程·图像识别
夜雪闻竹2 小时前
Embedding 模型选型与配置
gpt·开源·embedding·ai编程
小江的记录本2 小时前
【Java基础】核心关键字:final、static、volatile、synchronized、transient(附《思维导图》+《面试高频考点清单》)
java·前端·数据结构·后端·ai·面试·ai编程
怕浪猫3 小时前
AI 3D 大模型创作
aigc·openai·ai编程
孟健3 小时前
我把多 Agent 协作搬进 Hermes Kanban,才发现群聊派活真的不够用了
ai编程
陆业聪3 小时前
DNS优化实战:从运营商DNS到HttpDNS的进化之路
人工智能·aigc·职业发展
constCpp3 小时前
大模型是怎么“思考”的?
ai编程
日光明媚3 小时前
TensorRT-LLM 中对 wan 加速流程与方法
人工智能·python·计算机视觉·stable diffusion·aigc