简介:面向自然语言处理与知识图谱方向学习者,这套医疗问答系统项目以垂直网站数据为起点,完整走通数据采集、知识抽取、图谱构建与智能问答全流程,最终形成以疾病为中心的医疗知识图谱,实体规模约4.4万、关系30万,可回答18类问题,适合希望从零上手Neo4j图谱应用与命名实体识别的开发者参考。包内含31个文件,以8个Python源码、8个txt词典说明、10张PNG/JPG示意图为主,另有JSON图谱数据、PPTX方案讲解与若干pyc缓存文件,压缩包大小18.58MB。源码覆盖数据爬取、分词处理、图谱构建、问题分类与答案检索等模块,dict目录下预置疾病、症状、药品、食物等实体词典,示意图则清晰展示知识图谱整体结构与问答路由流程。目前已有1654人学习下载。读者可据此快速部署本地环境(数据已整理至data/medical.json),直接体验基于Cypher查询语句的问答服务,并对照架构图逐步复现从文本到图谱、从问题到答案的完整链路。
1. 医药知识图谱不是画一张大网:先想清楚问答和分析要什么
把垂直网站上的药品、疾病、成分数据倒腾进 Neo4j,再挂一层智能问答接口,听起来就是把网页抓下来、塞进图数据库、写几条查询的事。真做过的都知道,坑全在“垂直网站数据”这五个字上:药品别名、剂量单位、适应症措辞、不良反应描述,几乎没有两个网站写法一致。医药领域知识图谱的落地难点不在图数据库本身,而在把非结构化文本变成机器可查的实体和关系。本文从一个可复现的医药知识图谱构建方案出发,拆解基于 Neo4j 的图谱建模、垂直数据抓取清洗、问答接口与分析服务的完整路径,目标是让读者拿到一套能跑通最小闭环的实施思路——适合想用知识图谱做医药问答、药品分析或临床辅助检索的开发者。
2. 从垂直网站到三元组:医药数据抓取清洗与实体关系抽取的落地路径
2.1 为什么垂直网站比通用百科更适合做医药图谱
知识图谱的质量上限由数据源决定。通用百科词条结构松散、更新滞后,同一个药品在不同页面里的适应症写法差异极大,抽取时规则很难收敛。而垂直网站的药品说明书、疾病百科、药品库页面,字段相对固定,至少还有“药品名称”“适应症”“不良反应”“药物相互作用”这类明显的板块标题,抓下来之后按板块切分,比从纯文本里做命名实体识别省力得多。
数据源选型上,我一般会把目标网站分成两类。一类是药品说明书聚合站,提供结构化的说明书字段,适合抽药品、成分、适应症、禁忌;另一类是疾病百科站,提供症状、病因、治疗用药等叙述性文本,适合抽疾病、症状和治疗关系。两类数据互补,正好支撑后续的问答与分析。抓取前先人工翻十来个页面,把字段结构摸清楚,画一张页面字段映射表,比直接写爬虫再返工更高效。
抓取策略上遵循两个原则:低频次、全量去重。垂直站点的反爬压力通常不大,但没必要给自己添麻烦。设置 2 到 3 秒的请求间隔,做好 URL 去重和失败重试,数据落盘用 JSON Lines 格式,每行一条记录,方便后续分批清洗。
2.2 抓取脚本的最小实现与字段落盘
用 requests 加 BeautifulSoup 就能跑通最小抓取流程,不需要一上来就上 Scrapy。下面这段脚本针对一个典型的药品详情页结构:页面左侧是药品信息表,右侧是说明书正文区块。
import requests import json import time from bs4 import BeautifulSoup HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } def fetch_drug_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") drug = {} # 药品名称区,通常在 h1 或特定 class 下 title_tag = soup.find("h1") drug["name"] = title_tag.get_text(strip=True) if title_tag else None # 说明书板块按 h2/h3 标题切分 sections = {} current_heading = None for tag in soup.find_all(["h2", "h3", "p"]): if tag.name in ["h2", "h3"]: current_heading = tag.get_text(strip=True) sections[current_heading] = [] elif current_heading and tag.name == "p": text = tag.get_text(strip=True) if text: sections[current_heading].append(text) drug["sections"] = sections return drug def crawl_drug_list(list_url, max_pages=50): results = [] for page in range(1, max_pages + 1): url = f"{list_url}?page={page}" try: resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") links = soup.select("a[href*='/drug/']") seen = set() for a in links: href = a["href"] if href in seen: continue seen.add(href) detail = fetch_drug_page(href) if detail["name"]: results.append(detail) time.sleep(2) # 控制请求频率,避免触发反爬 except requests.RequestException as e: print(f"page {page} failed: {e}") continue return results if __name__ == "__main__": data = crawl_drug_list("https://example-vertical-site.com/drugs", max_pages=20) with open("raw_drugs.jsonl", "w", encoding="utf-8") as f: for item in data: f.write(json.dumps(item, ensure_ascii=False) + "\n") print(f"saved {len(data)} drugs")这段脚本把抓取分成两层:列表页解析出药品详情链接,详情页解析出药品名称和按标题切分的说明书区块。核心逻辑是用 h2/h3 标题把说明书切成“适应症”“不良反应”“禁忌”等片段,而不是把整页文本一股脑存下来,这样后续清洗和抽取时可以按板块精准定位。
参数上值得注意的有两个。一是time.sleep(2)不能省,垂直站点虽然反爬弱,但并发一高很容易被临时封 IP;二是resp.encoding = "utf-8"必须显式设置,很多医药站点是 GBK 或 GB2312 编码,requests 自动猜测会乱码。如果抓到一半中断,可以给crawl_drug_list加一个断点续传逻辑,用已落盘的 URL 集合过滤,避免重复抓取。
2.3 清洗与字段对齐:把“一堆文本”变成“一行记录”
抓下来的原始 JSON 里,说明书区块还是长文本,比如“本品适用于治疗高血压、心绞痛”这种句子。清洗阶段要做的不是分词,而是把长文本按板块归类到字段,并做单位、格式的归一化。常见的脏数据有几类:全角半角混用、剂量单位写法混乱(“mg”“毫克”混用)、适应症里的顿号和逗号不统一、同一药品在不同详情页的商品名和通用名顺序不一致。
我一般会抽一个清洗函数,专门做字段级处理。下面这段代码处理适应症和不良反应字段,把它们从文本拆成列表,并做基础归一。
import re import json UNIT_MAP = { "毫克": "mg", "毫g": "mg", "微克": "ug", "克": "g", } def normalize_unit(text): for k, v in UNIT_MAP.items(): text = text.replace(k, v) return text def split_symptom_text(text): # 统一分割符:逗号、顿号、分号、句号 text = re.sub(r"[,、;;。]", ",", text) parts = [p.strip() for p in text.split(",") if p.strip()] return parts def clean_drug_record(record): name = record.get("name", "").strip() sections = record.get("sections", {}) indications = sections.get("适应症", []) adrs = sections.get("不良反应", []) contra = sections.get("禁忌", []) cleaned = { "name": name, "indications": [], "adverse_reactions": [], "contraindications": [], } for text in indications: cleaned["indications"].extend(split_symptom_text(normalize_unit(text))) for text in adrs: cleaned["adverse_reactions"].extend(split_symptom_text(normalize_unit(text))) for text in contra: cleaned["contraindications"].extend(split_symptom_text(normalize_unit(text))) return cleaned def clean_all(raw_path, out_path): with open(raw_path, encoding="utf-8") as f: records = [json.loads(line) for line in f if line.strip()] cleaned = [clean_drug_record(r) for r in records if r.get("name")] with open(out_path, "w", encoding="utf-8") as f: for item in cleaned: f.write(json.dumps(item, ensure_ascii=False) + "\n") print(f"cleaned {len(cleaned)} records")清洗逻辑的关键在于“按板块抽取”而不是“全文解析”。因为抓取时已经按 h2/h3 切分过,适应症文本只会出现在indications列表里,不会混入禁忌内容。normalize_unit和split_symptom_text解决的是最常见的两种脏数据问题,足够支持图谱初始构建。
清洗后的数据质量要用统计来验证,不要肉眼抽查几个就完事。我通常会跑一个简单统计脚本:字段空值率、适应症列表平均长度、出现频次最高的前 20 个症状词。如果空值率超过某条线,比如名称字段为空超过 5%,就回头查抓取逻辑;如果适应症列表长度集中在 1 到 2,多半是分隔符处理没覆盖到。这里没有统一阈值,纯粹看数据源,但统计这一步必须有。
2.4 实体与关系抽取:先规则后模型,别一上来就训练 NER
医药领域实体抽取有两类做法:基于词典和规则的抽取,以及基于序列标注模型的抽取。对垂直网站数据,第一版建议用词典加规则,理由很简单:医药实体名词高度专业且封闭,药品名、疾病名、症状名在一个垂直站点内是有限的,维护一份实体词典的成本远低于标注训练集。模型方案的收益主要在准确率和召回率上,但需要有足够多样的标注语料,否则在小数据集上表现反而不如规则。
实体抽取分两类走。药品名和成分名可以直接从页面结构里拿,不需要额外识别;疾病和症状名需要从适应症、不良反应文本里抽。做法是先维护一个核心疾病词表,再结合触发词做规则匹配。比如“用于治疗高血压、心绞痛”里,“治疗”后面的词优先当作疾病实体;“可能导致恶心、呕吐”里,“导致”后面的词优先当作不良反应实体。
关系抽取同样走规则。适应症板块里的实体默认和当前药品构成“治疗”关系;不良反应板块里的实体构成“导致”关系;药物相互作用板块如果抓到“与 X 合用”这类描述,就构建“相互作用”关系。窗口和配对规则写在配置里,不要写死在代码里,方便数据源变化时调整。
import json SEED_DISEASES = ["高血压", "心绞痛", "糖尿病", "哮喘", "胃溃疡"] # 核心疾病词表 TRIGGER_PATTERNS = { "treats": [r"用于治疗(.+?)(?:[,。]|$)", r"适应症[::](.+?)(?:[,。]|$)"], "causes": [r"可能导致(.+?)(?:[,。]|$)", r"不良反应[::](.+?)(?:[,。]|$)"], } def extract_entities(text, patterns): entities = [] for pattern in patterns: matches = re.findall(pattern, text) for m in matches: for part in split_symptom_text(m): if part in SEED_DISEASES or len(part) <= 6: entities.append(part) return entities def build_triples(cleaned_record): triples = [] drug = cleaned_record["name"] for indication in cleaned_record["indications"]: matched = [d for d in SEED_DISEASES if d in indication] for d in matched: triples.append((drug, "treats", d)) for adr in cleaned_record["adverse_reactions"]: matched = [s for s in SEED_DISEASES if s in adr] for s in matched: triples.append((drug, "causes", s)) return triples这个抽取函数在“词典匹配”基础上加了正则触发词兜底,效果上既能抓住明显出现在词典里的疾病名,也能抽出一部分未收录的短实体。参数上值得留意的是len(part) <= 6这个条件:过长的实体大概率是句子碎片,比如“高血压伴有头痛”会被切出来,但它不是标准疾病名,后面实体链接时会对不上。这个阈值可以根据数据情况调大调小,但不要去掉,不然三元组里会混入大量噪声。
规则抽取的优点是透明可控,缺点是召回有限。第一版图谱跑通后,可以用未匹配文本的占比来评估该不该引入 NER 模型。具体做法是统计每条记录里有多少实体没被词典和规则命中,如果比例超过 30%,再考虑用预训练模型做补充抽取。
3. Neo4j 建模与导入:用 Cypher 把医药图谱跑起来
3.1 先定模型再动手:节点标签、关系类型与属性设计
Neo4j 建模的核心不是“把数据装进去”,而是“让查询好写”。医药知识图谱的最小可行模型是四类节点加四类关系:药品(Drug)、疾病(Disease)、成分(Ingredient)、症状(Symptom);关系包括治疗(treats)、导致(causes)、含有(contains)、相互作用(interacts_with)。问答和分析服务 90% 的查询都落在这些关系上,先把这个闭环打通,再考虑扩展。
节点属性不要贪多。每一个节点只需要保留“名称”“别名”和“数据类型”三个核心属性,其余信息放进节点的描述属性里就好。原因是图查询的强项在关系遍历,不在属性检索;如果你需要频繁按“生产厂家”过滤药品,那说明应该单独建一个 Company 节点,而不是给 Drug 塞一个厂家字符串属性。
关系方向必须统一。我一般定这样一个约定:治疗和导致关系从 Drug 指向 Disease/Symptom,交互关系在两个 Drug 之间不区分方向,含有关系从 Drug 指向 Ingredient。这个约定看似简单,但能省掉后续大量写查询时的方向记忆负担。很多踩坑都发生在关系方向混乱,写查询时被迫写无方向匹配,性能直线下降。
3.2 约束、索引与导入文件准备
导入之前先建约束和索引。约束保证实体唯一,避免重复节点;索引加速后续的实体链接查询。在 10 万节点以内的图谱规模里,索引带来的性能差异是数量级的,尤其在每轮问答都要做实体匹配的场景下。
下面这段 Cypher 在建库时执行一次即可:
CREATE CONSTRAINT drug_name_unique IF NOT EXISTS FOR (d:Drug) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT disease_name_unique IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT ingredient_name_unique IF NOT EXISTS FOR (i:Ingredient) REQUIRE i.name IS UNIQUE; CREATE INDEX drug_name_index IF NOT EXISTS FOR (d:Drug) ON (d.name); CREATE INDEX disease_name_index IF NOT EXISTS FOR (d:Disease) ON (d.name);约束和索引的区别要明确:约束解决的是数据一致性问题,防止同名药品出现两个节点;索引解决的是查询性能问题,让WHERE d.name = "阿莫西林"走索引而不是全表扫描。医药数据里别名极多,如果后续要按别名查,需要在别名属性上也建索引。
导入文件建议准备三份 CSV:drugs.csv、diseases.csv、ingredients.csv,关系数据放在relations.csv里。CSV 列头固定为source,relation,target,不区分实体类型,导入时在 Cypher 里判断类型。这个设计比按关系类型拆文件更灵活,数据源增加新关系类型时不用改导入脚本。
3.3 LOAD CSV 批量导入与增量更新
Neo4j 的LOAD CSV是最直接的数据导入方式,适合初始全量导入和日常增量更新。下面这段 Cypher 从本地文件导入药品节点,再从关系文件导入治疗关系:
// 导入药品节点 LOAD CSV WITH HEADERS FROM 'file:///drugs.csv' AS row MERGE (d:Drug {name: row.name}) ON CREATE SET d.alias = row.alias, d.data_type = row.data_type; // 导入疾病节点 LOAD CSV WITH HEADERS FROM 'file:///diseases.csv' AS row MERGE (d:Disease {name: row.name}) ON CREATE SET d.alias = row.alias; // 导入治疗关系 LOAD CSV WITH HEADERS FROM 'file:///relations.csv' AS row WITH row WHERE row.relation = 'treats' MATCH (source:Drug {name: row.source}) MATCH (target:Disease {name: row.target}) MERGE (source)-[:treats]->(target);MERGE在这里兼顾了去重和增量更新两个需求。节点已存在时不会重建新节点,而是通过ON CREATE SET补充属性。关系导入先匹配两端节点再建关系,能保证不会出现悬空关系;如果关系文件里有指向不存在节点的记录,MATCH会直接跳过,不会报错中断。
这里要注意file:///指向的是 Neo4j 服务器的import目录,不是本地文件系统。文件要放到 Neo4j 安装目录下的import文件夹,用绝对路径时要注意权限配置。另外,大数据量时USING PERIODIC COMMIT可以有效降低内存压力,实测 5 万条关系以上建议加上,批大小设置在 1000 到 5000 之间,太大会导致事务超时。增量更新的做法是:每次抓取任务结束,把新数据导出成同格式 CSV,再执行一遍同样的 Cypher,MERGE会自动跳过已存在的实体和关系。
3.4 实体链接:把问句里的“阿莫西林胶囊”映射到图谱节点
问答和数据分析能不能准确命中图谱,关键在实体链接这一步。用户输入“阿莫西林胶囊能治什么病”,图谱里存的可能是“阿莫西林”,直接等值匹配必然失败。实体链接要做的是把用户表达映射到图谱标准名。
常见做法是维护一份同义词表,把商品名、别名、常见错别字都映射到标准名。同时用 Elasticsearch 索引图谱实体名做模糊匹配,候选召回后再精确筛选。对百万实体以内的规模,直接用 Neo4j 的全文索引也能跑通,不需要引入额外搜索引擎。
// 创建全文索引 CREATE FULLTEXT INDEX drug_fulltext IF NOT EXISTS FOR (n:Drug) ON EACH [n.name, n.alias]; // 候选召回查询 CALL db.index.fulltext.queryNodes('drug_fulltext', '阿莫西林~') YIELD node, score RETURN node.name AS name, node.data_type AS data_type, score ORDER BY score DESC LIMIT 5;阿莫西林~是 Lucene 的模糊匹配语法,~后可以跟相似度阈值,默认是 0.75。这个查询返回的候选通常能达到 80% 以上的召回率,剩下的要靠同义词表兜底。实体链接的准确率直接决定问答质量,建议在接口层做一次人工可配置的候选校验逻辑,比如分数低于某个阈值时返回“未能识别实体”,而不是硬答。
4. 医药智能问答与分析服务:从 Cypher 模板到 Flask 接口
4.1 问答链路拆分:意图识别、实体链接与查询生成
医药领域问答不能做成开放式闲聊。用户的诉求基本可以归纳成几类:功效查询(这个药治什么病)、不良反应查询(这个药有什么副作用)、相互作用查询(这个药能和那个药一起吃吗)、禁忌查询(这个药什么情况不能吃)。把问题归类到这几类意图上,再映射到对应的 Cypher 模板,比训练一个生成式问答模型可控得多。
意图识别用规则就能覆盖大部分场景。触发词表放配置里:出现“治什么”“适应症”“功效”归为功效查询;出现“副作用”“不良反应”“吃了会”归为不良反应查询;出现“能不能一起吃”“相互作用”“合用”归为相互作用查询。每个意图对应一个查询模板,模板里预留实体位置,由实体链接模块填充。
4.2 基于模板的问答接口:Flask 加 Cypher 的最小实现
下面这段代码实现一个最小可用的问答接口,接收用户输入,先做意图识别,再抽取实体,拼接 Cypher 查询,返回结果。
from flask import Flask, request, jsonify from neo4j import GraphDatabase app = Flask(__name__) driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) INTENT_RULES = { "treats": ["治什么", "适应症", "功效", "作用"], "causes": ["副作用", "不良反应", "吃了会", "导致"], "interacts": ["能不能一起吃", "相互作用", "合用", "一起吃"], } def detect_intent(question): for intent, keywords in INTENT_RULES.items(): for kw in keywords: if kw in question: return intent return "unknown" def extract_entity(question): # 简化版实体链接:用同义词表直接匹配 with driver.session() as session: result = session.run( """ CALL db.index.fulltext.queryNodes('drug_fulltext', $query) YIELD node, score RETURN node.name AS name, score ORDER BY score DESC LIMIT 1 """, query=question + "~" ) record = result.single() if record and record["score"] > 0.8: return record["name"] return None def build_query(intent, entity): templates = { "treats": """ MATCH (d:Drug {name: $entity})-[:treats]->(dis:Disease) RETURN dis.name AS result """, "causes": """ MATCH (d:Drug {name: $entity})-[:causes]->(s:Symptom) RETURN s.name AS result """, "interacts": """ MATCH (d:Drug {name: $entity})-[:interacts_with]-(other:Drug) RETURN other.name AS result """, } return templates.get(intent) @app.route("/qa", methods=["POST"]) def qa(): data = request.get_json() question = data.get("question", "") intent = detect_intent(question) entity = extract_entity(question) if not entity: return jsonify({"error": "无法识别药品实体,请尝试输入完整药品名"}), 400 cypher = build_query(intent, entity) if not cypher: return jsonify({"result": []}) with driver.session() as session: result = session.run(cypher, entity=entity) items = [record["result"] for record in result] return jsonify({"intent": intent, "entity": entity, "result": items}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)这个接口把问答拆成了三个独立模块:意图识别在前,实体链接居中,图谱查询在最后。每一层都可以单独调参和验收。实体链接里score > 0.8的阈值很关键,设太低会把无关实体拉进来,设太高会漏掉别名。这个值需要根据实际数据调,建议在接口日志里记录每条查询的 score 分布,再决定要不要动阈值。
build_query里的 Cypher 模板是按意图拼接的,这是刻意为之。这样做的优点是查询完全可控,不会出现图数据库返回非预期结构的情况;缺点是意图数量有限。如果后续想要更自由的自然语言交互,可以考虑接 LLM 做意图理解和查询改写,但要对生成的 Cypher 做白名单校验,防止查询任意执行。
4.3 分析服务:药物相互作用挖掘与疗效关联分析
问答只是服务的一半,分析服务才是医药知识图谱价值持续放大的地方。最简单的分析是统计:某类疾病关联的药品数量排行、某药品涉及的不良反应数量、含某成分的药品列表。这些查询在图数据库里都是几条 Cypher 的事,比 SQL 多表 JOIN 直观得多。
更有价值的是药物相互作用分析。图谱里已经保存了interacts_with关系,可以查所有互相作用的药品对,按共现频率排序,找出高风险组合。
MATCH (a:Drug)-[r:interacts_with]-(b:Drug) WHERE id(a) < id(b) RETURN a.name AS drug_a, b.name AS drug_b, r.evidence AS evidence ORDER BY r.confidence DESC LIMIT 50;id(a) < id(b)这个技巧是为了避免重复对,因为无向关系在两个方向上都会被匹配到。r.evidence属性存的是抽取时记录的原文证据,比如“与华法林合用增加出血风险”,这个属性在人工核验时非常关键,没有证据的相互作用结论等于黑匣子。
分析服务还可以结合疾病节点做子图分析,比如查“同一种疾病关联的所有药品”,看它们的共同成分,找出潜在替代药。这类查询依赖图谱结构的灵活性,是传统关系数据库很难做到的。接口层建议用 WebSocket 或异步任务来做,因为分析查询往往比问答查询慢一个量级,同步请求会把连接占死。
5. 医药知识图谱避坑指南:五个高频问题与排查思路
5.1 药品和疾病重名,实体链接永远匹配到错误的节点
现象:用户问“阿司匹林能退烧吗”,实体链接把“阿司匹林”识别成了疾病节点。这在医药数据里很常见,因为部分药名和症状名存在字面重叠,比如“阿司匹林哮喘”是疾病,但用户真正想问的是药。
原因:全文索引同时匹配了 Drug 和 Disease 两类节点的名称,返回第一名不一定是正确类型。实体链接没有做类型过滤。
解决:在实体链接阶段,先根据意图确定目标实体类型。功效查询和不良反应查询的实体只能是 Drug,疾病症状查询的实体只能是 Disease。查询时加上类型条件,用WHERE node:Drug过滤,或者干脆只对目标类型建索引。
5.2 垂直网站更新后页面结构变化,清洗脚本一夜崩盘
现象:爬虫运行正常,但清洗后的记录里大量字段为空,或者适应症列表全是乱码。
原因:目标站点改版,h2/h3 标题结构变了,或者板块名称从“适应症”改成了“主治功能”,抓取时切分逻辑没有匹配到任何板块。
解决:给清洗脚本加上字段空值率监控,异常时自动告警,而不是等人工发现。板块名称做一个映射表,把“主治功能”“适应症”“适应病症”统一映射到 indication 字段。爬虫与解析分离,页面结构变化时只改解析层,不重跑数据抓取。
5.3 LOAD CSV 导入超时,关系导入一半就报错
现象:导入 10 万条关系数据时,Neo4j 日志报提交超时,数据库连接断掉,部分关系成功、部分失败。
原因:单事务内写入量太大,Neo4j 默认事务超时时间到了,或者内存不够用。
解决:加上USING PERIODIC COMMIT 2000,每 2000 行提交一次事务。同时把 Cypher 脚本改成幂等设计,MERGE而不是CREATE,这样失败后重新导入不会产生重复关系。导入前先跑一次MATCH (n) RETURN count(n),确认当前图规模,避免全量导入时内存溢出。
5.4 关系方向不一致,查询时漏数据或查不到
现象:有的treats关系从 Drug 指向 Disease,有的从 Disease 指向 Drug。查询MATCH (d:Drug)-[:treats]->(dis:Disease)时,返回结果只有一部分,另外一半被漏掉了。
原因:抽取脚本对不同数据源的处理逻辑不一致,或者手工修正数据时按照直觉建了反向关系。
解决:在导入前做一个关系方向校验脚本,统一检查所有三元组的 source 和 target 类型。比如规定treats的 source 必须是 Drug,target 必须是 Disease,不符合的直接丢弃或反转。这类检查要在 Cypher 里做也行,但用 Python 脚本更直观,逻辑清晰且方便记录错误明细。
def validate_triple(source, relation, target, type_map): allowed = { "treats": ("Drug", "Disease"), "causes": ("Drug", "Symptom"), "contains": ("Drug", "Ingredient"), } if relation not in allowed: return False src_type, tgt_type = allowed[relation] return type_map.get(source) == src_type and type_map.get(target) == tgt_type5.5 数据源里的同一种疾病,规格化后实体链接命中率偏低
现象:用户输入“冠心病”能查到数据,输入“冠状动脉粥样硬化性心脏病”就查不到,但两个关键词指同一个病。
原因:图谱里只存了垂直网站原文里出现的疾病名,没有做同义词合并。“冠心病”和“冠状动脉粥样硬化性心脏病”是不同的字符串,导致实体链接失败。
解决:维护同义词映射表,导入图谱前先把疾病名规范化。先保存原始名称,再映射到标准名,标准名作为节点name,原始名存入alias。全文索引同时索引name和alias,两种说法都能命中。这个表可以先用规则生成,再人工抽查修正,不用追求一步到位。
6. 图谱常识校验与运维:让问答和分析结果可信
图谱建完之后,第一个要做的不是急着上线接口,而是做一轮常识校验。我习惯写成一组 Cypher 脚本,每次数据更新后自动跑一遍。校验项包括:全图实体数、孤立节点数、每种关系的数量分布、是否存在没有treats关系的药品。孤立节点通常意味着抓取或导入时漏了关系,要留意。
问答接口上线前,准备一份回归测试集很重要。选三五十个典型问题,覆盖功效、不良反应、相互作用、禁忌四类意图,把期望结果人工标注好,接口每次改完代码后跑一遍回归,比对结果一致性。这一步能挡住绝大多数因为实体链接或模板改动引入的翻车问题。
日常运维里,数据更新的频率由源站决定,不需要实时同步。垂直网站通常不会频繁改说明书内容,按月或按季度增量更新即可。更新时用前面提到的 CSV 增量导入,跑完再执行校验脚本,比较更新前后各关系计数,差异异常就说明导入脚本出了错。
迭代方向上,自然语言理解可以逐步从规则转向模型。先用规则把图谱服务跑稳,积累用户问句数据,等声明的意图覆盖不住真实提问时,再引入微调后的分类模型。我个人的经验是:不要一开始就上大模型,知识图谱的强项是可解释性和可控性,这一优势应该尽量保持。
最后说一个习惯收尾:我现在每写一个查询模板,都会在旁边留一条测试用例作为注释。改代码或调参数时先跑测试用例,确认没影响旧查询再继续。这看似笨办法,但在这个领域,一个模板写错,后果往往不是报错,而是静默返回正确接口下的错误数据。希望这个方案能帮你少踩几个坑,把医药知识图谱真正用起来。
本文还有配套的精品资源,点击获取