Neo4j + Graph RAG 医疗知识图谱工程实践:患者教育问答真正需要的是“关系可追溯”

作者:swipe日期:2026/5/31

做医疗患者教育问答时,很多团队第一反应是把科普文章、指南摘要、院内宣教材料全部切片,然后丢进向量库。用户问“高血压要看哪个科”“为什么建议做血压监测”“糖尿病患者教育材料应该关联哪些检查”,系统就从文档里召回几段相似文本,再让大模型总结。

这条路能跑通,但很快会遇到一个问题:患者教育知识不是只有“文本相似”,还有大量“实体关系”。

比如“高血压”不是孤立的一段说明,它可能关联到常见表现、风险器官、建议就诊科室、常见检查、生活方式教育材料、随访提醒。用户真正想问的也不一定是“高血压是什么”,而是:

  • 高血压相关的患者教育材料有哪些?
  • 哪些疾病主题会关联到血压监测?
  • 某个症状常被哪些健康主题提到?
  • 为什么这个主题会被分到心内科患者教育?
  • 一个健康主题、一个检查项目、一份宣教材料之间是什么关系?

这些问题如果只靠向量检索,答案往往依赖大模型在多段文本之间临时拼关系;如果关系没有被同一段文本完整写出来,模型就容易漏掉、猜测或者答得含糊。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 解决的是另一类问题:当用户问的是关系、路径、层级、依赖和解释链路时,图谱比文本片段更可靠。

先把边界说清楚:这不是临床决策系统

医疗场景最容易犯的错误,是把“患者教育问答”做着做着变成“自动诊断和用药建议”。这条边界必须从架构设计开始就写清楚。

本文示例系统只做三类事情:

  1. 帮用户理解患者教育材料里的概念关系。
  2. 帮医院、健康管理机构或知识库团队组织宣教内容。
  3. 根据已有知识图谱回答“某主题关联哪些症状、检查、科室、材料”这类检索问题。

它不做这些事情:

  • 不根据个人症状给诊断结论。
  • 不推荐药物、剂量或治疗方案。
  • 不判断检查结果是否正常。
  • 不替代医生、护士、药师或其他专业人员。

例如,CDC 介绍高血压时明确提醒高血压通常没有明显症状,但可能影响心脏、大脑、肾脏和眼睛等器官;CDC 的糖尿病患者教育材料也把症状、检测和自我管理教育分成不同主题。我们可以把这些公开患者教育资料作为知识来源,但系统输出时必须保持“教育和检索”定位,而不是做临床判断。

在工程上,这意味着答案生成 prompt 里必须有类似约束:

1你是患者教育知识库助手,只能基于检索结果解释知识关系。
2不要给出诊断结论、治疗方案、药物剂量或个体化医疗建议。
3如果问题涉及症状判断、检查解读或治疗决策,应建议用户咨询合格医疗专业人员。
4

这个约束不是免责声明装饰,而是产品边界。Graph RAG 能让知识关系更清晰,但不能把普通问答系统变成医疗器械级应用。

图谱 schema:先设计“能回答的问题”,再设计节点

很多人做知识图谱会犯一个错误:看到名词就建节点,看到两个词同段出现就连边。这样做出来的图很快变成一团噪声。

医疗患者教育知识图谱应该反过来设计:先确定要回答的问题,再设计实体和关系。

本文的示例目标是回答这类问题:

  • 某个健康主题有哪些常见表现?
  • 某个健康主题通常归属哪个科室宣教范围?
  • 某个健康主题关联哪些检查项目?
  • 某个健康主题有哪些患者教育材料?
  • 某个检查项目被哪些健康主题引用?

因此我们只需要一个小而清晰的 schema:

节点类型含义示例
Disease疾病或健康主题高血压、2 型糖尿病
Symptom患者教育中提到的常见表现口渴、多尿、头痛
Exam检查或监测项目血压测量、糖化血红蛋白
Department科室或宣教归口心内科、内分泌科
EducationMaterial患者教育材料家庭血压监测指南、饮食运动教育

关系可以先控制在四类:

关系方向含义
HAS_SYMPTOMDisease -> Symptom主题材料中提到的常见表现
RECOMMENDS_EXAMDisease -> Exam主题材料中关联的检查或监测
BELONGS_TO_DEPARTMENTDisease -> Department宣教内容归口科室
HAS_EDUCATIONDisease -> 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_urlsource_doc_idsource_paragraphreview_statusupdated_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 的查询链路可以拆成四步:

  1. 用户提出自然语言问题。
  2. 大模型根据 schema 生成只读 Cypher。
  3. Neo4j 执行图查询,返回结构化关系结果。
  4. 大模型基于查询结果生成患者教育口径的回答。
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/communityNeo4jGraph@langchain/openaiChatOpenAI,以及 @langchain/langgraphStateGraph

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

