概念篇

项目三:Varve 时间感知本地 AI 记忆库 —— 零基础超详细教程

零基础友好可运行代码逐行精讲

项目三:Varve 时间感知 AI 记忆库 —— 零基础超详细教程


第〇部分:从场景讲起 —— AI 怎么才能"记住"你

0.1 一个月的两万条对话

笔记写给谁看:完全没写过代码的大一同学。你没听说过向量、没听说过数据库、没听说过 Rust,都没关系。我们从"你用 DeepSeek 聊天"这件小事开始,一步一步走到一个可以写进简历、投大厂存储岗和数据库内核岗的项目。

假设你每天都用 DeepSeek 或者通义千问聊天。你问它数学题、让它帮你改英语作文、跟它吐槽食堂的菜。一个月下来,你打开历史对话列表一看,密密麻麻,加起来有两万条对话。

现在,六月的一天,你问 AI 一句话:

"我上个月说的那个项目怎么样了?"

注意这句话里有两个关键信息。第一个是"上个月",这是一个时间范围。第二个是"那个项目",这是一个模糊的意思,你自己都说不清那个项目叫什么名字,但你知道你们聊过。

AI 要想回答这句话,它得做两件事。第一件事,它得真的把你两万条对话都"存"下来了,一条都不能丢。第二件事,它得能从两万条里把"上个月聊的、跟某个项目有关的那几条"给"找"出来。

存下来,不难,往文件里写就行。难的是"找回来"。两万条对话,逐条翻吗?翻到天荒地老。这就是我们要讲的"AI 记忆"问题。

0.2 AI 记忆问题到底是什么

现在做 AI 助手(也就是 AI Agent,智能体)的开发者,几乎人人都碰到这个问题。用户希望助手像一个老朋友,记得你三个月前说过你叫小明、你在准备考研、你对花生过敏。这些"记忆"不是模型天生就有的,是开发者在外面给它配了一个"仓库",把每条对话、每条事实都存进去,用的时候再取出来。

这个仓库,行业里一般叫"长期记忆库"。Varve 就是这样一个库。

更精确地说,Varve 是一个 Python 库(Python library)。什么叫"库"?就是别人写好的一包工具,你装到你的电脑上,然后用几行代码就能调用它。Varve 的安装就一行命令:

Rust
pip install varve

pip 是 Python 自带的"应用商店",install 就是安装。跑完这一行,Varve 就在你电脑里了,不用装任何别的东西。这一点很重要,后面第九部分我们会讲它是怎么做到的。

Varve 的用户不是你和我,而是"做 AI 助手的开发者"。开发者在代码里写上三行:

Python
import varve
db = varve.create("./agent_memory", dim=768, metric="cosine")
db.write(1, 1718000000, vector, {"text": "用户说他要做一个校园二手交易平台"})

这三行代码的意思是:引入 varve 这个库;在 ./agent_memory 这个文件夹里建一个记忆库;然后往里写一条记忆。至于 dim=768、metric="cosine"、1718000000 这些奇奇怪怪的数字是什么,别急,第一部分和第四部分会把它们一个一个讲透。

0.3 现有方案卡在哪里

你可能会问:这问题不是很常见吗?现成的工具一大堆,为什么还要做一个 Varve?

问得好。这正是这个项目的灵魂。我们先看现有的工具是怎么回答"我上个月说的那个项目"这个问题的。

现成工具的思路,概括起来是两步。第一步,把你这句话的意思和仓库里所有两万条对话的意思逐个比对一遍,算出"像不像"。第二步,比对完之后,再看每条对话的时间,把时间不在"上个月"的那些扔掉。

听起来没毛病?我们算一笔账。假设仓库里不是两万条,而是一百万条(一个重度用户用一年的量),而你只想要最近七天的,七天里大概只有两千条。现有方案要先做一百万次比对,然后扔掉其中的九十九万八千次结果。你为了吃两颗花生米,把整片花生地都刨了一遍。

数据越多,这种浪费越严重。用户越多、用得越久,AI 助手就越卡。这就是现有方案的死穴。

Varve 的思路反过来。它先把数据按时间切成一段一段地存放,每段都记住自己的时间范围。查询来了,先看"上个月"这个窗口跟哪些段有交集,跟整段都对不上的,直接跳过,连硬盘都不读一下。跳过一百万条里的九十九万八千条之后,只在剩下的两千条里做意思比对。

这个"跳过整段数据"的动作,行业里叫"剪枝"(pruning)。第五部分我们会专门用一整章讲它,因为它是这个项目的灵魂,也是面试时必被追问的点。

0.4 Varve 的整体长相

在深入细节之前,我们先看一眼 Varve 的全貌。它像一个两层的小楼。

楼上住的是 Python。开发者打交道的就是这一层,varve.create、db.write、db.query 这些函数都在这层。Python 是 AI 圈子最流行的语言,写起来简单,但跑起来慢。

楼下住的是 Rust。真正干活的是这一层:存数据、读数据、算距离、跳段。Rust 是一门以"快"和"安全"著称的编程语言,跑起来跟 C++ 一样快。楼上楼下之间有一个"翻译官",叫 PyO3,它让 Python 代码能直接调用 Rust 写的功能。

楼下内部又分成两个仓库。第一个仓库叫 LSM 引擎,存每条记忆的"编号、时间戳、简短内容"。第二个仓库叫向量段文件,存每条记忆的"向量"(向量是什么,下一部分就讲)。两个仓库通过一个单调递增的内部编号(InternalId)关联起来,像两本账用同一个学号对账。

整个教程剩下的部分,就是把这栋小楼一砖一瓦拆开给你看。


第一部分:向量到底是什么(全文最重要的一节,我们放慢讲)

1.1 先抛一个朴素做法:按字面匹配

我们先想一个最笨的办法。你要找"上个月聊过的项目",那就让电脑在每条对话里搜关键词"项目"两个字,行不行?

马上就会出问题。你上个月说的是"我打算做一个校园二手交易平台",整句话里没有"项目"两个字,但它明明就是在说项目。反过来,你可能说过"这个项目Deadline太紧了我不想做了",这句有"项目",但它是一句吐槽,不是你说的那个项目本身。

按字面匹配,也就是关键词搜索,会漏掉很多该找的,也会捞上来很多不该找的。因为人类说话,同一个意思有一万种说法。"帮我写个程序""帮我撸个代码""帮我搞个脚本",字面上完全不同,意思完全一样。

电脑是死脑筋,它只认识字。所以核心问题是:怎么让电脑理解一句话的"意思",而不是"字面"?

1.2 破局思路:把一句话变成一串数字

科学家想出了一个绝妙的办法,叫嵌入(embedding)。这个词直译是"嵌入",你不用管字面,记住它干的事就行:把一句话变成一串数字。

比如"猫喜欢吃鱼"这句话,经过一个 embedding 模型(一个专门训练好的转换器,比如 OpenAI 的 text-embedding 或者开源的 BGE 模型),出来的是一串数字,可能是几百个。这串数字有个学名,叫向量(vector)。

向量这个词你肯定在数学课上听过。高中数学里,向量就是一个有方向有长度的箭头,比如 (2, 8) 就是平面上从原点指向"横坐标 2、纵坐标 8"那个点的箭头。embedding 出来的向量,本质上也是这个东西,只不过不是二维的,是几百维的。

我们先用二维的来建立直觉,因为二维能画出来。

1.3 二维坐标例子:意思近的句子,坐标也近

假设我们有一个只输出二维向量的迷你 embedding 模型。我们喂给它三句话:

"猫喜欢吃鱼" → (2, 8) "狗喜欢吃肉" → (3, 7) "今天股市大涨" → (9, 1)

把这三个点画在坐标纸上。"猫喜欢吃鱼"的点在 (2, 8),"狗喜欢吃肉"的点在 (3, 7)。你看,这两个点挨得很近。为什么?因为这两句话意思很像:都是"某种动物喜欢某种食物"的句式,聊的都是宠物吃东西。

而"今天股市大涨"在 (9, 1),离前两个点远远的。因为它聊的是财经,跟宠物吃饭八竿子打不着。

这就是 embedding 的核心魔法:意思相近的句子,变成向量之后,在空间里的位置也相近。意思八竿子打不着的句子,向量也离得远。embedding 模型就是用海量文本训练出来的,它学会了把"意思"编码进"位置"里。

现在回到找记忆的场景。你问"我上个月说的那个项目怎么样了",这句话也被变成一个向量,比如 (5, 5)。然后电脑在仓库里找:哪些记忆的向量离 (5, 5) 很近?"我打算做一个校园二手交易平台"的向量可能是 (5.1, 4.9),很近,捞上来。某条"今天食堂的菜真难吃"的向量是 (0.5, 9),很远,不要。

关键词匹配做不到的事,向量做到了。因为匹配的对象从"字面"变成了"意思"。

1.4 768 维是什么意思

刚才的例子是二维的,只有两个数字,够直观,但不够用。二维平面只能表达"两个方面的差异",可人类语言的"意思"有无数个方面:是聊吃的还是聊代码、是开心的还是吐槽的、是正式的还是口语的……两个数字装不下这么多信息。

