返回博客2026年9月6日2 分钟阅读

我给 DeepSeek DSH 做了个 zvec-grep 插件

摘要

从一次代码语义搜索实验,到一个自动索引、可见状态、后台增量更新的 DeepSeek DSH 插件,我在 dsh-zvec-grep 开发中做了哪些取舍。

zvec-grep 接入 DeepSeek DSH

最近,我无意中了解到 zvec,这是一款由阿里巴巴开源的向量数据库。它直接运行在应用程序进程中,索引保存在本地,不需要另外维护一套数据库服务。随后出现的 zvec-grep 又把文件发现、代码切分、embedding、BM25、向量召回和结果融合组合起来,变成了面向代码仓库的检索工具。

为了把这次学习整理清楚,我做过一期视频:《阿里巴巴开源向量数据库 zvec 与 zvec-grep:代码搜索不只是向量检索》,感兴趣的朋友可以戳链接,B站同款在这里。视频主要回答“Codex 已经会搜索,为什么还需要 zvec-grep”,也记录了我用真实代码仓库演示精确搜索与语义召回的过程。后来开发插件时,关于工具分工、索引更新等的判断,都来自这次的学习。

一开始我只是想知道:Codex 配合 rg 已经很好用了,代码语义搜索还能补上什么?后来我在真实仓库里做尝试以后,又开始琢磨,能不能把这套能力放进其他 Agent。再往后,这个念头变成了一个 DeepSeek DSH 插件:dsh-zvec-grep

今天这篇文章,算是我的一期开发笔记吧。我会按实际开发过程,梳理从试用 zvec-grep 到设计插件的几次转折,以及我对“DSH插件应该怎样为用户工作”的理解。期望对大家有所帮助。

先确认它解决的是什么问题

已知函数名、配置键、错误信息或一段原文时,rg 依然是更合适的工具。它搜索的是当前磁盘,不需要索引,结果可以穷举,也没有索引过期的问题。

语义搜索的价值出现在另一种情况:我知道业务行为,却不知道代码采用了什么名字。

我在 chat-ollama 仓库里试过这样一个问题:用户在个人目录和项目目录里分别添加了自定义 Agent 能力,应用如何发现它们、处理重名,并在新的 Agent 请求里让它们生效?

这个问题没有给出文件名、函数名或变量名。普通文本搜索先从 custom agent capabilitiespersonal directory 之类的表达开始,没有命中。模型需要先猜到项目里把这类能力称作 skill,然后继续查目录扫描、合并规则和 Agent 创建入口。

把含义相同的英文问题交给 zvec-grep 后,第一次查询就召回了 Agent 创建入口和技能目录扫描实现。沿着这两个入口读取当前源码,可以还原完整流程:同时扫描用户目录和项目目录,按技能名称合并,项目级定义覆盖同名用户级定义,创建 Agent 时再把技能加入 middleware 和 instruction。

这次实验没有证明向量搜索可以替代 grep。它证明的是,在“不知道项目用了什么词”的时候,语义索引有机会给 Agent 一个更好的第一跳。找到入口以后,仍然要回到实时源码验证。

同一个问题直接换成中文,召回质量又明显下降。这也让我很早就确认了 zvec-grep 的局限性:embedding 模型、查询语言和仓库语言都会影响结果,插件还不能把“语义相关”包装成“回答正确”。

注,在视频中,我没有通过 agent 调用 zvec-grep 工具,而是直接通过 CLI 做的查询演示。

从自己试用到给其他 Agent 使用

zvec-grep 已经提供了 CLI 和面向多个 Agent 的安装流程。用户可以安装 zg,执行 zg install,再进入仓库运行 zg index。这很适合主动选择并配置一套检索工具的人。

但把它做成 DeepSeek DSH 插件时,我不想把这些步骤原样搬给用户。因为插件的意义不应该只是“安装后再告诉你去安装另一个工具”。