状态里只保留三个核心中间产物:cyphercontextanswer。这对应 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 里工作。否则模型可能生成 MedicationTreatmentDoctor 之类你图谱里根本没有的节点。

第二,强调关系方向。比如“哪些检查关联到高血压”可以写成从 DiseaseExam,也可以反向匹配,但关系真实方向不能错。

第三,明确医疗边界。用户如果问“我有这些症状是不是糖尿病”,这个系统不应该生成一条看似聪明的诊断查询,而应该在答案阶段提示咨询专业人员。

执行前先校验:不要无条件执行 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

这个工作流看起来是线性的,但它适合继续扩展。比如后续可以在 generateCypherexecuteGraph 之间加入 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 -> ExamExam -> 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,但不能信任它。
  • assertReadOnlyCypherassertSchemaAllowlist 是执行前防线。
  • queryNeo4j 要有超时和返回行数限制。
  • compactGraphRows 要把数据库结果整理成可控证据。
  • generatePatientEducationAnswer 只能基于证据回答。

如果系统以后要加向量检索,可以在 graphRows.length === 0 时进入向量检索兜底,也可以由问题路由器提前判断走哪条检索链路。

总结:医疗 Graph RAG 的价值是把关系变成证据

医疗患者教育知识库不是普通 FAQ。它里面既有长文本解释,也有大量实体关系:疾病主题关联症状、检查、科室、宣教材料、来源文档和审核状态。传统 RAG 能找到相似文本,但不一定能稳定回答关系型问题。

Neo4j + Graph RAG 的价值,是把这些关系变成可查询的工程资产:

  • 用户问关系,系统查图谱。
  • 图谱返回结构化证据。
  • 大模型基于证据组织患者教育口径的回答。
  • 答案能追溯到节点、关系和来源材料。

但这条路也有明确边界。它不应该做诊断,不应该推荐治疗,不应该绕过专业审核。医疗场景里的 Graph RAG 要先稳,再智能;先可追溯,再追求覆盖率;先控制边界,再提高表达能力。

如果把这套思路落到真实项目里,我会把默认方案定为“混合检索”:向量库负责语义召回,BM25 负责精确命中,Neo4j 负责关系查询,LLM 负责把证据整理成用户能理解的语言。Graph RAG 不是万能答案,但它补上了传统 RAG 最缺的一块:关系可执行、路径可追溯、证据可审计。

参考资料


Neo4j + Graph RAG 医疗知识图谱工程实践:患者教育问答真正需要的是“关系可追溯”》 是转载文章,点击查看原文


相关推荐


面试通关:JWT 认证与双 Token 机制深度解析
Lee川2026/5/9

面试通关:JWT 认证与双 Token 机制深度解析 本文专为面试准备,以问答形式拆解 JWT 认证体系的核心考点。每个问题都包含"面试怎么说"和"代码怎么写的"两个层面,让你既能侃侃而谈,也能落笔有物。 开篇:为什么面试官爱问 JWT? 在前后端分离成为主流的今天,身份认证 是每个系统必须解决的第一道门槛。JWT(JSON Web Token)作为无状态认证方案的代表,几乎出现在每一份后端/全栈岗位的 JD 中。面试官问 JWT,通常是在考察三个层面的理解:


【动态规划算法】(斐波那契数列模型详解)
承渊政道2026/4/29

🔥承渊政道:个人主页 ❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》 ✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介: 在算法学习的过程中,动态规划始终是一个绕不开的重要主题.它不仅是解决复杂问题的高效工具,也是面试和竞赛中出现频率极高的核心考点.对于初学者而言,动态规划之所以难,往往不在于代码实现本身,而在于如何理解


从0到1:Spring Boot 中WebSocket实战揭秘,开启实时通信新时代
常利兵2026/4/21

从0到1:Spring Boot 中WebSocket实战揭秘,开启实时通信新时代 引言:实时通信的需求与挑战 在当今数字化时代,互联网应用的实时交互需求日益增长。从在线聊天、股票行情实时更新,到多人协作办公、在线游戏等场景,实时通信已成为提升用户体验和业务效率的关键因素。传统的 HTTP 协议基于请求 - 响应模式,客户端发起请求,服务器被动响应,这种模式在实时通信场景中存在诸多局限性,如高延迟、高开销以及单向性(服务器无法主动推送数据,需客户端轮询) 。为了满足实时通信的需求,WebSo


公网 IP、私网 IP、路由表、转发表与 MAC 地址的关系
小红的布丁2026/4/12