所以真实的 embedding 模型输出的向量,维度高得多。Varve 的示例里写的是 dim=768,意思就是每个向量由 768 个数字排成一排。768 这个数字来自一个经典模型 BERT 的输出维度,现在很多模型也沿用这个尺寸,比如不少开源模型就是 768 维。也有 1024 维、1536 维的,模型不同维度不同。

你可以这么想:768 维就是给每句话在一个 768 维的"超级地图"上打了一个坐标。我们活在三维世界,想象不出 768 维长什么样,没关系,电脑也不需要想象,它只需要会算两个向量之间"近不近"就够了。

varve.create("./agent_memory", dim=768, ...) 里的 dim=768,就是告诉 Varve:"我打算存的向量,每个都是 768 个数字,你按这个规格给我准备仓库。"建库的时候必须说清楚,因为 Varve 要提前按这个尺寸安排存储格式。

1.5 余弦相似度:两根箭头的方向像不像

好,现在每条记忆都是一个向量,你的问题也是一个向量。"近不近"具体怎么算?

最直觉的算法是算直线距离,也就是欧氏距离(Euclidean distance),就是两点之间连根线量长度。但在高维世界里,工程师们更爱用另一个度量,叫余弦相似度(cosine similarity)。

余弦相似度的直觉是:不看两个箭头有多长,只看两个箭头的方向像不像。

想象你和同桌都从教室原点出发画箭头。你的箭头指向东北,他的箭头也指向东北,哪怕你的长他的短,你们俩"想的方向"是一致的,相似度就高。如果一个指向东北一个指向西南,方向完全相反,相似度就极低。如果两个箭头垂直,一个正东一个正北,那就是"毫无关系",相似度为零。

我们来做一个二维的手算例子,把公式钉死。余弦相似度的公式是:

Rust
cos(A, B) = (A·B) / (|A| × |B|)

拆开看。A·B 叫点积,就是把两个向量对应位置的数字分别相乘再相加。|A| 是 A 的长度,|B| 是 B 的长度。

假设向量 A = (3, 4),向量 B = (4, 3)。

第一步,算点积:A·B = 3×4 + 4×3 = 12 + 12 = 24。

第二步,算 A 的长度:|A| = √(3² + 4²) = √(9+16) = √25 = 5。

第三步,算 B 的长度:|B| = √(4² + 3²) = √(16+9) = √25 = 5。

第四步,代入公式:cos(A, B) = 24 / (5 × 5) = 24 / 25 = 0.96。

0.96 非常接近 1,说明这两个向量方向几乎一致。余弦相似度的取值范围是 -1 到 1:1 表示方向完全相同,0 表示垂直(毫无关系),-1 表示方向完全相反。

再算一对反例。A = (3, 4),C = (-3, -4)。点积 = 3×(-3) + 4×(-4) = -9 - 16 = -25。长度还是 5 和 5。cos = -25/25 = -1,方向完全相反,相似度最低。

这就是为什么 varve.create(..., metric="cosine") 里要写 metric="cosine":metric 就是"度量方式",我们在告诉 Varve,判断两个向量近不近,用余弦相似度这套算法。除了 cosine,常见的还有欧氏距离(l2)、内积(ip),建库时选一个就行,选好了就不能随便改,因为库里的数据都是按它排的。

1.6 一个重要分工:Varve 不做 embedding

这里要特别说清楚一件事,很多初学者会搞混。

把一句话变成向量,是 embedding 模型干的活,比如调 OpenAI 的接口,或者在本地跑一个 BGE 模型。这个过程很吃算力,需要神经网络。

Varve 完全不做这件事。 Varve 的定位非常克制:向量由调用方(也就是做 AI 助手的开发者)自己准备好,Varve 只负责两件事——存向量和找向量。

你在 db.write(1, 1718000000, vector, payload) 里看到的那个 vector,是开发者已经算好的 768 个数字。Varve 接过来,存好。你查询时再给一个 vector,Varve 拿它去库里找最近的。Varve 像一个仓库管理员,不生产货物,只保管和取货。

这个克制是设计上的优点:开发者想换什么 embedding 模型就换什么,Varve 一概不挑。后面第十部分讲面试问答时,"你的库边界在哪里"这种问题就可以拿这个说。


第二部分:向量检索与 KNN —— 一万个点里找最近的 5 个

2.1 把问题形式化

现在我们有:仓库里一万条记忆,每条是一个向量(先假装是二维坐标点)。又来了一个查询向量。我们要找:仓库里离查询点最近的 5 个点。

这个问题有个标准名字,叫 KNN(K-Nearest Neighbors,K 近邻)。K 就是"要几个",Nearest 是"最近的",Neighbors 是"邻居们"。KNN 就是"找出 K 个最近的邻居"。

在 Varve 的 API 里,这个 K 就是 db.query(time_range, vector, top_k=5) 里的 top_k。top_k=5 意思是"给我最像的前 5 条"。为什么是 5?因为 AI 助手通常把最相关的几条记忆塞进给大模型的提示词(prompt)里,塞太多会让提示词又臭又长,还烧钱,所以一般取个 3 到 10 条。

2.2 朴素做法:暴力法,以及它到底慢不慢

最朴素的做法,也是正确答案的"金标准",叫暴力法(brute force):把仓库里每个向量都拿出来,挨个跟查询向量算一次余弦相似度,全部算完,挑分数最高的 5 个。

暴力法的好处是结果绝对精确,它看过了每一个点,不可能漏。坏处是慢——慢不慢,我们算笔账。

算一次 768 维的余弦相似度,主要是 768 次乘法和加法,现代 CPU 做一次大概几微秒(1 微秒 = 百万分之一秒),我们往多了算,算 1 微秒。

仓库有 100 万条记忆。暴力法要算 100 万次,每次 1 微秒,总共 100 万微秒 = 1 秒。

也就是说,用户问一句话,光"找记忆"这一步就要 1 秒。这还只是一个人的请求。一个 AI 助手产品同时有几百个用户在问,服务器直接冒烟。而且注意,这 1 秒里还没有算"读硬盘"的时间——100 万条向量不可能全在内存里,从硬盘一条条读出来,时间还要再翻好几倍。

更要命的是,用户问的其实是"我上个月说的那个项目"。上个月的数据可能只有几千条。为了这几千条,我们算了一百万次相似度,其中 99.8% 的计算结果最后都被扔掉了。这就是浪费。

2.3 HNSW:不挨个算,也能找得又快又准

既然挨个算太贵,聪明人就想:能不能不算全部,也能找到最近的几个?

这就是"近似最近邻"(ANN,Approximate Nearest Neighbor)的思路:允许结果有一丢丢不准(比如找到的第 5 名其实是第 6 名),换来几十倍上百倍的速度。ANN 家族里最出名的算法,叫 HNSW(Hierarchical Navigable Small World,分层可导航小世界图)。

这个名字很唬人,我们用一个生活类比把它讲明白:寄快递。

假设你在海南三亚,要把一个包裹寄到黑龙江漠河的某个小区。快递绝不会从三亚出发一路开电动车到漠河。它是这么走的:

第一步,飞机。包裹先上飞机,从三亚飞到哈尔滨。飞机快,但只停省会级别的大城市,站点很稀疏。这一步是"粗定位",一下子跨越几千公里。

第二步,高铁。到了哈尔滨,包裹上高铁,到漠河所在的地区。高铁比飞机慢,但站点密一些。这一步是"中定位"。

第三步,电动车。到了当地站点,快递小哥骑电动车,走街串巷,精准送到小区楼下。电动车最慢,但最灵活,能到任何角落。这一步是"精定位"。

HNSW 就是把这个思路用在了向量上。它把向量组织成一张"分层图":

最上面一层,点非常稀疏,每个点之间跳着走,一步跨很远,相当于"飞机层"。从任意一个入口点出发,几步之内就能跳到离目标大致比较近的区域。

中间一层,点密一些,步子小一些,相当于"高铁层",在粗定位的基础上再缩小范围。

最下面一层,所有的点都在,每个点只跟离自己最近的几个点连着线,相当于"电动车层",在这一层慢慢走,就能找到真正最近的几个邻居。

查询的时候,从最上层进去,逐层往下走,越走越精细。整个过程只访问了图里极小一部分点,可能是全部点数的千分之一,但找到的结果跟暴力法比,准确率能有 95% 以上。一百万条数据,暴力法要 1 秒,HNSW 可能只要几毫秒。

这就是 FAISS、Chroma 这些成熟向量库里快查询的秘密武器。

2.4 Varve 的选择:v1 用暴力 KNN,够用,HNSW 留接口

那 Varve 为什么不用 HNSW?是不行吗?不是,是暂时不需要。

回忆一下 Varve 的核心思路:先做时间剪枝。剪完之后,候选集不是一百万条,而是几千条。在一千到几千条数据上做暴力 KNN 要多久?按每次 1 微秒算,2000 次 = 2 毫秒。毫秒级,人根本感觉不到。

也就是说,剪枝已经把"要算的数量"压下来了,暴力法在小集合上反而成了最优选择:它结果精确、实现简单、没有建索引的成本,而且数据是追加写入的,不用维护复杂的图结构。

