实战动手篇

项目三:用 Rust + PyO3 写时间感知的本地 AI 记忆库(实战动手篇)

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

项目三:迷你 Varve 实战动手篇

这篇教程要带你做什么

上一批概念教程你读过了,道理都懂,但一到键盘前就发懵——这一篇就是来治这个的。我们不讲新理论,只干一件事:手把手带你敲出一个"迷你 Varve",一个用 Rust 写的、带时间范围的向量记忆检索命令行程序,最后用 PyO3 + maturin 打包成 Python 库,让你在 Python 里 import 自己写的 Rust 引擎。

先说说真 Varve 是干嘛的。假设你有一个 AI Agent(智能体),它和你聊了三个月,攒下十万条历史对话。你问它"我上周让你查的那个数据库论文叫什么",它需要把这十万条记录翻出来,找出"时间上在最近一周、内容上和数据库论文最像"的那几条。每一条记录都被一个叫 embedding(嵌入向量)的技术变成了一串数字,比如 128 个浮点数,语义越像,两串数字的"余弦相似度"越高。

那么朴素的直觉做法是什么?把十万条全部算一遍相似度,再用 if 把时间不对的记录筛掉。这就是"全量向量检索再逻辑过滤时间"。问题很明显:你只想要 7 天的数据,却让计算机算了 100 天的距离,93% 的计算是白干的。

真 Varve 的核心创新就是反过来:先按时间物理剪枝,再段内向量检索。它把向量按时间戳切成一段一段存,每段头上记着"本段最早和最晚的时间戳"。查询时先看段头,时间窗口不挨着的段整段跳过,一次距离都不算。这就像你在抖音找"上周点赞过的视频",你不会把三年来的点赞列表从头翻到尾,你会直接翻到上周那一页。

我们这篇做的迷你版,只保留这个主干:分段存储 → 按时间剪枝 → 段内暴力 KNN(K-Nearest Neighbors,K 近邻)→ 计时对比两种方案。LSM 树、mmap、WAL 这些真 Varve 里的高级件我们不做,结尾会给你一张"差距地图",告诉你每一步差在哪。

做完之后你手里有什么

六个里程碑,每个都能跑:

配套代码在 代码/项目三/ 目录下,每个里程碑一个文件夹,都是完整项目。但我强烈建议你别复制粘贴,照着教程一个字符一个字符敲。零基础学编程,手指的记忆比眼睛的记忆靠谱。

读者环境假设是 Ubuntu 24.04 虚拟机,所有命令以它为准;Windows 备选方案会单独标注。


M0M0:环境准备——把 Rust 请进你的电脑

① 这一步做出什么

装好后,你能在终端里敲 rustc --version 看到版本号,能用 cargo new hello 创建第一个项目并跑出 Hello, world!。这一步不写任何自己的代码,但它是后面一切的地基。

Rust 是什么,为什么这个项目用它

Rust 是一门系统级编程语言,和 C++ 一个生态位:编译成机器码直接跑,没有垃圾回收(Garbage Collection,GC),速度在第一梯队。但它比 C++ 多一个杀手锏——编译器会在编译期强制检查"所有权",内存错误直接编译不过,不用等运行时才崩。

为什么真 Varve 用 Rust?两个原因。第一,快。向量检索是计算密集型活,十万条乘以 128 维就是一千多万次浮点运算,Python 纯循环扛不住,Rust 眨眼就完。第二,能拼。Rust 通过 PyO3 这个库可以把编译产物伪装成 Python 模块,Python 那边 import 进来就用,根本感觉不到对面是 Rust。这就是现在 AI 基础设施的流行配方:Python 管脸面(生态、易用),Rust 管心脏(性能)。

三个名词:rustc、cargo、rustup

这三个词新手最容易混,一次说清:

打个比方:rustup 是房产中介,帮你把房子(rustc)和物业(cargo)都安排好;之后你天天打交道的是物业 cargo。

② 安装与验证

Ubuntu 24.04 上,打开终端,敲:

Shell
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

这条命令的意思是:用 curl 从 rustup 官网下载一个安装脚本,通过管道 | 交给 sh 执行。中间会问你怎么装,敲 1 回车选默认安装就行。装完后,让当前终端认识新装的命令:

Shell
source "$HOME/.cargo/env"

然后验证:

Shell
rustc --version
cargo --version

Windows 备选:浏览器打开 https://rustup.rs,下载 rustup-init.exe,双击,一路回车。装完重开终端再验证。

⑤ 你应看到的输出

Rust
rustc 1.8x.0 (xxxxxxxx 20xx-xx-xx)
cargo 1.8x.0 (xxxxxxxx 20xx-xx-xx)

具体版本号看你安装时的日期,只要是 1.70 以上就没问题。两行都能打印出版本号,这步就过了。

第一个项目:cargo new hello

Shell
cd ~
cargo new hello
cd hello

cargo new hello 会创建一个名为 hello 的文件夹,进去看看里面有什么:

Rust
hello/
  Cargo.toml
  src/
    main.rs

Cargo.toml 是说明书:项目叫什么、版本几号、依赖哪些库,全写在里面。cargo 编译前先读它。src/main.rs 是正文:你的代码写在这里,main 是"主函数"的意思,程序从这里开始跑。一个是封面信息,一个是内容本身。

以后还会多出两个文件:Cargo.lock 是依赖版本的"锁",第一次编译时自动生成;target/ 是编译产物的堆放处,你编译出的可执行文件就在 target/debug/ 里。这两个都不用你管。

打开 src/main.rs,cargo 已经帮你写好了:

Rust
fn main() {
    println!("Hello, world!");
}

③ 逐行讲解

