简介:这套资源定位为中医药知识图谱实践入门材料,聚焦中药与方剂两类核心实体及其关系建模,适合计算机、人工智能及中医药信息学方向学生用于课程设计、毕业设计或科研起步。包内包含17个文件,共1.82MB,以Python脚本、JSON结构化数据、SVG图谱、HTML交互页面及Markdown文档为主,其中py文件负责图谱构建与关系推理,JSON数据为清洗后的实体关系集,SVG与HTML可直接呈现节点和路径。已有38人学习/下载,适合直接对照README运行验证,无需复杂配置即可一键生成知识图谱。资源不只提供可运行代码,还配有自动生成的可视化结果和详细文档,便于理解属性分布与路径推理;二次开发空间大,可扩展实体类型、接入Neo4j图数据库或对接中医问答模块。需注意版权声明仅限学习参考,禁止商用。
1. 中药知识图谱:不再靠翻阅教材查关系
做中医药数据分析的人,大概率都经历过这种尴尬:要回答“哪些方剂同时包含黄芪和当归”“某味药的归经在教材里写了三处不一致”,只能靠人工翻书、复制粘贴到Excel里慢慢比。知识图谱这套资源解决的核心问题,就是把“查数据”变成“查关系”。它的产物是一份可运行的Python工程:清洗好的中药与方剂结构化数据、Neo4j图谱模型、SVG可视化图谱和配套文档。适合正在做中医药信息化的开发者、刚接触知识图谱想找实战样本的研究者,以及需要在本地搭一套演示系统的工程师。数据量不大,但正好覆盖从建库到可视化的完整链路。
2. 先立本体后造数据:实体、关系与结构化CSV规范
2.1 为什么要先花时间定本体
做知识图谱最容易犯的错,是一上来就写爬虫、拉数据,等数据堆到几千条再想怎么建模。结果就是边清洗边改实体定义,改到一半发现“药”和“药材”没统一,“方剂”和“处方”各有一套ID,后续所有查询都得打补丁。这套资源给出的顺序是标准的自顶向下:先画本体,再写映射表,最后才处理数据。核心本体只有四类实体和六类关系,不追求覆盖全部中医概念,但保证每个查询路径闭环。
定本体的核心问题就三个:实体分几类、关系叫什么名字、属性保留哪些。这里做的是经典三元组设计,实体有中药、方剂、疾病/症状、药对。关系的命名角度刻意用了最贴近原文的谓词,比如“主治”“组成”“归经”“配伍”,而不是“has_symptom”这类为了泛化而牺牲可读性的叫法。原因是中医药查询场景里,领域用户习惯直接看到“某方剂组成含某药”这种自然映射,代码里多写几个空格比解释命名规范省时间。
2.2 实体与关系的三元组拆法
整套图谱实际落地的三元组结构如下表,导入时就是按这张表写映射脚本。
| 头实体 | 关系 | 尾实体 | 举例 |
|---|---|---|---|
| 方剂 | 组成 | 中药 | 四君子汤 — 组成 → 人参 |
| 中药 | 归经 | 经络/脏腑 | 黄芪 — 归经 → 脾经 |
| 中药 | 主治 | 疾病/症状 | 麻黄 — 主治 → 风寒感冒 |
| 中药 | 药性 | 属性值 | 附子 — 药性 → 大热 |
| 方剂 | 主治 | 疾病/症状 | 麻黄汤 — 主治 → 恶寒发热 |
| 中药 | 配伍 | 中药 | 柴胡 — 配伍 → 黄芩 |
注意“药对”这一类在表里其实是以“配伍”关系出现的,没有单独建成实体节点。这样做的好处是查询“与某味药存在配伍关系的所有药”就是一次关系遍历,不必把药对表单独维护成节点再映射。已经整理好的CSV数据里也保持了同样的结构,看完可以直接对比原始表格和体现本体的schema文件。
2.3 把教材内容整理成结构化CSV的分字段设计
拿到手的数据文件里,中药表有几个实际用得上、又容易在清洗阶段被忽略的字段:药材名、别名、性味、归经、功效、主治、使用注意。方剂表是方名、组成、功用、主治、用法。最值得留意的字段是“组成”:原始整理时很多方子的组成文本是“黄芪三钱、当归二钱”这种混合写法,不是标准结构化数据。
因此脚本里专门写了组成字段的转换规则:拆出中药名和剂量两个子字段,再映射到中药表的ID。那种“三钱”“二钱”之类的古剂量单位没有统一换算成克,保留原文,只是在建图谱时把剂量当作属性放在“组成”关系上,而不是塞进药名节点。这个决定挺重要,因为一旦在数据层做了换算,不少方剂考证时就会失真。清洗后的CSV可以在后续步骤里直接交给CQL脚本导入。
3. Python数据工程:爬取、清洗与去重的关键代码
3.1 数据来源怎么选、边界在哪
数据不是随手抓的,源头的可信度直接决定这图谱能不能拿出来给人看。资源里的数据来源是公开的中医药数据库与整理好的教材附录,不依赖某个特定网站的在线接口,因为在线接口随时可能改版,而且不少页面对请求频率有限制。整理方式就是把这部分公开资料下载成结构化表格后做清洗。
边界也说明一下:这份图谱的数据覆盖范围是常见中药和经典方剂,不是全量医学知识库,像少数民族药、地方习用药材基本没纳入。做药物研发、临床决策支持这类严肃用途,数据量是不够的;但做教学演示、文献计量、中医药知识问答原型,这份数据量级刚刚好。
3.2 清洗脚本:别名、炮制、剂量与单位规范化
拿到CSV之后,第一件事是统一命名,因为“黄芪”和“炙黄芪”在原始数据里经常出现两个完全独立的行,而图谱里需要表达“炙黄芪是黄芪的炮制品”。这里用了一个很朴素的做法规避:在中药表里增加“基原名”字段,炙黄芪、生黄芪这类行的基原名字段都指向黄芪。这样既能保留炮制差异,又不至于把节点数量膨胀一倍。
对于这种清洗逻辑,项目里的清洗脚本大概是这样的思路:
import pandas as pd # 读取原始中药表,格式:name, alias, property, meridian, efficacy, indications df = pd.read_csv("raw_herbs.csv", encoding="utf-8-sig") # 炮制规范化:对常见的炮制前缀做映射,保留到炮制品字段 processed_patterns = {"炙", "炒", "生", "煅", "酒"} def normalize_name(name): pure_name = name processed_flag = None for prefix in processed_patterns: if name.startswith(prefix) and len(name) > 1: pure_name = name[len(prefix):] processed_flag = prefix break return pure_name, processed_flag df[["pure_name", "processed_flag"]] = df["name"].apply( lambda x: pd.Series(normalize_name(x)) ) df.to_csv("herbs_normalized.csv", index=False, encoding="utf-8-sig") print(f"共处理 {len(df)} 条中药记录,保留原始name字段不变")逻辑说明:先把原字段保留一份,然后拆出“纯药名”和“炮制标记”。这样关系导入时节点归到纯药名,属性里保留炮制信息。参数上,processed_patterns集合可以根据实际数据继续加前缀,比如“盐”“麸炒”,需要改集合就行。清洗之后,原始name字段仍然留着,方便溯源。
3.3 去重与实体对齐:等价类合并的通用写法
数据清洗里最耗时间的不是字段规整,而是实体对齐。同一种药在不同方剂里可能写作“甘草”或“炙甘草”,同一首方剂在数据里可能一个叫“麻杏石甘汤”、一个叫“麻黄杏仁甘草石膏汤”。不对齐的话,图查询时会出现明明是一个实体,却分裂成两个节点,关系路径全部断裂。
资源里的对齐方式是“别名表 + 归一化键”。每个实体在CSV里预置一个canonical_id字段,别名表里存了至少几十个常见异名映射。代码执行时,先按照canonical_id合并,合并后重新分配内部ID,同时保留别名表为查询提供入口。
import json # 读取合并规则:canonical_id -> 标准实体名 with open("alignment_rules.json", encoding="utf-8") as fp: rules = json.load(fp) # 原则:所有别名都映射到标准名,不做模糊匹配 def get_canonical(raw_name: str) -> str: return rules.get(raw_name, raw_name) df["canonical_name"] = df["name"].map(get_canonical) # 按canonical_name分组合并,别名取合并集的并集 merged = ( df.groupby("canonical_name", as_index=False) .agg({"name": lambda x: "|".join(set(x)), "efficacy": "first", "meridian": "first"}) ) print(f"合并前 {len(df)} -> 合并后 {len(merged)} 个标准中药实体")逻辑说明:关键是get_canonical函数没有做任何相似度计算,完全靠对齐规则表做精确映射。理由很实际:中医药名词相似不等于同物,“当归尾”和“当归”就差一个字,功效完全不同,拼音或编辑距离一类算法会引入大量噪声。对齐规则表虽然维护起来费功夫,但对错完全可控,也比训练一个NER模型轻量得多。从这里能明显看到,数据工程环节大约贡献了整份资源一半以上的价值,建图谱本身反而只占用很少时间。
4. 入库Neo4j:CQL建模与查询验证
4.1 为什么选Neo4j而不是纯文件存储
数据清洗完之后,面临的决策是存在哪里。直接存成JSON或者SQLite也能做查询,但知识图谱查询本质上是多跳关系遍历。举个例子:“找出使用含黄芪的方剂来治疗气虚感冒的路径”,纯文件存储需要先把方剂表全部读入内存,再循环匹配组成字段和主治字段,复杂度高不说,代码也难维护。Neo4j的Cypher查询对这种路径检索是原生支持的,一条MATCH语句就能表达三跳以内的全部路径。这套资源自带Neo4j的Cypher导入脚本和完整数据文件,不需要自己从零写ORM层。
4.2 CQL建节点与关系:参数化导入的写法
节点导入用的是Cypher的LOAD CSV语句,脚本里分成两步:先建节点,再建关系。节点使用MERGE而不是CREATE,重复跑脚本不会制造重复节点,这是部署的时候很省心的设计。
// 导入中药节点,文件路径是neo4j import目录下的herbs_normalized.csv LOAD CSV WITH HEADERS FROM 'file:///herbs_normalized.csv' AS row MERGE (h:Herb {id: row.canonical_name}) ON CREATE SET h.alias = row.alias, h.efficacy = row.efficacy, h.meridian = row.meridian, h.taste = row.taste, h.property = row.property这段脚本的逻辑是一次性导入全部中药字段。注意点是最底层的MERGE只以canonical_name作为唯一标识,也就意味着前面清洗阶段的实体对齐质量直接决定最终图谱的节点数量。如果在清洗阶段没做别名合并,同一个药会被拆成多个节点,这个坑在下一章会专门展开。属性上alias字段可以直接存竖线分隔的字符串表,Neo4j里用split()函数查询时再展开,不必在导入阶段建数组。
关系导入需要两张表的连接操作,Cypher里写在LOAD CSV语句的WHERE子句中匹配方剂表和中药表:
// 导入“组成”关系,一行一个方剂-中药对 LOAD CSV WITH HEADERS FROM 'file:///formula_composition.csv' AS row MATCH (f:Formula {id: row.formula_id}) MATCH (h:Herb {id: row.herb_canonical_name}) MERGE (f)-[r:COMPOSE {quantity: row.dosage}]->(h)逻辑说明:先匹配两端的节点,再用MERGE建关系,关系上挂dosage剂量属性。这里的关系名用了COMPOSE而不是CONTAINS。因为语义上,方剂是“组成”的主动方,关系读起来应该是Formula -[COMPOSE]-> Herb,这样在后续做方剂拆解分析时,Cypher查询可以顺着COMPOSE关系从方剂出发遍历到所有药材,语义更顺。
4.3 查询验证:归经、主治、配伍三条路径
图谱建好之后,第一步不是急着做上层应用,而是先跑三条校验查询,验证数据没有丢失或错位。归经查询看的是从某味药出发能到多少个经络节点;主治查询验证方剂到症状的路径;配伍查询最有验证价值,因为药对数据是后加的,如果前置的实体对齐做得不对,很多配伍记录会指向不存在的药材ID,直接查不出来。
// 查询:找出与黄芪有配伍关系的中药及这些药在方剂中的出现次数 MATCH (h:Herb {id: '黄芪'})-[:COMPOSE]-(f:Formula)-[:COMPOSE]->(other:Herb) WITH other, count(DISTINCT f) AS freq MATCH (h)-[:PAIR]->(other) RETURN other.id, other.efficacy, freq ORDER BY freq DESC LIMIT 20这段的重点是第一个MATCH先找“黄芪出现在哪些方剂里,这些方剂还用了哪些其他药”,然后通过WITH把统计结果传递下去,最后再匹配配伍关系。逻辑上是在做一种简单的关联验证:既然教材说“黄芪-当归”是配伍药对,那图谱里这两味药应该在同首方剂中频繁共现。如果查询结果里某个配伍对的共现频率是0,基本可以断定清洗阶段实体对齐出了问题,回去看别名表就行。
参数说明:freq的阈值一般取10以上才认为共现有实际意义,低于10的可能是偶然同方。LIMIT 20是为了先看排名,实际使用中可以去掉限制输出全量结果。这类查询跑一遍大概只需要几十毫秒,数据量在几千条节点级别完全不会有性能压力。
5. SVG可视化图谱生成:避坑与常见问题排查
5.1 从关系数据到SVG:力导向布局的计算逻辑
Neo4j浏览器自带可视化,但做项目交付时总得把图嵌入网页或者导出成图片。这套资源里的SVG图谱是直接用Python从图数据算出来的。核心逻辑并不是把Neo4j渲染的结果截图,而是导出节点关系JSON,再用纯Python计算力导向布局坐标,最终写成SVG文件。
力导向布局的原理简述:假设每个节点之间有斥力,每条关系边有弹簧一样的引力,迭代几百轮之后让整个图稳定下来。代码里实际用的就是这种经典模型,迭代轮数设成了200次,惯性系数0.9。参数太大图会震荡半天不收敛,太小又容易陷在局部扭曲的布局里出不来。200轮在小图数据上大概几秒钟跑完,属于安全取值。
import json import math def compute_force_layout(nodes, edges, iterations=200, damping=0.9): # 初始化坐标,按圆心均匀分布 n = len(nodes) for i, node in enumerate(nodes): node["x"] = 500 + 300 * math.cos(2 * math.pi * i / n) node["y"] = 400 + 300 * math.sin(2 * math.pi * i / n) for _ in range(iterations): for i, u in enumerate(nodes): for j, v in enumerate(nodes): if i == j: continue dx = v["x"] - u["x"] dy = v["y"] - u["y"] dist = max(math.hypot(dx, dy), 1e-6) # 斥力:与距离平方成反比 force = 6000 / (dist * dist) u["x"] -= force * dx / dist u["y"] -= force * dy / dist for edge in edges: u = next(n for n in nodes if n["id"] == edge["source"]) v = next(n for n in nodes if n["id"] == edge["target"]) dx = v["x"] - u["x"] dy = v["y"] - u["y"] dist = max(math.hypot(dx, dy), 1e-6) # 引力:距离越大拉力越强 force = 0.01 * dist u["x"] += force * dx / dist u["y"] += force * dy / dist v["x"] -= force * dx / dist v["y"] -= force * dy / dist return nodes # 读取Neo4j导出的图谱JSON with open("graph_export.json", encoding="utf-8") as fp: graph_data = json.load(fp) nodes = compute_force_layout(graph_data["nodes"], graph_data["edges"]) print(f"布局计算完成,共 {len(nodes)} 个节点")逻辑说明:先在圆周上做等角初始化,保证初始状态下节点不重叠,这是防止布局收敛到局部极值的朴素有效做法。然后每次迭代分两步:先全网节点两两计算斥力,再对所有关系边计算引力。参数上,6000是斥力常数,单位面积越大节点越分散;0.01是引力系数,值越大图越紧凑。实际调试时看生成的SVG:如果中间挤成一团,就调大斥力常数;如果边长得太长,就调大引力系数。
5.2 渲染值:节点颜色、大小、连边粗细的调整
布局算出来后,渲染是另一个坑。中文节点名的长度差异很大,“四君子汤”和“丹参”的宽度差了四五个像素,直接固定节点半径会导致文字溢出或者节点间遮压。这里实际用的策略是:节点半径根据名称长度动态计算,最小半径16像素,每多一个字加2像素。颜色则按实体类型映射——中药用浅绿色,方剂用浅蓝色,症状/疾病用浅橙色。关系连边不设箭头,纯直线,粗细按关系出现的频次加权,共现实体之间的关系线更粗。
import html def build_svg(nodes, edges): svg_parts = ['<svg width="1200" height="900" xmlns="http://www.w3.org/2000/svg">'] svg_parts.append('<rect width="1200" height="900" fill="#f8f9fa"/>') for edge in edges: u = next(n for n in nodes if n["id"] == edge["source"]) v = next(n for n in nodes if n["id"] == edge["target"]) stroke_width = min(2 + edge.get("weight", 1) * 0.5, 8) svg_parts.append( f'<line x1="{u["x"]:.1f}" y1="{u["y"]:.1f}" ' f'x2="{v["x"]:.1f}" y2="{v["y"]:.1f}" ' f'stroke="#adb5bd" stroke-width="{stroke_width:.1f}"/>' ) for node in nodes: radius = 16 + 2 * max(0, len(node["name"]) - 2) color = {"herb": "#a3d9a5", "formula": "#a0c4ff", "disease": "#ffc9a3"}.get( node["type"], "#dee2e6" ) safe_name = html.escape(node["name"]) svg_parts.append( f'<circle cx="{node["x"]:.1f}" cy="{node["y"]:.1f}" r="{radius}" ' f'fill="{color}" stroke="#495057" stroke-width="1"/>' ) svg_parts.append( f'<text x="{node["x"]:.1f}" y="{node["y"]:.1f}" ' f'text-anchor="middle" dominant-baseline="central" ' f'font-size="12" fill="#212529">{safe_name}</text>' ) svg_parts.append("</svg>") return "\n".join(svg_parts) svg_content = build_svg(nodes, graph_data["edges"]) with open("graph_visualization.svg", "w", encoding="utf-8") as fp: fp.write(svg_content) print("SVG文件已生成")逻辑说明:每个节点输出两个元素,一个圆加一个文字标签。注意safe_name调用html.escape,这是必须的。原始数据如果混入特殊字符,直接拼接SVG会生成非法XML。stroke_width做上限控制到8,避免高权重边视觉上盖过所有节点。这套SVG可以直接拿浏览器打开,也可以嵌进HTML报告。想发布成网页,加一层img标签就够了。
5.3 避坑:四条必须知道的踩坑记录
现象一:部分药材和方剂在Neo4j浏览器里有显示,SVG里却找不到。 原因:Neo4j浏览器会在结果集里默认做关系展开,有些节点是查询过程中动态带出来的,并没有真正存在于图数据库里。而SVG生成脚本读的是显式导出的节点表,天然缺了这批虚拟节点。 解决:导出前先在Cypher里跑一遍MATCH (n) RETURN n,把所有节点落库,再执行导出JSON。我自己每次倒数据前都会强制跑一次这个全量查询。
现象二:生成的SVG里,节点和中文标签的位置错位,文字全跑到圆外面。 原因:字体宽度估算和SVG实际渲染宽度不一致。代码用字符数估算中文宽度,但Linux上渲染没有中文字体时,所有字形宽度几乎一致,计算完全失真。 解决:在服务器上装好中文字体,或者把font-family显式写成系统已有的中文字体名称。我在部署时发现最简单的方案是SVG里直接用family-name: sans-serif,大多数现代浏览器和PDF渲染器都能自动回退到系统中文。
现象三:LOAD CSV导入时,报“Couldn't load the external resource”错误。 原因:CSV文件名或路径和Neo4j的import目录配置不匹配,常见的是中文文件名没转码,或者直接把文件放在了项目目录而不是import目录。 解决:把CSV统一放进NEO4J_HOME/import目录,且文件名全部改成拼音或英文,比如herbs_normalized.csv,不在路径里保留中文。
现象四:关系数量虚高,图谱边密密麻麻,SVG直接变成一团线球。 原因:原始数据里“主治”关系没有区分主次,一张方剂动辄关联几十个症状,全部建成关系后边的数量是节点的十倍以上。 解决:资源里给了一个解药——为关系设置权重阈值,只保留出现频次超过阈值的关系。具体做法是在导出JSON时做个过滤,低于阈值的关系直接丢弃。阈值通常取2到3,低于2会丢失太多上下文,高于5会过滤掉一大部分有效信息。
6. 把图谱能力往下沉:拆方分析、替代药味与问答式查询
6.1 用图谱距离找替代药味
知识图谱建好了,纯粹放在那儿看结构意义很有限,它真正值钱的能力是关系推理。这里分享一个亲测实用的技巧:用图谱距离来做替代药推荐。思路很简单,两味药如果和同一个方剂群体保持相似的关系模式,那它们大概率能互相替代。Cypher里不需要复杂算法,一次子图匹配加Jaccard相似度计算就能给你一个不像从教材里翻出来的替代名单。
// 找出与“黄芪”共现方剂相似度最高的前10味替代药 MATCH (h:Herb {id: '黄芪'})-[:COMPOSE]->(f:Formula) WITH h, collect(DISTINCT f.id) AS source_formulas MATCH (candidate:Herb)-[:COMPOSE]->(f2:Formula) WHERE candidate.id <> h.id WITH h, source_formulas, candidate, collect(DISTINCT f2.id) AS target_formulas WITH h, candidate, source_formulas, target_formulas, [x IN source_formulas WHERE x IN target_formulas].size() AS intersection WITH h, candidate, source_formulas.size() AS s_size, target_formulas.size() AS t_size, intersection WHERE s_size > 0 AND t_size > 0 RETURN candidate.id, intersection * 1.0 / (s_size + t_size - intersection) AS jaccard ORDER BY jaccard DESC LIMIT 10这段查询不是数学上的严格Jaccard,但语义上完全一致:交集方剂数除以并集方剂数。相似度高的候选药意味着它们和黄芪出现在同一批方剂里,替换风险从经验层面被降下来了。实际使用时,不要直接用输出结果完稿,我会把Top10拉出来再做一轮教材核对,确认功效分类方向一致后才进入实验报告。
6.2 拆方分析:把一首方剂变成知识点子图
另一个实用的进阶用法是拆方。把一首方剂从“组成”关系出发,顺藤摸瓜找出每味药的归经、主治和药对关系,然后渲染成一张局部子图。这个过程特别适合做课程设计或教学演示,能把一张方剂背后的知识网络完整体现出来。
// 提取一首方剂的完整知识子图 MATCH (f:Formula {name: '四君子汤'})-[:COMPOSE]->(h:Herb) MATCH (h)-[:MERIDIAN]->(m) MATCH (h)-[:INDICATES]->(d) RETURN f, h, m, d这个查询出结果之后,我用前面的SVG脚本重新生成一张独立图谱,四君子汤在中心,人参、白术、茯苓、甘草围绕在四周,每一个药再展开归经和主治。效果比任何文字描述都直观。对做答辩演示或者组内分享的人,这一招节省的时间非常可观。
6.3 把SVG快照接进日报的一个小习惯
最后分享一个最简单的落地技巧:SVG文件不能直接在Windows照片查看器里打开,总觉得不方便,所以我每次都顺手多做一步,用Python的cairosvg库把SVG转成PNG。代码只有一行。
cairosvg graph_visualization.svg -o graph_visualization.png --output-width 1200 --output-height 900这样得到的PNG能直接在文档、课件、周报里引用。从那以后我每次换数据源跑完图谱流程,都会强制走一遍“导出JSON → 计算布局 → 生成SVG → 转PNG”四步,确认每个节点都渲染出来再往下做分析。希望这个工作流对你也有用,少走点我当年踩过的弯路。
本文还有配套的精品资源,点击获取