但 Varve 没有把这扇门焊死。代码里向量检索是抽象成接口的(Rust 里叫 trait,第七部分会讲),v1 的实现是暴力 KNN,将来要换成 HNSW,只需要新写一个实现塞进同一个接口,上层代码一行都不用改。第十部分讲面试的时候,这一点是体现"架构意识"的好素材。


第三部分:向量数据库家族扫盲 —— SQLite、sqlite-vec、Chroma、FAISS、Zvec

3.1 先讲数据库和 SQLite

数据库(database),说人话就是"一个帮你管数据的软件"。你往里面存数据,它帮你组织好;你要查,它帮你找。你不需要关心数据在硬盘上是怎么排列的,数据库全包了。

数据库有很多种,最大的是 MySQL、PostgreSQL 这种"服务器数据库"——它们本身是一个独立运行的程序,你的应用要连上它才能用。而 SQLite 走的是另一个极端:它不是一个服务器,它就是一个文件。

一个文件就是一个数据库。 你的微信聊天记录,在电脑里很可能就是一个 SQLite 文件;你手机里的很多 App,数据也是存在 SQLite 文件里。它不挑环境、不用装服务、不用配置,往项目里一嵌就能用。全世界装机量最大的数据库就是 SQLite,因为每一部手机里都有几十上百个它。

SQLite 存的是"表"(table):一行一行的记录,每行有若干列。比如一张聊天记录表,每行是一条消息,列是"发送者、时间、内容"。你用 SQL 语言(Structured Query Language,结构化查询语言)跟它说话,比如"把时间在上个月的行都给我"。

3.2 sqlite-vec:给 SQLite 装上向量能力

SQLite 本身不认识向量。它存数字、存文字都没问题,但"给我找离这个向量最近的 5 条"这种活,原生的 SQLite 干不了。

于是有人写了 sqlite-vec:一个给 SQLite 加的向量插件。装上它之后,你可以在 SQLite 里建一个专门存向量的"虚拟列",然后用它提供的函数做 KNN 查询。好处是轻量、一个文件、跟 SQLite 的生态无缝。很多小项目的 AI 记忆就是 SQLite + sqlite-vec 这么搭的。

3.3 Chroma、FAISS、Zvec 各是什么

这三个名字在 AI 记忆里出现率极高,我们一句话定位一个。

FAISS(Facebook AI Similarity Search):Meta(也就是 Facebook)开源的向量检索库。注意它是"库"不是"数据库"——它只管"给一堆向量,快速找出最近邻"这一件事,而且做得极致快,HNSW 等各种索引算法都有。但它不管存原文、不管时间、不管崩溃恢复,这些你得自己搭。它是"发动机",不是"整车"。

Chroma:一个开箱即用的向量数据库。你 pip 装上,几行代码就能存向量、存原文、存元数据,然后查询。它是"整车",适合快速做原型。很多 AI 教程里用的就是它。

Zvec:阿里开源的向量检索引擎,也是轻量级、可嵌入的向量库,定位跟 FAISS 类似,主打轻量快。

3.4 共同短板:时间范围查询 = 先全量比对再按时间扔

这些工具单个拎出来都很优秀,但它们面对"我上个月说的那个项目"这种带时间范围的查询时,做法惊人地一致,也惊人地浪费。

它们的标准做法是:第一步,在整个库里做向量检索,找出意思上最接近的一批;第二步,再看每条结果的时间戳,把不在时间范围内的扔掉。

这个做法有个专门的叫法,叫逻辑过滤(logical filtering):过滤发生在"逻辑层面",也就是数据都已经读出来了、算完了,只是在筛选结果的环节按条件扔掉一批。

我们打个比方。你在抖音上想找"上个月点赞过的、关于猫的视频"。逻辑过滤相当于:把你这辈子点过赞的十万个视频全部从仓库搬出来,摆满整个操场,逐个检查发布日期,不是上个月的就扔到一边。操场(内存和算力)被占得满满的,十万个视频你全摸了一遍,最后手里只剩下二十个。

我们把数字钉死。假设库里有 100 万条记忆,你要的时间窗口是最近 7 天,窗口里有 2000 条。逻辑过滤要做的事是:对 100 万条全部计算相似度(或者至少全部读取并检查时间),然后扔掉 99.8 万条。哪怕每次相似度计算只要 1 微秒,也是 1 秒的纯计算时间,再加上读 100 万条数据的磁盘时间。用户每问一句就等一秒多,产品没法用。

3.5 "扫描"这个词,讲清楚

刚才反复出现一个动作:"全部读出来看一遍"。这个动作在行业里有个专门的词,叫扫描(scan)。

扫描就是:把数据从头到尾、一条一条全看一遍。不看内容是什么,先看了再说。它是数据库里最简单也最贵的操作——简单是因为不用动脑子,贵是因为数据量大的时候,磁盘要一条一条读,CPU 要一条一条过。

数据库工程师的一生,有一半时间都在跟"怎么少扫描"作斗争。索引(index)、剪枝(pruning)、分区(partition),所有这些技术,本质上都是同一个目标:能不看的数据,就别让我看。

记住这句话。Varve 的全部意义,就是这句话在时间维度上的一次漂亮实践。


第四部分:时间戳、元数据、payload —— 一条记忆的"身份证"和"行李"

4.1 时间戳(timestamp):1718000000 这种数字怎么读

电脑怎么表示"2024 年 6 月 10 日下午 3 点 13 分 20 秒"这样一个时刻?它不用年月日时分秒,那太麻烦了(要考虑时区、闰年、夏令时)。电脑用的是一个笨但绝对可靠的法子:数数。

从 1970 年 1 月 1 日 0 点 0 分 0 秒(UTC 时间,也就是世界标准时)开始,一秒一秒数,数到你要表示的那个时刻,数了多少秒,那个数字就是时间戳(timestamp)。这个起点叫 Unix 纪元(Unix Epoch),所以这种时间戳也叫 Unix 时间戳。

我们来看 1718000000 这个数。它表示"从 1970 年元旦开始,过了 17.18 亿秒"。换算一下,17.18 亿秒大约等于 54.4 年,1970 + 54.4 ≈ 2024 年年中。精确地说,1718000000 对应的是 2024 年 6 月 10 日上午(UTC 时间)。

你在 Varve 的 API 里看到的 db.write(1, 1718000000, ...),第二个参数就是这条记忆发生的时间,用时间戳表示。时间戳的好处是:它就是一个整数,比较大小极其方便。"上个月"这个时间窗口,在电脑眼里就是两个整数:窗口开始的时间戳和窗口结束的时间戳。判断一条记忆在不在窗口里,就是判断它的时间戳是不是夹在这两个整数之间。一次整数比较,一纳秒都不用。

4.2 元数据(metadata):数据的数据

元数据(metadata),字面意思是"关于数据的数据"。说人话:描述一条数据的那些信息,就是这条数据的元数据。

拿一条聊天记录来说。"内容"是数据本身,比如"我打算做一个校园二手交易平台"。而"这条消息是谁说的(用户还是 AI)、什么时候说的、属于哪个会话",这些不是内容本身,而是描述这条内容的信息,它们就是元数据。

再举个例子。小明,学号 51,数学考了 135 分。如果"135 分"是数据,那么"这是小明的成绩""这是数学科目""这是期中考试"就是元数据。小红书一条评论,"评论正文"是数据,"评论者昵称、发布时间、点赞数"是元数据。

在 AI 记忆里,元数据极其重要,因为查询经常是冲着元数据来的。"我上个月说的"——查的是时间元数据。"Alice 告诉我的"——查的是说话人元数据。Varve 把每条记忆的时间戳当成一等公民来对待,整个架构都是围绕它设计的,这正是它跟其他向量库的区别。

4.3 payload:存向量时顺带存的那包内容

payload 这个词,直译是"有效载荷",火箭升空时,火箭壳子不是目的,壳子里装的卫星才是 payload。在编程世界里,payload 指"一次操作真正要运送的那包数据"。

在 Varve 里,db.write(1, 1718000000, vector, payload) 的第四个参数就是 payload:存这条向量时,顺带要存下的那包内容。典型情况下,payload 里装着:对话原文(text)、角色(role,是用户还是 AI)、会话 id(session_id)等等。查询找到向量之后,AI 要把原文拿回来塞给大模型,原文就在 payload 里。

Varve 对 payload 做了一个很讲究的拆分,分成 head 和 body 两部分。

head(头部):payload 里小而常用的字段,比如角色、会话 id、一小段摘要。head 走 LSM 引擎(第六部分细讲),跟时间戳、编号放在一起,查询时顺手就能拿到,几乎不额外花钱。

body(主体):payload 里的大文本,比如几千字的对话全文。大文本不塞进 LSM,而是走一个叫 value log(值日志)的独立文件。

为什么要拆?打个比方。LSM 引擎像你的书包,要经常翻动、整理、压缩。如果往书包里塞棉被(大文本),书包很快就撑爆了,整理起来也费劲。所以棉被放床底(value log),书包里只放一张纸条写着"棉被在床底第三个箱子"。查询时大多数情况下纸条上的信息就够用了,真要棉被再去床底拿。