fn main() { —— fn 是 function(函数)的缩写,声明一个函数。main 是约定俗成的入口名,程序启动先跑它。后面的 () 是参数列表,空着表示不收参数。{ 开始函数体。

println!("Hello, world!"); —— 打印一行。注意 println 后面那个感叹号:它说明 println! 不是一个普通函数,而是一个宏(macro)。宏是"在编译期帮你展开成别的代码"的代码生成器,你现在只需要记住:看到名字后面带 ! 的,就是宏。引号里是要打印的文本。行尾的分号表示"这句话说完了",Rust 里几乎每行结尾都有分号,忘掉它是新手第一大报错来源。

} —— 函数体结束。

④ 编译和运行

在 hello 目录里敲:

Shell
cargo run

cargo 会先编译再运行,一步到位。

⑤ 你应看到的输出

Rust
   Compiling hello v0.1.0 (/home/你的用户名/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.xxs
     Running `target/debug/hello`
Hello, world!

前三行是 cargo 在汇报编译进度,最后一行才是你的程序说的话。以后改完代码再 cargo run,如果编译没变化,Compiling 那行就没有了。

顺便解释一个以后天天见的细节:cargo 默认编译的是 dev(开发)模式,产物在 target/debug/ 里,编译快、跑得慢、带调试信息;加 --release 才是优化模式,产物在 target/release/ 里,编译慢、跑得快。M4 测性能必须用后者。为什么默认不直接优化?因为开发时你一分钟编译几十次,编译速度比运行速度重要;等要上线了才编译一次跑很久。这是"开发体验"和"运行性能"的权衡,Rust 把选择权交给你。

还有同学会问:能不能不装环境,用网页版在线编译器?可以跑 hello world,但这个项目后面要编译 pyo3、要跑十万条数据的 benchmark,在线环境都扛不住。老老实实装本地工具链,这是你作为"要写系统软件的人"的基本功。

⑥ 报错诊所

症状一:敲 rustc --version 显示 command not found。原因九成是装完没执行 source "$HOME/.cargo/env",或者你开了个新终端而安装脚本没把环境变量写进 .bashrc。先 source 一次试试;如果每次开终端都得 source,就把那行追加到 ~/.bashrc 末尾。

症状二:curl: command not found。精简版 Ubuntu 可能没装 curl,敲 sudo apt update && sudo apt install curl 再重试。

症状三:安装脚本卡在下载不动。网络问题,挂代理或者换时间段再来,别硬等。

症状四:Windows 上 cargo run 报错说找不到 linker(链接器)。那是因为缺 Microsoft C++ 生成工具,装 Visual Studio Build Tools 时勾选"C++ 桌面开发"工作负载即可。这也是为什么正文推荐 Ubuntu 虚拟机。

小练习:把 Hello, world! 改成 你好,Rust!,再 cargo run。然后故意删掉行尾的分号再跑一次,看看编译器怎么骂你——它会说 expected ;``,顺便记住这个报错长什么样,你以后会见它一百次。


M1M1:Rust 第一课——对话记录本

① 这一步做出什么

一个命令行小程序:内存里存 5 条和 DeepSeek 的对话记录,支持三种命令——list 打印全部、find <id> 按编号查一条、quit 退出。为了让你的输出和教程一字不差,我们把要执行的命令写死在代码里(真交互版留作练习)。

这一步的真正目的不是这个程序本身,而是让你把 Rust 最核心的几个概念全部摸一遍:let 和 mut、String 和 &str、struct、impl、Vec、for 循环、match、Option 和 Result、所有权和借用。这些概念后面每个里程碑都要用。

② 新建文件和完整代码

Shell
cd ~
cargo new m1_rust_basics
cd m1_rust_basics

把 src/main.rs 里的内容全部删掉,敲入下面的完整代码(Cargo.toml 不用动):

Rust
// M1:对话记录本 —— 你的 Rust 第一课
// 功能:内存里存 5 条 DeepSeek 对话,支持 list / find <id> / quit 三种命令

// 一条对话记录。struct 就是"自定义的一张表",下面三行是三个字段
struct Message {
    id: u64,      // 编号。u64 是"无符号 64 位整数",也就是 0 到 1844 亿亿
    ts: u64,      // 时间戳:从 1970 年 1 月 1 日零点到现在的秒数
    text: String, // 聊天内容。String 是"归自己所有的字符串",存在堆上
}

impl Message {
    // 关联函数,作用类似"构造函数":Message::new(...) 造一条记录
    // 注意参数 text 的类型是 &str(借来的字符串),不是 String
    fn new(id: u64, ts: u64, text: &str) -> Message {
        Message {
            id: id,
            ts: ts,
            text: String::from(text), // 把借来的 &str 复制成自己的 String
        }
    }
}

// 在记录本里按 id 找一条。
// 参数 &[Message] 表示"借一整本记录来看一下",不拿走所有权。
// 返回 Option<&Message>:找到就 Some(借你一条),没找到就 None。
fn find_message(messages: &[Message], id: u64) -> Option<&Message> {
    for m in messages {
        if m.id == id {
            return Some(m); // Some 是"有,东西在里面"
        }
    }
    None // None 是"没有"
}

// 执行一条命令。返回 Result<bool, String>:
// Ok(true) 表示继续,Ok(false) 表示退出,Err(...) 表示命令本身有问题。
fn run_command(messages: &[Message], cmd: &str) -> Result<bool, String> {
    // 按空白把命令切成几段,比如 "find 3" 切成 ["find", "3"]
    let parts: Vec<&str> = cmd.split_whitespace().collect();
    if parts.is_empty() {
        return Err("空命令".to_string());
    }

    match parts[0] {
        "list" => {
            println!("---- 全部 {} 条对话 ----", messages.len());
            for m in messages {
                println!("[id={}] ts={} | {}", m.id, m.ts, m.text);
            }
            Ok(true)
        }
        "find" => {
            if parts.len() < 2 {
                return Err("find 后面要跟一个 id,比如 find 3".to_string());
            }
            // parse 把字符串变成数字,返回 Result:可能成功,也可能失败
            match parts[1].parse::<u64>() {
                Ok(id) => match find_message(messages, id) {
                    Some(m) => {
                        println!("[id={}] ts={} | {}", m.id, m.ts, m.text);
                        Ok(true)
                    }
                    None => {
                        println!("没找到 id={} 的记录", id);
                        Ok(true)
                    }
                },
                Err(_) => Err("id 必须是一个非负整数".to_string()),
            }
        }
        "quit" => {
            println!("再见!");
            Ok(false)
        }
        _ => {
            println!("不认识的命令:{}", parts[0]);
            println!("支持:list / find <id> / quit");
            Ok(true)
        }
    }
}

fn main() {
    // 造 5 条对话,假装是和 DeepSeek 的聊天记录
    let messages: Vec<Message> = vec![
        Message::new(1, 1717401600, "你好,请介绍一下 Rust"),
        Message::new(2, 1717401660, "Rust 的所有权是什么?"),
        Message::new(3, 1717401720, "帮我写一个快速排序"),
        Message::new(4, 1717401780, "解释一下生命周期"),
        Message::new(5, 1717401840, "谢谢,再见"),
    ];

    // 为了让你屏幕上的输出和书上完全一致,我们不读键盘,把命令写死
    let commands = vec!["list", "find 3", "find 99", "find abc", "hello", "quit"];

    for cmd in commands {
        println!("> {}", cmd);
        match run_command(&messages, cmd) {
            Ok(go_on) => {
                if !go_on {
                    break;
                }
            }
            Err(e) => println!("出错了:{}", e),
        }
    }
}

③ 逐行讲解

struct 部分。struct Message { ... } 定义一个结构体(structure),就是"自定义的一张表":以后每个 Message 都有 id、ts、text 三个字段。好比学校登记学生信息,小明学号 51、数学 135 分——"学号"和"数学成绩"就是字段。u64 是无符号 64 位整数(unsigned 64-bit integer),无符号意思是"不带负号",所以最小是 0,最大约 1844 亿亿,存编号和时间戳绰绰有余。String 是 Rust 里"归自己所有的字符串",内容存在堆(heap)上,可以变长。

impl 部分。impl Message { ... } 是给 Message 这个类型"挂方法"的块。里面的 fn new(...) 是个关联函数(associated function),用 Message::new(...) 这种双冒号语法调用,作用相当于其他语言的构造函数。注意参数 text: &str:&str 读作"字符串切片"(string slice),是"借来的、只读的字符串"。为什么不直接收 String?因为调用方写的 "你好,请介绍一下 Rust" 这种字面量天生就是 &str,直接收最方便;函数内部再用 String::from(text) 复制一份存起来。函数体里 Message { id: id, ts: ts, ... } 是"造一个 Message 出来",这是 Rust 的表达式语法:块里最后一个不带分号的表达式就是这个块的返回值,所以不用写 return。

String 和 &str 的直觉版。这对双胞胎是新手第一道坎,给你一个生活化的分法。String 是"你自己买的书":书是你的,你可以在上面划重点(修改),可以借给别人,也可以卖掉(转移所有权),它占的是书柜里的真实空间(堆内存)。&str 是"站在书店翻看":书不归你,你只能读,读完放回去,书店打烊你也带不走。代码里的字符串字面量 "你好" 天生就是 &str——它印死在程序二进制里,不归你的变量所有。所以 Message::new 收 &str 当参数最省事:调用方拿字面量直接传,函数内部想要长期保存,就 String::from(text) 自己复印一本。什么时候用哪个?一句话:要存起来、要改,用 String;只是临时看一眼,用 &str。

find_message。参数 messages: &[Message]:& 是借用(borrow),[Message] 是切片(slice,"一串连续的东西"),合起来是"借一整本记录本看一眼,看完还你,不拿走"。返回值 Option<&Message>:Option 是 Rust 表示"可能有、可能没有"的类型,找到就 Some(m),找不到就 None。为什么不学某些语言返回 null?因为 Rust 没有 null,编译器强制你用 match 处理 None 的情况,从根上消灭了"空指针崩溃"。for m in messages 对借来的切片循环,m 的类型是 &Message,也是借用——我们只是翻看,不拿回家。

run_command。返回 Result<bool, String>:Result 是 Rust 表示"可能成功、可能失败"的类型,Ok 包着成功值,Err 包着错误说明。cmd.split_whitespace() 按空白切开字符串,.collect() 把切开的结果收进一个 Vec<&str>(可变长数组)。match parts[0] 是模式匹配,类似其他语言的 switch,但强制你覆盖所有情况:前三个分支是具体字符串,最后的 _ 是"其他一切"。漏掉 _ 编译器会骂你"non-exhaustive"。

注意 "find" 分支里的嵌套 match:外层处理"id 能不能解析成数字"("abc" 就不行,parse 返回 Err),内层处理"这个 id 存不存在"。parse::<u64>() 里的 ::<u64> 叫涡轮鱼语法(turbofish),告诉 parse 我要解析成 u64 这种类型。

main。let messages: Vec<Message> = vec![...] 用 vec! 宏一次性造出一个装有 5 条记录的可变长数组(Vector)。let commands = vec![...] 同理。for cmd in commands 这里没有 &,是把数组里的字符串挨个"拿走"——因为 commands 后面不再用了,拿走就拿走。run_command(&messages, cmd) 里的 &messages:把记录本借给函数用,用完所有权还在 main 手里,所以循环下一轮还能再借。

所有权和借用的直觉。这是 Rust 的灵魂,给你一句话版本:每本书(每份数据)在同一时刻只能有一个主人(owner),主人可以把书借给别人看(&,不可变借用),也可以借给别人批改(&mut,可变借用),但借出去的时候自己不能卖书,而且"批改借阅"同一时刻只能有一张。借的人看完必须还。编译器是图书管理员,违反任何一条,直接编译不过。这样做的好处是:内存什么时候释放,编译期就确定了,不用垃圾回收,也不会出现"书卖了还有人拿着借书卡"的悬空指针。

④ 编译和运行

Shell
cargo run

⑤ 你应看到的输出

因为命令是写死的,你的输出应该和下面一字不差:

Rust
> list
---- 全部 5 条对话 ----
[id=1] ts=1717401600 | 你好,请介绍一下 Rust
[id=2] ts=1717401660 | Rust 的所有权是什么?
[id=3] ts=1717401720 | 帮我写一个快速排序
[id=4] ts=1717401780 | 解释一下生命周期
[id=5] ts=1717401840 | 谢谢,再见
> find 3
[id=3] ts=1717401720 | 帮我写一个快速排序
> find 99
没找到 id=99 的记录
> find abc
出错了:id 必须是一个非负整数
> hello
不认识的命令:hello
支持:list / find <id> / quit
> quit
再见!

注意观察:find 99 和 find abc 是不同的失败路径——前者是"数字合法但不存在"(None),后者是"输入根本不是数字"(Err)。这就是为什么 Rust 要同时给你 Option 和 Result 两个类型。

你可能会问:别的语言一个异常就搞定的事,Rust 为什么搞两个类型?因为 Rust 的设计哲学是"错误也是返回值,必须在类型里看得见"。函数签名写着 Result<bool, String>,调用方一看就知道"这事可能失败,失败原因是个字符串",编译器还强制你处理——想装作看不见都不行。异常机制的问题是:函数会不会抛、抛什么,签名上完全看不出来,全靠文档和运气。存储系统、数据库这类基础设施最怕"没想到的崩溃路径",所以 Rust 社区的基础设施项目遍地开花,这套强制错误处理功不可没。写 M1 这段嵌套 match 时你觉得啰嗦,等 M4 十万条数据跑一夜不崩,你就懂这啰嗦买来了什么。

⑥ 报错诊所

症状一:cannot borrow immutable local variable as mutable。比如你在 main 里写 let messages = Vec::new(); 然后 messages.push(...)。let 声明的变量默认是 immutable(不可变)的,要改它就得写 let mut。Rust 的哲学是:可变性要明明白白写出来,不许偷偷摸摸。我们的代码用 vec![...] 宏一次造好,所以不需要 mut。

症状二:value borrowed here after move(所有权被 move 后又用)。比如在 main 里写 let v = messages; 然后又 println!("{}", messages.len())。let v = messages 把整本记录本的所有权搬给了 v,messages 从此是空壳,再用它就报这个错。改法:要么借用 let v = &messages;,要么明确复制 let v = messages.clone();。

症状三:mismatched types, expected u64, found i32(整数类型不匹配)。Rust 的整数分好多种:i32 是带符号 32 位,u64 是无符号 64 位,字面量默认是 i32。如果你写 Message::new(1, -5, "..."),-5 塞不进 u64,就报这个错。改法:检查每个数字的类型和字段声明对不对得上。

症状四:cannot borrow as mutable, because it is also borrowed as immutable(借用冲突)。比如你先 let first = &messages[0];(不可变借用还捏在手里),又 messages.push(...)(要可变借用)。图书管理员出场:有人正看着这本书,你就不能往书里塞新页。改法:把 push 挪到 first 用完之后,或者用 let first_id = messages[0].id; 只抄一个数字出来,借用立刻结束。

小练习:给程序加一条 find_text 关键词 命令,遍历所有记录,把 text 里包含这个关键词(用 m.text.contains(关键词))的记录全部打印出来。提示:新分支加在 match 里,签名不用改,难度集中在"循环里打印但不 return"。


M2M2:暴力 KNN——亲手写出向量检索的心脏

① 这一步做出什么

生成 1000 条 128 维的随机向量(假装是 DeepSeek 给 1000 条历史对话算出的 embedding),手写余弦相似度函数,然后随便挑一条当查询向量,从 1000 条里找出和它最相似的 5 条。这就是向量数据库最朴素的核心:暴力 KNN。

朴素做法失败的故事等 M3 再讲——这里我们先让"暴力"跑通,你才能体会到后面剪枝省了多少事。

先说暴力 KNN 的身价。n 条向量、每条 d 维,一次查询的成本是 n 乘 d 次乘加,也就是 O(n·d)。我们的 1000 乘 128 等于 12.8 万次乘加,现代 CPU 一毫秒不到就完事。但 n 涨到一百万、d 涨到 768 试试?7.68 亿次乘加,每次查询都要几秒。工业界用近似最近邻(Approximate Nearest Neighbor,ANN)算法比如 HNSW 图索引来扛这个量级,那是用"换准确率"换"快"。真 Varve 的思路不同:它不牺牲准确率,而是用时间剪枝把 n 砍小——你查 7 天,n 就从一百万变成七万,暴力法重新变得够快。这就是 M3 要干的事,也是"暴力 KNN"为什么值得学:它是被剪枝复活的基线算法。

为什么随机数要自己写 LCG,而不是用现成库?两个原因。第一,教程约定只用标准库,不引第三方 crate,你的 Cargo.toml 干干净净。第二,更重要:固定种子的伪随机让输出 100% 可复现,你跑出来的每个数字都和教程一致,排查问题时不用猜"是不是随机数不一样"。这个习惯请带进你以后的实验里:能固定种子就固定种子,能写死输入就写死输入。

② 新建文件和完整代码

Shell
cd ~
cargo new m2_knn
cd m2_knn

把 src/main.rs 替换成:

Rust
// M2:暴力 KNN —— 在 1000 条"对话向量"里找最相似的 5 条
// 每条向量 128 维,你可以把它想象成 DeepSeek 给每句历史对话算出的"语义指纹"

// 一条向量记录:编号 + 128 个浮点数
struct VectorRecord {
    id: u64,
    data: Vec<f32>, // f32 是 32 位浮点数,机器学习里最常用
}

// 线性同余发生器(Linear Congruential Generator,LCG):
// 一个自己能手算、能复现的伪随机数发生器。
// 每次调用把 state 往前推一格,并吐出一个 [-1, 1) 之间的 f32。
// 注意参数是 &mut u64:借进来并且允许修改,这样种子才能"走"。
fn next_f32(state: &mut u64) -> f32 {
    // 三个魔法数字来自经典教材;% 2147483648 是把结果压回 2 的 31 次方以内
    *state = (*state * 1103515245 + 12345) % 2147483648;
    // 取高位的 15 个比特,范围 [0, 32767]
    let v = (*state >> 16) as u32;
    // 映射到 [-1, 1)
    v as f32 / 32768.0 * 2.0 - 1.0
}

// 生成 n 条 dim 维的随机向量,seed 是种子:种子相同,结果永远相同
fn gen_vectors(n: usize, dim: usize, seed: u64) -> Vec<VectorRecord> {
    let mut state = seed; // mut:因为 next_f32 会改它
    let mut records = Vec::new(); // mut:因为要往里 push
    for i in 0..n {
        let mut data = Vec::new();
        for _ in 0..dim {
            data.push(next_f32(&mut state));
        }
        records.push(VectorRecord {
            id: i as u64, // as 是类型转换:usize 转成 u64
            data: data,
        });
    }
    records
}

// 余弦相似度(Cosine Similarity):
// 公式是 dot(a,b) / (|a| * |b|),结果越接近 1 越像,越接近 0 越无关。
// 参数 &[f32] 是"借一段浮点数切片看",只借不拿。
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
    let mut dot = 0.0f32;     // 点积:对应维度相乘再相加
    let mut norm_a = 0.0f32;  // a 的长度平方
    let mut norm_b = 0.0f32;  // b 的长度平方
    for i in 0..a.len() {
        dot += a[i] * b[i];
        norm_a += a[i] * a[i];
        norm_b += b[i] * b[i];
    }
    dot / (norm_a.sqrt() * norm_b.sqrt())
}

// 暴力 KNN:对所有记录算一遍相似度,排序,取前 top_k 个。
// 返回值是 (id, 分数) 的列表,分数从大到小。
fn query(records: &[VectorRecord], q: &[f32], top_k: usize) -> Vec<(u64, f32)> {
    let mut scores: Vec<(u64, f32)> = Vec::new();
    for r in records {
        let s = cosine_similarity(&r.data, q);
        scores.push((r.id, s));
    }
    // sort_by 接收一个比较规则。浮点数没有"全序",所以用 partial_cmp;
    // y.1 和 x.1 反过来写,就是从大到小排。
    // 我们的分数不会是 NaN,所以 unwrap 是安全的。
    scores.sort_by(|x, y| y.1.partial_cmp(&x.1).unwrap());
    // 只保留前 top_k 个,后面全扔掉
    scores.truncate(top_k);
    scores
}

fn main() {
    let records = gen_vectors(1000, 128, 42);
    println!("共生成 {} 条向量,每条 {} 维", records.len(), 128);
    println!(
        "第一条向量的前 4 维: {:.4}, {:.4}, {:.4}, {:.4}",
        records[0].data[0], records[0].data[1], records[0].data[2], records[0].data[3]
    );

    // 拿 id=42 那条向量本身当查询向量:
    // 它和它自己完全一样,所以第一名一定是 id=42,分数 1.0000。
    // 这里必须 clone:records 里的数据不能"搬走",只能复制一份新的。
    let q = records[42].data.clone();

    let t0 = std::time::Instant::now(); // 开始计时
    let results = query(&records, &q, 5);
    let elapsed = t0.elapsed(); // 结束计时

    println!("与 id=42 最相似的 5 条记录:");
    for (rank, item) in results.iter().enumerate() {
        println!("top{}: id={}, score={:.4}", rank + 1, item.0, item.1);
    }
    println!("暴力扫描 1000 条耗时:{:?}", elapsed);
}

③ 逐行讲解

LCG 随机数。真实的 embedding 要调大模型,我们没条件也没必要,用伪随机数代替。LCG 的公式就一行:state = (state * 1103515245 + 12345) % 2147483648。三个魔法数字来自 C 语言经典教材里的配置,你不用背。关键是"种子相同,序列就相同",所以你的输出和本教程一字不差。参数 state: &mut u64 是 M1 说的"可变借用":函数要修改种子本身,让它往后走。函数体里的 *state 是解引用(dereference),&mut 拿到的是"借条",星号 表示"透过借条直接操作本体"。>> 16 是右移 16 位,扔掉低位取高位(低位规律性强,高位更"随机")。最后一行把 [0, 32767] 的整数映射到 [-1, 1) 的小数。

gen_vectors。0..n 是区间语法,产生 0 到 n-1。for _ in 0..dim 里的下划线表示"这个循环变量我不打算用"。i as u64 的 as 是显式类型转换。两个 mut 注意一下:state 要被 next_f32 改,records 要被 push 改,都得 mut。

余弦相似度,为什么是它。embedding(嵌入向量)是大模型给文本算的"语义指纹":一句话变成 128 个(或 768 个、1536 个)浮点数,语义相近的句子,指纹的方向就相近。比如"怎么做红烧肉"和"红烧肉的做法"这两句话,字面上重叠不多,但 embedding 方向几乎一致,余弦相似度能到 0.8 以上;而"怎么做红烧肉"和"今天股市怎么样"就接近 0。这就是为什么向量检索能找到"字面不同但意思相同"的对话——关键词匹配做不到这一点。我们只关心方向不关心长度,所以公式要把长度除掉(归一化),这正是余弦相似度和朴素点积的区别。

cosine_similarity,公式翻译成代码。余弦相似度的数学公式是:分子是两个向量对应维度相乘再求和(点积),分母是两个向量各自长度的乘积。代码就是逐字翻译:dot += a[i] * b[i] 是点积;norm_a += a[i] * a[i] 累加的是 a 的长度的平方;最后 sqrt 开平方变回长度。0.0f32 后缀表示"这是一个 f32 类型的 0",不加后缀 Rust 会默认当成 f64,类型就对不上了。直觉理解:两个向量方向越一致,余弦越接近 1;垂直就是 0;完全相反是 -1。随机向量在 128 维空间里几乎互相垂直,所以等等你会看到除了"它自己"是 1.0000,其他都在 0.2 上下晃。

query。先把所有记录的 (id, 分数) 收进 scores,然后排序取前 k 个。sort_by(|x, y| ...) 里的 |x, y| ... 是闭包(closure),就是临时写的一个小函数。partial_cmp 返回 Option<Ordering>——浮点数世界里有个怪胎 NaN(Not a Number,非数),和任何数都没法比大小,所以"比较"这件事本身可能失败,用 Option 包着。我们 guaranteed 没有 NaN,直接 unwrap() 拆包。y 在前 x 在前,就是降序。truncate(top_k) 把第 k 个之后的全切掉。这是"最笨但最诚实"的 top-k 做法,堆优化留给真 Varve。

main 里的 clone。let q = records[42].data.clone(); 为什么不能写 let q = records[42].data;?因为那等于把向量从数组里"搬走",数组就破了个洞,Rust 不允许。clone 是明明白白复制一份新的,所有权归 q,原数组毫发无损。std::time::Instant::now() 是取当前时刻用于计时,{:?} 是调试格式打印,时间间隔会显示成 1.23ms 这种样子。

④ 编译和运行

Shell
cargo run

⑤ 你应看到的输出

Rust
共生成 1000 条向量,每条 128 维
第一条向量的前 4 维: 0.1646, 0.0396, -0.0681, 0.5540
与 id=42 最相似的 5 条记录:
top1: id=42, score=1.0000
top2: id=855, score=0.2333
top3: id=766, score=0.2328
top4: id=103, score=0.2195
top5: id=718, score=0.2161
暴力扫描 1000 条耗时:1.2ms

种子固定,所以前 5 行的每个数字你都应该一模一样。最后一行耗时和你的机器有关,Debug 模式下大概零点几到几毫秒,不用管具体数字。注意看 top2 到 top5:分数都在 0.2 附近,这就是我们上面说的"高维随机向量近乎垂直"。而 top1 是我们故意拿 id=42 自己当查询向量,所以分数一定是满分 1.0000——这个小技巧让输出可以精确预测,M3 和 M5 还会再用。

⑥ 报错诊所

症状一:expected f32, found f64。写 let mut dot = 0.0; 忘了 f32 后缀,Rust 默认浮点字面量是 f64,和 a[i](f32)相乘就类型不匹配。改法:写 0.0f32,或者 let mut dot: f32 = 0.0;。

症状二:cannot borrow as mutable。在 gen_vectors 里把 let mut state 写成 let state,下一行 next_f32(&mut state) 就报错——你递出去的借条是"可批改"的,但本体声明成了不可变。mut 要成双成对:声明处和借用处一个都不能少。

症状三:cannot move out of index of Vec(想从数组里搬走东西)。写 let q = records[42].data; 就会吃到这个错。数组里的元素不许搬走,改法就是教程里的 .clone(),或者 let q = &records[42].data; 只借用。

症状四:unwrap on None 运行期 panic(崩溃)。如果你在 sort_by 里比较的分数出现了 NaN,partial_cmp 会返回 None,unwrap 当场崩溃。什么情况下会有 NaN?比如向量全是 0,分母 sqrt 出来是 0,0 除以 0 就是 NaN。我们的 LCG 不会生成全零向量,所以安全;但你以后写真实代码,要在分母为 0 时做个保护。

小练习:把 top_k 改成 10,再把种子从 42 改成 7,跑一次。然后算一算:top10 里分数最低的是多少?体会一下"随机向量堆里,相似度天花板也就 0.2 出头"这件事——真实 embedding 里语义相关的向量能到 0.8 以上,这就是 embedding 的价值。


M3M3:时间段剪枝——本篇的灵魂

① 这一步做出什么

把 M2 的"一锅乱炖"升级成"按时间切段装箱":每 100 条记录装一个段(Segment),段头上记着本段最早和最晚的时间戳。查询"最近 7 天"时,先遍历所有段头,时间窗口和段不挨着的,整段跳过,一次距离都不算。你会亲眼看到 10 个段里 9 个被跳过,1000 次距离计算变成 70 次。

先说朴素做法为什么失败。M2 的做法是:管它三七二十一,1000 条全算一遍相似度,再慢慢挑。10 条数据时这没问题,10 万条呢?假设每条距离计算 1 微秒,10 万条就是 0.1 秒——用户每次提问都多等 0.1 秒,AI Agent 就显得呆头呆脑。而这 10 万条里,用户要的"最近 7 天"可能只占 7000 条,93% 的运算是白白烧掉的电。

机制上怎么破?关键是观察到:对话记录天生带时间戳,而且是按时间顺序产生的(新消息总比旧消息晚)。那么我们写入时就按时间顺序装箱,每箱贴个标签写着"本箱装的是第几号到第几号时间"。查询时只看标签,不开箱。这就是"物理剪枝"——数据在物理存放上就是分开的,跳过去就是真的没碰,不是一个 if 判完再扔。

"时间单调递增"这个假设靠谱吗?非常靠谱。你翻 DeepSeek 或千问的历史对话列表,新对话永远排在最上面,没人会把今天的对话塞到三个月前中间去。小红书的评论区也一样,新评论追加在末尾。抖音的点赞记录、微信的聊天记录,全是" append-only(只追加)"的时间流。真实系统里偶尔有乱序(比如网络延迟导致晚发的消息时间戳反而小),真 Varve 有对应的处理,我们迷你版先假设完美单调——这是教学版的合理简化。

用数字钉死:1000 条记录,每天 10 条共 100 天,每段 100 条也就是每段恰好装 10 天,共 10 段。查询第 93 到 100 天,只有最后 1 段(第 90 到 99 天)和窗口有交集,其余 9 段整段跳过。距离计算从 1000 次降到 70 次(段内还要逐条再筛一次时间,因为段边界和窗口边界不一定对齐)。省了 93%。

② 新建文件和完整代码

Shell
cd ~
cargo new m3_time_segments
cd m3_time_segments

把 src/main.rs 替换成:

Rust
// M3:时间段剪枝 —— 本篇的灵魂
// 核心思想:向量按时间戳分段存放。查询时先看每段的 [min_ts, max_ts],
// 和时间窗口不挨着的段整段跳过,一次距离都不用算。

// ---------- 随机数(和 M2 一模一样的 LCG) ----------
fn next_f32(state: &mut u64) -> f32 {
    *state = (*state * 1103515245 + 12345) % 2147483648;
    let v = (*state >> 16) as u32;
    v as f32 / 32768.0 * 2.0 - 1.0
}

// ---------- 余弦相似度(和 M2 一模一样) ----------
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
    let mut dot = 0.0f32;
    let mut norm_a = 0.0f32;
    let mut norm_b = 0.0f32;
    for i in 0..a.len() {
        dot += a[i] * b[i];
        norm_a += a[i] * a[i];
        norm_b += b[i] * b[i];
    }
    dot / (norm_a.sqrt() * norm_b.sqrt())
}