引言 学习网络时,最容易混淆的不是协议流程,而是几个看起来相近、其实不在一个层面的概念,比如: 私网 IP 和公网 IP路由表和转发表“在链路上”到底是什么意思MAC 地址和 IP 地址分别属于哪一层 这篇文章把这些概念放到同一条线上梳理清楚,尽量用能直接形成画面的方式去理解。 私网 IP、公网 IP 和 NAT 到底是什么关系 很多人第一次接触家庭网络时,容易把 192.168.x.x 这类地址叫成“虚拟 IP”。这个说法不够准确,更准确的叫法应该是: 公网 IP私网 IPNAT 什么是公网


大模型推理凭什么这么贵?从GRPO到BCR,推理效率之战全解析
陆业聪2026/4/4

一个反常识的观察:推理越强,账单越贵 DeepSeek-R1 横空出世之后,"o1-style 推理"几乎成了大模型进化的标配动作。CoT、长思考链、自我反思……这些机制确实让模型在数学、代码、逻辑推理上表现亮眼。但随之而来的问题是——每一次"深度思考",都是在烧钱。 最极端的案例:让 o1 解一道简单的小学数学题,它会生成好几百个 token 的"内心独白",然后给出一个一眼就能看出来的答案。这就像雇了个博士级顾问,让他帮你决定午饭吃什么——能力没问题,但成本不对等。 这个问题的本质不是"推理


SwiftUI 如何实现 Infinite Scroll?
RickeyBoy2026/3/27

欢迎点个 star:github.com/RickeyBoy/R… 面试题:用 SwiftUI 实现一个无限滚动列表,支持分页加载。 这道题我在面试中遇到过好几次,说实话第一次答的时候以为随便写个 LazyVStack + onAppear 就完事了。后来才发现,面试官真正想考的不是你会不会用 API,而是你对状态管理、性能优化、Task 生命周期这些东西到底理解多深。 我的思路是从最简方案出发,一步步暴露问题、一步步优化。在开始写代码之前,先聊一下架构选型。 为什么选 MVVM? 先说一下


基于 Cloudflare 生态的 AI Agent 实现
Surmon2026/3/19

2026 新年的一个夜晚,窗外炮竹烟花争相闪耀,脑海里灵光一闪:我这快十年的老博客能不能也赶一波时髦,实现一个真正「有用」的智能助手? 有用 的意思是,它不能是一个只会随便聊天的机器人,而是一个真正了解我(博主)、了解博客内容的 AI 分身。它最好能事无巨细地知道我写过哪些文章,了解我的观点、立场和经历,能根据访客的问题去知识库里精准地找到最相关的内容,再结合上下文给出自然又富有意义的回答。 它应该是一张鲜活、灵动的个人名片。 这并不是一个多么复杂的需求,开源工具和商业基建也已经很成熟了,但真正


从零开发一个掘金自动发布 Skill,并上架 Clawhub
小巫debug日记2026/3/10

从零开发一个掘金自动发布 Skill,并上架 Clawhub 本文记录了一次完整的 Skill 开发旅程:从一句「帮我创建一个可以自动发布文章到掘金的 skill」开始,到最终成功上架 Clawhub,全程真实还原每一个关键决策和踩坑过程。 背景:为什么要做这个 Skill? 我日常运营一个 AI 资讯账号,每天需要将 Markdown 格式的文章发布到多个平台,包括微信公众号、小红书、掘金等。其中微信公众号和小红书已经有现成的 Skill 可以用,但掘金没有。 每次发布掘金都要: 打开


Word 中 MathType 启动慢、卡顿、卡死 | 由于某种原因,PowerPoint 无法加载MathType……
斐夷所非2026/3/2

注:本文为 “office 中 MathType 启动、加载异常” 相关合辑。 图片清晰度受引文原图所限。 略作重排,如有内容异常,请看原文。 Word 2013 中 MathType 窗口启动延迟问题分析与解决方案 香蕉君达 发布于 2026-02-19 12:12 1 现象描述 通过快捷键或功能区按钮在 Word 2013 中插入公式时,编辑窗口启动延迟时长约为 3~4 秒,对文档编辑流程造成干扰。 测试表明,若系统中已存在至少一个处于打开状态的 MathType 窗口,后续公式


SpringBoot多环境配置实战指南
北极的代码2026/2/22

前言:在之前的开发环境中要跟改配置,测试环境也要改,每次切换环境都要手动修改配置文件 常常发生"我们在本地能运行,怎么部署到服务器就报错"的情况,一不小心就把测试环境的配置提交到代码库。因此我们提出了多环境开发配置。 多环境开发配置: 在SpringBoot中,多环境配置的管理核心是利用Profile机制,它允许我们为不同的运行环境(开发,测试,生产)定义独立的配置,并在应用启动时动态的激活,从而实现配置等隔离与灵活切换。 核心实现方式:Profile 特定配置文件 总之就

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读