ARTICLE 2026—0911FOLIO 2026—0911NOTE / 2026—0911ACRETIONDISKARCHIVUM PERSONAEOBSERVATION NOTES

目前所做的最有用的一个产物——ClinMedAgent-RAG

医学教材特化的AI Agent检索知识库

这个项目是什么

ClinMedAgent-RAG 是一个本地、可追溯、面向多 Agent 平台的医学教材检索层。它不训练模型,也不上网——它做的是把你的纸质医学教材变成可以逐条核对页码的检索证据。

引用优先:每条结果都带 《书名》· 原书页码 · 章节 · chunk_id。页码是校准过的原书页码,可以直接翻纸质书对照。 本地优先:PDF 不复制不上传,索引就是本地一个 SQLite 文件。 不绑定 Agent:同时提供 MCP(Codex)、pi 原生扩展 + Skill、以及一个 JSON bridge,底层是同一个检索库。 自带本地网站:medical-rag-web 打开教材工作台,可视化书库状态、逐页预览、导入与处理进度。 两种 PDF 都支持:文字层 PDF 直接解析(几秒完成、完全不碰 GPU);图片型 / 混合型走 OCR 流水线(rapidocr → 版面检测 → 表格还原 → 定向纠错 → 版面结构化 → 建索引)。

一条典型的检索长这样:

《组织学与胚胎学 人卫 第10版》 第158页 · 第8章 消化腺 · 11-消化腺 (二)门管区 相邻肝小叶之间呈三角形或椭圆形的结缔组织… chunk_id=4f1c… score=10.49 vector_similarity=0.29 match_reason: 完整题干原样出现;命中概念:肝门管区;向量相似度 0.29

最后那行 match_reason 不是装饰:它明确告诉你这条为什么被选中,让你能判断证据是否可信。

起点:一份逐条核对的审计清单

这轮工作不是从“加功能”开始的,而是从一份审计报告开始。报告列了十二三类问题:导入不校验 PDF、重建结构化结果不清理旧文件、并发无锁、上传无限制、缩写匹配过松……

我的第一步不是动手改,而是逐条核对。结论是:报告里的问题都真实存在,代码行号和机制都能对上。但更重要的是——核对过程中冒出了报告里没写的、更严重的问题。

下面这三个,是我认为这一轮最值钱的产出。

发现一:幽灵命中——一个 SQLite FTS5 的语义陷阱

chunks_fts 是一张 external-content 的 FTS5 表:

CREATE VIRTUAL TABLE chunks_fts USING fts5( text, section, content=‘chunks’, content_rowid=‘rowid’ );

外部内容表的特点是:索引和原文分离,FTS5 不存正文,只存词条。删除时的正确写法是把原始列值一起交回去,否则 FTS5 不知道该从索引里扣掉哪些词条:

❌ 原来的写法:传了空字符串 for row in rows: self.cx.execute( “INSERT INTO chunks_fts(chunks_fts, rowid, text, section) “ “VALUES(‘delete’, ?, ‘’, ‘’)”, (row[0],), )

传空字符串并不会报错,它只是什么都没删掉。于是:

重建索引时,旧词条永远留在 FTS 索引里; SQLite 会复用被删除行的 rowid,新块拿到同一个 rowid; 检索命中旧词条 → 拿 rowid 回 chunks 表 join → 返回的是新块的正文。

结果就是“旧词命中、新文本返回”的幽灵。用 FTS5 自带的自检可以直接看到:

INSERT INTO chunks_fts(chunks_fts, rank) VALUES(‘integrity-check’, 1); – DatabaseError: database disk image is malformed

修复只需要一行 SQL——把原始值带回去:

self.cx.execute( “INSERT INTO chunks_fts(chunks_fts, rowid, text, section) “ “SELECT ‘delete’, rowid, text, section FROM chunks WHERE book_id = ?”, (book_id,), )

在你现有的库上修复后:自检从 malformed 变成 OK,抽样幽灵命中 118 → 0,而教材数与块数(2 本 / 1596 块)一字未变。

顺便说,这个 bug 还教会我一件事:“删除看起来生效了”和“删除真的生效了”是两件事。不报错的删除,是最贵的那种删除。

发现二:522 字节的 PDF,能吃掉 20.8 GB 内存

导入 PDF 时我开始做真正的校验,然后写了个测试构造畸形文件。其中一个是“解压炸弹”:文件只有 522 字节,却声明了一个 20000×20000 pt 的页面。

