从零开始:用Rust构建一个版本化矢量数据库
矢量数据库正成为AI应用的核心组件,它通过向量嵌入实现语义搜索、相似性匹配等强大功能。然而,大多数开源方案缺乏版本控制能力,无法满足增量索引和CI工作流的需求。本文基于TerminusDB团队的实践,详细讲解如何用Rust编写一个支持版本化的矢量数据库,涵盖存储、索引、查询等关键环节。
为什么需要矢量数据库
矢量数据库用于存储和检索向量(即嵌入),利用向量之间的距离度量(如欧几里得距离、余弦相似度)实现语义相似性。例如,在Word2Vec中,king - man + woman ≈ queen。这种能力对以下任务至关重要:
- 全文搜索
- 实体解析(查找重复文档)
- 相似性搜索(推荐系统)
- 聚类
TerminusDB需要支持增量索引和提交版本管理,以便在CI工作流中精确追溯索引对应的数据版本。但现有开源矢量数据库均不支持版本化,因此他们决定自行实现。
小提示: 版本化索引在需要回滚或审计数据的场景中非常有用,例如金融、医疗等合规性要求高的领域。
核心概念:HNSW索引
向量数据库需要快速比较向量,因此必须构建索引结构来减少比对次数。他们选用了HNSW(Hierarchical Navigable Small World)图算法(Malkov & Yashunin)。HNSW在低维和高维空间均表现良好,且实现简单。他们借鉴了Rust计算机视觉领域的开源HNSW实现。
HNSW的核心思想是构建多层图结构:顶层节点用于快速导航,底层节点包含所有向量。搜索时从顶层开始,逐层向下,快速定位候选向量。
常见问题:为什么选择HNSW而不是其他索引?
- HNSW在数百万级向量上仍能保持亚秒级响应。
- 相比Annoy(Spotify使用的近似最近邻算法),HNSW支持动态插入和删除。
- 相比FAISS(Facebook开源的向量检索库),HNSW的内存占用更可控,适合嵌入式场景。
存储向量:页式存储与域管理
向量存储在域(Domain)中,每个域对应一组语义相关的向量。例如TerminusDB的不同数据提交属于同一域,因此共享相同的向量存储。
向量存储采用基于页面(Page-based)的缓冲区设计,每个页面大小与操作系统页面对齐,但对向量数据进行紧凑存储。每个向量分配唯一的索引号,通过索引号可映射到对应页面和偏移量。
在HNSW索引中,使用LoadedVec结构引用当前加载到内存的向量页面。当页面被逐出时,LoadedVec被回收,新页面可重新使用该缓冲区。
// 伪代码示意:向量索引到页面的映射
struct VecIndex {
page_id: u32,
offset: u32,
length: u32,
}
小提示: 页式存储可以实现高效的磁盘I/O,避免内存碎片。推荐每页固定存储512或1024个向量,便于预计算。
创建版本化索引
每个(域 + 提交)对对应一个独立的HNSW结构。索引过程如下:
- 如果是新索引,从空HNSW开始。
- 如果是增量索引,加载上一次提交的HNSW,然后执行插入、删除、替换操作。
- 变更由TerminusDB提供(通过差分算法找出提交间的变化),索引器只接收三种操作:
Insert、Delete、Replace。
索引本身维护在LRU缓存池中,按需加载或复用。由于只有在提交时才会进行破坏性操作,缓存始终保持一致。
保存索引时,使用原始向量索引作为替身序列化HNSW结构,这能保持索引文件紧凑。未来计划引入层级层(Layer)技术,使增量索引无需复制整个结构,但目前索引规模相比向量本身已经足够小,暂不需要优化。
常见问题:删除和替换操作如何实现?
目前仅实现了插入操作(一周的开发时间有限)。团队设计了以下方案(尚未实现):
- 在HNSW的高层中,直接忽略删除——因为高层节点仅用于导航,数量很少。
- 在零层(底层)但不在高层的节点:从图中删除,并尝试用附近节点替代被删除节点的连接。
- 在零层且也在高层的节点:标记为“已删除”,仍用于导航,但不加入候选池。
这个设计需要仔细验证,确保搜索精度不受影响。
查找嵌入:OpenAI + 自定义模板
TerminusDB使用OpenAI的嵌入API生成向量。在索引时,每个文档被发送给OpenAI,返回浮点向量列表。关键经验:嵌入的质量高度依赖上下文。直接提交原始JSON文档效果不佳。
他们发现,通过定义GraphQL查询 + Handlebars模板,可以生成高质量的嵌入。例如,对于《星球大战》中的People对象,模板将字段转换为自然语言描述:
{
"embedding": {
"query": "query($id: ID){ People(id : $id) { birth_year, created, desc, edited, eye_color, gender, hair_colors, height, homeworld { label }, label, mass, skin_colors, species { label }, url } }",
"template": "The person's name is {{label}}.{{#if desc}} They are described with the following synopsis: {{#each desc}} *{{this}} {{/each}}.{{/if}}{{#if gender}} Their gender is {{gender}}.{{/if}}{{#if hair_colors}} They ha ve the following hair colours: {{hair_colors}}.{{/if}}{{#if mass}} They ha ve a mass of {{mass}}.{{/if}}{{#if skin_colors}} Their skin colours are {{skin_colors}}.{{/if}}{{#if species}} Their species is {{species.label}}.{{/if}}{{#if homeworld}} Their homeworld is {{homeworld.label}}.{{/if}}"
}
}
未来计划:利用AI聊天模型从模式文档和数据结构中自动生成这样的模板,进一步降低使用门槛。
小提示: 模板中的字段描述越接近自然语言,嵌入效果越好。可以尝试加入关键属性(如颜色、特征、关系)来提升语义区分度。
实战:为星球大战数据索引
他们使用《星球大战》数据产品进行测试。首先发起索引请求:
curl 'localhost:8080/index?commit=o2uq7k1mrun1vp4urktmw55962vlpto&domain=admin/star_wars'
返回一个任务ID,可通过轮询确认完成。索引文件和向量文件分别命名为:admin%2Fstar_wars@o2uq7k1mrun1vp4urktmw55962vlpto.hnsw 和 admin%2Fstar_wars.vecs。
现在可以搜索:
curl 'localhost:8080/search?commit=o2uq7k1mrun1vp4urktmw55962vlpto&domain=admin/star_wars' -d "Who are the squid people"
返回结果:
[{"id":"terminusdb:///star-wars/Species/8","distance":0.09396297}, ...]
对应的嵌入文本为:
"The species name is Mon Calamari. They ha ve the following hair colours: none. Their skin colours are red, blue, brown, magenta. They speak the Mon Calamarian language."
注意:文本中从未出现“squid”(鱿鱼),但嵌入成功匹配了“squid people”的语义。
再试“Wise old man”:
curl 'localhost:8080/search?commit=o2uq7k1mrun1vp4urktmw55962vlpto&domain=admin/star_wars' -d "Wise old man"
返回结果是Yoda(尤达)的相关描述,尽管文本中只提到“最古老”,并未出现“聪明”或“人”等词。
常见问题:嵌入对上下文敏感,如何确保质量?
- 使用结构化的模板将字段转化为完整语句,避免缺失属性。
- 对于缺失字段(如
desc),使用条件语法避免产生空字符串。 - 如果数据中存在多种语言,建议统一使用英文模板(OpenAI嵌入对英文效果最佳)。
结论与展望
本教程详细介绍了如何使用Rust构建一个支持版本化的矢量数据库,涵盖HNSW索引、页式存储、增量索引、自定义嵌入模板等核心组件。实际测试表明,该数据库能够精准理解语义,即使查询词不出现在原始文本中也能返回正确结果。
未来计划包括:实现删除和替换操作的完整支持;引入层级层技术减少增量索引的冗余;以及利用AI自动生成模板,进一步简化用户工作。
虽然业界已有Pinecone等优秀产品,但作为一个与TerminusDB深度集成的sidecar组件,该数据库为技术栈较浅的用户提供了便捷、版本可控的语义搜索能力。如果你对项目源码感兴趣,欢迎访问TerminusDB仓库获取更多信息。
