做医疗患者教育问答时,很多团队第一反应是把科普文章、指南摘要、院内宣教材料全部切片,然后丢进向量库。用户问“高血压要看哪个科”“为什么建议做血压监测”“糖尿病患者教育材料应该关联哪些检查”,系统就从文档里召回几段相似文本,再让大模型总结。
这条路能跑通,但很快会遇到一个问题:患者教育知识不是只有“文本相似”,还有大量“实体关系”。
比如“高血压”不是孤立的一段说明,它可能关联到常见表现、风险器官、建议就诊科室、常见检查、生活方式教育材料、随访提醒。用户真正想问的也不一定是“高血压是什么”,而是:
- 高血压相关的患者教育材料有哪些?
- 哪些疾病主题会关联到血压监测?
- 某个症状常被哪些健康主题提到?
- 为什么这个主题会被分到心内科患者教育?
- 一个健康主题、一个检查项目、一份宣教材料之间是什么关系?
这些问题如果只靠向量检索,答案往往依赖大模型在多段文本之间临时拼关系;如果关系没有被同一段文本完整写出来,模型就容易漏掉、猜测或者答得含糊。Graph RAG 的价值就在这里:它不是替代医生,也不是让模型做诊断,而是把患者教育知识里的“疾病主题、症状、检查、科室、材料”建成一张可查询的关系网络,让 RAG 的检索结果从“相似文本”升级为“可追溯关系”。
这篇文章的主结论是:
医疗患者教育场景中的 Graph RAG,核心不是让大模型懂医学,而是把医学科普材料中的实体和关系结构化,让系统能回答“谁关联谁、为什么关联、从哪里追溯”的问题。工程上真正要做稳的是图谱 schema、关系方向、Cypher 生成约束、只读安全、证据来源和医疗边界。
本文只讨论患者教育和知识库检索,不讨论自动诊断、处方推荐、治疗决策。文中的疾病、检查、科室关系是工程建模示例,不能替代医生判断。
为什么医疗患者教育问答不只是“找相似文档”
普通 RAG 的基本流程是:文档切分、生成 Embedding、向量检索、把召回片段塞给大模型生成答案。它适合回答“这份材料里怎么解释某个概念”这类问题。
但医疗患者教育里常见的问题经常不是单点解释,而是关系型查询。
举个例子,用户问:
高血压患者教育应该覆盖哪些检查和宣教材料?
如果你的知识库里有很多文章,向量检索可能召回“高血压介绍”“家庭血压监测”“低盐饮食建议”“心血管风险管理”等片段。问题是,系统并不知道这些片段之间的结构关系。它只能把几个片段拼给模型,让模型自己判断哪些属于检查、哪些属于宣教材料、哪些只是背景说明。
如果改成图谱建模,知识可以表达为:
高血压关联血压测量高血压归属心内科高血压关联家庭血压监测教育高血压关联生活方式管理教育血压测量是检查项目家庭血压监测教育是患者教育材料
这时用户问“覆盖哪些检查和宣教材料”,系统可以沿着明确关系查,而不是让模型在文本里猜。
1flowchart LR 2 Q[用户问题] --> V[向量检索] 3 Q --> G[图谱检索] 4 5 V --> V1[返回语义相似段落] 6 V1 --> V2[模型从段落中归纳关系] 7 8 G --> G1[查询疾病主题相关实体] 9 G1 --> G2[返回检查 科室 材料等结构化关系] 10 11 V2 --> A[答案依赖上下文完整性] 12 G2 --> B[答案可追溯到图谱路径] 13
这不是说向量检索没用。医疗知识库里有大量长文、解释性文本、注意事项、患者问答,向量检索仍然是基础能力。Graph RAG 解决的是另一类问题:当用户问的是关系、路径、层级、依赖和解释链路时,图谱比文本片段更可靠。
先把边界说清楚:这不是临床决策系统
医疗场景最容易犯的错误,是把“患者教育问答”做着做着变成“自动诊断和用药建议”。这条边界必须从架构设计开始就写清楚。
本文示例系统只做三类事情:
- 帮用户理解患者教育材料里的概念关系。
- 帮医院、健康管理机构或知识库团队组织宣教内容。
- 根据已有知识图谱回答“某主题关联哪些症状、检查、科室、材料”这类检索问题。
它不做这些事情:
- 不根据个人症状给诊断结论。
- 不推荐药物、剂量或治疗方案。
- 不判断检查结果是否正常。
- 不替代医生、护士、药师或其他专业人员。
例如,CDC 介绍高血压时明确提醒高血压通常没有明显症状,但可能影响心脏、大脑、肾脏和眼睛等器官;CDC 的糖尿病患者教育材料也把症状、检测和自我管理教育分成不同主题。我们可以把这些公开患者教育资料作为知识来源,但系统输出时必须保持“教育和检索”定位,而不是做临床判断。
在工程上,这意味着答案生成 prompt 里必须有类似约束:
1你是患者教育知识库助手,只能基于检索结果解释知识关系。 2不要给出诊断结论、治疗方案、药物剂量或个体化医疗建议。 3如果问题涉及症状判断、检查解读或治疗决策,应建议用户咨询合格医疗专业人员。 4
这个约束不是免责声明装饰,而是产品边界。Graph RAG 能让知识关系更清晰,但不能把普通问答系统变成医疗器械级应用。
图谱 schema:先设计“能回答的问题”,再设计节点
很多人做知识图谱会犯一个错误:看到名词就建节点,看到两个词同段出现就连边。这样做出来的图很快变成一团噪声。
医疗患者教育知识图谱应该反过来设计:先确定要回答的问题,再设计实体和关系。
本文的示例目标是回答这类问题:
- 某个健康主题有哪些常见表现?
- 某个健康主题通常归属哪个科室宣教范围?
- 某个健康主题关联哪些检查项目?
- 某个健康主题有哪些患者教育材料?
- 某个检查项目被哪些健康主题引用?
因此我们只需要一个小而清晰的 schema:
| 节点类型 | 含义 | 示例 |
|---|---|---|
| Disease | 疾病或健康主题 | 高血压、2 型糖尿病 |
| Symptom | 患者教育中提到的常见表现 | 口渴、多尿、头痛 |
| Exam | 检查或监测项目 | 血压测量、糖化血红蛋白 |
| Department | 科室或宣教归口 | 心内科、内分泌科 |
| EducationMaterial | 患者教育材料 | 家庭血压监测指南、饮食运动教育 |
关系可以先控制在四类:
| 关系 | 方向 | 含义 |
|---|---|---|
| HAS_SYMPTOM | Disease -> Symptom | 主题材料中提到的常见表现 |
| RECOMMENDS_EXAM | Disease -> Exam | 主题材料中关联的检查或监测 |
| BELONGS_TO_DEPARTMENT | Disease -> Department | 宣教内容归口科室 |
| HAS_EDUCATION | Disease -> EducationMaterial | 主题关联的患者教育材料 |
这张图可以表示成:
1graph LR 2 H[Disease 高血压] -->|BELONGS_TO_DEPARTMENT| C[Department 心内科] 3 H -->|RECOMMENDS_EXAM| BP[Exam 血压测量] 4 H -->|HAS_EDUCATION| E1[EducationMaterial 家庭血压监测教育] 5 H -->|HAS_EDUCATION| E2[EducationMaterial 生活方式管理教育] 6 7 D[Disease 2型糖尿病] -->|BELONGS_TO_DEPARTMENT| EN[Department 内分泌科] 8 D -->|HAS_SYMPTOM| S1[Symptom 口渴] 9 D -->|HAS_SYMPTOM| S2[Symptom 多尿] 10 D -->|RECOMMENDS_EXAM| A1[Exam 糖化血红蛋白] 11 D -->|HAS_EDUCATION| E3[EducationMaterial 血糖监测教育] 12
这里有两个关键判断。
第一,Disease 在这个系统里更准确地说是“患者教育健康主题”,不等同于临床诊断对象。它只代表知识库组织内容的主题。
第二,关系方向必须统一。比如所有主题都从 Disease 指向 Exam,不要一部分写成 (Exam)-[:USED_FOR]->(Disease),另一部分写成 (Disease)-[:RECOMMENDS_EXAM]->(Exam)。关系方向混乱会直接导致 LLM 生成 Cypher 不稳定。
用 Cypher 初始化一张小型医疗患者教育图谱
下面用 Neo4j Cypher 建一个最小可运行图谱。为了避免重复执行产生重复节点,工程里推荐使用 MERGE,而不是教学 demo 常见的 CREATE。
1CREATE CONSTRAINT disease_name IF NOT EXISTS 2FOR (d:Disease) 3REQUIRE d.name IS UNIQUE; 4 5CREATE CONSTRAINT exam_name IF NOT EXISTS 6FOR (e:Exam) 7REQUIRE e.name IS UNIQUE; 8 9CREATE CONSTRAINT material_title IF NOT EXISTS 10FOR (m:EducationMaterial) 11REQUIRE m.title IS UNIQUE; 12
约束的作用不是“性能优化”这么简单。它首先保证同名实体不会被反复创建。知识图谱最怕实体重复,因为重复节点会让关系断裂。例如一个地方叫“2 型糖尿病”,另一个地方叫“2型糖尿病”,如果不做归一化,系统查询时会漏掉一半关系。
接着写入示例节点:
1MERGE (h:Disease {name: "高血压"}) 2SET h.category = "患者教育主题" 3 4MERGE (d:Disease {name: "2型糖尿病"}) 5SET d.category = "患者教育主题" 6 7MERGE (cardiology:Department {name: "心内科"}) 8MERGE (endocrine:Department {name: "内分泌科"}) 9 10MERGE (bp:Exam {name: "血压测量"}) 11MERGE (a1c:Exam {name: "糖化血红蛋白"}) 12MERGE (glucose:Exam {name: "血糖检测"}) 13 14MERGE (thirst:Symptom {name: "口渴"}) 15MERGE (urination:Symptom {name: "多尿"}) 16 17MERGE (bpGuide:EducationMaterial { 18 title: "家庭血压监测教育", 19 type: "患者宣教" 20}) 21 22MERGE (lifestyle:EducationMaterial { 23 title: "生活方式管理教育", 24 type: "患者宣教" 25}) 26 27MERGE (glucoseGuide:EducationMaterial { 28 title: "血糖监测教育", 29 type: "患者宣教" 30}) 31
然后写入关系:
1MATCH (h:Disease {name: "高血压"}) 2MATCH (cardiology:Department {name: "心内科"}) 3MATCH (bp:Exam {name: "血压测量"}) 4MATCH (bpGuide:EducationMaterial {title: "家庭血压监测教育"}) 5MATCH (lifestyle:EducationMaterial {title: "生活方式管理教育"}) 6MERGE (h)-[:BELONGS_TO_DEPARTMENT]->(cardiology) 7MERGE (h)-[:RECOMMENDS_EXAM]->(bp) 8MERGE (h)-[:HAS_EDUCATION]->(bpGuide) 9MERGE (h)-[:HAS_EDUCATION]->(lifestyle); 10 11MATCH (d:Disease {name: "2型糖尿病"}) 12MATCH (endocrine:Department {name: "内分泌科"}) 13MATCH (a1c:Exam {name: "糖化血红蛋白"}) 14MATCH (glucose:Exam {name: "血糖检测"}) 15MATCH (thirst:Symptom {name: "口渴"}) 16MATCH (urination:Symptom {name: "多尿"}) 17MATCH (glucoseGuide:EducationMaterial {title: "血糖监测教育"}) 18MERGE (d)-[:BELONGS_TO_DEPARTMENT]->(endocrine) 19MERGE (d)-[:RECOMMENDS_EXAM]->(a1c) 20MERGE (d)-[:RECOMMENDS_EXAM]->(glucose) 21MERGE (d)-[:HAS_SYMPTOM]->(thirst) 22MERGE (d)-[:HAS_SYMPTOM]->(urination) 23MERGE (d)-[:HAS_EDUCATION]->(glucoseGuide); 24
这段数据不是为了建立完整医学知识库,而是为了让 Graph RAG 的关系检索跑起来。真实系统里的每条边都应该带上来源,例如 source_url、source_doc_id、source_paragraph、review_status、updated_at。尤其是医疗内容,不能只存“关系存在”,还要能追溯“关系来自哪里、谁审核过、什么时候更新”。
一个更工程化的关系可以这样写:
1MATCH (h:Disease {name: "高血压"}) 2MATCH (bp:Exam {name: "血压测量"}) 3MERGE (h)-[r:RECOMMENDS_EXAM]->(bp) 4SET r.source = "CDC patient education", 5 r.review_status = "example_only", 6 r.updated_at = datetime(); 7
这会让后续答案生成更可信:系统不只是说“图谱里有这条关系”,还可以展示来源和审核状态。
Graph RAG 系统架构:让大模型生成查询,而不是生成事实
医疗患者教育 Graph RAG 的查询链路可以拆成四步:
- 用户提出自然语言问题。
- 大模型根据 schema 生成只读 Cypher。
- Neo4j 执行图查询,返回结构化关系结果。
- 大模型基于查询结果生成患者教育口径的回答。
1flowchart TD 2 U[用户问题] --> P[问题理解] 3 P --> C[LLM 生成只读 Cypher] 4 C --> S[Cypher 安全校验] 5 S --> N[Neo4j 图查询] 6 N --> R[实体关系结果] 7 R --> A[LLM 基于证据生成回答] 8 A --> O[患者教育口径输出] 9 10 subgraph KG[医疗患者教育知识图谱] 11 D[Disease] 12 Sym[Symptom] 13 Exam[Exam] 14 Dept[Department] 15 Mat[EducationMaterial] 16 end 17 18 N --> KG 19
注意这句话:让大模型生成查询,而不是生成事实。
在传统问答里,模型经常直接回答“你应该做什么”。在 Graph RAG 里,模型第一阶段只负责把自然语言问题转换成检索计划;事实来自 Neo4j 查询结果。第二阶段模型再把结构化结果组织成自然语言。这样做可以显著降低模型自由发挥的空间。
当然,“让模型生成 Cypher”本身也有风险,所以必须有安全校验和权限限制。后面会单独讲。
用 JavaScript 连接 Neo4j:先把数据库层跑稳
在接入 LangGraph 之前,建议先用 neo4j-driver 写一个最小查询脚本。这样可以把问题拆开:先确认数据和查询正确,再调 LLM。
1import neo4j from "neo4j-driver" 2 3const driver = neo4j.driver( 4 process.env.NEO4J_URI ?? "bolt://localhost:7687", 5 neo4j.auth.basic( 6 process.env.NEO4J_USERNAME ?? "neo4j", 7 process.env.NEO4J_PASSWORD ?? "12345678" 8 ) 9) 10 11export async function queryDiseaseEducation(topicName) { 12 const session = driver.session({ defaultAccessMode: neo4j.session.READ }) 13 14 try { 15 const result = await session.run( 16 ` 17 MATCH (d:Disease {name: $topicName}) 18 OPTIONAL MATCH (d)-[:RECOMMENDS_EXAM]->(exam:Exam) 19 OPTIONAL MATCH (d)-[:HAS_EDUCATION]->(material:EducationMaterial) 20 OPTIONAL MATCH (d)-[:BELONGS_TO_DEPARTMENT]->(department:Department) 21 RETURN d.name AS topic, 22 collect(DISTINCT exam.name) AS exams, 23 collect(DISTINCT material.title) AS materials, 24 collect(DISTINCT department.name) AS departments 25 `, 26 { topicName } 27 ) 28 29 return result.records.map(record => ({ 30 topic: record.get("topic"), 31 exams: record.get("exams"), 32 materials: record.get("materials"), 33 departments: record.get("departments"), 34 })) 35 } finally { 36 await session.close() 37 } 38} 39
这段代码在系统里的位置很底层:它不做自然语言理解,只负责执行明确的参数化 Cypher。
为什么这里要用 $topicName 参数,而不是字符串拼接?
因为用户输入不能直接拼到查询语句里。即使 Cypher 注入风险和 SQL 注入表现不完全一样,原则也是一致的:用户输入应该作为参数传入,而不是参与构造语法结构。Graph RAG 里如果让模型和用户输入共同影响查询语句,就更需要加安全边界。
为什么 session 用 READ?
患者教育问答阶段只需要读图谱,不应该有写权限。即使代码里误执行了写入语句,数据库权限也应该尽量拦住。这是医疗场景里非常重要的一层防线。
用 LangGraph 编排 Graph RAG 查询链路
接下来把自然语言问题接进来。技术栈可以沿用 @langchain/community 的 Neo4jGraph、@langchain/openai 的 ChatOpenAI,以及 @langchain/langgraph 的 StateGraph。
1import "dotenv/config" 2import { Neo4jGraph } from "@langchain/community/graphs/neo4j_graph" 3import { ChatOpenAI } from "@langchain/openai" 4import { StateGraph, START, END } from "@langchain/langgraph" 5import { HumanMessage } from "@langchain/core/messages" 6 7const graph = new Neo4jGraph({ 8 url: process.env.NEO4J_URI ?? "bolt://localhost:7687", 9 username: process.env.NEO4J_USERNAME ?? "neo4j", 10 password: process.env.NEO4J_PASSWORD ?? "12345678", 11}) 12 13const llm = new ChatOpenAI({ 14 model: process.env.MODEL_NAME, 15 temperature: 0, 16 configuration: { baseURL: process.env.OPENAI_BASE_URL }, 17}) 18
temperature: 0 在这里很重要。生成 Cypher 是一个结构化任务,追求的是稳定和可执行,不是表达多样性。温度越高,模型越可能输出解释、Markdown 代码块或者自创关系类型。
定义状态:
1const state = { 2 messages: { 3 value: (left, right) => left.concat(Array.isArray(right) ? right : [right]), 4 default: () => [], 5 }, 6 cypher: null, 7 context: null, 8 answer: null, 9} 10 11function userQuery(state) { 12 return state.messages[state.messages.length - 1].content 13} 14
状态里只保留三个核心中间产物:cypher、context、answer。这对应 Graph RAG 的三段式链路:生成查询、执行检索、生成回答。
生成 Cypher:必须把 schema 明确写给模型
生成 Cypher 的 prompt 不能写得太空。模型不知道你的图谱结构,除非你明确告诉它节点、关系和方向。
1async function generateCypher(state) { 2 const prompt = ` 3你是医疗患者教育知识图谱的 Neo4j Cypher 生成器。 4你只能生成只读查询,只返回 Cypher,不要解释,不要 markdown。 5 6节点: 7- Disease: 疾病或患者教育健康主题 8- Symptom: 患者教育材料中提到的常见表现 9- Exam: 检查或监测项目 10- Department: 科室或宣教归口 11- EducationMaterial: 患者教育材料 12 13关系方向: 14- (Disease)-[:HAS_SYMPTOM]->(Symptom) 15- (Disease)-[:RECOMMENDS_EXAM]->(Exam) 16- (Disease)-[:BELONGS_TO_DEPARTMENT]->(Department) 17- (Disease)-[:HAS_EDUCATION]->(EducationMaterial) 18 19规则: 201. 只能使用 MATCH、OPTIONAL MATCH、WHERE、RETURN、WITH、LIMIT。 212. 不允许 CREATE、MERGE、SET、DELETE、DETACH DELETE、CALL。 223. 不要生成诊断、治疗或用药建议相关查询。 234. 如果用户问某个主题关联哪些检查或材料,优先从 Disease 出发查询。 24 25用户问题:${userQuery(state)} 26` 27 28 const res = await llm.invoke([new HumanMessage(prompt)]) 29 return { cypher: res.content.trim() } 30} 31
这段 prompt 有三个目的。
第一,限定模型只能在已知 schema 里工作。否则模型可能生成 Medication、Treatment、Doctor 之类你图谱里根本没有的节点。
第二,强调关系方向。比如“哪些检查关联到高血压”可以写成从 Disease 到 Exam,也可以反向匹配,但关系真实方向不能错。
第三,明确医疗边界。用户如果问“我有这些症状是不是糖尿病”,这个系统不应该生成一条看似聪明的诊断查询,而应该在答案阶段提示咨询专业人员。
执行前先校验:不要无条件执行 LLM 生成的 Cypher
Graph RAG 最容易被忽视的风险是:你让大模型生成了数据库查询,然后直接执行。
本地 demo 可以这么写,生产环境不能这么写。尤其是医疗知识库,图谱数据可能包含院内材料、流程、审核状态、版本信息,不能让模型有写入或调用高危 procedure 的机会。
可以先加一个简单校验:
1function assertReadOnlyCypher(cypher) { 2 const normalized = cypher 3 .replace(/```cypher|```/gi, "") 4 .trim() 5 .toLowerCase() 6 7 const forbidden = [ 8 "create", 9 "merge", 10 "set", 11 "delete", 12 "detach", 13 "remove", 14 "drop", 15 "call", 16 "load csv", 17 ] 18 19 if (!normalized.startsWith("match") && !normalized.startsWith("optional match")) { 20 throw new Error("Only read-only MATCH queries are allowed") 21 } 22 23 if (forbidden.some(keyword => normalized.includes(keyword))) { 24 throw new Error("Unsafe Cypher keyword detected") 25 } 26} 27
这不是完整安全方案,但它体现了工程原则:LLM 输出只能被当作候选查询,不能被当作可信指令。
更严谨的做法还包括:
- 使用只读 Neo4j 用户。
- 对节点标签和关系类型做白名单。
- 限制返回行数和路径深度。
- 设置查询超时。
- 记录生成 Cypher、执行耗时、返回行数和错误类型。
医疗患者教育系统不一定有强监管属性,但它仍然涉及健康信息。只要系统看起来像“医疗问答”,用户就可能赋予它更高信任度,工程侧必须保守。
执行图查询并生成答案
查询执行函数可以这样写:
1async function executeGraphQuery(state) { 2 try { 3 assertReadOnlyCypher(state.cypher) 4 const rows = await graph.query(state.cypher) 5 return { context: JSON.stringify(rows) } 6 } catch (error) { 7 console.error("Graph query failed", { 8 cypher: state.cypher, 9 message: error.message, 10 }) 11 12 return { context: "GRAPH_QUERY_FAILED" } 13 } 14} 15
这里不要把所有异常都说成“未查询到知识”。空结果、语法错误、连接失败、权限错误是不同状态。真实系统至少要在日志里区分它们,否则后续排查会非常痛苦。
答案生成函数要更谨慎:
1async function generateAnswer(state) { 2 const prompt = ` 3你是患者教育知识库助手。请只根据「图谱检索结果」回答用户问题。 4 5边界: 6- 只能解释健康主题、症状、检查、科室、患者教育材料之间的知识关系。 7- 不要给出诊断结论。 8- 不要给出治疗方案、药物名称、剂量或个体化医疗建议。 9- 如果检索结果为空或失败,说明当前知识图谱没有足够证据。 10- 如果用户问题涉及症状判断、检查解读或治疗决策,建议咨询合格医疗专业人员。 11 12图谱检索结果:${state.context} 13用户问题:${userQuery(state)} 14` 15 16 const res = await llm.invoke([new HumanMessage(prompt)]) 17 return { answer: res.content } 18} 19
这个 prompt 的核心不是“语气温和”,而是把生成边界写死。Graph RAG 返回的是关系证据,不是个体化医疗判断。模型可以说“图谱显示高血压主题关联血压测量和家庭血压监测教育”,但不能说“你应该如何治疗高血压”。
最后编排工作流:
1const workflow = new StateGraph({ channels: state }) 2 .addNode("generateCypher", generateCypher) 3 .addNode("executeGraph", executeGraphQuery) 4 .addNode("generateAnswer", generateAnswer) 5 .addEdge(START, "generateCypher") 6 .addEdge("generateCypher", "executeGraph") 7 .addEdge("executeGraph", "generateAnswer") 8 .addEdge("generateAnswer", END) 9 10const app = workflow.compile() 11 12export async function runGraphRAG(question) { 13 return app.invoke({ 14 messages: [new HumanMessage(question)], 15 }) 16} 17
这个工作流看起来是线性的,但它适合继续扩展。比如后续可以在 generateCypher 和 executeGraph 之间加入 validateCypher 节点,在查询失败后加入 repairCypher 节点,在图谱结果为空时走向量检索兜底。
一个完整查询例子:从自然语言到图谱路径
假设用户问:
高血压患者教育应该覆盖哪些检查和材料?
模型在 schema 约束下应该生成类似查询:
1MATCH (d:Disease {name: "高血压"}) 2OPTIONAL MATCH (d)-[:RECOMMENDS_EXAM]->(exam:Exam) 3OPTIONAL MATCH (d)-[:HAS_EDUCATION]->(material:EducationMaterial) 4OPTIONAL MATCH (d)-[:BELONGS_TO_DEPARTMENT]->(department:Department) 5RETURN d.name AS topic, 6 collect(DISTINCT exam.name) AS exams, 7 collect(DISTINCT material.title) AS materials, 8 collect(DISTINCT department.name) AS departments 9LIMIT 10 10
Neo4j 返回结构化结果后,模型可以回答:
1根据当前患者教育知识图谱,高血压主题关联的检查/监测项目包括:血压测量。 2关联的患者教育材料包括:家庭血压监测教育、生活方式管理教育。 3该主题当前归口科室为:心内科。 4 5以上内容仅来自当前知识图谱,用于患者教育材料检索,不构成诊断或治疗建议。 6
这个答案有几个特点:
- 它没有扩写药物治疗。
- 它没有判断用户是否患病。
- 它没有把常识补成图谱事实。
- 它明确说明来自当前知识图谱。
这就是 Graph RAG 在医疗患者教育场景里的正确姿势:用结构化关系支持知识检索,用边界控制避免越权回答。
为什么不是只用向量库:三种检索应该组合
医疗知识库的检索对象很复杂。既有长篇患者教育文章,也有科室流程、检查说明、问答材料、术语解释、宣教视频脚本。单一检索方式很难覆盖全部问题。
| 检索方式 | 适合问题 | 不适合问题 | 医疗知识库里的位置 |
|---|---|---|---|
| 向量检索 | “这段材料怎么解释”“有没有相似问答” | 稳定关系、多跳路径、强约束查询 | 召回长文本和语义相近材料 |
| BM25/关键词检索 | 检查名、编码、材料标题、科室名称 | 模糊表达、跨文档关系 | 精确命中术语和标题 |
| Neo4j Graph RAG | 主题关联哪些症状/检查/科室/材料 | 大段原文解释、开放式总结 | 回答关系型问题和提供可追溯路径 |
更实际的架构通常是混合检索:
1flowchart TD 2 Q[用户问题] --> Router[问题路由] 3 4 Router -->|关系型问题| Graph[Neo4j 图谱检索] 5 Router -->|语义解释问题| Vector[向量检索] 6 Router -->|术语标题问题| Keyword[BM25 关键词检索] 7 8 Graph --> Evidence[证据整理] 9 Vector --> Evidence 10 Keyword --> Evidence 11 12 Evidence --> Guard[医疗边界检查] 13 Guard --> LLM[基于证据回答] 14 LLM --> User[患者教育口径输出] 15
比如:
- “高血压关联哪些患者教育材料?”优先走图谱。
- “家庭血压监测这份材料讲了什么?”优先走向量检索。
- “HbA1c 这个检查在哪些材料里出现?”优先走关键词检索,再结合图谱查关系。
Graph RAG 不应该被神化。它补的是“关系可查询”这一层,不是替代所有检索。
从文档到图谱:真实项目里更难的是数据治理
上面的 Cypher 是手写示例。真实医疗知识库通常要从文档中抽取实体和关系。
一个较完整的导入链路是:
1flowchart TD 2 Doc[患者教育文档] --> Parse[解析与清洗] 3 Parse --> Chunk[按主题切分] 4 Chunk --> Extract[实体关系抽取] 5 Extract --> Normalize[实体归一化] 6 Normalize --> Review[人工或规则审核] 7 Review --> Neo4j[写入 Neo4j] 8 9 Chunk --> Embed[生成 Embedding] 10 Embed --> VectorDB[写入向量库] 11 12 Neo4j --> QA[Graph RAG 问答] 13 VectorDB --> QA 14
这里最难的不是“怎么写入 Neo4j”,而是四件事。
第一,实体归一化。比如“2 型糖尿病”“2型糖尿病”“成人糖尿病”“T2D”可能在材料里混用。是否合并,怎么合并,需要业务规则。
第二,关系审核。医学患者教育材料里,关系不是普通网页标签。一个“疾病 -> 检查”的关系,如果后续用于回答用户问题,就应该知道它来自哪个材料、是否经过审核、是否适用于当前机构口径。
第三,版本管理。患者教育材料会更新,图谱边也要更新。不能只追加不删除,否则旧关系会残留。
第四,证据回溯。最终答案最好能追溯到文档来源,而不是只返回图谱节点。否则用户或审核人员无法判断答案依据。
因此真实落地时,图谱边至少应该具备这些属性:
1MATCH (d:Disease {name: "2型糖尿病"}) 2MATCH (exam:Exam {name: "糖化血红蛋白"}) 3MERGE (d)-[r:RECOMMENDS_EXAM]->(exam) 4SET r.source_doc_id = "patient-edu-diabetes-001", 5 r.source_title = "糖尿病患者教育材料", 6 r.review_status = "reviewed", 7 r.version = "2026-05", 8 r.updated_at = datetime(); 9
有了这些属性,Graph RAG 才能从“会回答”升级为“能审计”。
参数与配置:哪些设置会影响稳定性
第一是模型温度。Cypher 生成建议使用 temperature: 0。这是结构化生成任务,不需要创意。温度高会让输出不稳定,尤其容易出现解释文字、错误标签或自创关系。
第二是 schema prompt。Graph RAG 的 prompt 不是普通问答 prompt,而是数据库 schema 的自然语言接口。节点、关系、方向、禁止事项都要写清楚。schema 一变,prompt 必须同步更新。
第三是查询限制。生产系统里建议给每次图查询设置:
- 最大返回行数。
- 最大路径跳数。
- 查询超时。
- 允许的节点标签和关系类型。
- 只读权限。
第四是上下文格式。小 demo 可以直接 JSON.stringify(rows),但真实系统里结果可能很长。更好的做法是把结果整理成紧凑证据:
1{ 2 "topic": "高血压", 3 "departments": ["心内科"], 4 "exams": ["血压测量"], 5 "materials": ["家庭血压监测教育", "生活方式管理教育"], 6 "source": ["patient-edu-hypertension-001"] 7} 8
这样模型更容易按事实回答,也更容易控制 token 成本。
第五是 fallback 策略。图谱查不到时,不要立刻编答案。可以有三种策略:
- 明确告诉用户当前图谱没有足够证据。
- 转向向量检索查找相关宣教材料。
- 把问题转给人工或专业渠道。
医疗患者教育场景里,宁可少答,也不要无证据扩写。
常见误区
第一个误区:把 Graph RAG 当成“更强大模型”。Graph RAG 强的是关系检索,不是医学推理能力。模型仍然可能生成错误 Cypher、误读结果或越界回答,所以必须有校验和边界。
第二个误区:把所有医学名词都抽成节点。图谱不是词典。节点应该服务于可回答的问题。患者教育问答里,常用的节点可能是主题、症状、检查、材料、科室,而不是材料里出现的每一个名词。
第三个误区:忽略关系方向。Disease -> Exam 和 Exam -> Disease 都能表达关联,但系统里必须统一。关系方向一乱,Cypher 生成就会变成概率游戏。
第四个误区:图谱没有来源。医疗知识库的每条关系都应该能追溯到材料和审核状态。没有来源的图谱很难进入严肃业务流程。
第五个误区:让模型直接回答诊断问题。用户问“我口渴多尿是不是糖尿病”,患者教育系统最多能说“这些表现可出现在相关健康教育材料中,是否患病需要由专业人员结合检查判断”,不能给诊断。
第六个误区:用 Graph RAG 替代向量检索。患者教育材料大量是解释性长文,图谱适合关系,向量适合原文召回,两者应该组合。
工程建议:医疗场景要先稳,再追求智能
第一,先从少量高频主题做起。比如高血压、糖尿病、慢病随访、检查说明、术前宣教。不要一开始就试图覆盖所有疾病。
第二,schema 要保守。先定义少数稳定关系,例如关联检查、归口科室、关联教育材料。不要急着建复杂治疗路径。
第三,把医学内容来源和审核状态作为一等公民。Graph RAG 的答案最好能展示“来自当前知识图谱中的哪类材料”,内部日志要能追溯到具体文档。
第四,LLM 只做语言接口和表达整理,不做最终医学判断。所有医学事实都应该来自图谱、文档或经过审核的知识源。
第五,做评测时分三层看:
- Cypher 是否生成正确。
- 图谱结果是否召回正确关系。
- 最终回答是否忠实于查询结果并遵守医疗边界。
第六,保留人工审核入口。医疗患者教育不是普通闲聊,内容改动、关系新增、材料下线都应该有审核流程。
第七,记录完整链路。至少记录用户问题、生成 Cypher、图查询结果摘要、答案、是否触发医疗边界提醒。没有可观测性,Graph RAG 出错时很难定位到底是抽取错、查询错,还是模型总结错。
一个更接近生产的最小骨架
如果把上面的思路压缩成一个工程骨架,可以是这样:
1async function answerPatientEducationQuestion(question) { 2 const cypher = await generateCypher(question) 3 const normalizedCypher = stripMarkdownFence(cypher) 4 5 assertReadOnlyCypher(normalizedCypher) 6 assertSchemaAllowlist(normalizedCypher, { 7 labels: ["Disease", "Symptom", "Exam", "Department", "EducationMaterial"], 8 relationships: [ 9 "HAS_SYMPTOM", 10 "RECOMMENDS_EXAM", 11 "BELONGS_TO_DEPARTMENT", 12 "HAS_EDUCATION", 13 ], 14 }) 15 16 const graphRows = await queryNeo4j(normalizedCypher, { 17 timeoutMs: 3000, 18 maxRows: 50, 19 }) 20 21 if (graphRows.length === 0) { 22 return answerWithNoEvidence(question) 23 } 24 25 const evidence = compactGraphRows(graphRows) 26 return generatePatientEducationAnswer(question, evidence) 27} 28
这段代码表达了几个工程原则:
generateCypher可以用 LLM,但不能信任它。assertReadOnlyCypher和assertSchemaAllowlist是执行前防线。queryNeo4j要有超时和返回行数限制。compactGraphRows要把数据库结果整理成可控证据。generatePatientEducationAnswer只能基于证据回答。
如果系统以后要加向量检索,可以在 graphRows.length === 0 时进入向量检索兜底,也可以由问题路由器提前判断走哪条检索链路。
总结:医疗 Graph RAG 的价值是把关系变成证据
医疗患者教育知识库不是普通 FAQ。它里面既有长文本解释,也有大量实体关系:疾病主题关联症状、检查、科室、宣教材料、来源文档和审核状态。传统 RAG 能找到相似文本,但不一定能稳定回答关系型问题。
Neo4j + Graph RAG 的价值,是把这些关系变成可查询的工程资产:
- 用户问关系,系统查图谱。
- 图谱返回结构化证据。
- 大模型基于证据组织患者教育口径的回答。
- 答案能追溯到节点、关系和来源材料。
但这条路也有明确边界。它不应该做诊断,不应该推荐治疗,不应该绕过专业审核。医疗场景里的 Graph RAG 要先稳,再智能;先可追溯,再追求覆盖率;先控制边界,再提高表达能力。
如果把这套思路落到真实项目里,我会把默认方案定为“混合检索”:向量库负责语义召回,BM25 负责精确命中,Neo4j 负责关系查询,LLM 负责把证据整理成用户能理解的语言。Graph RAG 不是万能答案,但它补上了传统 RAG 最缺的一块:关系可执行、路径可追溯、证据可审计。
参考资料
- CDC: About High Blood Pressure
- CDC: Symptoms of Diabetes
- CDC: Diabetes Testing
- MedlinePlus: High Blood Pressure
《Neo4j + Graph RAG 医疗知识图谱工程实践:患者教育问答真正需要的是“关系可追溯”》 是转载文章,点击查看原文。