仓库地址: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。