跳到主要内容

仓库上下文:tree-sitter 切片、Tantivy 索引与 RRF 混合检索

30 秒导读: Tabby 的"聪明"不在模型,而在它先把你整个代码仓库读懂、切碎、建好索引, 于是补全或提问时能一瞬间捞出"仓库里最相关的几段代码"塞进 prompt。这一章讲清这条链路: 从 tree-sitter 切片Tantivy 双路索引(向量 + 关键词)→ 查询时两路并跑 + RRF 融合排名

本章聚焦"检索"这一侧。前一环——补全如何消费这些捞回来的片段——见 第 2 章; 后一环——Answer Engine 如何编排这套检索——见 第 5 章


1. 这是什么(先建直觉)

要解决的问题: 大模型的上下文窗口装不下一个几十万行的仓库。你在 foo.rs 里写代码时, 真正有用的参考可能在 bar.rs 的某个函数里——怎么在毫秒级把那几段找出来?

Tabby 的答案: 把"找相关代码"做成一套信息检索(IR)系统。离线时把仓库嚼碎、建索引; 在线时把"当前光标附近的代码 / 用户的问题"当查询词,去索引里捞 Top-K。

一句话类比:它给你的代码仓库建了一个"搜索引擎"——既能像 Google 那样按关键词搜 (BM25),又能按"语义相似"搜(向量 embedding),最后把两份结果排名合并。

为什么要两种搜索一起上? 各有各的盲区:

检索方式擅长盲区
BM25(关键词)精确命中标识符名 getFileExtension换个说法就搜不到("读文件扩展名")
Embedding(向量)抓语义近似对精确符号名不敏感,可能漏掉

Tabby 把两者结果用 RRF(Reciprocal Rank Fusion,倒数排名融合) 合并,取长补短——这是本章的重点。


2. 顶层全景(检索的完整链路)

整条链路分两个阶段:离线建索引(indexing)和在线查询融合(retrieval)。先看全图:

离线:建索引 (crates/tabby-index)
─────────────────────────────────────────────────────────
git 仓库 ──sync/checkout──▶ 遍历文件 ──▶ ① 切片(CodeIntelligence)
│ tree-sitter 抽 tags
│ CodeSplitter 按语法切 chunk

② 每个 chunk 双路编码
│ · embedding 向量 → 二值化成 token
│ · 源码文本 → 关键词 token

③ 写进一个 Tantivy 索引
(corpus=code, doc→chunk 层级)

在线:查询 + 融合 (crates/tabby/src/services/code.rs)
─────────────────────────────────────────────────────────
查询内容 ──┬──▶ A. 向量查询(embedding token) ─┐
└──▶ B. 关键词查询(BM25 body) ─┤

RRF 融合 (RANK_CONSTANT=60) ──▶ 过阈值 ──▶ Top-K 命中

怎么读这张图: 左→右是数据流。上半区是"把仓库变成可检索的东西",下半区是"拿查询去检索并融合"。 注意 A、B 两路并行跑同一个索引,最后在 RRF 汇合——这是 Tabby 检索的核心设计。

各部件一句话职责:

部件干什么在哪
CodeIntelligence读文件、抽 tags、切 chunkcrates/tabby-index/src/code/intelligence.rs
CodeBuilder把 chunk 编码成 Tantivy 文档(向量+关键词)crates/tabby-index/src/code/mod.rs
index_repository遍历仓库、增量写索引、垃圾回收crates/tabby-index/src/code/index.rs
TantivyDocBuilder / Indexer通用的"文档→chunk"落库封装crates/tabby-index/src/indexer.rs
IndexSchema定义 corpus/doc/chunk 三层 schemacrates/tabby-common/src/index/mod.rs
CodeSearchImpl在线查询 + RRF 融合crates/tabby/src/services/code.rs

3. 切片:把文件变成可检索的 chunk

这节讲图里的 :一份源码文件,怎么变成一组带元数据的"块"(chunk)。

3.1 为什么不能整文件塞进索引

检索的最小单位是 chunk,不是文件。文件太大,整份算一个向量,语义会被"平均"糊掉; 按行硬切又会把一个函数拦腰斩断。Tabby 的做法:用语法边界来切