体积检查完全拦不住它——文件小得可怜。但渲染成位图时:

20000 pt ÷ 72 × 300 DPI = 83333 px 83333 × 83333 × 3 字节(RGB)≈ 208 亿字节 ≈ 20.8 GB(19.4 GiB)

而 render_page_png、OCR、版面检测都会按 DPI 把页面渲染成位图。也就是说,一个半 KB 的文件足以在预览阶段把进程打爆。

所以校验链里加了一道几何守卫:单边超过 200 英寸(14400 pt)或单页面积超过 80 倍 A4,直接拒绝。实测现在已经能正确拦下:

校验结果:已拒绝 -> 文件第 1 页尺寸异常(20000×20000 pt),疑似损坏或恶意构造的 PDF

这里顺便修正我自己一个单位错误:我最初记的是“20.8 GB”,按二进制算是 19.4 GiB。两个数字都对,只是单位不同——成文时必须写明,否则读者拿计算器一按就会认为数据有假。

发现三:两个进程同时打开一个空库,会直接崩

审计里有一条建议:“测试应补充两个进程同时重建索引”。我照做了,写完跑通,就以为完事了。

后来越想越不对——它通得太顺利了。于是把并发进程数从 2 提到 4,问题立刻稳定复现:

File "medical_rag/library.py", line 182, in init
    self.cx.execute("PRAGMA journal_mode = WAL")
sqlite3.OperationalError: database is locked

根因是我自己加的优化:为了“重建索引时读者不被阻塞”,我在连接初始化时切 WAL。但把库从默认日志模式切成 WAL 需要短暂独占锁,而这个锁不受 busy_timeout 保护。两个进程同时首次打开一个新库时会撞车——旧代码反而是“碰巧”通过的。

修法的关键在于:读当前模式不需要独占锁,而 WAL 是写进库头的持久属性——一旦是 WAL,后续进程根本不必再切。

def _enable_wal(connection, attempts=6) -> str:
    for attempt in range(attempts):
        try:
先读:读不需要独占锁,已是 WAL 就直接返回(消除撞锁的根源)
            mode = connection.execute("PRAGMA journal_mode").fetchone()[0].lower()
            if mode == "wal":
                return mode
            connection.execute("PRAGMA journal_mode = WAL")
            return ...
        except sqlite3.OperationalError:
            time.sleep(0.05 * (attempt + 1))   # 退避重试
    return ""

最后一点很重要:WAL 只是并发优化,不是正确性前提。所以最终失败时降级返回,绝不让 Library 构造失败。基础设施的优化项,不该有能力把整个服务搞挂。

我自己引入、又自查出来的缺陷

写完代码不等于写对。这一轮里我自己造成并被测试或复检抓出来的问题,列出来比只讲成绩更有用:

缺陷 后果 怎么被抓到
向量清理写在 DELETE FROM chunks 之后 子查询恒为空 → 旧向量永不删除 → 重新索引撞 UNIQUE 约束 重排阶段才暴露
打分循环里提前 pop(“vector”),而重排阶段要读它 向量通道静默失效,功能看着在、实际全程 0 分 写“向量确实提高分数”的测试时
把锁键写进 pending 任务的 meta[“lock_key”] 用户放弃上传后,该教材被永久阻塞 写“放弃的上传不应阻塞”的测试时
401 时没读请求体就关连接 Windows 直接发 RST,客户端拿到 WinError 10053 而不是状态码 一个偶发失败的测试——它其实不是偶发
目录页降权的正则写成 {3,} 与代码库自己的 […]{2,} 约定不符 → 降权从不触发 写降权单测时
indexed_at 在字段列表里但没进数据字典 该列永远写成 NULL 断言“指纹齐全”时

还有两个“猜错了”的假设,靠实测纠正:

以为目录页/无聊词只跑 FTS5 optimize 就能回收空间。实测反而从 22.61 MB 涨到 23.96 MB(合并段时临时扩张),必须再做 VACUUM 才真正释放到 15.47 MB。所以最终固化成 doctor –compact 固定两步。 以为截断的 PDF 一定会被拒绝。实测 MuPDF 会自动修复(is_repaired=True),页数完整、正文可读。硬拒绝会误伤大量可修复的真实扫描件,于是改成:不拒绝,但回传标记并在导入日志里明确告警。判断该由“内容可用性”决定,而不是一刀切。

修完之后:三档优先级