// ---------- 段:一段时间内的向量装箱 ----------
// 每条记录是 (内部 id, 时间戳, 向量) 的三元组
struct Segment {
    min_ts: u64, // 本段最早的时间戳
    max_ts: u64, // 本段最晚的时间戳
    vectors: Vec<(u64, u64, Vec<f32>)>,
}

impl Segment {
    fn new() -> Segment {
        // 空段还没有任何记录,先把 min 放到最大、max 放到 0,
        // 这样第一条记录进来时一定会刷新它们
        Segment {
            min_ts: u64::MAX,
            max_ts: 0,
            vectors: Vec::new(),
        }
    }

    fn push(&mut self, id: u64, ts: u64, data: Vec<f32>) {
        if ts < self.min_ts {
            self.min_ts = ts;
        }
        if ts > self.max_ts {
            self.max_ts = ts;
        }
        self.vectors.push((id, ts, data));
    }

    fn len(&self) -> usize {
        self.vectors.len()
    }

    // 本段和时间窗口 [start, end) 有没有交集?
    // 窗口是"左闭右开":包含 start,不包含 end。
    fn overlaps(&self, start: u64, end: u64) -> bool {
        // 不重叠只有两种情况:整段在窗口左边,或整段在窗口右边。
        // 取反就是"有交集"。
        !(self.max_ts < start || self.min_ts >= end)
    }
}