这个设计在数据库里有个名字,叫"键值分离"(key-value separation),著名的 WiscKey 论文讲的就是它。Varve 把 payload body 分出去,正是这个思想的落地。第六部分我们还会回到这里。


第五部分:剪枝(pruning)—— 本项目的灵魂

5.1 仓库类比:一百万个箱子

想象一个巨大的仓库,里面有一百万个纸箱子,每个箱子里装着你的一条记忆。箱子上贴着日期标签。

现在仓库管理员接到一个任务:"把上个月进的箱子里,跟'项目'有关的找出来。"

现有方案(逻辑过滤)的做法:管理员推来一辆大车,把一百万个箱子一个一个搬下来,一个一个打开,看内容是不是跟"项目"意思相近,再看日期是不是上个月。不是上个月的,扔回去。一百万个箱子,全过了一遍手。

Varve(物理剪枝)的做法:这个仓库从建的第一天起,就是按日期摆放的。一月进的货放最左边一区,二月放旁边一区,依此类推。每个区域的墙上挂着牌子:"本区货物日期:5 月 1 日至 5 月 31 日"。管理员接到任务,先看牌子:"上个月"是 5 月,那就只走到 5 月那一区,其他区域连走都不走过去。然后只在 5 月区里翻箱子看内容。

哪个快,一目了然。差别不是快一点半点,是"碰多少箱子"这个根本问题:一个要碰一百万个,一个只碰两千个。

5.2 逻辑过滤 vs 物理剪枝,用数字钉死

我们把这笔账彻底算清。

设定:库里 100 万条记忆,每条向量 768 维。查询窗口是最近 7 天,窗口内有 2000 条。每次余弦相似度计算按 1 微秒估,从磁盘读一条向量数据按几微秒估(机械硬盘更慢,这里往少了算)。

逻辑过滤的成本: 第一步,读取 100 万条向量(或者至少扫描全部索引)。哪怕索引在内存里,也要算 100 万次相似度,约 1 秒。如果向量在磁盘上,读 100 万条 × 每条 768 维 × 4 字节 ≈ 3 GB 数据,光磁盘读取就是好几秒到几十秒。 第二步,对算完的结果按时间过滤,扔掉 99.8 万条。 总成本:秒级到十秒级。

物理剪枝的成本: 第一步,看每个段的时间范围牌子。段的数量是几十个到几百个,看牌子只是内存里的整数比较,微秒级,忽略不计。 第二步,只有 2~3 个段跟 7 天窗口有交集,只读这几个段的向量,约 2000 条,算 2000 次相似度,约 2 毫秒。 总成本:毫秒级。

一秒对两毫秒,差了五百倍。数据规模越大、时间窗口越窄,差距越夸张。这就是"逻辑过滤"和"物理剪枝"的本质区别:过滤是看了再扔,剪枝是压根不看。

5.3 段(segment):按时间切好的数据段

剪枝要能成立,前提是"仓库按时间摆放"。在 Varve 里,这个"区域"的学名叫做段(segment)。

数据写入 Varve 时,是按时间顺序一批一批进来的。积累到一定数量,Varve 就把这一批"封口",变成一个段文件,落到磁盘上。每个段有两样东西。

第一样是数据本身:这一批记忆的向量、编号、时间戳、payload。

第二样是段的"牌子",也就是段的元信息:本段最小时间戳是多少、最大时间戳是多少、有多少条数据、文件在哪。这个牌子非常关键,它是剪枝的依据。牌子很小,常驻内存,查询时看牌子不用碰磁盘。

因为 v1 强制时间单调递增写入(第九部分细讲这个契约),所以段的时间范围天然是不重叠或基本不重叠的:先写的段时间早,后写的段时间晚,像火车车厢一样一节一节按顺序排。

5.4 时间感知查询规划器:先看牌子再动手

查询来了,db.query(time_range=("2024-05-01", "2024-06-01"), vector, top_k=5)。Varve 内部第一个醒过来的组件,是时间感知查询规划器(time-aware query planner)。

"查询规划器"(query planner)这个词来自传统数据库,意思是"决定这次查询该怎么执行的军师"。SQLite、MySQL 里都有查询规划器,它们决定用哪个索引、按什么顺序查表。Varve 的规划器干的活更简单也更聚焦:看时间窗口跟哪些段有交集。

规划器的逻辑用伪代码写出来就是这样(伪代码不是真代码,是用文字描述逻辑):

Rust
对于库里的每一个段:
    如果 段的最大时间戳 < 查询窗口的开始时间:
        跳过(整段都太早)
    否则如果 段的最小时间戳 > 查询窗口的结束时间:
        跳过(整段都太晚)
    否则:
        把这个段加入候选名单

两个整数比较,就决定了一个段的生死。被跳过的段,不产生任何磁盘读取——它的向量文件静静地躺在硬盘上,我们连文件名都没去碰。这就是"物理"两个字的分量:跳过发生在物理存储层面,不是读出来再扔。

候选名单里的段(通常只有一两个),才轮到向量检索上场:在这几个段里做暴力 KNN,找出窗口内意思最接近的 top_k 条。

整个流程总结成一句话:先用时间剪掉 99% 的数据,再在剩下的 1% 里做语义检索。 这就是"段级时间物理剪枝与向量检索联合执行",这句话第十部分会原样出现在简历里。

5.5 退化行为:主动把丑话说在前面

任何一个好系统都要诚实地回答一个问题:"什么时候你的绝招不灵?"

Varve 的剪枝,有一个明显的失效场景:查询窗口覆盖全部历史的时候。

比如用户问"我跟你认识以来,聊过的所有项目",时间窗口从第一条记忆到最后一条。这时候每个段的时间范围都跟窗口有交集,一个段都剪不掉,规划器的候选名单就是全部段。剪枝失效,Varve 退化成什么?退化成全量暴力扫描——跟现有方案一模一样,100 万条全算一遍。

这不丢人,因为"查全部历史"本来就没有可剪的东西,任何系统都逃不掉。诚实地把这一点写出来,反而显得你对系统理解透彻。面试时面试官最欣赏的,就是能主动说出自己系统边界的候选人。

也正因为有退化行为,Varve 的 benchmark(基准测试,就是给系统测速)固定报三档成绩:7 天窗口、90 天窗口、全历史窗口。前两档展示剪枝的威力(毫秒级),第三档诚实地展示退化后的水平(跟暴力法持平)。三档一起报,别人才信你的数据没有挑肥拣瘦。


第六部分:Varve 的存储架构 —— LSM 引擎 + 向量段文件

6.1 两套存储,各司其职

Varve 的磁盘上有两套存储系统,像两个分工不同的仓库。

第一个仓库是 LSM 引擎,存的是"小而常查"的东西:每条记忆的编号(InternalId)、用户给的 id、时间戳、payload head、以及 id 到 InternalId 的映射。一句话,LSM 管"账本"。

第二个仓库是向量段文件,存的是"大块头":768 维的向量本体。一句话,向量段管"货物"。

为什么拆成两套?因为这两类数据的脾气完全不同。账本小、更新频繁、要支持按键快速查("给我编号 1024 的时间戳");货物大、写完就不改、查询时要成片地读("把这个段的 2000 个向量都给我")。用同一种存储伺候两种脾气,必然两头不讨好。下面我们分别讲。

6.2 LSM 引擎:MemTable、WAL、SSTable

LSM 的全称是 Log-Structured Merge-Tree(日志结构合并树)。名字吓人,思想朴素,一句话概括:所有写入都只往文件末尾追加,绝不回头改旧数据;攒够一批就整理一次。

这个思想为什么这么流行?因为硬盘(尤其是机械硬盘,SSD 也一样)有个怪脾气:顺序写(往末尾接着写)飞快,随机写(跳到文件中间改某个位置)慢得离谱。LSM 把所有写操作都变成顺序写,写入速度直接拉满。RocksDB、LevelDB、Cassandra,半个数据库世界都是 LSM 的信徒。

LSM 由三个部件组成,我们用"记账"来类比。

第一个部件:MemTable(内存表)。 它是内存里的一张有序表(可以想成一个自动保持排序的字典)。新写入的数据,先进 MemTable。内存读写是纳秒级的,所以写入飞快。类比:会计手边的一张草稿纸,来一笔账先记在草稿纸上,按时间排好。

第二个部件:WAL(Write-Ahead Log,预写日志)。 内存有个致命弱点:断电就没了。所以每条数据进 MemTable 之前,先往 WAL 文件里追加一行。WAL 是磁盘文件,追加写很快。万一程序崩溃、电脑断电,重启后把 WAL 从头到尾读一遍,就能把 MemTable 恢复到崩溃前的样子。类比:会计在草稿纸旁边放一本流水账,每笔账先在流水账上抄一行再记草稿。草稿纸丢了不要紧,照着流水账重抄一遍就行。这就是崩溃恢复(crash recovery),数据库的命根子。

第三个部件:SSTable(Sorted String Table,有序字符串表)。 MemTable 不能无限长大,内存就那么多。攒到一定的量,Varve 把 MemTable 整个"封口",按序写成磁盘上的一个 SSTable 文件,然后 MemTable 清空接着用。SSTable 是不可变的(immutable):写完之后永远不改,要删要改都靠后续新文件来覆盖。类比:草稿纸记满了,誊到正式的账本上归档,草稿纸撕掉换新的。

