目前所做的最有用的一个产物——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,全部成功才整体替换,失败则回滚——旧结果保持可用,也不会出现“半套新结果”。
检索层:四个刻意的“不做”
技术报告里“做了什么”容易写,“不做什么”才有信息量。这一轮有四个明确的取舍,理由都写进了代码文档:
-
向量不做独立全库召回,只重排词法候选的前 200 条。 没有 ANN 索引(sqlite-vec / faiss 属于新依赖)时,向量若独立扫描全库,延迟会重新变成线性增长——正好抵消掉二元组索引带来的可扩展性收益。所以向量在这里负责“细化排序”,不承担召回。
-
默认向量后端是词法级,不做同义词泛化。 心梗 和 心肌梗死 仍然不相似。这是明确取舍:今天不装任何东西也能用,将来接真语义模型只需换后端。把它叫作“语义检索”是不诚实的,所以文档里写清楚了。
-
不内置 LLM 重排。 逐候选调用本地大模型会把一次检索从几十毫秒变成数秒。而且当时环境里根本没有可用的 embedding 模型,无法端到端验证——与其发一条未经验证、会拖慢检索的默认路径,不如把接口留出来。
-
相邻页只标注、不合并。 引用必须保留具体页码才能校验;而且上下文本来就带回了前后页内容,合并反而弱化页码定位。
数据修复实操
修复现网数据我按四步走,每一步都留退路:
备份:用 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 个 |
几点反思
-
最危险的 bug 是不报错的那种。 “失败后仍显示成功”比崩溃严重得多,因为它把错误固化成数据,再以“教材原文”的身份流向下游。这一轮优先级最高的全是这类问题。
-
测试要能抓住真缺陷,而不是适配 bug。 审计里有条测试用的是 b”%PDF-1.4\n” 这 9 个字节的假 PDF——它依赖的恰恰是我要修的那个 bug。加上校验后它立刻失败,这才让我发现:测试通过不代表行为正确,可能只是测试在替 bug 背书。同理,那个“两个进程并发”的测试 2 个进程能过、4 个进程必挂——并发测试的进程数本身就是测试强度。
-
先实测,再下结论。 这一轮我至少猜错了两次(optimize 能回收空间、截断必被拒绝)。两次都是测量结果直接推翻了我的推断。推测写进代码就是 bug,写进报告就是误导。
-
“不做”也要有明确理由。 向量不独立召回、默认词法向量不做同义泛化、不发未验证的 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。所有数字都来自本机实测输出,可以按文中的命令逐条复现。