// ---------- 迷你 Varve 引擎 ----------
struct MiniVarve {
    capacity: usize, // 每段最多装多少条
    segments: Vec<Segment>,
}

impl MiniVarve {
    fn new(capacity: usize) -> MiniVarve {
        MiniVarve {
            capacity: capacity,
            segments: Vec::new(),
        }
    }

    // 写入一条记录。我们约定时间戳单调递增(新消息总是更晚),
    // 所以永远往最后一段追加;最后一段满了就切新段。
    fn write(&mut self, id: u64, ts: u64, data: Vec<f32>) {
        // 先只读地看一眼:要不要开新段?
        let need_new = match self.segments.last() {
            None => true,                          // 一段都还没有
            Some(seg) => seg.len() >= self.capacity, // 最后一段满了
        };
        if need_new {
            self.segments.push(Segment::new());
        }
        // last_mut 拿最后一段的可变借用;上面保证了至少有一段,所以 unwrap 安全
        let seg = self.segments.last_mut().unwrap();
        seg.push(id, ts, data);
    }

    // 查询:先剪枝,再对候选段做暴力 KNN。
    // 返回 (结果列表, 总段数, 跳过段数, 实际距离计算次数)。
    fn query(
        &self,
        start: u64,
        end: u64,
        q: &[f32],
        top_k: usize,
    ) -> (Vec<(u64, f32)>, usize, usize, usize) {
        let total = self.segments.len();
        let mut skipped = 0;
        let mut computed = 0;
        let mut scores: Vec<(u64, f32)> = Vec::new();

        // 第一步:物理剪枝,整段跳过
        for seg in &self.segments {
            if !seg.overlaps(start, end) {
                skipped += 1;
                continue; // 这一段一次距离都不用算
            }
            // 第二步:候选段内暴力扫描
            for item in &seg.vectors {
                // 段有交集不代表每条都在窗口里,逐条再看一眼
                if item.1 < start || item.1 >= end {
                    continue;
                }
                scores.push((item.0, cosine_similarity(&item.2, q)));
                computed += 1;
            }
        }

        scores.sort_by(|x, y| y.1.partial_cmp(&x.1).unwrap());
        scores.truncate(top_k);
        (scores, total, skipped, computed)
    }
}

// ---------- 造数据:1000 条记录,每天 10 条,共 100 天 ----------
// 返回 (id, ts, data) 的列表,时间戳严格递增
fn gen_records(n: usize, dim: usize, seed: u64) -> Vec<(u64, u64, Vec<f32>)> {
    let mut state = seed;
    let mut records = Vec::new();
    for i in 0..n {
        let day = (i / 10) as u64; // 每 10 条算一天
        let ts = day * 86400 + ((i % 10) as u64) * 3600; // 一天 86400 秒,每小时一条
        let mut data = Vec::new();
        for _ in 0..dim {
            data.push(next_f32(&mut state));
        }
        records.push((i as u64, ts, data));
    }
    records
}

fn main() {
    let records = gen_records(1000, 128, 42);

    let mut engine = MiniVarve::new(100); // 每段最多 100 条,也就是 10 天
    for r in &records {
        // r.2 是向量;write 要拿走所有权,所以 clone 一份给它
        engine.write(r.0, r.1, r.2.clone());
    }

    println!("写入 {} 条记录,共切成 {} 段", records.len(), engine.segments.len());
    for (i, seg) in engine.segments.iter().enumerate() {
        println!("段{}: [{} , {}],共 {} 条", i, seg.min_ts, seg.max_ts, seg.len());
    }

    // 查询"最近 7 天":第 93 天凌晨到第 100 天凌晨
    let day: u64 = 86400;
    let start = 93 * day;
    let end = 100 * day;

    // 用 id=995 的向量当查询向量(它是第 99 天的记录,在窗口里),
    // 所以第一名一定是它自己,分数 1.0000
    let q = records[995].2.clone();

    let (results, total, skipped, computed) = engine.query(start, end, &q, 5);
    println!("查询窗口: [{} , {})", start, end);
    println!("共 {} 段,跳过 {} 段,实际计算 {} 次距离", total, skipped, computed);
    println!("窗口内最相似的 5 条:");
    for (rank, item) in results.iter().enumerate() {
        println!("top{}: id={}, score={:.4}", rank + 1, item.0, item.1);
    }
}