对 Varve 来说,SSTable 还承担一个关键职责:段级时间边界。每个 SSTable 文件里的数据是按时间攒出来的一批,所以每个文件天然带一个时间范围(最小时间戳到最大时间戳)。第五部分说的"段牌子",主要就是从 SSTable 的元信息来的。查询规划器看牌子跳段,跳的就是这些 SSTable。

SSTable 会越攒越多,查询时要翻的文件就变多,所以后台还有个 compaction(压缩合并)过程:把几个小的 SSTable 合并成一个大的,顺便扔掉被覆盖的旧数据。类比:年底把十二本月份账合并成一本年度总账。Varve 里被覆盖的旧数据(比如重复 write 产生的旧版本)就是在 compaction 时被真正回收的。

6.3 向量段文件:append-only + mmap

向量这边,设计得同样朴素。向量段文件是 append-only(只追加)的:新向量来了,往当前段文件末尾追加;段攒够了就封口,换下一个段。段内数据 immutable(不可变),跟 SSTable 一个脾气。

为什么向量也敢这么干?因为 Varve v1 强制时间单调写入,向量天然按时间顺序到达,追加就是最自然的写法。追加写又一次把所有磁盘写变成了顺序写。

段文件封口之后,读它的时候用了一招关键的:mmap。

6.4 mmap(memory map,内存映射):专门讲透

mmap 的全称是 memory map,内存映射。它是操作系统提供的一个功能,我们用类比讲。

朴素做法(普通文件读):你在图书馆看到一段好内容,要抄到笔记本上才能带回家用。操作系统读文件就是这样:应用程序说"我要读文件的第 1000 到 2000 字节",操作系统把这段数据从磁盘读出来,复制到程序的内存里,程序才能用。数据搬了一次家,花了一份搬运的力气。

mmap 的做法:你不抄书了。你直接把书摊在桌上,在要看的那一页插个书签。书还是图书馆的书,但你翻书签的速度跟翻自己笔记本一样快。mmap 就是让操作系统把一个文件直接"摊"在程序的内存地址空间里:文件没有真的被复制进内存,但程序访问内存地址就像访问数组一样,访问到哪个位置,操作系统就自动把文件对应的那一页从磁盘换进来。

用代码世界的语言说:mmap 之后,读文件就像读数组。data[1024] 这种写法,背后可能触发一次磁盘读,也可能命中内存缓存,但程序员的代码里完全不用管,操作系统全包了。

mmap 对 Varve 的好处有三。第一,省掉一次数据复制,向量直接从磁盘页缓存被 CPU 读走。第二,内存管理交给操作系统:内存紧张时操作系统自动把不常用的页扔回磁盘,Varve 不用自己写缓存逻辑。第三,配合"段内暴力 KNN"完美:暴力法本来就是从头到尾顺序扫一遍段内向量,mmap 的顺序读性能极好。

当然 mmap 不是银弹,比如它不适合频繁随机写。但 Varve 的向量段是 append-only 写完就不再改的只读文件,正好是 mmap 最擅长的场景。面试时"为什么用 mmap"这个问题,答案就是这一段的浓缩。

6.5 为什么向量图绝不进 LSM

有个经典的设计抉择,面试必考:将来上 HNSW 的时候,图的节点和边,存哪里? 有人第一反应是"存进 LSM 呗,反正有现成的引擎"。Varve 的答案是:绝不。

原因在于 HNSW 的访问模式。回忆第二部分的快递类比:HNSW 查询是"在图上跳来跳去",从入口点跳到邻居、再跳到邻居的邻居,一次查询要跳几十上百跳。每一跳都是一次"根据节点 id 取节点数据"的点查。

如果图存进 LSM,每一跳就是一次 LSM 点查。LSM 点查要付什么代价?数据可能在 MemTable,也可能在若干层 SSTable 里的任意一层,最坏情况要翻好几个文件;每个文件里还要走布隆过滤器(bloom filter,一种快速判断"肯定不在"的小结构)、走索引块、再走数据块。这一串开销,就叫读放大(read amplification):逻辑上你只读一个节点,物理上可能读了好几个文件的好几个块。

HNSW 一次查询几十上百跳,每跳都付一遍读放大的税,加起来就是几十倍的放大。图索引本来是为了快,结果被存储层的税拖死。这就是"图遍历的每一跳都要付 LSM 点查的读放大税"这句话的含义。

所以 Varve 的选择是:向量(以及将来的图结构)留在 append-only 段文件里,用 mmap 读,节点数据在文件里紧凑排列,一跳就是一次数组下标访问,读放大约等于一。LSM 只管它擅长的:小数据、键值、时间范围。

6.6 InternalId:两套存储的对账暗号

两套存储怎么对上?Varve 用了一个简单到家的办法:每条记忆进库时,发一个单调递增的内部编号 InternalId。第 1 条是 1,第 2 条是 2,永不重复,永不回退,像火车站的取票号。

LSM 里存一份映射:用户给的 id(可能是字符串、可能是任意数字)→ InternalId → 时间戳、payload head。向量段文件里,每个向量旁边就贴着它的 InternalId。查询时在向量段里算出"InternalId 1024 最像",拿着 1024 回 LSM 一查,payload head 到手。

注意一个关键设计:两边只认 InternalId,绝不互相引用物理偏移。什么叫物理偏移?就是"这条数据在文件的第几个字节"。为什么不用?因为物理偏移太脆弱了:compaction 一合并,文件重排,偏移全变,另一边存的"第 1024 字节"就指到别人家数据去了。InternalId 是逻辑编号,不管文件怎么搬,编号不变,两本账永远能对上。这是用一点点存储开销(多存一个编号)换来架构的稳健,非常值。

6.7 upsert 与孤儿向量

最后一个细节。用户用同一个 id 调了两次 db.write,第二次把第一次覆盖了——这在数据库里叫 upsert(update or insert,有则更新无则插入)。LSM 处理 upsert 很自然:追加一条新记录就行,读的时候取最新的,旧记录在 compaction 时被扔掉。

但向量段文件是 append-only 的,旧向量还在段文件里躺着,没人理它了。它成了孤儿向量:数据还在,但没有任何 InternalId 映射指向它,查询永远不会命中它。孤儿向量占着磁盘,怎么办?等 compact(压缩整理)时统一回收:重写段文件,只拷活向量,孤儿自然消失。

"先留着垃圾,定期大扫除"是存储系统的通行哲学,比"每删一条就立刻整理"快得多。LSM 的 SSTable 是这样,Varve 的向量段也是这样。


第七部分:Rust 语法从零速成(只讲看懂项目代码需要的部分)

7.0 为什么用 Rust

Varve 的核心为什么不用 Python 写?因为 Python 慢。算 100 万次余弦相似度,纯 Python 要几十秒,Rust 只要一秒不到。写存储引擎、数据库内核这种"斤斤计较每一微秒"的活儿,行业标配是 C、C++ 或 Rust。

那为什么不选 C++?因为 Rust 在保持 C++ 级别速度的同时,多给了一样东西:编译期内存安全。C++ 里程序员手一抖就会写出"访问了已经释放的内存"这种 bug,轻则崩溃,重则安全漏洞。Rust 的编译器会在编译时就把这类错误全抓出来,宁可不让你编译通过,也不让你带着炸弹上线。所以 Rust 圈有句玩笑话:"跟编译器搏斗一下午,换来半夜不被报警电话叫醒。"

另外,Rust 能通过 PyO3 跟 Python 严丝合缝地拼起来(第八部分讲),这对"给 Python 开发者用的库"来说是决定性优势。

好,现在开始学 Rust。我们只学项目代码里用得上的,每个语法点配一段小代码,逐行讲。

7.1 let 和 mut:变量默认不许改

Rust
let x = 5;
let mut count = 0;
count = count + 1;

第一行:let 是"声明一个变量",意思是"创建一个名字叫 x 的盒子,装进 5"。注意 Rust 不用写类型(比如 int),编译器看右边的值自动推断,这叫类型推断。

第二行:let mut 里的 mut 是 mutable(可变)的缩写。意思是"这个盒子里的东西以后可以换"。count 装了 0,但我们要给它加一,所以必须提前声明它可变。

第三行:把 count 里的数拿出来加一,再放回去。如果第二行没有写 mut,这一行编译直接报错:"cannot assign twice to immutable variable"(不能给不可变变量赋值两次)。

Rust 的哲学:默认一切不许改,想改必须明说。 这避免了一大类"我不知道什么时候数据被谁改了"的 bug。在 Varve 里,段文件封口后对应的数据结构就是不可变的,跟这个哲学一脉相承。

7.2 所有权(ownership):一本书只能有一个主人

这是 Rust 最独特、也最重要的概念。规则一句话:每块数据在任一时刻只能有一个主人(变量),主人没了,数据就被销毁。

Rust
let a = String::from("用户说要做二手平台");
let b = a;
println!("{}", b);

第一行:创建一个字符串(String 是 Rust 里"拥有自己内容的字符串"),主人是 a。

