GitHub仓库发布——增强版Qdrant MCP服务器

仓库地址:SinonJZH/mcp-server-qdrant(当前版本 0.9.0,Apache 2.0) 前情提要:《给 AI 一份外置记忆:我的个人知识库部署全记录》——我的个人知识库搭建记

一、上回说到

之前写过一篇讲 AI 外部记忆库的文章:用 Qdrant 存记忆,官方的 mcp-server-qdrant 做 MCP 封装,让 AI 编码助手在会话之间拥有一个长期记忆库。文章里留过一个当时就很别扭的伏笔——

MCP 的 store 只有新增没有更新,重复写入即永久冗余,故入库前必须查重。

这句话反过来说就是:一旦存错了、存旧了、需要修正了,AI 自己毫无办法。它只能眼睁睁看着一条过时的结论躺在库里,每次语义检索都被它污染;唯一的处理方式是我自己开终端、跑脚本、直打 Qdrant REST 去删去改。AI 负责制造垃圾,我负责倒垃圾。

记忆库不是日志。日志只增不改天经地义,但记忆是要维护的:结论会过时,决策会翻案,半年前的踩坑解法如今有了更优雅的版本。一个只有 store 和 find 的记忆库,本质上是一本不许涂改的账本——而记账的人偏偏是最常写错的那个。

于是就有了这个 fork。今天正式发布:SinonJZH/mcp-server-qdrant,在官方仓库基础上补全了记忆的删改能力,外加一整套发布基建。

二、官方为什么不加,我为什么要 fork

这个需求不是没人提过——提了一年多了。

2025 年 7 月,社区开了 issue #74《Need tool to edit or delete records》:楼主抱怨只能靠给每条记录打时间戳、变更时重复 store 来模拟「改」,结果检索 top 10 里九条都是同一条记忆的历史版本,甚至是「5 条 X 加 5 条 deprecate X」。底下陆续有人附议,也有人分析了根因:store 用随机 UUID 生成 point ID,upsert 永远撞不上已有记录——更新在结构上就不可能发生。