③ 逐行讲解

Segment 结构体。三个字段:min_ts 和 max_ts 是"段头标签",vectors 是"箱子里装的东西"。每条记录是 (u64, u64, Vec<f32>) 三元组,依次是内部 id、时间戳、向量。元组(tuple)就是"把几个不同类型的值捆成一个",用 .0、.1、.2 访问。

Segment::new 的小伎俩。空段还没有记录,min_ts 和 max_ts 填什么?填 0 和 0 行吗?不行,那样第一条记录进来时 ts < min_ts 永远不成立(时间戳都是正的),min_ts 就永远是错的 0。所以反过来:min 先放 u64::MAX(最大的 u64),max 先放 0,第一条记录进来必定同时刷新两个。这是"哨兵值"技巧,以后写数据库代码会经常见到。

push。&mut self 表示"这个方法要改自己"——min_ts、max_ts、vectors 都要动。两个 if 维护标签,最后一行把记录塞进箱子。

overlaps,本篇最值钱的三行。判断"段和窗口有没有交集"。正面想(有交集的各种姿势:窗口包住段、段包住窗口、左搭边、右搭边)很容易漏;反面想只有两种"不重叠":段完全在窗口左边(max_ts < start),或段完全在窗口右边(min_ts >= end)。!(A || B) 一取反,就是"有交集"。写区间判断时永远先想反面,这是老程序员的肌肉记忆。窗口约定为左闭右开 [start, end),这是计算机行业的通行约定,能避免"边界那条记录算哪边"的扯皮。

MiniVarve::write。先 self.segments.last() 只读看一眼最后一段满没满(last() 返回 Option<&Segment>,空 Vec 时是 None),决定要不要开新段。注意这里 borrow 的微妙之处:last() 的不可变借用在 match 结束后就死了,所以后面 self.segments.push(...) 的可变操作合法;然后 last_mut() 重新拿一个可变借用去追加。编译器逐行盯着借用的生死,这就是传说中的"生命周期"最直观的一次露面。

query。四元组返回值:结果列表、总段数、跳过段数、距离计算次数——后三个纯粹是为了让你亲眼看到剪枝的威力。第一层循环是剪枝:!seg.overlaps(...) 就 skipped += 1 然后 continue(跳过本次循环),这段里哪怕有 100 条向量,也一条都不碰。第二层循环是候选段内的暴力扫描,但注意还有一个逐条的 if:段和窗口有交集,不代表段里每条都在窗口里(比如段跨了窗口边界),所以逐条再筛一次时间,然后才算距离。computed 计的就是真金白银的距离计算次数。

gen_records。1000 条记录,i / 10 是第几天(整数除法,自动舍去小数),i % 10 是当天第几条,乘 3600 秒变成"每小时一条"。时间戳严格递增——这是我们 write 能"永远往最后一段追加"的前提。

main。engine.write(r.0, r.1, r.2.clone()) 又见 clone:records 数组我们还要留着(后面要取 records[995]),不能把向量搬走,所以复制一份给引擎。查询向量取 id=995,它是第 99 天的记录,稳稳落在窗口里,top1 一定是它自己。let (results, total, skipped, computed) = engine.query(...) 是解构(destructuring):把四元组一次拆成四个变量。

④ 编译和运行

Shell
cargo run

⑤ 你应看到的输出

Rust
写入 1000 条记录,共切成 10 段
段0: [0 , 810000],共 100 条
段1: [864000 , 1674000],共 100 条
段2: [1728000 , 2538000],共 100 条
段3: [2592000 , 3402000],共 100 条
段4: [3456000 , 4266000],共 100 条
段5: [4320000 , 5130000],共 100 条
段6: [5184000 , 5994000],共 100 条
段7: [6048000 , 6858000],共 100 条
段8: [6912000 , 7722000],共 100 条
段9: [7776000 , 8586000],共 100 条
查询窗口: [8035200 , 8640000)
共 10 段,跳过 9 段,实际计算 70 次距离
窗口内最相似的 5 条:
top1: id=995, score=1.0000
top2: id=945, score=0.3663
top3: id=987, score=0.1876
top4: id=970, score=0.1588
top5: id=965, score=0.1420

每个数字都可以亲手验算。段 0 的 max_ts:第 9 天第 9 条,9 乘 86400 加 9 乘 3600 等于 810000,对上了。查询窗口起点 8035200 等于 93 乘 86400,对上了。段 8 的 max_ts 是 7722000,小于 8035200,所以段 0 到段 8 全部跳过,9 段,对上了。窗口里装着第 93 到 99 天的记录,7 天乘每天 10 条等于 70 次距离计算,对上了。看到"共 10 段,跳过 9 段"这一行,你就亲眼见证了物理剪枝:90% 的数据连碰都没碰。

⑥ 报错诊所

症状一:borrow of moved value。在 main 里写 engine.write(r.0, r.1, r.2);(漏了 clone),循环第二轮再用 r 就报错——r.2 的所有权第一轮就被 write 拿走了。改法:要么 clone,要么把 for 改成 for r in records(直接消费掉数组),但那样后面 records[995] 就用不了了,所以这里必须 clone。

症状二:cannot borrow as mutable, because it is also borrowed as immutable。如果你在 write 里把 let last = self.segments.last(); 的 last 存下来,后面又 self.segments.push(...),就会冲突:不可变借用 last 还活着,就不能可变地 push。我们的代码把"看一眼"的结果浓缩成一个 bool(need_new),借用立刻结束,冲突就消失了。记住这个套路:先把要读的读出来变成普通值,再去改。

症状三:integer overflow(整数溢出,debug 模式直接 panic)。如果你在 Segment::new 里手滑写成 max_ts: 0 - 1 想表达"最大 u64",debug 模式下 0 减 1 当场溢出崩溃。正确写法是 u64::MAX。

症状四:区间判断写反。把 overlaps 写成 self.max_ts > start && self.min_ts < end 看起来也对,但边界全错(等于号没处理):段尾恰好等于窗口起点时该不该算重叠?用我们的反面写法 !(max_ts < start || min_ts >= end),边界天然正确。写完区间判断,挑三个边界案例手动验算:段在窗口正左、正右、恰好贴边。

小练习:把窗口改成"第 10 天到第 20 天"(start = 10 * day,end = 20 * day),先心算应该跳过几段、算几次距离,再改代码跑一遍对答案。然后想想:如果窗口跨在段中间(比如 start = 95 * day),skipped 和 computed 会怎么变?


M4M4:性能对比实验——简历素材生产线

① 这一步做出什么

把数据量放大到十万条(100000 条 128 维向量,每天 1000 条、共 100 天,约 50 MB 内存),然后搭一个对照实验:同一个查询,用两种方案各跑一遍并计时。

时间窗口分三档:7 天、30 天、全历史。你会看到方案B在 7 天窗口下少算 93% 的距离,耗时同步大幅下降;而全历史窗口下方案B退化成方案A——每个段都和窗口有交集,剪枝一无所获。这个"退化"不是 bug,是规律:剪枝的收益等于窗口外数据的比例。

这一步就是真 Varve 简历上"7 天 / 90 天 / 全历史三档 benchmark"的迷你雏形。面试时你能讲清楚每一档为什么是这个数字,比背八股文强十倍。

② 新建文件和完整代码

Shell
cd ~
cargo new m4_benchmark
cd m4_benchmark

把 src/main.rs 替换成:

Rust
// M4:性能对比实验 —— 简历素材
// 灌入 100000 条 128 维向量(每天 1000 条,共 100 天),
// 对比两种查询方案在三档时间窗口下的耗时:
//   方案A 逻辑过滤:全量算距离,再用 if 筛时间(传统做法)
//   方案B 物理剪枝:先按段的时间范围整段跳过,再算距离(Varve 的做法)
// 注意:本实验必须用 cargo run --release 跑,否则慢 10 倍以上!

use std::time::Instant;

// ---------- 随机数(LCG,和前面一样) ----------
fn next_f32(state: &mut u64) -> f32 {
    *state = (*state * 1103515245 + 12345) % 2147483648;
    let v = (*state >> 16) as u32;
    v as f32 / 32768.0 * 2.0 - 1.0
}

// ---------- 余弦相似度 ----------
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
    let mut dot = 0.0f32;
    let mut norm_a = 0.0f32;
    let mut norm_b = 0.0f32;
    for i in 0..a.len() {
        dot += a[i] * b[i];
        norm_a += a[i] * a[i];
        norm_b += b[i] * b[i];
    }
    dot / (norm_a.sqrt() * norm_b.sqrt())
}

// ---------- 段和引擎(和 M3 一样) ----------
struct Segment {
    min_ts: u64,
    max_ts: u64,
    vectors: Vec<(u64, u64, Vec<f32>)>,
}

impl Segment {
    fn new() -> Segment {
        Segment {
            min_ts: u64::MAX,
            max_ts: 0,
            vectors: Vec::new(),
        }
    }
    fn push(&mut self, id: u64, ts: u64, data: Vec<f32>) {
        if ts < self.min_ts {
            self.min_ts = ts;
        }
        if ts > self.max_ts {
            self.max_ts = ts;
        }
        self.vectors.push((id, ts, data));
    }
    fn len(&self) -> usize {
        self.vectors.len()
    }
    fn overlaps(&self, start: u64, end: u64) -> bool {
        !(self.max_ts < start || self.min_ts >= end)
    }
}

struct MiniVarve {
    capacity: usize,
    segments: Vec<Segment>,
}