第二行:let b = a 不是"复制一份",而是转移所有权(move)——这本书从 a 手里交给了 b。从这一刻起,a 手里什么都没有了。

第三行:打印 b,没问题。

如果你在第三行写 println!("{}", a);,编译报错:"value borrowed here after move"(值在移动之后还被使用)。为什么 Rust 这么较真?因为如果 a 和 b 都能用,那函数结束时到底谁来销毁这个字符串?销毁两次会崩溃,都不销毁会内存泄漏。Rust 用"一个主人"把这个问题从根上消灭了。

类比:图书馆的一本书,借给你了,借阅证上就写你的名字。你要转借给同桌,得先过户——过户之后你就没资格再拿它了。

7.3 借用(&):借来看,不拿走

所有权这么严格,那"我只是想看一下数据,不想抢走"怎么办?用借用(borrowing),符号是 &。

Rust
fn describe(text: &String) -> usize {
    text.len()
}
let s = String::from("今天食堂的菜真难吃");
let n = describe(&s);
println!("{} 个字", n);

第一行:定义函数 describe,参数类型是 &String。& 表示"借用一个 String,不拿走所有权"。箭头 -> usize 表示函数返回一个 usize(无符号整数,就是不小于零的整数)。

第二行:text.len() 返回字符串长度。函数里只是读了一下,看完就还。

第四行:调用时写 &s,意思是"把 s 借给函数看看"。借完之后,s 还是主人,后面照样能用(第五行就用到了 n,s 也还健在)。

借用规则:可以同时有任意多个"只读借用",或者只有一个"可写借用"(&mut),但两者不能同时存在。类比:一本书可以同时很多人围观,但只允许一个人动笔改,而且改的时候不许别人围观。这条规则在编译期就消灭了多线程数据竞争,Rust 的"无畏并发"就是这么来的。

7.4 struct 和 impl:数据 + 行为

struct(structure,结构体)是"把几个字段捆成一个类型",类比 C++ 或 Java 的类。impl(implementation,实现块)是给这个类型挂函数的地方。

Rust
struct Memory {
    internal_id: u64,
    timestamp: i64,
}

impl Memory {
    fn new(internal_id: u64, timestamp: i64) -> Self {
        Memory { internal_id, timestamp }
    }
}

第一到四行:定义结构体 Memory,两个字段。u64 是 64 位无符号整数(0 到约 1844 亿亿),装 InternalId 这种永不回退的编号正合适。i64 是 64 位有符号整数(可正可负),装时间戳。

第六行:impl Memory { ... } 意思是"下面这块里的函数都属于 Memory 这个类型"。

第七行:fn 是定义函数。fn new(...) -> Self 是一个构造函数,返回一个 Self,Self 就是"本类型",也就是 Memory。注意参数列表里没有 self——没有 self 参数的函数相当于"静态方法",用 Memory::new(...) 来调用。

第八行:Memory { internal_id, timestamp } 是创建一个 Memory 实例。这是简写,全写是 Memory { internal_id: internal_id, timestamp: timestamp },字段名和变量名相同时可以只写一遍。这一行没有分号——Rust 里代码块最后一行不带分号的表达式就是返回值。

7.5 enum 和 match:快递状态与模式匹配

enum(enumeration,枚举)表示"一个值只能是这几种情况之一"。match 是"分情况处理"。用快递类比:一个包裹的状态,要么是"已发货",要么是"运输中",要么是"已签收",不可能既是又是。

Rust
enum WriteStatus {
    Accepted,
    RejectedOutOfOrder { expected: u64, got: u64 },
}

fn report(status: WriteStatus) {
    match status {
        WriteStatus::Accepted => println!("写入成功"),
        WriteStatus::RejectedOutOfOrder { expected, got } => {
            println!("时间戳乱序:期望大于 {},收到 {}", expected, got);
        }
    }
}

第一到四行:定义枚举 WriteStatus,两种变体。Accepted 不带数据。RejectedOutOfOrder 带两个字段:expected(期望的时间戳下界)和 got(实际收到的)。Rust 的枚举比 C 语言那种"只是个数字"的枚举强得多:每个变体可以携带自己的数据。

第七行:match status 开始分情况。WriteStatus::Accepted => ... 意思是"如果是 Accepted 这种情况,就执行箭头右边的代码"。

第八到十行:如果是 RejectedOutOfOrder,花括号里把字段"拆"出来变成局部变量 expected 和 got,然后打印。这个动作叫解构(destructuring)。

match 最妙的一点:编译器强制你把所有情况都列出来,漏一个就编译失败。永远不会出现"忘了处理某种状态"的 bug。Varve 里写入结果、查询错误都是用 enum + match 处理的。

7.6 Option 和 Result:向 null 宣战

无数编程语言都有一个叫 null(空指针)的东西,表示"这里没有值"。无数程序员都栽在"我以为这里有值,其实是 null,一用就崩"上,这个 bug 类型被称为"十亿美元的错误"。

Rust 的做法:没有 null。用两个枚举代替。

Option:表示"要么有值,要么没有"。

Rust
fn find_memory(id: u64) -> Option<Memory> {
    // 找到就返回 Some(memory),找不到就返回 None
}

Option<Memory> 只有两种可能:Some(memory)(有,装在 Some 里)或 None(没有)。调用方必须用 match 把两种情况都处理了才能拿到值,想偷懒直接用?编译器不答应。null 崩溃在编译期就被消灭了。

Result:表示"要么成功,要么带着错误失败"。

Rust
fn read_segment(path: &str) -> Result<Segment, std::io::Error> {
    // 成功返回 Ok(segment),失败返回 Err(错误信息)
}

Result<Segment, Error> 也是两种可能:Ok(segment) 或 Err(e)。Rust 还有个顺手语法 ?:let seg = read_segment("seg_001.dat")?; 意思是"成功就解包继续走,失败就立刻把错误返回给我的调用者"。Varve 里所有磁盘读写都是 Result 风格,错误处理一目了然。

7.7 Vec\<T\>:动态数组

Vec<T> 是 Rust 的动态数组(vector,跟数学向量同名,别搞混):一个会自动扩容的数组,里面装着 T 类型的元素。

Rust
let mut scores: Vec<f32> = Vec::new();
scores.push(0.96);
scores.push(0.83);
let best = scores[0];

第一行:创建一个装 f32(32 位浮点数,就是小数)的空 Vec。Vec::new() 是调用 Vec 类型的构造函数。let mut 因为要往里加东西。

第二、三行:push 往末尾加一个元素。KNN 算相似度时,所有分数就是存在这样一个 Vec 里。

第四行:scores[0] 按下标取第 0 个元素(编程里数数从 0 开始)。

Varve 的暴力 KNN 核心就是:一个 Vec<f32> 存所有候选向量的相似度,排序,取前 top_k 个。

7.8 trait:会游泳的东西都能下水

trait 相当于其他语言的"接口"(interface):定义一组能力,谁实现了这些能力,谁就能被当作这类东西用。

Rust
trait VectorIndex {
    fn search(&self, query: &[f32], top_k: usize) -> Vec<(u64, f32)>;
}

struct BruteForceIndex { /* 段内向量数据 */ }

impl VectorIndex for BruteForceIndex {
    fn search(&self, query: &[f32], top_k: usize) -> Vec<(u64, f32)> {
        // 挨个算余弦相似度,排序取前 top_k
    }
}

第一到三行:定义 trait VectorIndex,要求一个能力:search。&self 表示"借用调用者自己"(相当于 this)。query: &[f32] 是"借用一排 f32",&[f32] 叫切片(slice),就是"数组的一段,只借不拿"。返回类型 Vec<(u64, f32)> 是一个数组,每个元素是一个二元组(括号包起来的 (u64, f32) 叫 tuple):InternalId 和相似度分数。

第五行:定义 BruteForceIndex(暴力索引)结构体。

第七行:impl VectorIndex for BruteForceIndex 意思是"给 BruteForceIndex 实现 VectorIndex 这个能力"。

第九到十行:给出 search 的具体实现——暴力法。

trait 的价值在 2.4 节说过:将来写 HNSW,就再定义一个 struct HnswIndex 然后 impl VectorIndex for HnswIndex,上层代码只认 VectorIndex 这个接口,换实现不用改调用方。这就是"留接口"在代码里的真实长相。

7.9 生命周期:只讲直觉

生命周期(lifetime)是 Rust 最劝退的概念,但看懂 Varve 只需要一句直觉:借来的东西,不能比原主活得久。

Rust
fn first(v: &Vec<f32>) -> &f32 {
    &v[0]
}

这个函数借来一个 Vec,返回其中第一个元素的借用。Rust 编译器会检查:返回的这个借用,使用它的时候原来的 Vec 必须还活着。如果你写一个函数,返回一个指向函数内部局部变量的借用,编译器直接拒绝——因为函数一结束局部变量就销毁了,借出去的东西指向一片废墟,这叫悬垂引用(dangling reference),C++ 里这种 bug 能藏半年,Rust 里根本编译不过。

代码里偶尔出现的 <'a> 这种标记,就是给借用贴上"寿命标签",让编译器能检查"借来的"和"原主"的寿命关系。看到它时知道是这么回事就够了。

