中药方剂知识图谱问答系统:从爬虫到Neo4j全链路实现
2026/9/15 18:46:26 网站建设 项目流程

简介:一套面向中医药信息化的完整项目源码,基于知识图谱实现对中药方剂的深度组织、可视化呈现与智能问答。该项目适合计算机专业或中医药交叉领域的毕设、课程设计学习者,旨在解决方剂数据碎片化、检索效率低等问题。压缩包共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.txtfangji.jsonrelations_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.pyget_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[@class='properties']//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", encoding="utf-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.txtfangji.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, name=name) nodes_cache[key] = node return nodes_cache[key] subgraph = [] with open("raw_data/zhongyao_spo.txt", encoding="utf-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:i+500])) print(f"已提交 {i+len(subgraph[i:i+500])}/{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)

MERGECREATE的区别在于: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, symptom=symptom) 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) 方剂 -- 客体 有哪些 -- 疑问标记

代码里通过遍历依存弧,找到VOBATT关系中类型为"症状"的子节点,再反查父节点是否为意图动词,就能把整句话归一到"症状查方剂"模板。这样做的好处是,即使换成"什么方剂可以缓解风寒感冒",只要词性标注和依存结构一致,依然能命中。

# KGQA/ltp.py 关键片段 from pyltp import Segmentor, Postagger, Parser segmentor = Segmentor(model_path="model/ltp_data_v3.4.0/cws.model") postagger = Postagger(model_path="model/ltp_data_v3.4.0/pos.model") parser = Parser(model_path="model/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 f
XX方剂的组成FANGJI_TO_HERB方剂名MATCH (f {name: XX})-[:组成]->(m) RETURN m
XX药材的功效HERB_TO_EFFECT药材名MATCH (h {name: XX})-[:功效]->(e) RETURN e
XX和XX能否同用HERB_CONFLICT两个药材名MATCH (a)-[:配伍禁忌]->(b) WHERE a.name=XX AND b.name=XX

KGQA里的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 需要nodeslinks两个数组。neo2json.py就是这个转换层:把每个方剂、药材、症状实体映射为节点,把关系映射为带sourcetarget的边。

# 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.htmlsearch.html共用的一套 JS 逻辑,在static/js目录下,建议你改样式时先确认是同一份还是各页面单独打包。

5.2 启动与部署常见报错

这个项目最常见的坑集中在环境依赖上。requirements.txt里锁定了py2neoflaskpyltp等版本,但缺少neo4jPython 驱动的显式依赖。如果你只装了py2neoquery_graph.py里的neo4j.GraphDatabase会直接 ImportError。正确做法是:

pip install -r requirements.txt pip install neo4j==4.4.11 python app.py

app.py启动前要确认 Neo4j 已运行,并且conf里的bolt_port和认证信息与config.py匹配。config.pyNEO4J_URI默认写的是bolt://localhost:7687,如果你的 Neo4j 装在 Docker 容器里,这里要改成宿主机的 IP,且docker run时要映射7474:74747687:7687两个端口。

前端图谱不显示时,打开浏览器开发者工具看 Network 面板,确认/api/graph返回的数据里nodes非空。很多时候后端报错被 Flask 吞掉,只返回 500,你需要在app.py里临时打开debug=True,或者在neo2json.pyconvert_to_echarts入口加一行records = list(records)强制物化结果,因为 Neo4j 的 Result 对象只能迭代一次,前端拿不到数据往往是后端已经迭代完了。

最后一个性能技巧:图谱全量渲染时节点超过 200 个,浏览器会明显卡顿。我一般会在query_graph.py里加一个limit参数,默认只返回实体度最高的前 50 个节点,用户在图例上点击某类实体时才动态展开。这样的交互方式,既保住了"可视化"的直观感,又不会让前端卡死。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询