3.2 tree-sitter 抽取"定义/引用"tags

第一步不是切,是读懂结构CodeIntelligence::find_tagstree-sitter-tags 扫一遍语法树, 抽出"这里定义了一个函数""那里引用了一个符号"这类 Tag:

// crates/tabby-index/src/code/intelligence.rs:24 CodeIntelligence::find_tags
let mut context = TagsContext::new();
let Ok((tags, has_error)) = context.generate_tags(&config.0, content.as_bytes(), None)
else { return empty; };

抽出的每个 Tag 记录了它的字节范围、名字范围、行范围、是否是定义(is_definition)、语法类型名等 (code/types.rs:69 Tag;位置用 Point{row,column},types.rs:57)。哪些语言支持,由 code/languages.rs 里一张 language → TagsConfiguration 的表决定(python/rust/java/scala…)。

直觉:tags 相当于给文件建了一份"符号目录",让后续能知道每个 chunk 里"住着"哪些定义。

3.3 内容寻址:compute_source_file_id = 路径 + 语言 + git blob 哈希

在切之前,Tabby 先给文件算一个唯一 id。关键在于:这个 id 里含文件内容的 git blob 哈希:

// crates/tabby-index/src/code/intelligence/id.rs:10 get_git_hash
git2::Oid::hash_file(git2::ObjectType::Blob, path)?.to_string()

SourceFileId{path, language, git_hash} 序列化成字符串就是文件的 id(id.rs:14)。 这么设计的妙处:内容一变,哈希就变,id 就变——于是"文件是否被改过"退化成"id 是否还匹配", 这正是后面增量索引和垃圾回收的基石(见 §4.4)。校验逻辑在 intelligence.rs:62 check_source_file_id_matched

3.4 按语法切:CodeSplitter + CHUNK_SIZE

真正切片在 CodeIntelligence::chunks(intelligence.rs:165)。它优先用 text_splitterCodeSplitter——一个懂 tree-sitter 语法的切割器,尽量在语法边界处断开:

// crates/tabby-index/src/code/intelligence.rs:156 stream_code_chunks
let splitter = CodeSplitter::new(config.0.language.clone(), chunk_size)
.expect("Failed to create code splitter");
  • chunk 大小 默认 CHUNK_SIZE = 512(intelligence.rs:21),某些语言可在 tabby-commonlanguages.rs 里用 chunk_size 字段覆盖(languages.rs:63)。
  • 语言未知时降级:走 stream_text_chunks(纯 TextSplitter,intelligence.rs:135),不看语法。
  • 切完还要还原起始行号:chunks 里用 line_number_from_byte_offset(intelligence.rs:188) 把字节偏移换算成行号,好让命中结果能定位回源码。

compute_source_file(intelligence.rs:76)把这一切汇成一个 SourceCode (types.rs:11):id、git_url、commit、相对路径、语言、tags,外加一组代码质量指标 (最大行长、字母数字占比等,intelligence.rs:210 compute_metrics)——这些指标后面用来过滤 (见 §4.3)。


4. 索引构建与存储:双路编码进 Tantivy

这节讲图里的 ②③:一个 chunk 怎么被编码成两种"可搜索的 token",写进 Tantivy (一个 Rust 版 Lucene 全文检索库)。

4.1 三层 schema:corpus → document → chunk

所有东西都进同一个 Tantivy 索引,靠 schema 的层级区分。IndexSchema (crates/tabby-common/src/index/mod.rs:40)定义了三层:

corpus ("code" 或 "structured_doc") —— 一批文档的分组
└─ document (field_id) —— 一个文件;attributes 只存不检索
└─ chunk (field_chunk_id) —— 检索的最小单位;tokens 被索引

关键字段:field_chunk_tokens(chunk 的匹配 token,只索引不存,mod.rs:130)和 field_chunk_attributes(chunk 的 JSON 元数据,存+索引)。代码 chunk 的属性字段名在 index/code/mod.rs:12fields:CHUNK_BODYCHUNK_FILEPATHCHUNK_LANGUAGECHUNK_GIT_URLCHUNK_START_LINE