7.10 速成小结

到这里,你已经认识了 Rust 的核心零件:let/mut 管可变性,所有权管"谁是主人",借用管"只看不拿",struct/impl 管数据和行为,enum/match 管分情况,Option/Result 管"有没有"和"成不成",Vec 是动态数组,trait 是能力接口,生命周期保证借用不出事。读 Varve 的源码,遇到的语法不会超出这个范围太多。剩下的细节,边读边查完全来得及。


第八部分:PyO3 + maturin + wheel —— Rust 和 Python 怎么牵手

8.1 问题:Rust 快,但 AI 开发者都用 Python

我们有了一个两难。写存储引擎,Rust 又快又安全,是不二人选。但 Varve 的用户是做 AI 的开发者,他们的世界全是 Python:模型是 Python 调的,数据是 Python 处理的,服务是 Python 搭的。你递给他们一个 Rust 库,他们根本没法用——总不能让全世界的 AI 开发者为了装你的库先去学 Rust 吧。

所以必须有一座桥,让 Python 代码能直接调用 Rust 写的功能,调用起来跟普通 Python 函数一模一样。这座桥就是 PyO3。

8.2 PyO3:Rust 和 Python 之间的翻译官

PyO3 是一个 Rust 库,作用是:你在 Rust 代码里加几个标记,它就帮你把 Rust 函数包装成 Python 能认识的模块。看一段最小例子:

Rust
use pyo3::prelude::*;

#[pyfunction]
fn cosine(a: Vec<f32>, b: Vec<f32>) -> f32 {
    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
    dot
}

#[pymodule]
fn varve_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(cosine, m)?)?;
    Ok(())
}

逐行讲。

第一行:use 是 Rust 的"引入",把 pyo3 库里常用的东西(prelude 就是"常用全家桶")引进来,后面才能用 #[pyfunction] 这些标记。星号表示"全部引入"。

第三行:#[pyfunction] 是一个属性宏(attribute),写在函数头上,意思是"PyO3 啊,请把下面这个函数打包成 Python 能调用的样子"。编译时 PyO3 会自动生成一大堆胶水代码,处理 Python 对象和 Rust 类型之间的转换。

第四行:一个普通的 Rust 函数 cosine,收两个 Vec<f32>(就是两个向量),返回一个 f32。对 Rust 程序员来说这就是普通函数,没有任何 Python 的影子。

第五行:函数体,算两个向量的点积。a.iter() 拿到 a 的迭代器(iterator,一个"挨个吐出元素"的东西);.zip(b.iter()) 把 a 和 b 的迭代器像拉链一样对齐,每次吐出一对 (x, y);.map(|(x, y)| x * y) 对每对元素做乘法(竖线包着的 |(x, y)| x * y 是闭包,即临时小函数);.sum() 全部加起来。四个操作链式串起来,这就是点积 A·B。Rust 这种链式写法叫迭代器风格,又短又快,编译后跟你手写循环一样快。

第七行:#[pymodule] 意思是"下面这个函数定义了一个 Python 模块"。模块名叫 varve_core。

第八行:函数收一个模块对象 m。PyResult<()> 是 PyO3 版的 Result,() 表示"成功了也没啥可返回的,就返回个空"。

第九行:wrap_pyfunction!(cosine, m) 把 cosine 打包,m.add_function(...)? 把它塞进模块里。结尾的 ? 在 7.6 讲过:出错就把错误抛给上层。

第十行:Ok(()),一切顺利。

有了 PyO3,Varve 的写法就是:所有重活(WAL、SSTable、KNN、mmap)用 Rust 写,然后用 #[pyfunction] 和 #[pyclass](给 Python 暴露一个类,比如 db 对象)标记出来,Python 那边 import varve 之后完全感觉不到 Rust 的存在。

8.3 maturin 和 wheel:为什么 pip install 不用装编译器

桥搭好了,还有最后一个问题:分发。

普通的 Rust 库,用户得先装 Rust 编译器,再花一二十分钟编译源码。这对 AI 开发者是不可接受的——他们的电脑上可能连 C 编译器都没有,装个库还要先装一套工具链,扭头就走。

解决方案分两步。第一步是理解 wheel:Python 世界的"预编译包"格式。一个 wheel 文件(后缀 .whl)里装的是已经编译好的二进制,pip install 的时候直接解压、安放,完事,全程不需要编译器。类比:别人卖乐高是卖零件让你自己拼,wheel 是我们在工厂里拼好了,你拆盒即玩。

第二步是 maturin:一个专门给"Rust + PyO3 项目"打造 wheel 的工具。你在项目目录里跑一句 maturin build --release,它就调用 Rust 编译器把代码编译成机器码,再按 wheel 的标准格式打包好。开发时跑 maturin develop,一条命令编译并装进你当前的 Python 环境,立刻就能 import 测试。

8.4 cibuildwheel:一次构建,四个平台

还有一层麻烦:Windows、Mac(Intel 芯片)、Mac(苹果 M 系列芯片)、Linux,四个平台的机器码互不通用。你在自己 Windows 电脑上编译出来的 wheel,Mac 用户装不了。难道要买四台电脑轮流编译?

不用,用 cibuildwheel。它是一个自动化工具,配合 GitHub Actions(GitHub 提供的云端自动构建服务)使用:你往仓库一推代码,云端自动启动四个平台的虚拟机,每台虚拟机上跑 maturin,各编译各的 wheel,最后汇总成一排文件:varve-xxx-win_amd64.whl、varve-xxx-macosx_arm64.whl、varve-xxx-manylinux_x86_64.whl……一股脑传到 PyPI(Python Package Index,Python 的官方应用商店,pip install 就是从这儿下载的)。

用户那边,pip 会自动识别他的系统,挑匹配的 wheel 下载。Windows 用户拿 Windows 版,Mac 用户拿 Mac 版,谁都不用装编译器。这就是"pip install varve 即用"背后的完整流水线,也是简历上"四平台预编译 wheel"这句话的实际工作量所在。


第九部分:API 逐行精讲 —— 一条数据的完整旅程

9.1 varve.create:开库

Python
import varve

db = varve.create("./agent_memory", dim=768, metric="cosine")

第一行:引入 varve 库。import 是 Python 的关键字,意思是"把别人写好的库拿进来用"。

第三行:调 create,三个参数。

第一个参数 "./agent_memory":库放在哪个文件夹。./ 表示"当前目录下",也就是说在程序运行的目录里建一个叫 agent_memory 的文件夹,WAL、SSTable、向量段文件全都住在这个文件夹里。Varve 是本地库,没有服务器,数据就老老实实躺在你硬盘上。

第二个参数 dim=768:向量维度,768 维。第一部分讲过,建库时必须定死,之后每条 write 进来的向量都必须是 768 个数字,多一个少一个都会被拒绝。

第三个参数 metric="cosine":相似度度量用余弦相似度,第一部分 1.5 节讲透了。

create 背后发生了什么:Python 调用经过 PyO3 进入 Rust;Rust 检查 ./agent_memory 文件夹是否存在,不存在就创建;然后做崩溃恢复——检查有没有上次没写完的 WAL,有就重放(replay),把 MemTable 恢复到上次退出前的状态;接着扫描已有段文件的"牌子"(时间范围元信息)加载进内存;最后把一个 db 对象返回给 Python。这个 db 对象表面上是 Python 对象,骨子里是一个 Rust 结构体的句柄。

如果文件夹里已有旧库,create 是"打开"而不是"清空重建"——数据都还在。这就是本地库跟内存变量的本质区别:程序关了再开,记忆还在。

9.2 db.write:一条写入的完整旅程

Python
db.write(id="msg_10231", timestamp=1718000000, vector=vec, payload={"role": "user", "text": "我想做一个校园二手平台"})

一个参数一个参数看。

id="msg_10231":用户给的业务 id,字符串也行数字也行。Varve 内部会给它发一个 InternalId,并记住两者的映射。

timestamp=1718000000:这条记忆发生的时间,Unix 时间戳。第四部分讲过怎么读。

vector=vec:768 个浮点数,调用方自己用 embedding 模型算好的。

payload={...}:顺带存的那包内容,Python 字典(dict,键值对的集合),里面 role 是说话角色,text 是原文。小字段会进 head,大文本会进 body(value log)。

这条 write 从 Python 走进 Rust 之后,完整旅程是这样的:

第一步,时间戳契约检查。Varve v1 要求单调写入:新写入的时间戳必须不小于库里已有的最大时间戳。1718000000 比当前最大时间戳大或等于,放行;如果小了,默认直接报错(返回一个带详细信息的错误),除非建库时开了 allow_out_of_order=True,乱序数据会进一个专门的"溢出段"(overflow segment),单独存放、查询时单独处理。为什么强制单调?因为只有写入有序,段的时间范围才不重叠,第五部分的剪枝才成立。这是用一点点灵活性换来的架构确定性。

第二步,分配 InternalId。比如这是第 1024 条,InternalId = 1024。单调递增,永不复用。