impl MiniVarve {
    fn new(capacity: usize) -> MiniVarve {
        MiniVarve {
            capacity: capacity,
            segments: Vec::new(),
        }
    }
    fn write(&mut self, id: u64, ts: u64, data: Vec<f32>) {
        let need_new = match self.segments.last() {
            None => true,
            Some(seg) => seg.len() >= self.capacity,
        };
        if need_new {
            self.segments.push(Segment::new());
        }
        let seg = self.segments.last_mut().unwrap();
        seg.push(id, ts, data);
    }
    // 方案B:物理剪枝查询。返回 (结果, 候选段数, 跳过段数, 距离计算次数)
    fn query(
        &self,
        start: u64,
        end: u64,
        q: &[f32],
        top_k: usize,
    ) -> (Vec<(u64, f32)>, usize, usize, usize) {
        let mut candidate = 0;
        let mut skipped = 0;
        let mut computed = 0;
        let mut scores: Vec<(u64, f32)> = Vec::new();
        for seg in &self.segments {
            if !seg.overlaps(start, end) {
                skipped += 1;
                continue;
            }
            candidate += 1;
            for item in &seg.vectors {
                if item.1 < start || item.1 >= end {
                    continue;
                }
                scores.push((item.0, cosine_similarity(&item.2, q)));
                computed += 1;
            }
        }
        scores.sort_by(|x, y| y.1.partial_cmp(&x.1).unwrap());
        scores.truncate(top_k);
        (scores, candidate, skipped, computed)
    }
}

// ---------- 方案A:逻辑过滤(传统做法) ----------
// 对全部记录算距离,再筛时间。返回 (结果, 距离计算次数)
fn query_plan_a(
    records: &[(u64, u64, Vec<f32>)],
    start: u64,
    end: u64,
    q: &[f32],
    top_k: usize,
) -> (Vec<(u64, f32)>, usize) {
    let mut computed = 0;
    let mut scores: Vec<(u64, f32)> = Vec::new();
    for r in records {
        // 先算距离:不管时间合不合,全部算一遍
        let s = cosine_similarity(&r.2, q);
        computed += 1;
        // 再筛时间:不在窗口里的白算了
        if r.1 >= start && r.1 < end {
            scores.push((r.0, s));
        }
    }
    scores.sort_by(|x, y| y.1.partial_cmp(&x.1).unwrap());
    scores.truncate(top_k);
    (scores, computed)
}

// ---------- 造数据:100000 条,每天 1000 条,共 100 天 ----------
fn gen_records(n: usize, dim: usize, seed: u64) -> Vec<(u64, u64, Vec<f32>)> {
    let mut state = seed;
    let mut records = Vec::new();
    for i in 0..n {
        let day = (i / 1000) as u64;
        let ts = day * 86400 + ((i % 1000) as u64) * 86; // 一天内每 86 秒一条
        let mut data = Vec::new();
        for _ in 0..dim {
            data.push(next_f32(&mut state));
        }
        records.push((i as u64, ts, data));
    }
    records
}

// 跑一轮对比:同一个窗口,两个方案各跑一次
fn bench_one(
    label: &str,
    start: u64,
    end: u64,
    records: &[(u64, u64, Vec<f32>)],
    engine: &MiniVarve,
    q: &[f32],
) {
    println!("== 窗口:{} [{}, {}) ==", label, start, end);

    let t0 = Instant::now();
    let (_ra, computed_a) = query_plan_a(records, start, end, q, 5);
    let ms_a = t0.elapsed().as_secs_f64() * 1000.0;
    println!(
        "方案A(逻辑过滤):计算 {} 次距离,耗时 {:.1} ms",
        computed_a, ms_a
    );

    let t1 = Instant::now();
    let (_rb, candidate, skipped, computed_b) = engine.query(start, end, q, 5);
    let ms_b = t1.elapsed().as_secs_f64() * 1000.0;
    println!(
        "方案B(物理剪枝):候选 {} 段,跳过 {} 段,计算 {} 次距离,耗时 {:.1} ms",
        candidate, skipped, computed_b, ms_b
    );

    let saved = (computed_a - computed_b) as f64 / computed_a as f64 * 100.0;
    println!("少算了 {:.1}% 的距离计算", saved);
    println!();
}

fn main() {
    println!("正在生成 100000 条 128 维向量(约 50 MB 内存)...");
    let records = gen_records(100000, 128, 42);

    let mut engine = MiniVarve::new(200); // 每段 200 条,共 500 段
    for r in &records {
        engine.write(r.0, r.1, r.2.clone());
    }
    println!("写入完毕,共 {} 段", engine.segments.len());
    println!();

    // 查询向量随便拿一条,这里用 id=55555(第 55 天)
    let q = records[55555].2.clone();

    let day: u64 = 86400;
    bench_one("最近 7 天", 46 * day, 53 * day, &records, &engine, &q);
    bench_one("最近 30 天", 35 * day, 65 * day, &records, &engine, &q);
    bench_one("全历史", 0, 100 * day, &records, &engine, &q);

    println!("注意:全历史窗口下方案B退化为方案A —— 所有段都有交集,剪枝没有省任何东西。");
    println!("这正是真 Varve 简历上 7 天 / 90 天 / 全历史三档 benchmark 的雏形。");
}

③ 逐行讲解

大部分是 M3 原样搬过来的,只讲新的。

use std::time::Instant; —— 从标准库引入计时器。M2 里我们写全路径 std::time::Instant::now(),这里用 use 引入后就可以直接写 Instant::now()。

query_plan_a,方案A。注意它的循环顺序:先 cosine_similarity 算距离、computed += 1,然后才 if 筛时间。顺序就是罪过——距离已经算完了,筛掉也是白算。这就是"逻辑过滤":过滤发生在计算之后,数据在物理上全部经过了 CPU。对照方案B:continue 发生在 cosine_similarity 之前,被跳过的段连函数都没进。

bench_one。把"跑一轮对比"抽成一个函数,因为同样的流程要跑三档窗口。参数 label: &str 是窗口的中文名。Instant::now() 取开始时刻,t0.elapsed() 拿到流逝的时间,as_secs_f64() 转成秒(f64),乘 1000 变毫秒。(_ra, computed_a) 的下划线前缀表示"这个变量我故意不用",不然编译器会警告 unused variable。saved 那行算百分比:注意要先 as f64 转成浮点再做除法,否则整数除法会把 0.93 直接砍成 0——这是整数除法陷阱,新手必踩。

main。MiniVarve::new(200) 每段 200 条,十万条切 500 段,每天 1000 条恰好占 5 段。三档窗口的边界都是拿 day 乘出来的,数字钉死:7 天窗口是第 46 天凌晨到第 53 天凌晨(取中间段,效果等同于任意 7 天),30 天窗口是第 35 到 65 天,全历史是 0 到第 100 天。

④ 编译和运行

这次必须加 --release:

Shell
cargo run --release

release 模式会打开编译器优化,比默认的 debug 模式快 10 倍以上。debug 模式里塞满了调试信息和运行时检查(比如整数溢出检查),适合开发,完全不适合测性能。第一次 release 编译会慢一点,正常现象。

⑤ 你应看到的输出

段数、次数、百分比是确定的;毫秒数和你的机器有关,下面是我推演的一台普通虚拟机的量级,你的数字不必一样,但比例关系必须一样:

Rust
正在生成 100000 条 128 维向量(约 50 MB 内存)...
写入完毕,共 500 段

== 窗口:最近 7 天 [3974400, 4579200) ==
方案A(逻辑过滤):计算 100000 次距离,耗时 12.5 ms
方案B(物理剪枝):候选 35 段,跳过 465 段,计算 7000 次距离,耗时 0.9 ms
少算了 93.0% 的距离计算

== 窗口:最近 30 天 [3024000, 5616000) ==
方案A(逻辑过滤):计算 100000 次距离,耗时 12.4 ms
方案B(物理剪枝):候选 150 段,跳过 350 段,计算 30000 次距离,耗时 3.8 ms
少算了 70.0% 的距离计算

== 窗口:全历史 [0, 8640000) ==
方案A(逻辑过滤):计算 100000 次距离,耗时 12.6 ms
方案B(物理剪枝):候选 500 段,跳过 0 段,计算 100000 次距离,耗时 12.5 ms
少算了 0.0% 的距离计算

注意:全历史窗口下方案B退化为方案A —— 所有段都有交集,剪枝没有省任何东西。
这正是真 Varve 简历上 7 天 / 90 天 / 全历史三档 benchmark 的雏形。

验算一遍"最近 7 天":7 天乘每天 1000 条等于 7000 条,所以方案B算 7000 次;每段 200 条,7000 条恰好占 35 段,候选 35 段;500 减 35 等于 465,跳过 465 段;7000 除以 100000,少算 93.0%。全部对得上。耗时的比例也大致跟着距离次数走:方案B的耗时约等于方案A乘上窗口内数据占比。

盯住全历史那一行:方案B和方案A耗时几乎一样。面试时如果面试官问"你的剪枝什么时候没用",你就答:窗口覆盖全量数据时退化为全量扫描,因为每段都和窗口有交集;所以这类系统的前提是"查询大多带时间局部性",比如 AI 记忆检索里用户绝大多数问的是近期的事。

再教你读 benchmark 的一个心法:先看次数,再看毫秒。毫秒数会被机器、负载、编译器影响,噪音很大;距离计算次数是算法决定的,100% 可复现。次数降了 93%,毫秒数就必然大致跟着降 93%——因为程序的全部耗时几乎都在 cosine_similarity 里,别的开销可以忽略。这就是"复杂度分析支配实际性能"的一次直观验证:你砍掉的是计算量本身,不是边边角角的常数。以后做任何性能优化,先问自己:我砍掉的是哪一部分计算?砍了百分之几?答不上来的优化都是玄学。

⑥ 报错诊所

症状一:忘了 --release,跑出来说方案A要几百毫秒,两个方案都慢得离谱。这不是 bug,是你用 debug 模式测性能。性能实验永远 release。

症状二:saved 打印出来是 0.0 或者看起来不对劲。检查是不是写成了 (computed_a - computed_b) / computed_a * 100——两个 usize 整数相除,结果直接取整。必须先 as f64 再除。

症状三:thread 'main' has overflowed its stack 或者内存被吃光。十万条 128 维 f32 约 50 MB,再加上 clone 进引擎的一份,总共一百多 MB,正常虚拟机扛得住;但如果你把维度手滑写成 12800,内存就爆炸了。改回来。