4.2 一个 chunk 的双路编码(核心)

每个 chunk 会生成两套 token塞进同一个 field_chunk_tokens——这正是"一个索引支持两种搜索"的诀窍。 入口是 CodeBuilder::build_chunk_attributes(code/mod.rs:71):

先把 chunk 包一层"文件名围栏",再送去 embedding:

// crates/tabby-index/src/code/mod.rs:117
let rewritten_body = format!("```{}\n{}\n```", source_code.filepath, body);

然后 build_binarize_embedding_tokens(code/mod.rs:131)干两件事:

  1. 关键词 token:code::tokenize_code(body) 把源码按 \w+ 正则切成词 (index/code/tokenizer.rs:4,配 RemoveLongFilter(64) 丢掉超长 token)——这是 BM25 那一路的燃料。
  2. 向量 token:调 embedding.embed() 得到浮点向量,再二值化成 token。

二值化(binarize) 是精髓——把连续向量塞进"倒排索引"这套本来只认词的机制里:

// crates/tabby-common/src/index/mod.rs:343 binarize_embedding
if *value <= 0.0 { format!("embedding_zero_{i}") }
else { format!("embedding_one_{i}") }

即:第 i 维大于 0 就产出 token embedding_one_i,否则 embedding_zero_i。 于是一个 D 维向量变成 D 个"词",向量相似度检索就退化成了词的重合度检索,复用同一套 Tantivy 倒排索引。

4.3 落库:TantivyDocBuilder 与质量过滤

通用的"文档→chunk→Tantivy 文档"封装是 TantivyDocBuilder (crates/tabby-index/src/indexer.rs:50);它的 build(indexer.rs:63)把每个 chunk 拆成 一个子文档,把 tokens 写进 field_chunk_tokens(indexer.rs:162),并统计失败的 chunk 数。 create_code_builder(code/mod.rs:154)用 corpus::CODE 实例化它。

不是所有文件都值得索引。is_valid_file(code/index.rs:212)用 §3.4 的指标卡了一批阈值:

阈值含义
MAX_LINE_LENGTH_THRESHOLD300单行太长(多半是压缩/生成文件)
AVG_LINE_LENGTH_THRESHOLD150平均行太长
MIN_ALPHA_NUM_FRACTION0.25字母数字太少(多半是二进制/数据)
MAX_NUMBER_OF_LINES100000文件行数上限
MAX_NUMBER_FRACTION0.5数字占比过半(多半是数据表)

(常量见 code/index.rs:22-26)

4.4 增量索引与垃圾回收:靠内容 id 省活

index_repository(code/index.rs:28)遍历仓库,每 100 个文件提交一批(index.rs:65)。 真正省钱的是 add_changed_documents(index.rs:106)里的增量判断:

// crates/tabby-index/src/code/index.rs:176 require_updates
if indexer.is_indexed(id) && !indexer.has_failed_chunks(id) { return false; }

因为 id 含内容哈希(§3.3),没改过的文件 id 不变 → 已索引 → 直接跳过,不用重算 embedding。 反过来,垃圾回收 garbage_collection(index.rs:79)遍历索引里所有 id,凡是 check_source_file_id_matched 不再匹配(文件被删或改)的,就删掉——不需要 checkout 分支,光比 id 即可。

仓库本身的拉取/更新在 code/repository.rs:sync_repository(repository.rs:32)克隆或 git pull,resolve_commits(repository.rs:49)决定索引哪些 ref(默认 HEAD), checkout(repository.rs:102)切到对应分支。


5. 混合检索与 RRF 融合(本章重点)

这节讲图的下半区:一个查询进来,怎么两路并跑融合排名。全在 crates/tabby/src/services/code.rs

5.1 两路并行查询

入口 CodeSearchImpl::search_in_language(code.rs:52)对同一个查询内容跑两次检索:

A 路 · 向量检索(code.rs:58):把查询 embed,同样二值化成 token,构造 embedding_tokens_query(index/mod.rs:355)去匹配 chunk 的 embedding_one/zero_i token。 每个维度命中贡献 1/D 的常量分(new_multiterms_const_query,index/mod.rs:364), 本质是近似向量相似度