优先级 内容
P0 数据正确性 导入/上传双重 PDF 校验(失败删 .part、任务转 error);结构化产物改为暂存目录 + 原子替换;移除不可用的 copy=False;同书进程内互斥
P1 安全与工程化 上传 2 GiB 上限 + 几何守卫;文件名截断保留后缀、避开 Windows 保留名;默认禁止非本机监听 + 访问令牌;medical-rag doctor / /api/health;依赖拆成三个 requirements;books 表 9 列来源指纹
P2 检索质量 缩写只认显式白名单;中文二元组 FTS5 索引 + 规范化缓存;排序信号(目录/图注降权、概念集中度、标题匹配)+ match_reason;可插拔向量后端 + 特征式重排

其中暂存 + 原子替换值得单独说一句,因为它修的是最阴的一类 bug:重建结构化结果时,旧代码直接覆写新文件,第二次生成的章节比第一次少时,旧章节文件会原样留下,重新索引后过期内容继续被检索到。现在产物先写进 .staging-xxxx,全部成功才整体替换,失败则回滚——旧结果保持可用,也不会出现“半套新结果”。

检索层:四个刻意的“不做”

技术报告里“做了什么”容易写,“不做什么”才有信息量。这一轮有四个明确的取舍,理由都写进了代码文档:

  1. 向量不做独立全库召回,只重排词法候选的前 200 条。 没有 ANN 索引(sqlite-vec / faiss 属于新依赖)时,向量若独立扫描全库,延迟会重新变成线性增长——正好抵消掉二元组索引带来的可扩展性收益。所以向量在这里负责“细化排序”,不承担召回。

  2. 默认向量后端是词法级,不做同义词泛化。 心梗 和 心肌梗死 仍然不相似。这是明确取舍:今天不装任何东西也能用,将来接真语义模型只需换后端。把它叫作“语义检索”是不诚实的,所以文档里写清楚了。

  3. 不内置 LLM 重排。 逐候选调用本地大模型会把一次检索从几十毫秒变成数秒。而且当时环境里根本没有可用的 embedding 模型,无法端到端验证——与其发一条未经验证、会拖慢检索的默认路径,不如把接口留出来。

  4. 相邻页只标注、不合并。 引用必须保留具体页码才能校验;而且上下文本来就带回了前后页内容,合并反而弱化页码定位。

数据修复实操

修复现网数据我按四步走,每一步都留退路:

备份:用 SQLite 的 backup() API 做一致性快照(而不是直接拷文件,避免 WAL 状态不一致); 修 FTS:doctor –repair 重建索引,复验自检与幽灵命中; 重建索引:走 pipeline 的真实 index 阶段,让两本教材带上来源指纹; 压缩:doctor –compact(FTS optimize + VACUUM)。

结果:

22.61 MB → 15.47 MB(释放 7.14 MB) 1596 个证据块 · 三套索引一致 · 指纹齐全 · 检索正常

顺带修好了一个很实用的能力:books 表现在会记录 PDF 的 source_sha256 / mtime / size / content_hash / pipeline_version / page_offset,因此“PDF 已替换但网页还显示旧索引”能被自动识别——旧库第一次打开时 doctor 会直接告警“索引缺少来源指纹”。

工程化的另一半

一个能跑的原型和一个人敢用的工具,差别常常在“出问题时怎么知道”。

所以补上了 medical-rag doctor(同时暴露为 GET /api/health):检查解释器、依赖、索引库一致性、来源时效性、工作区状态,有 error 时退出码为 1,可以直接进 CI。

它还顺手抓出了我自己两个问题:一个是假阴性——自检跑在主环境里,却去查装在另一个虚拟环境里的 Paddle 依赖,于是把已安装的包报成缺失(改成在目标解释器里查版本,实测约 88 ms/次);另一个是 Path(None) 崩溃。

依赖也拆成了三份并给出实测版本矩阵——因为 OCR 需要 Python 3.14,而 PaddlePaddle 的 GPU 轮子不覆盖 3.14,所以必须两套解释器:

用途 Python 关键依赖
核心 / 检索 / 文字层 ≥3.10(实测 3.14.3) PyMuPDF
OCR / 纠错 / 结构化 3.14.3 rapidocr 3.9.2、onnxruntime-gpu 1.26.0
版面 / 表格 3.12.10 paddlepaddle-gpu 3.2.2(CUDA 12.9 / cuDNN 9.9.0)

这些版本是从本机实测读出来的,不是抄文档。

结构与分发:为什么有两个东西不能动

