ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

中药方剂知识图谱问答系统:从爬虫到Neo4j全链路实现

中药方剂知识图谱问答系统:从爬虫到Neo4j全链路实现 简介一套面向中医药信息化的完整项目源码基于知识图谱实现对中药方剂的深度组织、可视化呈现与智能问答。该项目适合计算机专业或中医药交叉领域的毕设、课程设计学习者旨在解决方剂数据碎片化、检索效率低等问题。压缩包共56个文件仅4.33MB其中10个Python脚本承担数据爬取、知识抽取、图谱写入与问答流程CSS/JS/HTML组成前端展示界面JSON文件保存方剂及关系数据辅以字体图片等资源结构清晰。已有68人学习下载。通过本套代码可掌握从爬虫采集与三元组构建、LTP自然语言处理、Neo4j图数据库建图到基于Flask的Web交互与可视化展示的完整链路目录包含KGQA问答模块、neo_db图数据库操作、templates前端页面等附依赖清单和说明文档便于快速运行。适合希望快速搭建中医药知识图谱演示系统或作为毕业设计框架的开发者参考。1. 为什么中药方剂需要一个知识图谱问答系统传统的中药方剂数据大多以关系型数据库或文本表格的形式存在一张表存药材一张表存方剂外键关联一下查起来不难但人对信息的理解是网状的——一味药影响哪些方剂、一个方剂针对哪些症状、药材之间的配伍禁忌链路用表结构很难直观表达。这个项目把方剂、药材、功效、主治症状全部抽成三元组SPO导入 Neo4j 图数据库再配合 LTP 做中文分词与依存句法分析让你可以直接输入治疗风寒感冒的方剂有哪些这类自然语言问题系统先解析意图再转成 Cypher 查询最后把子图返回给前端 ECharts 渲染。适合三类人做中医药信息化的研究者、毕业设计需要完整技术栈演示的学生、以及想了解爬虫 NLP 图数据库 Web 可视化如何串成一条链路的后端工程师。源码里能直接看到从原始文本到图查询的全过程不是 demo 级别的空壳。2. 从爬虫到三元组中药方剂数据的 SPO 抽取与对齐2.1 数据文件到底长什么样解压后raw_data目录里有三个核心文件zhongyao_spo.txt、fangji.json、relations_zhongyao.json。前两个是数据源头第三个是关系约束文件。我用一个最简示例说明 SPO 格式黄芩 清热燥湿 功效 黄芩 味苦 性味 黄芪 补气固表 功效 黄芪 治气虚乏力 主治每一行是实体1 \t 关系 \t 实体2对应知识图谱里的(头实体)-[关系]-(尾实体)。relations_zhongyao.json则定义了关系白名单保证导入 Neo4j 时关系类型不会失控。fangji.json是方剂维度的结构化数据一个方剂包含组成药材、剂量、功效、主治、用法等字段。这里有个值得注意的设计选择zhongyao_spo.txt只覆盖单味中药的属性关系而方剂与药材的组成关系、方剂与症状的主治关系是由fangji.json结合关联脚本生成的。也就是说系统把中药属性和方剂配伍分成两张数据视图最后统一折叠进图数据库。这样做的理由是属性关系可以无限扩展而方剂关系需要专业数据源约束分开管理更利于后续更新。2.2 扒数据与关系抽取脚本spider目录下是采集脚本常见做法是请求百科或药典页面用 XPath 抽取药材信息表再解析功效主治性味归经这些字段转成上述 SPO 行。以get_character_array.py和get_hlm_character.py为例前者抓取药材性状特征数组后者抓取特定药材条目下的每一条属性生成的特征数组会直接追加到 SPO 文本中。# get_character_array.py 核心逻辑示意 import requests from lxml import etree url https://example.com/zhongyao/黄芩 resp requests.get(url, headers{User-Agent: Mozilla/5.0}) html etree.HTML(resp.text) # 抽取药材属性表格 attrs html.xpath(//table[classproperties]//tr) spo_list [] for tr in attrs: tds tr.xpath(./td/text()) if len(tds) 2: head, tail tds[0].strip(), tds[1].strip() spo_list.append(f黄芩\t{head}\t{tail}) # 写回 zhongyao_spo.txt with open(raw_data/zhongyao_spo.txt, a, encodingutf-8) as f: f.write(\n.join(spo_list))这段代码说明一个基本方法把网页表格的行映射成 SPO。你需要在调试时把tds打印出来看字段是否对齐很多页面会把别名写成多行文本直接split会错位。我一般会先统计一下head字段的取值分布如果出现几十种非预期值说明数据源页面结构调整了需要同步更新选择器。2.3 数据质量问题与对齐策略SPO 抽取后必须做实体对齐否则知识图谱就是一堆孤岛。常见问题有三个同一药材多种写法山萸肉 vs 山茱萸、剂量单位混用三钱 vs 9g、功效描述口语化治咳嗽 vs 止咳。源码中get_hlm_character.py的末尾会做一个简单归一化把全角括号转半角、去掉尾部句号、按预置词典做同义词替换。这个词典没有单独文件写死在脚本的alias_dict变量里你如果要扩展数据需要在这里维护一份药材别名映射表。问题类型示例处理策略药材别名山萸肉 / 山茱萸维护别名 dict统一成正名剂量混写三钱 / 9g统一换算成克功效冗余治咳嗽 / 止咳去除动词前缀按功效词归并关系歧义归肺经 vs 肺热区分归经与病症关系类型做完对齐后再把fangji.json里的方剂实体与 SPO 里的药材实体关联。这一步靠creat_graph.py执行它会读取方剂组成字段生成(方剂)-[组成]-(药材)关系。前处理做得好不好直接决定后面 Cypher 查询结果是否准确。3. 把三元组灌进 Neo4j创图脚本与 Cypher 查询设计3.1 py2neo 批量写入的打开方式neo_db目录下的creat_graph.py负责建图。它读取zhongyao_spo.txt和fangji.json通过py2neo连接本地 Neo4j 实例。这里的核心问题是写入性能一条条graph.create()在数据量上万时极慢所以要批量提交。# creat_graph.py 关键片段 from py2neo import Graph, Node, Relationship, Subgraph graph Graph(bolt://localhost:7687, auth(neo4j, password)) # 缓存节点避免重复创建 nodes_cache {} def get_or_create_node(label, name): key f{label}_{name} if key not in nodes_cache: node Node(label, namename) nodes_cache[key] node return nodes_cache[key] subgraph [] with open(raw_data/zhongyao_spo.txt, encodingutf-8) as f: for line in f: head, relation, tail line.strip().split(\t) h_node get_or_create_node(Herb, head) t_node get_or_create_node(Attr, tail) rel Relationship(h_node, relation, t_node) subgraph.append(rel) # 每 500 条提交一次 for i in range(0, len(subgraph), 500): graph.create(Subgraph(subgraph[i:i500])) print(f已提交 {ilen(subgraph[i:i500])}/{len(subgraph)} 条关系)这段代码有三个关键点用Subgraph批量提交代替逐条create速度提升一个数量级用nodes_cache缓存已存在的节点避免重复创建出多个同名实体关系类型直接用 SPO 文本里的关系字符串后续查询时再利用relations_zhongyao.json做关系归一化。注意bolt://端口是 7687不是 HTTP 的 7474很多人在连接这里卡住报错Unauthorized时先检查用户名密码再检查 Neo4j 是否启动了 bolt 协议。3.2 方剂关系的折叠与合并方剂关系不是直接来的需要从fangji.json解析。一个方剂的结构类似方名、组成、功效、主治。组装关系时要拆出三个维度(方剂)-[功效]-(症状)、(方剂)-[组成]-(药材)、(药材)-[主治]-(症状)。合并时注意不要重复建边。我会用 Cypher 的MERGE来保证幂等性。// 幂等建边示例 MATCH (f:FangJi {name: 麻黄汤}) MATCH (m:Herb {name: 桂枝}) MERGE (f)-[:组成 {dosage: 9g}]-(m)MERGE和CREATE的区别在于CREATE无条件创建新边重跑脚本会生成重复关系MERGE先匹配再创建适合批量导入脚本重复执行。每条边的dosage属性来自fangji.json的剂量字段这是方剂图谱区别于一般药材图谱的关键信息后面做配伍分析时会用到。3.3 查询层封装query_graph.py 的设计图谱建好后业务层不能直接到处写 Cypher否则前端每个接口都得改查询。query_graph.py把所有查询封装成函数返回统一的字典结构方便 Flask 路由直接调用。# query_graph.py 核心函数 from neo4j import GraphDatabase class GraphQuery: def __init__(self, uri, user, password): self.driver GraphDatabase.driver(uri, auth(user, password)) def query_fangji_by_symptom(self, symptom): # 通过症状反向查方剂返回方剂组成药材功效 cypher MATCH (f:FangJi)-[:主治]-(s:Symptom {name: $symptom}) OPTIONAL MATCH (f)-[:组成]-(m:Herb) RETURN f.name AS fangji, collect(m.name) AS herbs, f.功效 AS effect LIMIT 20 with self.driver.session() as session: records session.run(cypher, symptomsymptom) return [dict(r) for r in records]单元测试时可以直接在 Cypher Shell 里执行这段语句验证返回的herbs数组有没有重复。LIMIT 20是为防止方剂过多时响应体过大。OPTIONAL MATCH非常关键如果某个方剂没有导出组成关系MATCH会直接过滤掉整行而OPTIONAL MATCH会保留方剂并让药材列表为空。做知识图谱查询时这个细节决定前端图谱是否会出现节点丢失。4. 基于 LTP 依存句法的问答解析与模板匹配4.1 为什么选 LTP 而不是简单关键词匹配问答系统部分在KGQA目录下核心依赖是哈工大 LTP模型文件在model/ltp_data_v3.4.0。之所以不用正则硬匹配因为自然语言问法的变体实在太多哪些药能治咳嗽和咳嗽吃什么方剂语义相同但表面对不齐。LTP 提供分词、词性标注、依存句法分析能从句子结构里抽出核心实体和意图动词。整体流程是用户输入 → LTP 分词和依存分析 → 提取核心词与疑问词 → 匹配意图模板 → 转 Cypher → 执行并返回结果。意图模板定义在KGQA目录的配置里每一类意图对应一个查询模式。4.2 依存句法怎么帮我们提取问句要素拿治疗风寒感冒的方剂有哪些这句话来说LTP 依存分析会给出类似结构治疗 -- 核心谓语 (HED) 风寒感冒 -- 宾语 (VOB) 方剂 -- 客体 有哪些 -- 疑问标记代码里通过遍历依存弧找到VOB或ATT关系中类型为症状的子节点再反查父节点是否为意图动词就能把整句话归一到症状查方剂模板。这样做的好处是即使换成什么方剂可以缓解风寒感冒只要词性标注和依存结构一致依然能命中。# KGQA/ltp.py 关键片段 from pyltp import Segmentor, Postagger, Parser segmentor Segmentor(model_pathmodel/ltp_data_v3.4.0/cws.model) postagger Postagger(model_pathmodel/ltp_data_v3.4.0/pos.model) parser Parser(model_pathmodel/ltp_data_v3.4.0/parser.model) words list(segmentor.segment(治疗风寒感冒的方剂有哪些)) postags list(postagger.postag(words)) arcs parser.parse(words, postags) # 遍历依存关系寻找 核心谓词-宾语 结构 for arc in arcs: # arc.head 是父节点索引arc.relation 是关系名 if arc.relation VOB: child_word words[arc.head - 1] # 注意 LTP 索引从 1 开始 parent_word words[arc.head - 1] print(f动词: {parent_word}, 宾语: {child_word})这段代码有个典型坑arc.head是父节点的位置但 LTP 的索引从 1 开始而words是 Python 列表从 0 开始所以取词必须words[arc.head - 1]写错就 IndexError。另一个坑是 LTP 模型文件对 Python 版本有编译要求pyltp在 Python 3.10 上经常编译失败我建议用 Python 3.7 或 3.8 跑问答模块Web 展示层可以用更高版本两个服务分开部署。4.3 意图模板与 Neo4j 查询的映射模板匹配是整个问答准确率的关键。下面这张表是项目里常见意图类型和对应 Cypher 的映射用户问题意图类型抽取要素目标查询治疗XX的方剂SYMPTOM_TO_FANGJI症状名MATCH (f)-[:主治]-(s {name: XX}) RETURN fXX方剂的组成FANGJI_TO_HERB方剂名MATCH (f {name: XX})-[:组成]-(m) RETURN mXX药材的功效HERB_TO_EFFECT药材名MATCH (h {name: XX})-[:功效]-(e) RETURN eXX和XX能否同用HERB_CONFLICT两个药材名MATCH (a)-[:配伍禁忌]-(b) WHERE a.nameXX AND b.nameXXKGQA里的query.py先做实体识别把分词结果与图数据库中已有实体做模糊匹配得到一个置信度高于阈值才进模板匹配。如果实体识别概率低会返回换个说法再试一次。这一步是问答系统体验差异的关键直接做字符串精确匹配的话黄芩写成黄岑就废了。4.4 语音输入与兜底机制README 里提到支持语音识别实现方式一般是在前端用 Web Speech API 或其他语音转文字 SDK把音频转成文字后再走上面的问答链路。templates/search.html里有一个麦克风按钮点击后录制识别结果填入搜索框。// templates/search.html 语音识别接口示意 const recognition new webkitSpeechRecognition(); recognition.lang zh-CN; recognition.onresult function (event) { const text event.results[0][0].transcript; document.getElementById(question).value text; submitQuestion(text); }; recognition.start();语音识别的文本往往带语气词嗯治疗那个风寒感冒的方剂这类输入直接进入 LTP 会干扰依存分析。我的做法是在提交前先做一次停用词过滤保留治疗、风寒感冒、方剂这类实词再进意图匹配。如果匹配失败兜底方案是直接用余弦相似度对用户问句和所有实体名计算相似度返回 Top 5 实体作为联想结果。5. 知识图谱的可视化输出与部署排错5.1 从查询结果到前端图形数据的转换后端返回的是 Cypher 记录列表前端 ECharts 需要nodes和links两个数组。neo2json.py就是这个转换层把每个方剂、药材、症状实体映射为节点把关系映射为带source和target的边。# neo2json.py 核心转换逻辑 import json def convert_to_echarts(records): nodes, links [], [] node_ids set() for record in records: source record.get(source) target record.get(target) rel record.get(rel) if source and source[name] not in node_ids: nodes.append({ id: source[name], name: source[name], category: source[label], symbolSize: 50 if source[label] FangJi else 30, }) node_ids.add(source[name]) if target and target[name] not in node_ids: nodes.append({ id: target[name], name: target[name], category: target[label], symbolSize: 30, }) node_ids.add(target[name]) links.append({source: source[name], target: target[name], label: rel}) return {nodes: nodes, links: links} # 供 Flask 路由调用jsonify(convert_to_echarts(records))转换时一个重要参数是symbolSize方剂类型的节点必须大于药材和症状否则图谱层次感出不来。前端KGQA.html里用graph.category做图例分组点击节点后触发邻接关系高亮这个交互是index.html和search.html共用的一套 JS 逻辑在static/js目录下建议你改样式时先确认是同一份还是各页面单独打包。5.2 启动与部署常见报错这个项目最常见的坑集中在环境依赖上。requirements.txt里锁定了py2neo、flask、pyltp等版本但缺少neo4jPython 驱动的显式依赖。如果你只装了py2neoquery_graph.py里的neo4j.GraphDatabase会直接 ImportError。正确做法是pip install -r requirements.txt pip install neo4j4.4.11 python app.pyapp.py启动前要确认 Neo4j 已运行并且conf里的bolt_port和认证信息与config.py匹配。config.py中NEO4J_URI默认写的是bolt://localhost:7687如果你的 Neo4j 装在 Docker 容器里这里要改成宿主机的 IP且docker run时要映射7474:7474和7687:7687两个端口。前端图谱不显示时打开浏览器开发者工具看 Network 面板确认/api/graph返回的数据里nodes非空。很多时候后端报错被 Flask 吞掉只返回 500你需要在app.py里临时打开debugTrue或者在neo2json.py的convert_to_echarts入口加一行records list(records)强制物化结果因为 Neo4j 的 Result 对象只能迭代一次前端拿不到数据往往是后端已经迭代完了。最后一个性能技巧图谱全量渲染时节点超过 200 个浏览器会明显卡顿。我一般会在query_graph.py里加一个limit参数默认只返回实体度最高的前 50 个节点用户在图例上点击某类实体时才动态展开。这样的交互方式既保住了可视化的直观感又不会让前端卡死。本文还有配套的精品资源点击获取
返回列表