给 coding agent 做检索,一直有个尴尬的分工问题:ripgrep 精确、快,但只认字面;向量检索理解语义,但要先建索引、还要挑 embedding 模型;BM25 卡在中间,能排序但不懂含义。三者各管一段,agent 要么自己组合,要么干脆只 grep 一下了事。
zvec-grep(命令行工具 zg)想把这层缝起来:用一个统一接口,把词法、全文、语义三种检索都接进来,本地优先,默认不把代码传出去。它在 README 里的自我定位是 “Agent-friendly hybrid workspace search across code and non-code content”。
这篇文章不复述文档,而是把 v0.2.0 的源码翻一遍,回答四个问题:
- 三种检索(ripgrep / BM25 / 向量)到底怎么统一成一个接口,RRF 融合的细节是什么?
- 分层架构怎么组织,依赖方向朝哪?
- 检索管线的「自适应召回」怎么工作?
prefer-symbol和trackEntityId是干嘛的? - 索引管线怎么做到增量、不重复 embed,又扛得住远程服务的限流?
基线:
@zvec/zvec-grep@0.2.0,仓库github.com/zvec-ai/zvec-grep。文中代码路径均相对仓库根目录。
一、定位:三种检索,两条路径
先看最上层的抽象。zg 的检索入口暴露的是「一次查询,返回最相关片段」,但它底下其实有两条互补的路径,见 docs/05-architecture.md:
| 路径 | 擅长 | 数据来源 |
|---|---|---|
| Indexed retrieval | 意图、相关概念、排序关键词 | 工作区索引里的 BM25/FTS + 向量 |
| Managed ripgrep | 已知文本、符号、路径、正则 | 直接扫工作区文件 |
- indexed 路径需要先
zg index建索引,之后查询可以把词法(FTS)和向量候选各自召回,再用 RRF 融合排序。 - ripgrep 路径(
zg query --rg)穷举扫描,不需要 embedding 模型、不需要索引,开箱即用。
这两条路径共享同一套「工作区感知过滤」(include/exclude、globs、fileTypes、maxDepth 等),返回的都是面向终端阅读或 agent 上下文的 file-oriented 结果。对 agent 来说,调用方不用关心背后走的是哪条路——这正是「统一检索层」的意义。
下面这张架构图概括了整个项目的分层:
二、分层架构:依赖自上而下,底层可替换
zg 的代码组织非常干净,六层,数据流从入口一路流到存储,每一层只依赖它下面那一层:
- 入口(
cli/mcp):zg命令行,以及一个 Streamable HTTP 的 MCP Server(protocol 2.0)。agent 通常通过zg install配置的本地 MCP 端点进来。 - Service 服务编排(
engine/service):ZvecGrepService,暴露index / context / info / dropIndex / disableIndex。它是查询规划和上下文选择的地方,也是远程 embedding 授权包装挂载的地方。 - Pipeline 管线(
engine/pipeline):search(检索)和indexing(索引)两条管线,是真正的算法核心。 - Extraction 抽取(
engine/extraction):结构感知切分——CodeExtractor(tree-sitter)、MarkdownExtractor、TextExtractor、ImageExtractor。 - Storage 存储(
engine/storage):本地 SQLite,FTS5 全文 + 向量表 + 文件/实体/片段元数据,支持增量读写。 - Models 模型(
engine/models):可插拔的EmbeddingModel,本地 transformers 或远程 qwen,索引和查询可以分别指定模型。
两个横切面值得单独点出来:Daemon(watch / runtime / job-scheduler)和 Authorization(远程 embedding 守卫)贯穿 service 与 models。后面第六节会专门讲授权。
依赖方向的约束带来一个直接好处:Storage 和 Models 完全不感知上面的编排,所以换一个 embedding 模型、或者换一个存储后端,都不需要动管线的逻辑。这也是为什么这个项目能把「本地模型」和「远程模型」用同一套接口接住。
三、检索管线:多路召回,RRF 融合
检索的核心在 src/engine/pipeline/search/index.ts。整条管线可以概括成六步:
3.1 路由展开:fts / vector / prefer-symbol
一次 context 请求会被 normalizeContextRequest 拆成若干 query group:每个主查询默认生成两条基础路由——一条 FTS、一条 vector。真正有意思的是 prefer-symbol 选项:当你搜的东西很可能是一个代码符号时,buildRecallRoutes 会先从 query 里提取符号名(用 token 正则 /[A-Za-z_~][A-Za-z0-9_:~]*/g,去掉关键字黑名单,:: 会解析成 owner 符号),然后追加一条带 symbolNames 过滤的 FTS 路由:
// buildRecallRoutes 里 prefer-symbol 追加的路由(示意)
routes.push({
mode: "fts",
filter: { ...filter, symbolNames },
})
也就是说,prefer-symbol 不是替换,而是加路:在原有 fts + vector 之外,再补一条「只在这个符号名上做 FTS」的精确通道。符号级命中天然更可信,RRF 融合时会自然把它顶上去。
3.2 自适应召回:越找不到越深挖
这是整个管线里我最喜欢的设计。向量 ANN 搜索有个经典矛盾:depth 取太小,语义命中可能被截断;取太大,又白白多算、多传输。
collectAdaptiveRecall 的解法是渐进加深:每轮取 depth 条,判断是否「饱和」(命中数 ≥ depth,说明结果可能被截断),不饱和就停,饱和就翻倍重试,直到凑够目标候选数或触及上限:
const RECALL_INITIAL_DEPTH = 200;
const RECALL_MAX_DEPTH = 2000;
const RECALL_GROWTH_FACTOR = 2;
const RECALL_TARGET_FACTOR = 5;
// 目标候选数 = max(limit × 5, 50)
于是 depth 走 200 → 400 → 800 → 1600(封顶 2000)。代价是可控的:只有真正「命中很多」的查询才会深挖,普通查询一轮 200 就结束。
3.3 RRF:排名倒数求和
召回的多条路由、多轮结果,最终交给 fuseCandidates 融合。它没有去对齐各家打分(BM25 分数和余弦相似度根本不可比),而是用 RRF(Reciprocal Rank Fusion)——只看排名,不看绝对分:
// fuseCandidates 核心:把 rank 换成贡献分累加
candidate.score += 1 / (RRF_K + recall.rank); // RRF_K = 60
一个实体如果在 FTS 里排第 1、在 vector 里排第 3,它的 RRF 分就是 1/(60+1) + 1/(60+3)。这个公式免调参,天然把词法和语义两条腿接起来,还顺带记录 matchedBy(fts / vector / fts+vector),让结果能解释「我到底是被哪条路找出来的」。
3.4 可观测性:trackEntityId
RRF 虽然好用,但一旦某个实体「该出现却没进 top-k」,排查起来很痛苦——你不知道是没召回,还是召回了被融合分压下去了。forceTrackEntity 就是为这个设计的:给它一个 entityId,它会强制加一条 FTS/vector 追踪路由,先按 groupIds 精确查这个实体,再退化到 restrictFilterToFile 兜底,最终把这个实体「为什么排在这个位置」的证据(trace)带出来。这是典型的「诊断优于猜测」的实现取向。
3.5 上下文选择:覆盖优先 + 全局补位
当一次请求带多个 query group 时,selectAndRankContextItems 先按 entityId/range 去重,然后做两步选择:先覆盖优先——轮流从每个主 group 里挑它排名最高的那条(selectionReason: "coverage"),保证每个 query 都有代表;再全局补位——按跨组 RRF 分从高到低填满剩余名额(selectionReason: "global_fill")。上限是 DEFAULT_CONTEXT_PRIORITY_LIMIT = 6。这样多 query 的结果既不会只照顾某一个 query,也不会漏掉全局最强的那几条。
四、索引管线:增量更新,分批嵌入
检索好不好,一半取决于索引建得对不对。索引实现在 src/engine/pipeline/indexing/index.ts:
4.1 增量 diff:哈希省掉重复 embed
embedding 是整个链路里最贵的部分,所以「什么不该重做」比「什么该做」更重要。computeDiffFromFiles 对每个扫描到的文件算一个三件套——sizeBytes + lastModifiedTime + contentHash,再对照索引里已有的记录分五类:
unchanged:三者一致 → 跳过,不重 embed;modified:contentHash变了 → 重抽、重 embed;added:索引里没有 → 新建;pending:上次没跑完(indexedTime === null)→ 补索引;deleted:索引里有但扫描不到了 → 删记录。
这个「用内容哈希而不是 mtime 单字段」的判定很关键:mtime 会被 git checkout、touch 之类动作刷掉,而哈希能兜住「文件看起来变了其实没变」的假阳性。
4.2 自适应并发:越顺越快,出错就退
indexFiles 把抽取出来的 fragment 按 maxBatchSize 分批,交给 AdaptiveEmbeddingScheduler 调度。它的策略简单直接:
// 成功连击够多 → 升并发
if (successStreak >= Math.max(4, currentConcurrency * 2)) currentConcurrency++;
// 限流 / 瞬时错误 → 并发减半(但不低于 min)
currentConcurrency = Math.max(min, Math.floor(currentConcurrency / 2));
配合两条重试路径:瞬时错误最多重试 3 次(基础退避 500ms),限流最多 6 次(基础退避 2000ms),都是指数退避 + 抖动;限流时还会设置 cooldownUntil。如果整个批次反复失败,就回退成逐条 embed,宁可慢,不整批废掉。
这套「乐观升、悲观降」的调度,本质上是把远程 embedding API 的限流当成一等公民来对待——这也是它敢把 qwen 这类远程模型和本地模型放进同一套索引逻辑的前提。
4.3 commit + optimize
每个文件处理完,commitFile 调 storage.replaceFile 把 fragment + vector 落库,并记录截断信息;全部跑完后 optimizeStorage 调 storage.finalizeWrites() 最终化写入。整个 index 过程套在 withHomeWriteLock 里,避免多进程(CLI + daemon)同时写坏索引。
五、结构感知抽取:为什么是 fragment 而不是 chunk
传统向量检索按固定窗口切 chunk,代码会被切得七零八落。zvec-grep 的 Extraction 层做的是结构感知:CodeExtractor 用 tree-sitter 把代码切成一个符号一个 EntityFragment——函数、类、方法各自独立,带签名、breadcrumb(所属结构路径)、symbolType;MarkdownExtractor 按标题层级切;TextExtractor 处理纯文本;ImageExtractor 则对接多模态模型。
这个设计的回报在过滤和证据两个层面显现:fragment 上的 symbol_name、symbol_type 字段可以直接进查询过滤(prefer-symbol 就靠它),而返回的「命中片段 + 所属符号 outline」让 agent 拿到的不只是一段裸文本,还有它在代码里的位置和身份。
六、信任边界:本地优先,远程要授权
local-first 是这套设计最硬的一条约束,落在代码里就是 createServiceEmbeddingModel(src/engine/service/zvec-grep.ts):
const model = createEmbeddingModel(reference, modelOptions);
if (model.info.provider !== "qwen") return model; // 本地模型直接放行
// qwen 才包一层授权守卫
const authorizedModel = {
async embed(contents, embedOptions) {
await authorize({ /* provider / model / endpoint / contentKinds ... */ });
return await model.embed(contents, embedOptions);
},
...
};
翻译成人话:只有远程 provider(qwen)才会被授权守卫拦截,而且拦截点是 embed() 本身——每次真正要把内容发出去之前,先检查是否已授权。授权分两种:--allow-remote 只授权当前这一次命令;zg auth grant --capability embedding --scope workspace 生成一个带签名的 workspace 授权,CLI 和 MCP server 共用。
配套的状态边界也很清楚:工作区索引落在 <workspace>/.zvec-grep/,全局配置和 daemon 状态落在 ~/.zvec-grep/,本地模型缓存在 ~/.zvec-grep/models;MCP server 只监听 loopback,用 Bearer 保护本地端点。Bearer 认证管的是「谁能调本地服务」,授权管的是「数据能不能出本机」,两者是两回事——文档里反复强调这一点,实现上也确实分了层。
默认模型是 local/potion-code-16m-v2(Model2Vec,1,024 token 输入、256 维),第一次用时下载到本地缓存;要长上下文或跨语言,再上 jina-embeddings-v2-base-code(8,192 token)或 embeddinggemma-300m 这类 GGUF 模型。选型文档给的原则很务实:先用能覆盖你语言和输入长度的最小模型,跑通再换大的。
小结:什么时候该用它
把源码翻完,zvec-grep 真正想解决的不是「又一个向量搜索库」,而是把三种检索的工程复杂度收敛到一个 local-first 的接口后面:
- 统一:ripgrep / BM25 / 向量,一条
zg命令,RRF 融合,matchedBy标注来源; - 可解释:
trackEntityId让你能查「为什么没进 top-k」,trace带出每条命中的证据; - 增量与自适应:内容哈希决定「该不该重 embed」,自适应并发决定「多快、多稳」,自适应召回决定「该挖多深」;
- 信任边界清晰:默认本地、数据不出机器,远程 embedding 必须显式授权,且授权与 MCP 认证分离。
反过来,它也有明确的边界:不是一个通用向量数据库(那是它底层的 zvec 库的活),不做实时网页搜索,不替代需要全局索引的 RAG。它最对味的使用场景,是给一个跑在仓库里的 coding agent 当「检索层」——既想要 grep 的精确,又想要语义检索的「懂意思」,还不想把代码上传到别人的服务器。
如果只想要一个精确的 grep,zg query --rg 就够;如果仓库敏感、必须本地推理,potion-code-16m-v2 开箱即用;如果追求跨语言、长上下文,再考虑换模型或上远程。选择权始终在调用方手里,这是这套设计最让人舒服的地方。
相关链接
- 仓库源码:zvec-ai/zvec-grep
- 架构文档:docs/05-architecture.md
- 检索管线:docs/04-pipeline.md
- Embedding 模型与授权:docs/07-embedding.md
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付