症状四:index out of bounds。如果你把查询向量改成 records[100000]——一共十万条,合法下标是 0 到 99999,100000 越界,运行时直接 panic。Rust 的数组下标检查是运行时的,编译器不管。

小练习:加第四档窗口"最近 1 天"(start = 99 * day,end = 100 * day),先心算候选几段、算几次距离、少算百分之几,再跑一遍对答案。再想想:如果把段容量从 200 改成 2000,7 天窗口下候选段数和 computed 会怎么变?段越大,剪枝粒度越粗——这就是"段大小"这个超参数的权衡。


M5M5:打包成 Python 库——pip install 你自己的 Rust 引擎

① 这一步做出什么

把 M3 的引擎用 PyO3(Rust 和 Python 之间的绑定层)包一层,再用 maturin(专门打包 Rust 写的 Python 库的工具)编译安装进 Python 虚拟环境。最后在 Python 里 import mini_varve,写入 1000 条记录,查询打印前 5 条——全程 Python 语法,底下跑的全是你的 Rust 代码。这就是真 Varve"pip install 即用"的完整链路,一模一样,只是规模迷你。

为什么需要这层?AI 工程师的世界是 Python 的:PyTorch、LangChain、各种 Agent 框架全在 Python 生态里。但 Python 慢,算不动十万级向量。于是行业配方是:核心引擎用 Rust/C++ 写,穿一件 Python 外衣。NumPy、Polars、 pydantic-core 全是这么干的,Varve 也是。你做完这一步,就亲手摸到了这个配方。

② 新建文件和完整代码

这一步不新建 cargo 项目,我们手动搭一个 maturin 项目。先装 Python 侧的环境(Ubuntu):

Shell
sudo apt update
sudo apt install python3-dev python3-venv
python3 -m venv ~/venv-varve
source ~/venv-varve/bin/activate
pip install maturin

python3-dev 是 Python 的头文件(编译扩展模块要用),python3-venv 是虚拟环境工具。虚拟环境(virtual environment)是一个隔离的 Python 小房间,我们往里面装 mini_varve,不污染系统 Python。source ... activate 是走进房间,之后命令行提示符前面会多个 (venv-varve)。

Windows 备选:从 python.org 装 Python 3,然后 python -m venv venv、venv\Scripts\activate、pip install maturin。

然后建项目,这次要四个文件:

Shell
mkdir -p ~/m5_pyo3/src
cd ~/m5_pyo3

文件一:Cargo.toml

TOML
[package]
name = "mini_varve"
version = "0.1.0"
edition = "2021"

# crate-type = ["cdylib"]:编译成"动态链接库",
# 也就是一个 Python 能直接 import 的 .so / .pyd 文件
[lib]
name = "mini_varve"
crate-type = ["cdylib"]

[dependencies]
# pyo3 是 Rust 和 Python 之间的"翻译官"
# extension-module 这个 feature 表示:我们要编译成 Python 扩展模块
pyo3 = { version = "0.22", features = ["extension-module"] }

文件二:pyproject.toml(Python 世界的说明书,告诉 pip 用 maturin 打包)

TOML
[build-system]
requires = ["maturin>=1.5,<2.0"]
build-backend = "maturin"

[project]
name = "mini_varve"
version = "0.1.0"
description = "迷你 Varve:带时间范围的向量记忆检索(教学版)"
requires-python = ">=3.8"

文件三:src/lib.rs(注意:是 lib.rs 不是 main.rs,库没有 main 函数)

Rust
// M5:把 M3 的引擎打包成 Python 库
// 这一层只做两件事:
//   1. 引擎核心(Segment / MiniVarve / cosine_similarity),和 M3 完全一样
//   2. 用 pyo3 的宏给核心"穿一件 Python 外衣"

use pyo3::prelude::*;

// ---------- 引擎核心(和 M3 一样,只是改成了库文件 lib.rs) ----------

fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
    let mut dot = 0.0f32;
    let mut norm_a = 0.0f32;
    let mut norm_b = 0.0f32;
    for i in 0..a.len() {
        dot += a[i] * b[i];
        norm_a += a[i] * a[i];
        norm_b += b[i] * b[i];
    }
    dot / (norm_a.sqrt() * norm_b.sqrt())
}

struct Segment {
    min_ts: u64,
    max_ts: u64,
    vectors: Vec<(u64, u64, Vec<f32>)>,
}

impl Segment {
    fn new() -> Segment {
        Segment {
            min_ts: u64::MAX,
            max_ts: 0,
            vectors: Vec::new(),
        }
    }
    fn push(&mut self, id: u64, ts: u64, data: Vec<f32>) {
        if ts < self.min_ts {
            self.min_ts = ts;
        }
        if ts > self.max_ts {
            self.max_ts = ts;
        }
        self.vectors.push((id, ts, data));
    }
    fn len(&self) -> usize {
        self.vectors.len()
    }
    fn overlaps(&self, start: u64, end: u64) -> bool {
        !(self.max_ts < start || self.min_ts >= end)
    }
}

// #[pyclass]:把这个 Rust 结构体暴露给 Python,变成一个 Python 类。
// Python 那边会看到一个叫 MiniVarve 的类,可以 new、可以调方法。
#[pyclass]
struct MiniVarve {
    capacity: usize,
    segments: Vec<Segment>,
}

// 普通 impl:Rust 自己用的辅助函数,Python 看不见
impl MiniVarve {
    fn push_record(&mut self, id: u64, ts: u64, data: Vec<f32>) {
        let need_new = match self.segments.last() {
            None => true,
            Some(seg) => seg.len() >= self.capacity,
        };
        if need_new {
            self.segments.push(Segment::new());
        }
        let seg = self.segments.last_mut().unwrap();
        seg.push(id, ts, data);
    }
}

// #[pymethods]:这个 impl 块里的函数会变成 Python 类的方法
#[pymethods]
impl MiniVarve {
    // #[new]:Python 里 MiniVarve(100) 会调到这里
    #[new]
    fn new(capacity: usize) -> Self {
        MiniVarve {
            capacity: capacity,
            segments: Vec::new(),
        }
    }

    // #[staticmethod]:Python 里 MiniVarve.create(100) 也能造一个。
    // 两种写法都给你,和真 Varve 的 API 习惯保持一致。
    #[staticmethod]
    fn create(capacity: usize) -> Self {
        MiniVarve::new(capacity)
    }

    // PyResult<()>:表示"可能出错,出错就抛 Python 异常"。
    // Python 传过来的 list 会被 pyo3 自动转成 Vec<f32>。
    fn write(&mut self, id: u64, ts: u64, vector: Vec<f32>) -> PyResult<()> {
        self.push_record(id, ts, vector);
        Ok(())
    }

    // 查询:返回 (id, 分数) 的列表,Python 那边看到的是 list of tuple
    fn query(
        &self,
        start: u64,
        end: u64,
        vector: Vec<f32>,
        top_k: usize,
    ) -> PyResult<Vec<(u64, f32)>> {
        let mut scores: Vec<(u64, f32)> = Vec::new();
        for seg in &self.segments {
            if !seg.overlaps(start, end) {
                continue;
            }
            for item in &seg.vectors {
                if item.1 < start || item.1 >= end {
                    continue;
                }
                scores.push((item.0, cosine_similarity(&item.2, &vector)));
            }
        }
        scores.sort_by(|x, y| y.1.partial_cmp(&x.1).unwrap());
        scores.truncate(top_k);
        Ok(scores)
    }

    // 顺手暴露一个小工具:看看现在切成几段了
    fn segment_count(&self) -> usize {
        self.segments.len()
    }
}

// #[pymodule]:定义 Python 模块本身。
// 函数名 mini_varve 必须和 Cargo.toml 里 [lib] 的 name 一致。
// Python 里 import mini_varve 时,就会执行这个函数完成注册。
#[pymodule]
fn mini_varve(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_class::<MiniVarve>()?;
    Ok(())
}

文件四:demo.py

Python
# M5 演示:在 Python 里使用我们用 Rust 写的 mini_varve 库
# 运行前确保:虚拟环境已激活,并且已经执行过 maturin develop

import random

import mini_varve

DIM = 128
DAY = 86400

# 造引擎:每段最多 100 条
engine = mini_varve.MiniVarve.create(100)

# 写入 1000 条记录:每天 10 条,共 100 天
# 向量用 Python 的 random 生成,种子固定为 42
random.seed(42)
probe = None  # 记住 id=995 的向量,待会儿拿它当查询向量
for i in range(1000):
    day = i // 10
    ts = day * DAY + (i % 10) * 3600
    vec = [random.uniform(-1.0, 1.0) for _ in range(DIM)]
    if i == 995:
        probe = vec
    engine.write(i, ts, vec)

print(f"写入 1000 条,共 {engine.segment_count()} 段")

# 查询"最近 7 天"里和 id=995 最相似的 5 条
start = 93 * DAY
end = 100 * DAY
results = engine.query(start, end, probe, 5)

print("最近 7 天里最相似的 5 条:")
for rank, (rid, score) in enumerate(results):
    print(f"top{rank + 1}: id={rid}, score={score:.4f}")

# 因为查询向量就是 id=995 自己,所以 top1 一定是 id=995,score=1.0000

③ 逐行讲解

Cargo.toml 的新东西。[lib] 段的 crate-type = ["cdylib"]:crate 是 Rust 对"一个编译单元"的称呼,cdylib 是 C 动态链接库(C dynamic library)。编译产物在 Linux 上是一个 .so 文件(Windows 上是 .pyd/.dll),Python 的 import 机制能直接加载它。pyo3 = { version = "0.22", features = ["extension-module"] }:这是我们整个教程唯一引入的第三方 crate,features 里的 extension-module 告诉 pyo3"我要被 Python 加载",而不是"我要嵌入 Python"。

pyproject.toml。Python 打包的标准说明书。build-backend = "maturin" 是灵魂:告诉 pip "这个包不是纯 Python,用 maturin 来构建"。maturin 会调 cargo 编译 Rust,再把产物包成 Python 认识的模块。

lib.rs 里的四个宏,一个一个说。

#[pyclass]:套在 struct MiniVarve 头上,意思是"把这个 Rust 结构体注册成一个 Python 类"。之后 Python 那边 mini_varve.MiniVarve 就能找到它。#[...] 这种写法叫属性(attribute),是给编译器看的指令。