我希望安装命令结束以后,下面这些事情都由插件接管:

  1. 从当前 Session 知道正在使用哪个 workspace。
  2. 第一次进入 workspace 时自动建立索引。
  3. 文件发生变化后自动增量更新。
  4. Agent 在适合的任务里获得语义搜索工具。
  5. 用户能看见索引正在建立、刷新、可用,还是出了错。
  6. DSH 退出或插件卸载时,索引任务与后台资源能一起释放。

最后,安装只剩一条命令:

npx @deepseek-ai/dsh plugin --profile web add @sugarforever/dsh-zvec-grep

不再需要单独运行 zg installzg index,也不需要启动 MCP Server。

现在启动 DSH 就好。比如:

npx @deepseek-ai/dsh web

没有包装 CLI,也没有重写 zvec-grep

最初很容易想到两种实现。

一种是从 DSH 调用 zg CLI,或者接入它的 MCP Server。这条路能复用现成入口,但会引入额外进程、配置文件和服务生命周期。用户安装了一个插件,背后却还要理解另一个常驻服务为什么没有启动、端口是否冲突、配置是否写对。

另一种是直接基于 zvec 重写代码搜索。这又走到了另一个极端:文件发现、Git ignore、代码切分、文档提取、embedding、索引 schema、增量 diff、BM25、RRF、结果裁剪和异常恢复,都需要重新实现。它没有改善用户体验,只是把上游已经做过的工程再做一遍。

最后我选择了中间位置:直接使用 zvec-grep 正式导出的 createZvecGrep() 引擎 API。

DeepSeek DSH Session
  -> dsh-zvec-grep workspace runtime
  -> createZvecGrep()
  -> zvec-grep 的扫描、切分与混合检索
  -> 本地 zvec 索引

这样既保留 zvec-grep 已有的检索能力,又让 DSH 插件管理 workspace、工具注册、状态展示与资源释放。插件不是给 CLI 再包一层,而是在适配两个项目的生命周期。

workspace 才是运行时的边界

DSH 创建或恢复 Session 时,插件从不可变的 session.header.cwd 取得 workspace 路径。相同 workspace 下的多个 Session,共用一个搜索引擎、一个 watcher 和一套索引协调状态。

这件事看起来只是做了一个 Map,但它决定了插件会不会在用户开三个对话后,把同一个仓库索引三遍。运行时会先把路径转换成真实路径,避免符号链接和不同写法把同一目录识别成多个 workspace。

首次索引在后台开始。zvec_search 不负责触发刷新,也不会为了等待索引而导致工具调用长时间不返回。如果当前状态是 indexingrefreshingerror,它会立即返回结构化状态;只有索引处于 ready 时,才会查询现有索引。

我在开发过程中曾采用“第一次搜索自动等待索引完成”的行为。真实使用以后,我改了这个决定。大仓库的首次索引时间不可预测,让工具调用静默地挂在那里,Agent 和用户都不知道是在工作还是已经卡住。即时返回状态以后,Agent 可以稍后重试,也可以先退回精确搜索。Agent 或用户应该足够智能地去判断,当索引正在进行时,是该等待还是跳过。

这是我对插件行为的一条基本判断:耗时的后台准备可以自动完成,但不能把等待隐藏在一次看不见进度的调用里。

索引“保鲜”不能只靠一句“自动更新”

语义检索多了一份索引,就一定会遇到索引与当前文件不一致的问题。插件启动后会监听 workspace 的新增、修改和删除事件,把短时间内连续发生的路径合并,再调用 index({ changedPaths }) 做增量更新。

第一版使用 Chokidar。后来在真实 workspace 里观察资源占用时,我把它换成了 Node.js 原生递归 fs.watch。插件只在 watcher 层硬过滤 .git.zvec-grepnode_modules,其他文件是否应该进入索引,继续交给 zvec-grep 的扫描规则判断。

原生 watcher 更直接,但操作系统事件不是绝对可靠的。为了修复可能漏掉的变化,运行时默认每小时再安排一次完整校准。这里的结构是:

文件事件 -> 750ms 合并 -> changedPaths 增量索引
                     
每小时定时任务 ---------> 完整校准