B 路 · 关键词检索(code.rs:70):把查询 tokenize_code 成词,body_query (index/code/mod.rs:41)对 field_chunk_tokens 做 BM25 匹配。

两路都用 code_search_query(index/code/mod.rs:65)套上过滤条件:必须同 corpus、同 source_id、 同语言;若查询带了当前文件路径,还会把该文件本身排除(Occur::MustNot,mod.rs:92)—— 避免补全时把"正在编辑的这段"当参考捞回来。注意:语言/来源/路径这些过滤条件的打分被压成 0 (ConstScoreQuery::new(..., 0.0)),不污染相关性得分。

5.2 RRF:把两份排名合并

问题来了:A 路给的是"向量分",B 路给的是"BM25 分"——量纲不同,没法直接相加。 Tabby 用 RRF(倒数排名融合):不看分数绝对值,只看名次

核心公式在 compute_rank_score(code.rs:157):

// 一条结果排在第 rank 位(从 0 起),它的 RRF 贡献:
1.0 / (RANK_CONSTANT + (rank + 1) as f32) // RANK_CONSTANT = 60.0

一条 chunk 的最终 RRF 分 = 它在 A 路的倒数排名 + 在 B 路的倒数排名。 merge_code_responses_by_rank(code.rs:88)用一个 HashMap<chunk_id, scores> 把两路结果对齐相加:

A 路名次 B 路名次 RRF 合并分
chunk X 1 → 1/61 + 3 → 1/63 = 0.0322 ← 两路都靠前,赢
chunk Y 2 → 1/62 + (未命中) 0 = 0.0161
chunk Z (未命中) 0 + 1 → 1/61 = 0.0164

为什么加常量 60? RANK_CONSTANT(code.rs:86)压平头部差距——让第 1 名和第 2 名的分差 不至于悬殊,使"两路都还不错"的结果能胜过"某一路第一、另一路没有"的结果。这就是取长补短的落点。

CodeSearchScores{rrf, bm25, embedding}(api/code.rs)三个分都保留:rrf 用来排序, bm25/embedding 用来过阈值。

5.3 融合后的收尾:阈值 + 每文件限量

融合排完序,还有两道关卡(code.rs:129-142):

  1. 每个文件最多留 2 个 chunk:retain_at_most_two_hits_per_file(code.rs:145)—— 防止一个大文件霸屏,保证结果多样性。
  2. 三重阈值过滤:bm25 > min_bm25_scoreembedding > min_embedding_scorerrf > min_rrf_score,最后 take(num_to_return)。默认阈值在 CodeSearchParams::default(api/code.rs):
参数默认值作用
min_embedding_score0.75向量分下限
min_bm25_score8.0关键词分下限
min_rrf_score0.028融合分下限
num_to_score40每路先取多少条来打分
num_to_return20最终返回多少条

注意 num_to_score(40)> num_to_return(20):先宽召回、再融合、后收窄,是典型的 "召回—精排"两段式。命中结果 create_hit(code.rs:169)还会回查该文件的 commit,拼出完整 CodeSearchDocument(含 body、filepath、start_line 等)交给上层。


6. 附:structured doc 索引(问答引擎用)

代码不是唯一被索引的东西。同一套 Tantivy schema 还承载了另一个 corpus:structured_doc (corpus::STRUCTURED_DOC),给 Answer Engine 的知识问答用(详见 第 5 章)。

它复用 §4 的 doc→chunk 结构和二值化 embedding,只是文档类型不同。类型定义在 crates/tabby-index/src/structured_doc/types/:

类型常量大致内容
WebDocumentKIND_WEB爬取的网页
IssueDocumentKIND_ISSUEGitHub issue
PullDocumentKIND_PULLPull request
CommitDocumentKIND_COMMITgit commit
PageDocumentKIND_PAGE站内页面
IngestedDocumentKIND_INGESTED外部导入文档

