中药知识图谱构建实战:数据清洗、Neo4j导入与可视化
简介这套资源定位为中医药知识图谱实践入门材料聚焦中药与方剂两类核心实体及其关系建模适合计算机、人工智能及中医药信息学方向学生用于课程设计、毕业设计或科研起步。包内包含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, encodingutf-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, indexFalse, encodingutf-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, encodingutf-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_indexFalse) .agg({name: lambda x: |.join(set(x)), efficacy: first, meridian: first}) ) print(f合并前 {len(df)} - 合并后 {len(merged)} 个标准中药实体)逻辑说明关键是get_canonical函数没有做任何相似度计算完全靠对齐规则表做精确映射。理由很实际中医药名词相似不等于同物“当归尾”和“当归”就差一个字功效完全不同拼音或编辑距离一类算法会引入大量噪声。对齐规则表虽然维护起来费功夫但对错完全可控也比训练一个NER模型轻量得多。从这里能明显看到数据工程环节大约贡献了整份资源一半以上的价值建图谱本身反而只占用很少时间。4. 入库Neo4jCQL建模与查询验证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, iterations200, damping0.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, encodingutf-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 width1200 height900 xmlnshttp://www.w3.org/2000/svg] svg_parts.append(rect width1200 height900 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( fline x1{u[x]:.1f} y1{u[y]:.1f} fx2{v[x]:.1f} y2{v[y]:.1f} fstroke#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( fcircle cx{node[x]:.1f} cy{node[y]:.1f} r{radius} ffill{color} stroke#495057 stroke-width1/ ) svg_parts.append( ftext x{node[x]:.1f} y{node[y]:.1f} ftext-anchormiddle dominant-baselinecentral ffont-size12 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, encodingutf-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导入时报“Couldnt 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”四步确认每个节点都渲染出来再往下做分析。希望这个工作流对你也有用少走点我当年踩过的弯路。本文还有配套的精品资源点击获取