返回文章列表
OrbitX Logo专栏文章
从零到一构建智能终端

深入 OrbitX 智能终端的架构设计与实现细节,从底层 Mux 架构到前端渲染优化,从 Shell Integration 到 AI 功能集成

从零到一构建智能终端(六):代码语义搜索的向量数据库设计

探讨如何构建面向代码的向量数据库系统,包括 AST 感知分块、嵌入向量索引、余弦相似度搜索和混合检索策略

12 min read
6

引言

传统的代码搜索依赖关键词匹配——输入 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, ...]

不同语言的提取规则:

语言提取的节点类型
Pythonfunction_definition, class_definition
TypeScript/JSfunction_declaration, class_declaration, method_definition, arrow_function
Rustfunction_item, impl_item, struct_item, enum_item, trait_item
Gofunction_declaration, method_declaration, type_declaration
Javamethod_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 是经验值,可根据实际效果调整。

语义搜索的局限

语义搜索擅长找"意思相近"的代码,但对精确匹配反而不敏感:

查询: "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。语义搜索权重更高,因为它是主要的搜索方式;关键词搜索作为补充,确保精确匹配不被漏掉。

关键词匹配评分

简单的关键词搜索评分考虑两个因素:

  1. 覆盖率:匹配了多少个查询词
  2. 位置:匹配出现在内容前面得分更高

增量索引

文件变更检测

使用文件哈希(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-small40080
BGE-M31024200
nomic-embed-text1024200
text-embedding-3-small512100

大上下文模型可以用更大的块,减少块数量;小模型需要更小的块以保证质量。

搜索流程

完整的搜索流程:

用户查询
    │
    ├─→ 检查索引缓存 ─→ 命中则直接使用
    │         │
    │         ↓ 未命中
    │   从磁盘加载向量 → 构建 HNSW 索引 → 缓存
    │
    ↓
查询 Embedding (调用 API)
    │
    ├──────────────────┐
    ↓                  ↓
语义搜索            关键词搜索
(HNSW Top-K)       (字符串匹配)
    │                  │
    └────────┬─────────┘
             ↓
        RRF 融合
             │
             ↓
      返回 SearchResult
      (路径, 行号, 分数)

总结

代码语义搜索系统的核心设计:

  1. AST 感知分块:使用 Tree-sitter 按语法单元分割,保持代码完整性
  2. 向量归一化:L2 归一化后,点积即余弦相似度
  3. HNSW 索引:O(log n) 查询复杂度,支持动态插入
  4. 混合检索:RRF 算法融合语义搜索和关键词搜索
  5. 增量索引:哈希检测变更,只索引修改的文件

核心思路:理解代码的结构(AST 分块)+ 理解代码的语义(Embedding)+ 兼顾精确匹配(混合搜索)。