从零到一构建智能终端(六):代码语义搜索的向量数据库设计
探讨如何构建面向代码的向量数据库系统,包括 AST 感知分块、嵌入向量索引、余弦相似度搜索和混合检索策略
引言
传统的代码搜索依赖关键词匹配——输入 handleAuth,只能找到包含这个字符串的代码。但如果你想找"处理用户认证的逻辑",关键词搜索就无能为力了。
语义搜索通过将代码转换为高维向量,让"意义相近"的代码在向量空间中距离更近。本文探讨如何为代码库构建一个语义搜索系统。
核心概念
嵌入向量(Embedding)
嵌入是将离散对象(文本、代码)映射到连续向量空间的过程:
代码片段 → Embedding Model → [0.12, -0.34, 0.56, ...]
关键特性:
- 语义相似的内容,向量距离近
- 维度固定(如 768、1536 维)
- 可以计算向量间的相似度
为什么代码需要特殊处理?
代码不同于自然语言:
| 特性 | 自然语言 | 代码 |
|---|---|---|
| 结构 | 松散的段落 | 严格的语法树 |
| 分割点 | 句子边界 | 函数/类边界 |
| 上下文 | 段落内 | 作用域、导入、类型 |
| 符号意义 | 有限 | {, }, -> 有结构意义 |
直接把代码当文本处理,会丢失结构信息。
系统架构
三层设计
┌─────────────────────────────────────────────────────────┐
│ Search Layer │
│ SemanticSearch + HybridSearch + KeywordSearch │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ Index Layer │
│ VectorIndex (内存索引) + FileStore (持久化) │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ Processing Layer │
│ TextChunker (分块) + Embedder (向量化) │
└─────────────────────────────────────────────────────────┘
智能分块:AST 感知
为什么不能简单按行数分割?
python# 按 100 行分割可能把函数切成两半
def complex_function():
# ... 50 行代码 ...
# ---- 分割线 ----
# ... 剩余 30 行 ...
return result
函数被切断后,两个片段都失去了完整语义。
Tree-sitter 分块
Tree-sitter 是一个增量解析库,能快速生成语法树。利用它按语法单元分块:
源代码
│
▼ Tree-sitter Parse
语法树 (AST)
│
▼ 遍历 + 提取
[Function, Class, Method, ...]
不同语言的提取规则:
| 语言 | 提取的节点类型 |
|---|---|
| Python | function_definition, class_definition |
| TypeScript/JS | function_declaration, class_declaration, method_definition, arrow_function |
| Rust | function_item, impl_item, struct_item, enum_item, trait_item |
| Go | function_declaration, method_declaration, type_declaration |
| Java | method_declaration, class_declaration, interface_declaration |
分块类型标注
每个块记录其类型,便于后续过滤和理解:
ChunkType:
| Function # 函数定义
| Class # 类定义
| Method # 方法定义
| Struct # 结构体/impl
| Generic # 无法识别的代码块
大块的 Stride 处理
当一个函数超过 token 限制(如 8192 tokens)时,需要拆分。直接切割会丢失上下文,使用 滑动窗口(Stride) 保持重叠:
原始函数: [████████████████████████████████████]
↓ Stride 分割
Chunk 1: [████████████]
Chunk 2: [████████████] ← 重叠区域
Chunk 3: [████████████]
参数设计:
- 窗口大小:目标 token 数的 90%(留 10% 缓冲)
- 重叠比例:约 20%(stride_overlap = chunk_size / 5)
重叠确保跨越边界的代码逻辑在至少一个块中完整出现。
向量索引
数据结构
VectorIndex:
dimension: usize # 向量维度(如 768, 1536)
vectors: HashMap<ChunkId, Vec<f32>> # ID → 向量
chunks: HashMap<ChunkId, ChunkMetadata> # ID → 元数据
元数据包含:
- 文件路径
- 代码位置(行号范围)
- 块类型(Function/Class/...)
- 内容哈希(用于去重和变更检测)
L2 归一化
存储向量前进行 L2 归一化:
normalized[i] = vector[i] / ||vector||
其中 ||vector|| = sqrt(sum(vector[i]^2))
归一化后,点积等于余弦相似度:
cos(a, b) = (a · b) / (||a|| × ||b||)
如果 ||a|| = ||b|| = 1,则:
cos(a, b) = a · b
这样搜索时只需计算点积,省去除法运算。
HNSW 索引
当向量数量增长时,暴力搜索(O(n))变得不可接受。我们使用 HNSW(Hierarchical Navigable Small World) 算法构建近似最近邻索引:
HNSW 结构:
Layer 2: [●]────────────────[●] (最稀疏,长距离跳跃)
│ │
Layer 1: [●]────[●]────[●]──[●] (中等密度)
│ │ │ │
Layer 0: [●]─[●]─[●]─[●]─[●]─[●]─[●]─[●] (最密集,所有向量)
核心参数:
M = 16:每个节点的最大连接数,影响图的密度ef_construction = 200:构建时的搜索宽度,影响索引质量ef_search:查询时的搜索宽度,动态调整:(top_k * 8).clamp(32, 256)
搜索流程:
HNSW_Search(query, k, ef_search):
1. 从最高层的入口点开始
2. 贪心搜索:每层找到最近的 ef_search 个邻居
3. 下降到下一层,以当前最近邻为起点继续搜索
4. 在 Layer 0 返回 Top-K 结果
为什么选择 HNSW?
- 查询复杂度 O(log n),远优于暴力搜索的 O(n)
- 召回率高(通常 > 95%)
- 支持动态插入,无需重建索引
- 内存开销可控(每向量约 1.5x 原始大小)
索引缓存策略
HNSW 索引构建有一定开销,因此需要缓存。设计要点:
- LRU 驱逐:最多缓存 3 个工作区,超出时驱逐最久未用的
- 内存上限:设置 256MB 上限,防止内存膨胀
- 签名验证:索引签名(更新时间、模型、维度、块数)变化时自动失效
- 并发控制:使用锁防止同一工作区被并发构建
相似度阈值
设置阈值(如 0.3)过滤低质量结果。阈值太低噪音多,太高则漏掉相关结果。0.3 是经验值,可根据实际效果调整。
混合搜索(Hybrid Search)
语义搜索的局限
语义搜索擅长找"意思相近"的代码,但对精确匹配反而不敏感:
查询: "getUserById"
语义搜索可能返回: "fetchUserDetails", "loadUserProfile" ...
而不是包含 "getUserById" 的代码
RRF 融合算法
Reciprocal Rank Fusion(RRF) 是一种简单有效的结果融合方法:
RRF_score(d) = Σ (weight_i / (k + rank_i(d)))
其中:
d是一个文档/代码块rank_i(d)是该文档在第 i 个搜索结果中的排名k是常数(通常为 60)weight_i是该搜索方式的权重
直觉:排名越靠前,贡献越大;多个来源都返回的文档,分数更高。
混合搜索流程
查询: "用户认证 handleAuth"
│
├────────────────────┐
↓ ↓
语义搜索 关键词搜索
(Embedding) (字符串匹配)
│ │
└─────────┬──────────┘
↓
RRF 融合
↓
最终排序结果
权重配置:语义搜索 0.7,关键词搜索 0.3。语义搜索权重更高,因为它是主要的搜索方式;关键词搜索作为补充,确保精确匹配不被漏掉。
关键词匹配评分
简单的关键词搜索评分考虑两个因素:
- 覆盖率:匹配了多少个查询词
- 位置:匹配出现在内容前面得分更高
增量索引
文件变更检测
使用文件哈希(BLAKE3)检测变更:计算文件哈希与 Manifest 中记录的哈希对比,相同则跳过,不同则重新索引。
清单(Manifest)
维护索引状态的元数据,包括版本、Embedding 模型、向量维度、文件哈希映射和块元数据。清单用 JSON 存储,便于调试和检查。
存储结构
.oxi/
├── manifest.json # 索引清单
├── vectors/ # 向量数据(按源文件组织)
│ └── src/
│ ├── main.rs.oxi
│ └── lib.rs.oxi
└── metadata/ # 文件元数据
向量数据使用 bincode 序列化,比 JSON 更紧凑、解析更快。
性能优化
并行索引
索引多个文件时并行处理,但需要限制并发数(如 4)避免:
- 内存耗尽(每个文件的 Embedding 请求占用内存)
- API 限流(远程 Embedding 服务通常有速率限制)
批量 Embedding
将多个文本块合并为一次 API 请求,减少网络往返,显著提升索引速度。
Token 估算
在分块前估算 token 数,避免超出模型限制。代码中符号多({, }, ;, -> 等),每个 token 包含的字符更少:
- 代码密度高:约 4.2 字符/token
- 混合内容:约 4.4 字符/token
- 自然语言:约 4.8 字符/token
模型适配
不同 Embedding 模型的推荐分块大小:
| 模型 | 目标 Tokens | 重叠 Tokens |
|---|---|---|
| BGE-small | 400 | 80 |
| BGE-M3 | 1024 | 200 |
| nomic-embed-text | 1024 | 200 |
| text-embedding-3-small | 512 | 100 |
大上下文模型可以用更大的块,减少块数量;小模型需要更小的块以保证质量。
搜索流程
完整的搜索流程:
用户查询
│
├─→ 检查索引缓存 ─→ 命中则直接使用
│ │
│ ↓ 未命中
│ 从磁盘加载向量 → 构建 HNSW 索引 → 缓存
│
↓
查询 Embedding (调用 API)
│
├──────────────────┐
↓ ↓
语义搜索 关键词搜索
(HNSW Top-K) (字符串匹配)
│ │
└────────┬─────────┘
↓
RRF 融合
│
↓
返回 SearchResult
(路径, 行号, 分数)
总结
代码语义搜索系统的核心设计:
- AST 感知分块:使用 Tree-sitter 按语法单元分割,保持代码完整性
- 向量归一化:L2 归一化后,点积即余弦相似度
- HNSW 索引:O(log n) 查询复杂度,支持动态插入
- 混合检索:RRF 算法融合语义搜索和关键词搜索
- 增量索引:哈希检测变更,只索引修改的文件
核心思路:理解代码的结构(AST 分块)+ 理解代码的语义(Embedding)+ 兼顾精确匹配(混合搜索)。