增量路径负责日常响应速度,定时校准负责最终修复漂移。搜索本身则使用 autoUpdate: false,避免前台查询又偷偷启动另一套刷新逻辑。谁负责更新、谁负责查询,在插件里职责明确分开。

状态不是装饰,而是功能的一部分

如果插件自动建立索引,却不告诉用户发生了什么,那么“零配置”很快就会变成“无法判断”。第一次下载本地 embedding 模型、建立较大仓库的索引、处理一批文件变化,都可能需要时间。

因此我给 DSH Web 增加了一个很小的状态胶囊。它会显示 LoadingIndexingRefreshingReadyError。展开以后还能看到当前 workspace 和待处理变化数量。

DSH Web 中展开的 Zvec 索引状态胶囊

服务端状态接口只接受 Session ID,再从 DSH 自己的 Session 列表中查找 workspace。浏览器不能通过查询参数随便读取另一个本地路径的状态。客户端切换 Session 时也会取消旧请求,避免较慢的响应把新 workspace 的状态覆盖掉。

这个 UI 没有参与搜索排序,却改变了插件能否被理解。一个后台功能,只要存在准备时间和失败状态,就应该给用户留下可观察的界面,而不是只把日志写进终端。

退出时也要完成工作

首次索引可能持续很久。最初的实现里,如果 DSH 在索引过程中退出,插件的 close() 会等待尚未完成的任务,结果是用户关闭应用时反而被后台索引阻塞。

我为这个场景补了一个会挂起的测试,再用 AbortController 把取消信号传给首次索引与后续刷新。插件卸载时先取消任务,清理 debounce timer 和定时校准,关闭 watcher,最后释放每个 workspace 的引擎。

自动启动的资源,也应该自动结束。

插件要替用户承担复杂度

开发 dsh-zvec-grep 以后,我对插件的理解比开始时更具体了。

插件不是把一个库暴露出来就结束。它应该利用宿主已经知道的信息,例如 Session 的 workspace;应该选择合适的自动化边界,例如后台建立与更新索引;应该把不可避免的不确定性暴露出来,例如索引状态和错误;还应该保留退出路线,让 Agent 在索引未就绪时继续使用 rg

这也是为什么插件会在 DSH 中注册一段 system prompt,提醒 Agent 做出工具分工:不知道具体措辞或文件位置,需要语义发现或跨文件搜索时,使用 zvec_search;已知标识符、字面量、正则,或者需要穷举所有结果时,使用精确 grep。zvec_search 自己的工具描述还会进一步说明,它适合查找架构、关系、控制流和跨文件证据。

默认 embedding 是本地的 local/potion-code-16m-v2,源文件、模型推理和索引可以留在机器上。索引保存在 workspace 的 .zvec-grep/ 下,项目应把它加入忽略规则。多语言召回仍然取决于模型,搜索结果也始终只是候选证据,不是对当前代码的最终判断。

从第一次对照实验到插件能够自动工作,我真正想保留的不是“向量搜索比 grep 更强”这个结论,而是一种组合方式:让语义搜索负责不确定的第一跳,让精确搜索负责已知文本和完整匹配,再让 Agent 回到当前文件完成验证。


如果你对插件感兴趣,欢迎点赞。使用中如有问题或建议,请在 GitHub 提交 issue 或 PR。


相关文章

最近一封 · Sample

新版 DSH 为什么启动不了 dsh-lark

一次插件兼容性升级的完整复盘:从干净环境复现启动崩溃,到识别 npm 产物与源码的差异,再用真实 tarball 完成端到端验收。

—— william

Letters

来信

里面装的是

  • 新文章 — 写完一篇就寄一封,不攒货
  • 这周读到的、看到的、好用的工具
  • 正在折腾的实验,附带翻车记录

约莫 1–2 周一封 · 随时退订

合作伙伴

CompeteMap — 英国及爱尔兰学生竞赛一站式搜索

数学、编程、科学、写作等各类竞赛信息汇总,支持按年龄和科目筛选,再也不错过报名截止日。

准备开始了吗?

先简单说明目标,我会给出最合适的沟通方式。