这个 issue 挂了九个多月,2026 年 4 月底被关闭:没有实现,没有替代方案,也没有一句来自官方的回应。同一天,另一个直白的《delete》(#101)被官方以「#69 的 duplicate」名义关闭——而 #69《more mcp function calls》自己至今还开着。

需求这扇门关上了,社区就去敲实现的门。今年 3 月,两份 PR 递了上去:#116 加 qdrant-delete,#121 加 qdrant-edit。有意思的是,官方没人来合并,社区倒是相当积极——有人自发把 PR 测试清单里没勾的项逐条验证完,有人拿 #74 里的真实场景对 PR 做了回归。到今天快七个月过去,两份 PR 仍然挂在 open 状态。

官方的极简立场其实不难理解——上篇文章我自己还引用过那句「工具越少,误用面越小」。删改意味着破坏性操作,意味着权限、确认、审计一整串新问题,不做确实更安全。

但理解不等于接受。issue 关了,PR 挂着,记忆库里的脏数据可不会自己消失。个人知识库是自己的地盘,与其等一个不知道会不会来的 merge,不如 fork 一份自己动手——好在 Apache 2.0,改起来名正言顺。

三、改了什么:总览

fork 基于上游 v0.8.1 之后的 master,改动一览:

官方仓库 本 fork
工具 qdrant-store / qdrant-find 新增 qdrant-update / qdrant-delete
find 返回 正文 + metadata 额外带 point ID 和相似度分数
只读模式 隐藏 store store / update / delete 一起隐藏
Docker 有 Dockerfile(从 PyPI 装包),无官方镜像 源码构建,GHCR 自动发布
安装 uvx mcp-server-qdrant(PyPI) uvx --from git+… 直装,不发 PyPI
文档 英文 中英双语(README.md / README.en.md)
客户端插件 Kiro Power Kiro Power + DSH 插件

核心增量是前两行,后面几行是为了让「发布」这件事成立的配套工程。下面挨个说。

四、核心:把删改的「句柄」还给 AI

find:先把 ID 带回来

要删改一条记忆,前提是能指认它。官方的 find 只返回正文和 metadata,AI 看着一堆相似的内容,根本无法向数据库指认「我要改的就是这条」;#74 里社区把根因分析得很透:store 用随机 UUID 当 point ID,upsert 永远撞不上旧记录,「更新」在结构上就无从谈起。所以删改功能的第一步不在删改本身,而在 find:

现在每条检索结果都带上了真实的 point ID 和相似度分数:

<entry><id>a3f9c1d2…</id><score>0.8234</score>
  <content>embedding 一致性红线:jinaai/jina-embeddings-v2-base-zh(768 维)…</content>
  <metadata>{"project": "personal", "type": "pitfall"}</metadata>
</entry>

上篇文章说过「感觉会骗人,命中列表不会」——删改操作只认命中列表里的 ID,这个设计后面还会提到。

update:以新换旧,ID 不变

qdrant-update 按 point ID 原地更新条目:新内容用当前配置的 embedding 模型重新嵌入,point 保持 ID 不变,metadata 传入即整体替换、不传即清空。这就是上篇那个「重复写入即永久冗余」的坑的正解——修正一条过时结论,现在是 update 一条,而不是再 store 一条新的、让旧的那条变成永远检索得到的幽灵。

两个防御性的细节:

  • 更新前先 retrieve 校验目标存在。point ID 不存在时什么都不改,只返回一条提示——绝不借着更新的名义,用一个捡来的 ID 往库里塞新条目;
  • 若配置了 QDRANT_READ_ONLY,update 和 store、delete 一起隐藏,只读模式名副其实。

delete:先查后删,删了什么都有据可查

qdrant-delete 接受一组 point ID 批量删除。实现上同样是先查后删:先把命中的 point 连 payload 一起取回来,删除完成后在返回里逐条列出被删内容的前 100 字符,外加一份「没找到、因此什么都没删」的 ID 清单。实际返回长这样:

Deleted 2 entries from collection knowledge:
  - a3f9c1d2…: embedding 一致性红线:jinaai/jina-embeddings-v2-base-zh(768 维,…
  - 7b1e88f0…: 集合命名向量规则:fast-<模型短名>,第三方直连 REST 需对上此名…
No point found for ID 0d42aa15… in collection knowledge; nothing was deleted for them.

删除不可逆,所以删完的回执必须让人能核对——这是数据库运维的老规矩,放进 AI 工具里同样成立。

为什么只认 ID,不给「按内容删」

语义相邻不等于同一条。给 AI 一把「把关于 X 的记忆都删了」的刀,等于拿向量当主键用,早晚误删。所以这个 fork 刻意不提供任何按内容(或按 filter)删改的入口:想删改,先 find、看命中列表、确认 ID,再动手。多一步,但每一步都有据可查。

另一个护栏的位置也值得说:delete 的工具描述里明文写着「删除不可逆,调用前必须征得用户确认,只准传 qdrant-find 返回过的 ID」。工具描述是模型每次决定是否调用时都会读的文本——README 是写给人看的,工具描述是写给模型看的,安全规则要放在后者才真的生效。

五、工程化:能跑和能发布是两回事

Dockerfile 重写:一个隐蔽的坑

官方 Dockerfile 的核心一行是 uv pip install mcp-server-qdrant——从 PyPI 装。对官方自己没问题,对 fork 是个天坑:PyPI 上的 mcp-server-qdrant 是官方包,不带删改功能。直接沿用的话,镜像照样能跑,工具列表里就是悄悄少两个,不特意对比根本发现不了。

所以 Dockerfile 整个重写了:

  • 从本仓库源码构建,uv 钉死在 0.13.0(与 uv.lock 的维护版本一致),uv sync --frozen 严格按锁安装,构建可复现;
  • 依赖清单与源码分两层 COPY,配合 Buildx 缓存挂载——改代码不触发依赖重装;
  • 基础镜像升到 python:3.12-slim,默认以 streamable-http 传输运行,容器内预设 0.0.0.0:8000;
  • embedding 模型不进镜像:FASTEMBED_CACHE_PATH 指向挂载卷,缓存里已有模型时还可以设 HF_HUB_OFFLINE=1 完全离线。

发布流水线:测试不过不发版

新增一条 GitHub Actions 流水线发布到 GHCR(ghcr.io/sinonjzh/mcp-server-qdrant),几处刻意的取舍:

  • 测试是发布的前置门禁:先跑 pytest(uv 包缓存 + fastembed 模型缓存都做了缓存),过了才构建推送。上游独立的 pytest / pre-commit / pypi-publish 三条 workflow 删掉了,测试折进发布流水线——fork 的发布节奏里,测试只为一件事服务:别把坏的东西推出去;
  • 标签策略:推 master 发滚动的 master 和 sha-* 标签;打 v* tag 发 X.Y.Z 和 X.Y;坚决不打 latest——滚动标签是部署事故的温床,生产环境应该钉死版本;
  • 并发控制:concurrency 按 ref 分组、取消进行中的旧构建,连续推 master 时不排队、不白烧 runner。

六、怎么装

PyPI 的 mcp-server-qdrant 名字被官方占用,本 fork 不发 PyPI,用 uvx 从 GitHub 直装:

QDRANT_URL="http://localhost:6333" \
COLLECTION_NAME="memories" \
EMBEDDING_MODEL="jinaai/jina-embeddings-v2-base-zh" \
uvx --from git+https://github.com/SinonJZH/mcp-server-qdrant mcp-server-qdrant

首次运行会从源码构建,稍慢;以后更新代码加 --refresh 重新拉取即可。偏好容器的话:

docker run -d -p 8000:8000 \
  -e QDRANT_URL="http://your-qdrant-server:6333" \
  -e EMBEDDING_MODEL="jinaai/jina-embeddings-v2-base-zh" \
  ghcr.io/sinonjzh/mcp-server-qdrant:0.9.0

Claude Desktop、Cursor、Claude Code、VS Code 的配置 JSON 在 README 里都有现成的;另外仓库里还打了两个客户端插件包:dsh-plugin/(DSH 插件,零配置连本地 Qdrant)和 kiro-power/(Kiro 的 Power,上游本就有这个目录,改成了指向本仓库)。

对存量用户零迁移成本:payload 结构没动(还是 document + metadata 两层),embedding 模型自己配,官方服务器写的集合 fork 直接能读写,反之亦然——升级带来的全部变化,就是工具列表里多出两个名字。

七、写在最后

上篇文章的结尾说,记忆的价值不在于存,在于检得到。这回算是补完了后半句:检得到,还得改得动、删得掉。

变化其实比「多两个工具」要大。之前的记忆库是个只增不改的流水账,维护全靠库外人肉;现在 AI 查重之后可以顺手 update,发现过时结论可以建议 delete——而且按工具描述里写死的规矩,删之前还知道先来问我。AI 第一次能自己收拾自己的记忆了。

上篇还有一句话:技术栈是公共知识,纪律是私有资产。这次的 fork 算是在纪律上长出了一小块代码——把「入库前查重」「删除前确认」这些私有的纪律,固化成了公共的工具。

如果哪天官方把 #116 / #121 合并了,这个 fork 的核心增量也就功成身退。在那之前,它就是我的记忆库唯一的维护入口。

仓库在此,欢迎提 issue 和 PR。