为什么用知识图谱,而不是向量库
大多数 RAG 系统的做法是:把文档切片、做 embedding,把 top-k 最近邻塞进 prompt。这套思路对于自由形式的散文没问题,但在医疗场景下会出问题:
- 关系本身有意义。 “药物 X 治疗疾病 Y” 与 “药物 X 在患疾病 Y 时禁用” 的 embedding 几乎一样,但临床含义完全相反。
- 幻觉的代价更高。 编造一个剂量或禁忌不是 UX 问题,而是安全问题。
- 来源必须可追溯。 答案中的每条事实都应该能定位到一个用户可审计的结构化来源。
本项目把向量库整体替换成 Neo4j 上的有类型知识图谱,并把问答闭环改成:解析问题 → 把实体对齐到 KG 节点 → 跑模板化 Cypher 查询 → 让 LLM 把结构化结果转成自然语言。
知识图谱设计
KG 由 DiseaseKG 医疗知识图谱数据集构建。
实体 —— 8 类,约 4.46 万节点
| 类型 | 数量 | 备注 |
|---|---|---|
| 疾病 Disease | 8,808 | 7 个属性:描述、病因、预防、治愈时长、治愈概率、易感人群、基本信息 |
| 药品 Drug | 3,828 | 关联到生产商 Producer |
| 食物 Food | 4,870 | 「宜吃 / 忌吃」靠关系类型区分,而不是节点类型 |
| 检查 Check | 3,353 | 诊断检查项 |
| 科室 Department | 54 | 临床科室 —— 用于路由分诊 |
| 生产商 Producer | 17,201 | 药品厂商(商业品牌层) |
| 症状 Symptom | 5,998 | 患者侧的主诉 |
| 治疗方法 Cure | 544 | 疗法 / 操作类型 |
关系 —— 11 类,约 31.2 万条边
疾病 ↔ 症状 · 疾病 ↔ 常用药 · 疾病 ↔ 推荐药 · 疾病 ↔ 所需检查 · 疾病 ↔ 推荐饮食 · 疾病 ↔ 忌口 · 疾病 ↔ 并发症 · 疾病 ↔ 治疗方法 · 疾病 ↔ 科室(属于) · 药品 ↔ 生产商 · 药品 ↔ 在售药。
「常用药 vs. 推荐药」的两层划分很重要:它让 Agent 能区分「实际处方什么」和「临床上更优是什么」 —— 这正是纯 LLM 回答容易抹平的差别。
为什么选 Neo4j
- Cypher 模式匹配天然适合「图里关于 X 怎么说」这类问题。
- 在
:Disease(name)、:Symptom(name)等字段上建约束索引,让实体解析查询保持 O(1)。 - 多跳遍历(如「疾病 → 并发症 → 症状」)一条查询搞定,不用 JOIN 级联。
医疗实体抽取的 NER
面对「某种疾病的患者能否食用某种食物」这类典型查询,系统需要先定位两个实体(一个 Disease、一个 Food),才能挑出对应的 Cypher 模板。开箱即用的通用 NER 漏掉很多领域词,所以训练了一个专用模型。
架构
输入 token
│
▼
RoBERTa-WWM-EXT (上下文 embedding)
│
▼
2 层 Bi-LSTM (序列依赖)
│
▼
线性分类器 (token 级 logits)
│
▼
BIO 标签 (B-Disease、I-Disease、B-Symptom、…、O)
为什么这样组合:RoBERTa 已经能给出强的上下文表示,但 BIO 序列标注还是受益于 BiLSTM 头部显式的序列偏置 —— 而且这套模型小到一张 GPU 就能跑。
数据增强 —— F1 提升的来源
测试集 baseline F1 是 96.77%。三种增强策略把它拉到了 97.40%:
- 实体替换。 把识别出的实体换成 KG 词表里同类型的另一个实体,BIO 标签同步对齐。强迫模型依赖上下文,而不是死记硬背的字面形式。
- 实体遮蔽。 把实体 token 替换成
[MASK],让模型把它们恢复出来 —— 提升对 OOV 词的鲁棒性。 - 实体拼接。 把两条短的单实体句子粘成一条更难的多实体样本,针对单句中出现两个同类型实体的稀有情况。
0.63 的 F1 提升听起来不多,但在曲线上端,每一个 false positive 都会让一条 Cypher 查询走错路由,所以边际价值很高。
实体对齐
NER 给出的是 span,不是 KG 节点 ID。同一实体的表面形式会变(缩写、全称、同义词、别名)。每个识别出的 mention 通过 TF-IDF 余弦相似度 在节点名 + 别名文本上对齐到对应类型最近的节点。便宜、确定、推理时不需要加载 embedding 模型。
不要标注分类器的意图识别
需要 16 类意图来覆盖问题空间,例如:
ask_symptom · ask_cause · ask_complication · ask_treatment · ask_check · ask_department · ask_drug · ask_drug_producer · ask_diet_do · ask_diet_avoid · ask_prevent · ask_cure_time · ask_cure_prob · ask_susceptible · ask_disease_intro · ask_overview
训练分类器意味着标几千条医疗问句 —— 成本高,schema 一动就脆。所以意图改用 34B LLM 通过 prompt 工程 处理:
- 系统 prompt 列出 16 个意图,每个一行描述。
- 每个意图配 3–5 条 few-shot 示例,把模型框定到期望的输出格式。
- 思维链草稿步骤先让模型识别实体、再推理哪个意图最匹配,最后以 JSON 形式输出意图标签。
权衡:延迟比分类器高,但加一个新意图就是改 prompt,不是重训练 —— 对一个 schema 还在迭代的研究产物来说很重要。
检索与生成的流程
用户提问
│
▼
[1] 意图分类(LLM + few-shot + CoT) → intent ∈ {16}
│
▼
[2] BERT-NER(RoBERTa + BiLSTM) → mentions[]
│
▼
[3] TF-IDF 实体对齐 → KG 节点 ID[]
│
▼
[4] 按 (intent, entity-type) 模板化的 Cypher → KG 三元组
│
▼
[5] LLM 答案合成(Qwen / Llama) → 落地的回答
│ 以检索到的三元组 + 用户提问为上下文
▼
最终回答(底层三元组随时可查)
每一对 (intent, entity-type) 对应一个 固定的 Cypher 模板。例如:
// ask_diet_avoid 针对 Disease
MATCH (d:Disease {name: $name})-[:no_eat]->(f:Food)
RETURN f.name AS food
模板都是确定性的,审一次就够;LLM 只能看到对齐后的三元组,永远不会看到嵌入到 Cypher 中的原始用户输入 —— 所以「prompt 注入即 Cypher 注入」从结构上就不可能。
合成阶段是 LLM 真正发挥价值的地方:把一串结构化三元组合成为流畅的自然语言回答,同时严格以检索到的集合为事实来源。
前端与运维
- Streamlit UI 带登录(用户 / 管理员双角色)、持久化会话历史,以及在 Qwen 与 Llama 之间运行时切换的开关 —— 方便做并排对比。
- 多窗口会话 —— 在不同 LLM 上验证答案而不丢上一轮上下文。
- 管理员视图 可以查看每条问题的原始 NER 输出、对齐到的 KG 节点、执行的 Cypher —— 这个「展示工作过程」面板事后被证明是最有价值的调试面。
接下来想做什么
- 置信度评分:综合 NER 置信度、实体对齐余弦相似度、Cypher 结果集大小(空结果 ≠ 自信地说「没有」),给每条回答打分。
- 冲突检测:当同一个患者画像在不同疾病下产出相互矛盾的饮食建议(同一个食物既「宜吃」又「忌吃」),把冲突显式抛出来,而不是悄悄选一个。
- 多语言 NER:扩展模型以处理多语种代码切换的医疗术语 —— 在新加坡这种多语种临床场景下很常见。
状态
已完成(研究助理岗位,2025-01 — 2025-05)。源代码归托管实验室所有,未公开;架构如上所述。