第三步,写 WAL。把"id 映射、InternalId、时间戳、payload head"序列化成一行,追加到 WAL 文件,并确保落盘。先写日志再干活,崩溃可恢复,这是数据库的铁律。

第四步,进 MemTable。这条记录插入内存有序表。至此写入就算成功了——之后的步骤对用户透明,可以异步发生。

第五步,向量进段文件。768 个浮点数追加到当前活跃的向量段文件末尾,旁边贴上 InternalId = 1024 的标签。

第六步(后台),MemTable 攒够了,冻结成 SSTable 落盘,段文件也同步封口,新段的"牌子"(时间范围)登记到内存的段目录里。之后 WAL 可以截断,因为数据已经进了更永久的家。

整条链路里,用户感知到的延迟只到第四步:一次 WAL 追加加一次内存插入,微秒级。这就是 LSM 架构"写入快"的原因。

9.3 db.query:一次查询的完整旅程

Python
results = db.query(time_range=(1717200000, 1718000000), vector=qvec, top_k=5)

参数:time_range 是一个二元组,窗口的开始时间戳和结束时间戳;vector 是查询向量;top_k=5 要最像的 5 条。

返回值 results 是一个列表,每个元素大概长这样:{"id": "msg_10231", "score": 0.93, "timestamp": 1717980000, "payload_head": {...}}。score 是余弦相似度,越高越像。

查询的完整旅程:

第一步,进 Rust,时间感知查询规划器醒来。把 time_range 换算成两个整数下界和上界。

第二步,剪枝。规划器遍历内存里的段目录,对每个段的牌子做两次整数比较(5.4 节的伪代码)。段的最大时间戳小于窗口下界?跳过。段的最小时间戳大于窗口上界?跳过。其余进候选名单。被跳过的段,磁盘零读取。

第三步,候选段内暴力 KNN。对每个候选段,用 mmap 把段文件映射进来,逐个向量算余弦相似度(7.8 节那个 search 实现),维护一个"目前最像的 5 个"的小顶堆(一种总是能快速拿到最小值的结构,用来在流式数据里保持 top-k)。多个候选段的结果最后合并,取全局 top 5。

第四步,回表取 payload。手里现在只有 5 个 InternalId 和分数。拿着 InternalId 回 LSM 查:先查 MemTable,再按新到旧查 SSTable,拿到时间戳、用户 id、payload head。如果调用方还要全文,再按 head 里的指针去 value log 取 body。

第五步,组装成 Python 字典列表,通过 PyO3 跨桥送回 Python。

注意第四步的设计:payload 的读取只发生 5 次(top_k 次),而不是候选集大小次。如果候选段有 2000 条,相似度算了 2000 次,但 LSM 点查只做了 5 次。贵操作按结果数付费,不按候选数付费,这又是省钱的一刀。

9.4 db.get_payload:按 id 取回整条记忆

Python
full = db.get_payload("msg_10231")

这是最简单的一个 API:给个业务 id,把整条 payload(含 body 全文)取回来。内部流程:LSM 查 id 映射拿 InternalId 和 head,head 里有 body 在 value log 里的位置,一次定位读回全文。AI 助手在 query 拿到 top 5 之后,通常用 get_payload 把这几条的完整对话原文捞出来,塞进给大模型的提示词里。

至此,Varve 的四个核心 API 全部讲完。create 建库开库,write 单调写入,query 先剪枝再检索,get_payload 按 id 取全文。就这四个动词,撑起了整个"AI 记忆"的读写闭环。


第十部分:简历怎么写 + 面试怎么答

10.1 可直接放进简历的项目描述

下面这段可以直接用,再按你的实际数据微调数字:

Varve:面向 AI Agent 的时间感知本地长期记忆库(Python + Rust,独立项目)

如果简历篇幅紧张,留前两条加第四条的一小句就够。

10.2 面试最可能追问的 10 个问题与答案要点

问题 1:物理剪枝和逻辑过滤到底差在哪? 答:逻辑过滤是把全部数据读出来、算完相似度,再按时间条件扔结果,计算和 I/O 都按全量付费;物理剪枝是在执行前就根据段的时间边界元信息整段跳过,被跳过的段连磁盘都不读。100 万条里查 7 天(约 2000 条),逻辑过滤要处理 100 万条,物理剪枝只碰 2000 条,差两个数量级。核心区别一句话:过滤是"看了再扔",剪枝是"压根不看"。

问题 2:你的剪枝什么时候失效? 答:查询窗口覆盖全部历史时,所有段都有交集,剪枝失效,退化为全量暴力扫描,性能与基线持平。这是诚实的能力边界,所以 benchmark 固定报 7 天、90 天、全历史三档,不回避退化档。

问题 3:为什么向量索引(比如 HNSW)不放进 LSM? 答:因为图遍历是跳跃式点查,一次查询几十上百跳;LSM 点查有多层 SSTable 的读放大,每跳都要付一遍税,累计放大几十倍,把图索引的速度优势拖没。所以向量与将来的图结构留在 append-only 段文件里用 mmap 访问,一跳约等于一次数组下标。LSM 只管它擅长的小键值和时间范围元数据。

问题 4:为什么选 Rust 不用纯 Python? 答:两层原因。性能上,向量相似度计算和文件 I/O 是 CPU 密集加磁盘密集,纯 Python 比 Rust 慢一到两个数量级,存储引擎这种斤斤计较的层必须用编译型语言。安全上,Rust 的所有权和借用检查在编译期消灭内存错误和并发数据竞争,做"替别人保管数据"的软件,崩溃和损坏是不可接受的。而 Python 留给上层 API,因为用户(AI 开发者)在 Python 生态里。

问题 5:mmap 是什么?为什么用它? 答:mmap 是操作系统把文件直接映射到进程内存地址空间的机制,之后读文件就像读数组,缺页时由 OS 自动换入。好处:省一次内核到用户态的数据复制;缓存管理交给 OS 的页缓存,不用自己写;顺序扫描(暴力 KNN 正好是全段顺序扫)性能极好。段文件是不可变只读的,恰是 mmap 最擅长的场景。

问题 6:为什么强制时间戳单调写入?乱序了怎么办? 答:单调写入保证段的时间边界不重叠,剪枝的正确性和效率都建立在这上面;它还让向量段可以无脑 append-only。乱序默认报错,把契约违反暴露在调用方;可选 allow_out_of_order 模式下乱序记录进独立溢出段,查询时单独扫它,不污染主段序列。

问题 7:为什么 v1 用暴力 KNN 而不是直接上 HNSW? 答:剪枝之后候选集只有几千条,暴力法毫秒级,结果精确、实现简单、对追加写友好、无索引维护成本。HNSW 的复杂度在全历史退化档才值得付,所以检索层抽象成 trait,v1 实现暴力法,HNSW 作为后续迭代无缝替换。

问题 8:两套存储为什么用 InternalId 关联,而不是直接存文件偏移? 答:文件偏移在 compaction 后会失效,引用偏移等于埋下悬空指针;InternalId 是逻辑编号,单调递增、永不复用,物理布局怎么变都对得上账。用少量存储开销换架构稳健。同 id 重复 write 走 upsert,旧向量成为孤儿,由 compact 回收。

问题 9:payload 为什么要拆 head 和 body? 答:键值分离思想。head 小且常查(角色、会话 id、摘要),跟时间戳一起放 LSM,回表时顺手拿到;body 是大文本,塞 LSM 会撑大 MemTable、拖慢 compaction,所以进独立 value log,按需一次定位读取。查询路径上 LSM 点查只做 top_k 次,不按候选数付费。

问题 10:PyO3 / maturin / cibuildwheel 这一串是干嘛的? 答:PyO3 让 Rust 函数和类暴露为 Python 模块,是跨语言胶水;maturin 把 PyO3 项目编译打包成 wheel(预编译二进制包,用户无需装 Rust 工具链);cibuildwheel 在 CI 里对四个平台批量构建 wheel 发 PyPI。所以用户体验是 pip install varve 即用。

10.3 面试收尾话术

如果面试官问"这个项目还有什么可以做的",别慌,这是送分题,按远近说三条:第一,把 HNSW 接进 VectorIndex trait,主攻全历史退化档;第二,溢出段的乱序数据定期归并重排,收缩乱序的代价;第三,段内向量做量化压缩(比如 PQ,乘积量化,把浮点向量压缩成短码),把内存和 I/O 再砍一刀。说完这三条,面试官会知道你不只做完了作业,还想得比作业远。


结语

我们从"你问 DeepSeek 上个月的项目"开始,一路走完了:向量是什么、KNN 和 HNSW 怎么找最近邻、向量数据库家族各自的定位、时间戳元数据 payload 这些名词、剪枝为什么是这个项目的灵魂、LSM 和 mmap 怎么搭起存储层、Rust 的核心语法、PyO3 和打包分发的链路、四个 API 背后的完整数据旅程,最后落到简历和面试。

这套知识的真正价值,不在于你背下了多少名词,而在于你理解了一个反复出现的思想:好的系统设计,都是先想清楚数据长什么样、查询长什么样,再决定把什么放一起、把什么分开、把什么提前跳过。 Varve 把这个思想在"时间"这个维度上做了一次干净的示范。

祝你面试顺利。

(全文完)