项目要开源分发,根目录得干净。于是把 6 个流水线脚本收进 tools/、启动脚本收进 scripts/、缓存与构建产物统一收进 .build/,并从全部 19 个提交的历史里移除了一个 436 KB 的第三方网页快照。

但有两样我坚持不动,理由值得说明:

.pi/ 必须留在仓库根目录。pi 代理是按项目根发现 .pi/extensions/ 与 .pi/skills/ 的,挪到二级目录等于直接废掉 Agent 集成——有清晰的目的,就该放在它必须待的地方。 .venv-ocr* 不动。虚拟环境里是内嵌的绝对路径,迁移有真实风险,而且它本来就被 gitignore,完全不影响分发包。

同时补上了此前缺失的两件事:MIT 许可证(没有许可证默认是“保留所有权利”,别人无权使用)和真正可安装的 pyproject.toml。后者我做了实证而不是“看着对”——构建出 wheel 并检查内容:

clinmedagent_rag-0.3.0-py3-none-any.whl(116 KB) ✓ 包含 webui/ 三个前端文件(否则装完打开网站是白屏) ✓ 包含 4 个 console_scripts ✓ 未误打包 tools/ 与 tests/

首次推送还遇到两个真实的小插曲:一是 Git 的 schannel TLS 后端与本地代理重签的证书链不兼容(换成 openssl 即通);二是远程仓库已有一个 GitHub 自动生成的占位提交、与本地无共同祖先,于是做了 force-push 覆盖——但先确认了远程那个提交里只有 LICENSE 和 README 占位文件,没有别人的东西。

值得记一笔的谨慎之处:git filter-branch –all 会顺手重写我机器上 305 个 Agent 检查点引用,很可能废掉回滚能力。所以我只重写要发布的那条分支——重写后核对:19 个提交全部保留、提交主题完整、目标文件在已发布历史中出现 0 次。

数字一览

指标 之前 现在
测试 65 项通过 231 项通过(另 17 个 subTest 场景)
FTS 自检 database disk image is malformed OK
幽灵命中(抽样 12 词) 118 0
索引库体积 22.61 MB(重建后) 15.47 MB
过期教材 2 本(无指纹) 0
检索延迟 全表扫描 3–57 ms(二元组索引驱动)
PDF 校验 只看扩展名 魔数 + 可打开 + 未加密 + 页数≤6000 + 几何守卫 + 完整性标记
根目录散落脚本 6 个 0 个

几点反思

  1. 最危险的 bug 是不报错的那种。 “失败后仍显示成功”比崩溃严重得多,因为它把错误固化成数据,再以“教材原文”的身份流向下游。这一轮优先级最高的全是这类问题。

  2. 测试要能抓住真缺陷,而不是适配 bug。 审计里有条测试用的是 b”%PDF-1.4\n” 这 9 个字节的假 PDF——它依赖的恰恰是我要修的那个 bug。加上校验后它立刻失败,这才让我发现:测试通过不代表行为正确,可能只是测试在替 bug 背书。同理,那个“两个进程并发”的测试 2 个进程能过、4 个进程必挂——并发测试的进程数本身就是测试强度。

  3. 先实测,再下结论。 这一轮我至少猜错了两次(optimize 能回收空间、截断必被拒绝)。两次都是测量结果直接推翻了我的推断。推测写进代码就是 bug,写进报告就是误导。

  4. “不做”也要有明确理由。 向量不独立召回、默认词法向量不做同义泛化、不发未验证的 LLM 重排——这些限制都写进了文档。把能力边界说清楚,比多宣称一个“AI 语义检索”更有价值,尤其是当你的输出会被别人当作带页码的证据来引用时。

试用

git clone https://github.com/AnAcretiondisk9986/ClinMedAgent-RAG.git
cd ClinMedAgent-RAG
python -m venv .venv
.venv/Scripts/pip install -e .
medical-rag doctor          # 环境自检:解释器 / 依赖 / 索引库 / 时效性
medical-rag-web             # 打开教材工作台,导入并处理你的第一本书

只做检索、不跑 OCR 的话,核心安装(仅 PyMuPDF)就够了——doctor 会把缺失的可选流水线报成 warn 而不是 error。

仓库里已经带了一份可直接翻看的教材级索引示例(2 本教材 / 1596 个证据块),以及 231 项测试覆盖失败路径与并发场景。

本轮共 19 个提交,全部已推送到 main。所有数字都来自本机实测输出,可以按文中的命令逐条复现。

读完了FINIS这一页,显影完成

文章到这里结束本条目至此结束把这一刻留在纸上,再去看一看生活。