#[pymethods]:套在一个 impl 块上,意思是"这个块里的函数,全部变成 Python 类的方法"。注意我们有两个 impl MiniVarve 块:普通那个(push_record)Python 看不见,是内部 helper;带 #[pymethods] 的那个才是对外的门面。分离的好处:内部逻辑随便改,对外接口保持稳定。

#[new]:标在 new 上,它就是 Python 的构造函数,Python 里 MiniVarve(100) 会调到这里。我们又额外加了个 #[staticmethod] create,让 MiniVarve.create(100) 也能造——真 Varve 的 API 就是这种风格,create 语义更明确。

PyResult<T>:Rust 的 Result 在 pyo3 里的 Python 版。返回 Ok,Python 正常拿到值;返回 Err,Python 那边抛出一个异常。类型转换是 pyo3 自动做的:Python 的 list 进来自动变 Vec<f32>,Rust 的 Vec<(u64, f32)> 出去自动变 list of tuple。&mut self 和 &self 的含义和前面一模一样:write 要改内部状态所以 mut,query 只读所以不 mut。

#[pymodule]:定义模块本身。函数名 mini_varve 必须和 Cargo.toml 里 [lib] name 一字不差,否则 import 时找不到入口。参数 m: &Bound<'_, PyModule> 是 pyo3 0.22 的写法,意思是"借一个模块对象进来",m.add_class::<MiniVarve>()? 把我们的类注册进模块,? 是"如果出错就把错误往上抛"的简写。

demo.py。Python 代码你应该看着亲切:random.seed(42) 固定随机种子,列表推导式造 128 维向量,f"..." 是格式化字符串。注意 engine.write(i, ts, vec)——你在调用 Rust!参数跨过语言边界:Python int 变 Rust u64,Python list 变 Rust Vec<f32>。查询向量还是用 id=995 自己,老技巧,top1 铁定满分。

跨语言边界到底发生了什么。engine.write(i, ts, vec) 这一行看着普通,底下走了三步。第一步,pyo3 检查参数个数和类型:vec 是个 Python list,pyo3 遍历它,把每个 Python float 转成 Rust 的 f32,装成一个 Vec<f32>——这一步是有成本的拷贝,所以真实系统里会尽量用 NumPy 数组做零拷贝传递,我们教学版不管。第二步,进入 Rust 世界,write 函数体跑的全是编译好的机器码,和 Python 解释器再无关系,速度就是 Rust 的速度。第三步,返回值再跨回去:Ok(()) 在 Python 那边就是"正常返回 None"。查询返回的 Vec<(u64, f32)> 则被 pyo3 包成 list of tuple,Python 直接用。理解这条边界,你就理解了为什么这个配方流行:跨边界的次数少(一次查询跨两次),边界内的计算量大(十万次距离),翻译成本被摊薄到几乎为零。

④ 编译和运行

确保虚拟环境已激活(提示符前有 (venv-varve)),然后:

Shell
cd ~/m5_pyo3
maturin develop
python demo.py

maturin develop 的意思是:编译 Rust 代码,并直接装进当前虚拟环境(相当于开发模式的 pip install)。第一次要下载编译 pyo3 的一堆依赖,一两分钟正常。以后改了 Rust 代码,重新跑一次 maturin develop 即可。

⑤ 你应看到的输出

maturin develop 末尾会打印类似 Installed mini_varve-0.1.0 的一行。然后 demo.py 的输出:

Rust
写入 1000 条,共 10 段
最近 7 天里最相似的 5 条:
top1: id=995, score=1.0000
top2: id=930, score=0.2361
top3: id=967, score=0.1992
top4: id=958, score=0.1604
top5: id=946, score=0.1603

段数、top1 是铁律。top2 到 top5 由 Python 的 random.seed(42) 决定,也是固定的。回看一下 M3 的输出:同样的 10 段、同样的窗口语义,只是这次驱动引擎的是 Python。你的 Rust 引擎已经是一个真正的 Python 库了。

⑥ 报错诊所

症状一:maturin 报错 Couldn't find a python interpreter。它找不到 Python。九成是虚拟环境没激活——检查提示符前有没有 (venv-varve),没有就 source ~/venv-varve/bin/activate。

症状二:编译时报 Python.h: No such file or directory。缺 Python 头文件,sudo apt install python3-dev 装上再来。Windows 上一般不会有这个问题(python.org 安装包自带头文件)。

症状三:error: failed to run custom build command for pyo3-... 且里面提到版本。pyo3 对 Rust 版本有要求,rustup 没更新的话敲 rustup update;如果还要求更新的 Python,就检查 python --version 是不是 3.8 以上。

症状四:demo.py 报 ModuleNotFoundError: No module named 'mini_varve'。两种可能:maturin develop 没跑成功(往上翻它的输出找 error),或者你在另一个终端里跑 python 而那个终端没激活虚拟环境。模块是装进虚拟环境的,换个环境就没了。

症状五:改了 lib.rs 之后 python 里行为没变。忘了重新 maturin develop。Python 加载的是编译产物,不重新编译安装,它看到的永远是旧版本。

小练习:给 #[pymethods] 里加一个 fn ts_range(&self) -> (usize, u64, u64) 方法,返回 (段数, 所有段里最早的 min_ts, 最晚的 max_ts),重新 maturin develop,在 demo.py 里打印出来。提示:遍历 self.segments,用和 Segment::push 里一样的"比较刷新"套路。


结尾:从迷你版到真 Varve,还差什么

差距地图

我们的迷你版只有主干,真 Varve 在主干上还挂了这些零件。每一项你都可以当成下一个自学目标:

持久化。迷你版的数据全在内存里,程序一关就没了。真 Varve 要把段写到磁盘文件,重启后还在。配套的就是 mmap(memory-mapped I/O,内存映射文件):把磁盘文件直接映射成内存地址,读文件像读数组一样,操作系统自动管缓存,这是高性能存储引擎的标配。

LSM 树存元数据。真 Varve 用 LSM 树(Log-Structured Merge-Tree,日志结构合并树)管理段元数据:哪些段、每段的时间范围、段的统计信息。LSM 是 LevelDB、RocksDB 这些存储引擎的骨架,写快读稳,适合"段不停产生"的追加型负载。

InternalId 关联。迷你版的 id 就是个序号。真 Varve 里每条向量要关联回原始内容(对话文本、用户 id、会话 id),InternalId 是向量和元数据之间的桥梁,查询返回 id 后要能秒回原文。

WAL 崩溃恢复。WAL(Write-Ahead Log,预写日志):每次写入先追加一条日志再动数据,程序崩了重开时按日志重放,数据不丢。我们的 write 直接改内存,断电即失忆;加了 WAL 才敢说自己是个"存储系统"。

value log。向量本体和元数据分开存放:元数据走 LSM,大块向量走 value log(值日志),避免大值拖垮 LSM 的合并过程。这是 WiscKey 论文的思路,也是现代 KV 存储的常见设计。

cibuildwheel 四平台 wheel。真 Varve 能 pip install 是因为发布了编译好的 wheel(Python 二进制分发包),Linux、macOS、Windows、各 Python 版本各一份,靠 cibuildwheel 在 CI 里批量构建。我们 M5 的 maturin develop 只在你自己机器上装了本地版,离"发给别人用"还差这一步。

简历怎么写

给你一段可以直接用的描述(写在项目经历里):

笔记迷你 Varve:带时间范围的向量记忆检索引擎(Rust + PyO3)。仿照 Varve 的核心设计,实现"向量按时间戳分段存储、查询先按段时间范围物理剪枝、候选段内暴力 KNN(余弦相似度)"的检索链路;用 PyO3 + maturin 打包为 Python 库供上层调用。自写 benchmark 在 10 万条 128 维向量上对比"逻辑过滤"与"物理剪枝"两种方案:7 天窗口下减少 93% 的距离计算,30 天窗口减少 70%,并分析了全历史窗口下剪枝退化为全量扫描的原因。

注意这段的写法:有技术栈、有设计要点、有量化数字、有对局限的分析。面试官最想看的恰恰是最后那句"分析局限"——知道方案什么时候失效的人,才真的懂这个方案。

面试话术

如果面试官问"讲讲这个项目",按这个顺序说:

第一层,讲场景。AI Agent 的长期记忆检索:十万条历史对话,每条是 embedding 向量,查询同时带语义条件(向量相似)和时间条件(最近 N 天)。

第二层,讲矛盾。传统做法是"全量算相似度再 if 筛时间",计算量和总数据量成正比,窗口越小浪费越大。

第三层,讲你的方案。写入按时间单调追加、满 N 条切段,段头记 min/max 时间戳;查询两步走,先看段头剪枝,再在候选段内暴力 KNN。7 天窗口 90% 的段整段跳过。

第四层,讲数字和边界。10 万条数据上 7 天窗口省 93% 距离计算;全历史窗口退化为全量扫描,因为剪枝收益等于窗口外数据占比;段大小是权衡,段越大粒度越粗。

第五层,讲还缺什么。主动提差距地图里的几项:持久化(mmap 段文件)、WAL 崩溃恢复、LSM 管元数据、InternalId 关联原文、cibuildwheel 发 wheel。主动暴露知识边界,比被问穿强得多。

动手清单

做完这篇,你应该能独立做到:装 Rust 工具链;写出 struct/impl/match/Vec 的小程序并讲清所有权和借用;手写余弦相似度和暴力 KNN;实现按时间分段的剪枝查询并算清每一步的段数和次数;用 release 模式跑对照实验并解释三档窗口的结果;用 PyO3 + maturin 把 Rust 引擎打包成 Python 库并调通。哪一步做不到,回到对应里程碑重敲一遍。敲代码这件事,没有捷径,只有手感。

下一步往哪走

如果你想把这个迷你版继续养大,按难度从低到高给你一条路。第一步,给引擎加持久化:退出前把 segments 用二进制格式写进一个文件,启动时读回来,用标准库的 File 和 write_all 就够。第二步,加 WAL:write 之前先往 wal.log 追加一行,main 启动时重放日志,体会"崩溃恢复"这四个字的分量。第三步,把暴力 KNN 的排序换成大小为 k 的堆(标准库的 BinaryHeap),数据量上到百万级时你会看到差距。第四步,读真 Varve 的源码或者 RocksDB 的设计文档,对照差距地图一项一项认亲。每完成一步,你的简历描述就能多长一句实话。

祝面试顺利。