(枚举 StructuredDocFields 及各 KIND_* 常量见 structured_doc/types.rs:24-82。) 它们都实现 BuildStructuredDoc(types.rs:64),build_chunk_attributes 里同样调 binarize_embedding 产出向量 token(types.rs:123 build_tokens)——所以代码检索与文档检索 共享同一套倒排 + 二值向量机制,只是查询侧走不同的 schema 字段。


7. 巧妙之处(可借鉴)

  • 二值化 embedding = 用全文索引做向量检索。 不引入专门的向量库,把浮点向量拍成 embedding_one_i / embedding_zero_i 这样的"词",复用 Tantivy 倒排——工程上极省事 (index/mod.rs:343)。
  • 内容寻址的文件 id。 id 里含 git blob 哈希,让"增量索引"和"垃圾回收"都退化成廉价的 id 比对,不必重算 embedding、甚至不必 checkout(intelligence/id.rs:10code/index.rs:176)。
  • RRF 而非分数相加。 回避了"向量分 vs BM25 分量纲不一致"的经典难题,只看名次 (code.rs:157)。加常量 60 是抑制头部方差的常见工程手法。
  • 过滤条件零打分。 语言/来源/路径用 ConstScoreQuery(0.0) 参与召回但不参与排序, 干净地分离"过滤"与"相关性"(index/code/mod.rs:71)。
  • 每文件限 2 条 + 先宽后窄。 保证结果多样性,避免大文件霸屏(code.rs:145)。

8. 边界与局限(诚实)

  • tags 支持的语言有限:只覆盖 code/languages.rs 那张表里的语言,其余走纯文本切片,失去语法感知。
  • 二值化是有损的:把浮点向量压成 1 bit/维,只保留符号信息,牺牲精度换索引复用——是近似检索,不是精确最近邻。
  • 索引与 checkout 强绑定:index_repository 会真的 checkout 分支到工作区(code/index.rs:44), 对本地仓库是有副作用的操作。
  • 阈值是硬编码的经验值:min_bm25_score=8.0 之类(api/code.rs)对不同仓库/embedding 模型未必最优, 代码里没有自适应机制。
  • 写入并发上限:Indexer 每个 writer 固定 150MB heap(indexer.rs:200 附近),大仓库靠分批提交扛。

9. 代码地图(导航索引)

主题文件关键符号
tree-sitter 抽 tagscrates/tabby-index/src/code/intelligence.rsCodeIntelligence::find_tags
切片 / 行号还原crates/tabby-index/src/code/intelligence.rsCodeIntelligence::chunks, stream_code_chunks, CHUNK_SIZE
内容寻址文件 idcrates/tabby-index/src/code/intelligence/id.rsSourceFileId, get_git_hash
chunk 数据模型crates/tabby-index/src/code/types.rsSourceCode, Tag, Point
chunk 双路编码crates/tabby-index/src/code/mod.rsCodeBuilder::build_chunk_attributes, build_binarize_embedding_tokens
二值化 embeddingcrates/tabby-common/src/index/mod.rsbinarize_embedding, embedding_tokens_query
增量索引 / GCcrates/tabby-index/src/code/index.rsindex_repository, add_changed_documents, require_updates, garbage_collection, is_valid_file
通用落库封装crates/tabby-index/src/indexer.rsTantivyDocBuilder, Indexer
三层 schemacrates/tabby-common/src/index/mod.rsIndexSchema, corpus::CODE
关键词 tokenizercrates/tabby-common/src/index/code/tokenizer.rstokenize_code
查询构造 / 过滤crates/tabby-common/src/index/code/mod.rscode_search_query, body_query, language_query
仓库同步crates/tabby-index/src/code/repository.rssync_repository, resolve_commits, checkout
在线查询 + RRFcrates/tabby/src/services/code.rsCodeSearchImpl::search_in_language, merge_code_responses_by_rank, compute_rank_score, RANK_CONSTANT
检索参数 / 分数crates/tabby-common/src/api/code.rsCodeSearchParams, CodeSearchScores
structured doccrates/tabby-index/src/structured_doc/types.rsStructuredDocFields, BuildStructuredDoc, KIND_*