療知識圖譜問答系統(tǒng):從實體識別到Cypher查詢)
簡介面向醫(yī)療知識圖譜與問答系統(tǒng)入門者的Python項目資料完整覆蓋知識圖譜構建、醫(yī)療實體/關系數(shù)據(jù)整理以及基于規(guī)則匹配的對話系統(tǒng)實現(xiàn)。資源共58個文件包含設計報告Word文檔、Python源碼與醫(yī)療數(shù)據(jù)、項目運行截圖等其中py腳本負責圖譜構建與問答流程txt為詞典數(shù)據(jù)png/jpg為界面與圖譜可視化展示壓縮包約20MB。已有2570人學習下載。項目分為簡單知識圖譜、醫(yī)療知識圖譜構建、以及對話系統(tǒng)三個模塊可直接運行體驗從數(shù)據(jù)到圖譜再到問答的完整鏈路遇到自由輸入分析不足、輸出受限等局限時還可結合設計報告與源碼理解其規(guī)則匹配原理為后續(xù)引入深度學習模型做過渡。適合作為課程設計、畢業(yè)設計或知識圖譜入門的學習參考。1. 醫(yī)療知識圖譜問答系統(tǒng)把“高血壓患者能吃布洛芬嗎”變成一條圖查詢你輸入“高血壓患者能吃布洛芬嗎”傳統(tǒng)搜索會拆出“高血壓”和“布洛芬”兩個詞然后各查各的資料卻回答不了“能不能同服”?;赑ython實現(xiàn)的醫(yī)療知識圖譜問答系統(tǒng)解決的正是這類多實體、多跳關系的問題把疾病、癥狀、藥品、科室、檢查組織成一張圖再把自然語言問句翻譯成對這張圖的查詢最后拼成人話返回。做這個東西不是給大醫(yī)院做臨床決策而是給健康類App、導診機器人、醫(yī)學檢索工具一個能定制的問答底座。想入門的Python開發(fā)者能借此把分詞、實體識別、圖數(shù)據(jù)庫、意圖分類串成一條完整的工程流水線已經(jīng)有后端經(jīng)驗的人則能直接在這套結構上擴充數(shù)據(jù)和模型把答案從“能查到”做到“答得準”。2. 系統(tǒng)架構與組件選型五個環(huán)節(jié)、一個圖庫、一個FastAPI服務2.1 問答流水線拆解從問句到Cypher查詢的五個環(huán)節(jié)拿到這個問題時我會先畫一條管道而不是先寫代碼。整條問答流水線分五個環(huán)節(jié)問句預處理、實體識別與鏈接、意圖識別、槽位填充與查詢生成、答案生成。問句進來先分詞、去停用詞然后從分詞結果里挑出疾病、癥狀、藥品、科室這類實體接著判斷用戶到底想問什么是問癥狀還是問禁忌還是問科室再把這些信息填進預設的查詢模板最后把圖數(shù)據(jù)庫返回的記錄組轉成一句自然語言。五個環(huán)節(jié)的耦合方式?jīng)Q定了后面好不好維護。我的做法是每個環(huán)節(jié)輸出固定的結構實體識別輸出一個實體列表意圖分類輸出一個意圖標簽查詢生成只接收這兩個結果。這樣你換分詞器、換意圖模型都不會牽連到查詢層。先跑通一個FastAPI服務讓外部請求能進到流水線里來。from fastapi import FastAPI from pydantic import BaseModel from qa import QAPipeline app FastAPI() qa QAPipeline() # 內部串起分詞 - 實體 - 意圖 - 查詢 - 答案 class Question(BaseModel): text: str app.post(/qa) def answer_question(q: Question): result qa.run(q.text) return { answer: result.answer, intent: result.intent, entities: result.entities, }這段代碼把HTTP接口和問答邏輯分開QAPipeline是后面章節(jié)要實現(xiàn)的組合類result里除了答案還帶意圖和實體方便調試時定位是哪一環(huán)出了問題。接口層不做業(yè)務判斷只負責接收問句和返回結構化結果這樣后面無論接命令行、Web頁面還是微信小程序都只需要再套一層適配器。2.2 存儲選型為什么不用MySQL全文索引而是用圖數(shù)據(jù)庫存儲層我一般直接選Neo4j不是因為圖數(shù)據(jù)庫聽起來高級而是因為醫(yī)療知識天然是圖結構。一個“高血壓”節(jié)點連著“頭暈”癥狀、“硝苯地平”藥品、“心內科”科室這種關系的查詢如果用MySQL需要設計四五張關聯(lián)表然后做多表JOIN查詢深度一旦超過兩層腳本就變得很難讀。而Neo4j的Cypher查詢里多跳關系的表達幾乎是白話。數(shù)據(jù)模型按節(jié)點和關系拆我的設計如下節(jié)點類型典型例子主要屬性疾病高血壓、2型糖尿病name, icd10, department癥狀頭暈、干咳、心悸name, describe藥品布洛芬、阿莫西林name, dosage, usage科室心內科、呼吸內科name檢查血常規(guī)、心電圖name, purpose關系類型要表達語義而不是模糊的“相關”。比如疾病-[:HAS_SYMPTOM]-癥狀、藥品-[:TREATS]-疾病、藥品-[:CONTRA_INDICATION]-癥狀、疾病-[:BELONGS_TO]-科室。關系本身就是答案里最難解釋的部分把關系名定清楚了后面寫查詢模板才能直接復用。也有場景不適合上Neo4j如果你的問答只做單實體、單跳查詢比如只查“阿莫西林的說明書”那MySQL加全文索引反而更省事。我的判斷標準是——問句里有沒有出現(xiàn)兩個及以上實體以及是否需要關系屬性。出現(xiàn)“同時含兩個實體、要走關系”這類需求再上Neo4j不遲。2.3 Python環(huán)境與服務端骨架先把連接層寫對開始寫代碼前先檢查本機的Python環(huán)境我用的是Python 3.8以上版本。新手最容易在環(huán)境上卡住建議先用python --version確認版本再用python -m venv venv建獨立虛擬環(huán)境。日常開發(fā)我習慣用VS Code配好Python插件后直接F5啟動服務斷點調試問句處理流程比反復打印日志快得多。服務端依賴控制在最小集合一個requirements.txt文件就能裝完fastapi uvicorn neo4j jieba pandas連接Neo4j時要注意Python驅動有兩個常用選擇官方neo4j驅動和py2neo。我推薦直接用官方驅動它維護穩(wěn)定也支持事務和批量提交。連接串用Bolt協(xié)議不是HTTPfrom neo4j import GraphDatabase class Neo4jClient: def __init__(self, uri, user, password): self.driver GraphDatabase.driver(uri, auth(user, password)) def query(self, cypher, paramsNone): with self.driver.session() as session: records session.run(cypher, params or {}) return [record.data() for record in records] def close(self): self.driver.close()連接參數(shù)寫在配置里不要硬編碼在代碼里。uri填bolt://localhost:7687如果填成7474會一直卡在連接超時。這個封裝類后面所有查詢都要用你可以在query方法里打印Cypher語句和參數(shù)排查問題時會輕松很多。3. 醫(yī)療知識圖譜構建實操從數(shù)據(jù)清洗到實體關系抽取入庫3.1 數(shù)據(jù)源選擇與結構化清洗出能入庫的DataFrame醫(yī)療圖譜的數(shù)據(jù)源決定了系統(tǒng)答案的上限。常見公開數(shù)據(jù)源包括ICD-10中文疾病分類、藥品說明書的結構化字段、醫(yī)院科室對照表、部分開源的中文醫(yī)學知識圖譜子集。這些數(shù)據(jù)格式不統(tǒng)一可能是Excel、CSV、JSON甚至PDF我的做法是先統(tǒng)一轉成CSV再用pandas做清洗把數(shù)據(jù)變成節(jié)點表和關系表兩類。這里要特別提醒不要用未脫敏的真實患者病歷醫(yī)療數(shù)據(jù)涉及隱私和合規(guī)開發(fā)和演示階段只用公開的結構化數(shù)據(jù)就夠跑通全流程。清洗的關鍵步驟是去重、補空值和規(guī)范化。比如“高血壓”和“高血壓病”在不同數(shù)據(jù)源里可能同時存在如果不合并后面的實體鏈接就會斷裂。import pandas as pd # 讀入原始疾病表 df pd.read_csv(raw_disease.csv, encodingutf-8) print(df.head(), df.shape) # 先看字段和規(guī)模 # 去重同一標準名只保留一個 df df.drop_duplicates(subset[name_std], keepfirst) # 標準化統(tǒng)一科室名去掉前后空格和全角空格 df[name_std] df[name_std].str.strip().str.replace( , ) # 空值處理科室缺失時填“未分類”癥狀缺失時保留空列表 df[department] df[department].fillna(未分類) # 輸出清洗后的節(jié)點表 df[[name_std, icd10, department]].to_csv(disease_node.csv, indexFalse)drop_duplicates按標準名字段去重避免同一個疾病在建圖時生成兩個節(jié)點。fillna補空值是為了后續(xù)導入Neo4j時不需要額外處理None值。清洗完的disease_node.csv每條記錄就是一個疾病節(jié)點。同樣的邏輯也適用于藥品表和癥狀表只是字段不同藥品表要留dosage和usage字段。3.2 實體與關系抽取自定義詞典加規(guī)則抽取的落地寫法知識圖譜里最有價值也最花時間的是關系數(shù)據(jù)。從公開數(shù)據(jù)里拿到的往往是“疾病有哪些癥狀”“藥能治什么病”這樣的半結構化文本需要用抽取腳本把它變成關系列表。我沒有一開始就上復雜的BIO標注模型先用自定義詞典加規(guī)則把80%的典型關系抽出來。jieba默認詞典幾乎沒有醫(yī)學詞需要準備一個custom_dict.txt每行寫一個詞格式是“詞 詞頻 詞性”。比如“硝苯地平 10 nz”“2型糖尿病 10 nz”。加載后分詞結果會明顯貼合醫(yī)學實體。import jieba import re jieba.load_userdict(custom_dict.txt) def extract_entities(text): 從問句或描述文本中提取候選實體 words jieba.lcut(text) entities [] for word in words: if word in medicine_set or word in disease_set or word in symptom_set: entities.append(word) return entities # 規(guī)則抽取藥品用法例如“一次2片一日3次” dosage_pattern re.compile(r(一次|每次)(\d)(片|粒|袋|毫升)?.{0,4}(一日|每天)(\d)次) m dosage_pattern.search(阿莫西林用法一次2片一日3次) if m: print(提取劑量:, m.group(2), 粒/片, 頻率:, m.group(4), 次/日)實體抽取這步只負責圈定“候選詞”不做語義判斷。用三個集合medicine_set、disease_set、symptom_set來過濾分詞結果是因為在問答場景里問句很短不需要全部詞都參與查詢。藥品劑量用正則抽是因為說明書里的劑量寫法高度模板化正則比模型更可靠。這些規(guī)則后續(xù)要維護到一個獨立配置文件里不要散落在各處代碼中。3.3 批量入庫Neo4j用MERGE加UNWIND替換逐條CREATE建圖時最容易犯的錯誤是拿到數(shù)據(jù)后立刻用CREATE逐條插入。少量測試數(shù)據(jù)這樣寫沒問題幾千條數(shù)據(jù)時速度就會慢到懷疑人生。正確做法是先用約束保證節(jié)點唯一性再用UNWIND批量寫入。先啟動Neo4j執(zhí)行一次約束創(chuàng)建語句CREATE CONSTRAINT disease_unique IF NOT EXISTS FOR (n:疾病) REQUIRE n.name IS UNIQUE;Python側的導入腳本用官方驅動按批提交from neo4j import GraphDatabase import pandas as pd driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, password)) diseases pd.read_csv(disease_node.csv).to_dict(records) insert_cypher UNWIND $rows AS row MERGE (d:疾病 {name: row.name_std}) ON CREATE SET d.icd10 row.icd10, d.department row.department ON MATCH SET d.icd10 row.icd10, d.department row.department RETURN count(d) with driver.session() as session: for i in range(0, len(diseases), 100): # 每100條一批 batch diseases[i:i100] result session.run(insert_cypher, rowsbatch) print(batch, i, inserted, result.single().value())MERGE是建圖的關鍵它先按name查重節(jié)點存在就更新屬性不存在就創(chuàng)建配合上面的唯一約束就能避免重復節(jié)點。分批提交每批100條既不會撐爆內存出錯了也只需要重跑失敗的那一批。ON CREATE和ON MATCH兩個分支把屬性更新的邏輯寫全這樣腳本重復執(zhí)行是冪等的。關系數(shù)據(jù)的入庫邏輯類似只是Cypher變成兩段匹配再加合并UNWIND $rows AS row MATCH (d:疾病 {name: row.disease}) MATCH (s:癥狀 {name: row.symptom}) MERGE (d)-[:HAS_SYMPTOM]-(s)這里有個隱含要求癥狀節(jié)點必須已經(jīng)存在否則第二條MATCH匹配為空整個批次都不會寫入。所以入庫順序是先節(jié)點后關系而且要確保兩邊的名稱都標準化過。4. 問答模塊拆開做實體識別、意圖分類與Cypher模板生成4.1 實體識別與歸一化把“高血壓”和“原發(fā)性高血壓”指到同一個節(jié)點實體識別在問答里的任務不是“找出醫(yī)學名詞”而是“找出圖譜里存在的節(jié)點”。很多系統(tǒng)跑完分詞后直接把詞拿去匹配結果圖譜里存的是“高血壓病”問句里是“高血壓”匹配失敗返回空答案。做這一步我靠一張同義詞表來兜底。把所有可能指向同一節(jié)點的寫法歸到一個standard_name上。# synonyms.py SYNONYM_MAP { 高血壓: 高血壓病, 原發(fā)性高血壓: 高血壓病, 高血壓病: 高血壓病, 糖尿病: 2型糖尿病, 血糖高: 2型糖尿病, 布洛芬: 布洛芬緩釋膠囊, } def normalize_entity(entity): 把同義詞歸一到標準名 return SYNONYM_MAP.get(entity, entity)實際執(zhí)行時normalize_entity要放在圖譜查詢之前調用。比如問句“高血壓患者能吃布洛芬嗎”中抽出的“高血壓”先歸一成“高血壓病”再作為查詢參數(shù)傳入匹配成功的概率會高很多。同義詞表本質上是成本最低的知識更新手段。每發(fā)現(xiàn)一個“問不到答案”的case查一下是不是同義詞問題是的話往表里加一行。這張表積累到500行以上時系統(tǒng)的有效回答率會有肉眼可見的提升。如果你想用向量相似度或者模型來做語義歸一化那是進階方向但它沒辦法完全替代同義詞表因為醫(yī)療名詞的別名差異遠大于通用文本的語義差異。4.2 意圖識別先定規(guī)則后用模型意圖識別的目標是判斷用戶問的是哪一類問題。我按醫(yī)療問答的高頻場景把意圖分成五類意圖標簽問法示例需要的槽位disease_symptom高血壓有什么癥狀疾病symptom_disease頭暈是什么病癥狀contraindication高血壓能吃布洛芬嗎疾病、藥品disease_drug高血壓吃什么藥疾病department_query高血壓掛什么科疾病在數(shù)據(jù)量不足的初期規(guī)則就能覆蓋主要問法。每個意圖配幾個關鍵詞和正則在問句里找。規(guī)則識別不到的再考慮上分類模型比如TextCNN或BERT。不要一上來就訓練模型規(guī)則已經(jīng)能解決七八成問題。def detect_intent(text, entities): 基于規(guī)則識別意圖返回意圖標簽和槽位 dis any(e in entity_types.get(疾病, []) for e in entities) med any(e in entity_types.get(藥品, []) for e in entities) sym any(e in entity_types.get(癥狀, []) for e in entities) if dis and med: return contraindication, {disease: dis, drug: med} if dis and (癥狀 in text or 表現(xiàn) in text or 有什么反應 in text): return disease_symptom, {disease: dis} if sym and (什么病 in text or 怎么辦 in text): return symptom_disease, {symptom: sym} if dis and (吃什么藥 in text or 用藥 in text): return disease_drug, {disease: dis} if dis and (掛什么科 in text or 哪個科室 in text): return department_query, {disease: dis} return unknown, {}這里的判定順序就是優(yōu)先級順序先判斷多實體組合比如“疾病藥品”組合優(yōu)先走到禁忌查詢否則“高血壓能吃什么藥”這類問句會因為同時出現(xiàn)“癥狀”兩個字被誤判成癥狀查詢。規(guī)則里的關鍵詞要覆蓋口語變體“有什么反應”“需要注意什么”這類說法都要加進去否則會落到unknown。4.3 查詢模板與答案生成寫一組能覆蓋主流問法的Cypher模板有了意圖和槽位下一步就是把槽位填進Cypher模板。模板用字典維護每個意圖對應一條查詢語句QUERY_TEMPLATES { disease_symptom: MATCH (d:疾病 {name: $disease})-[:HAS_SYMPTOM]-(s:癥狀) RETURN s.name AS symptom , contraindication: MATCH (drug:藥品 {name: $drug})-[:CONTRA_INDICATION]-(sym:癥狀) WHERE sym.name IN MATCH (d:疾病 {name: $disease})-[:HAS_SYMPTOM]-(s:癥狀) RETURN s.name RETURN drug.name AS drug, sym.name AS symptom , disease_drug: MATCH (d:疾病 {name: $disease})-[:TREATS]-(drug:藥品) RETURN drug.name AS drug, drug.usage AS usage , }禁忌查詢的模板值得一提它先用疾病-癥狀找高血壓這個病的癥狀集再看布洛芬的禁忌癥狀集合里有沒有和它重疊的這正好利用了圖的多跳查詢能力。查詢結果組答案時要給空結果留退路def compose_answer(intent, records): if not records: return 圖譜中暫時沒有找到相關信息建議更換問法或補充數(shù)據(jù)。 if intent disease_symptom: symptoms [r[symptom] for r in records] return 常見癥狀有 、.join(symptoms[:8]) if intent disease_drug: drugs [r[drug] for r in records] return 常用藥物包括 、.join(drugs[:5]) return 查詢到了相關結果請參考 str(records[:3])答案生成不追求改寫先把實體和關系原樣呈現(xiàn)保證信息準確。等圖譜數(shù)據(jù)和模板穩(wěn)定后再做同義改寫不遲。records[:8]和records[:5]這類的截斷很重要用join拼接過多名詞會讓答案變得很長用戶根本讀不完不如只給最主要的信息。5. 常見避坑與排查問不到答案時先檢查這四個環(huán)節(jié)5.1 圖譜里明明有數(shù)據(jù)問答卻返回空現(xiàn)象用Cypher在Neo4j Browser里查“高血壓”的記錄能查到問答系統(tǒng)返回“找不到相關信息”。原因實體歸一化沒做全。用戶輸入“高血壓”圖譜節(jié)點存的是“高血壓病”兩邊對不上。同義詞表里沒有這組映射實體識別再準也會在查詢這步靜默失敗。解決第一步加日志把查詢前的槽位值和查詢Cypher打到控制臺。看到disease“高血壓”而圖譜里是“高血壓病”就能確定是歸一化問題。往SYNONYM_MAP里補一行映射即可。排查動作要形成習慣每次“返回空”先確認是實體沒對齊、模板寫錯還是數(shù)據(jù)缺失而不是盲目改模板。5.2 neo4j驅動連接失敗認證和協(xié)議不匹配現(xiàn)象neo4j.exceptions.ServiceUnavailable提示無法連接到服務器。原因連接串寫錯是最高頻原因。我把URI寫成http://localhost:7474但官方驅動只認Bolt協(xié)議。還有一種是Neo4j默認只允許neo4j用戶在本地認證如果密碼改了沒同步也會報認證錯誤。解決URI改成bolt://localhost:7687。用戶名密碼再三確認密碼可以在Neo4j Desktop的數(shù)據(jù)庫設置里重置。我一般把連接信息放進.env文件代碼里用os.getenv讀取既方便換環(huán)境也避免把密碼提交到代碼倉庫。5.3 批量導入幾萬條數(shù)據(jù)太慢跑了半小時還沒完現(xiàn)象節(jié)點插入10000條腳本執(zhí)行時間超過30分鐘Neo4j控制臺CPU飆高。原因每循環(huán)一次就session.run(create...)每次提交一個事務。事務提交的固定開銷遠比數(shù)據(jù)操作本身大數(shù)據(jù)量上來后自然慢。解決改成UNWIND批量提交每批100到200條能在一兩分鐘跑完。配合索引約束的MERGE同一批數(shù)據(jù)重跑也不會產(chǎn)生重復節(jié)點。如果還想更快可以臨時關掉約束全部導入后再CREATE CONSTRAINT重建索引但要注意這會讓重跑測試變得不安全我這邊的做法是保留約束、調大批次優(yōu)先保證可重復執(zhí)行。5.4 實體識別把“干咳”拆成“干”和“咳”實體錯位導致答案全錯現(xiàn)象問“長期干咳是什么病”識別出的癥狀實體是“咳”而不是“干咳”查詢結果指向了一堆不相關的咳嗽類型。原因jieba默認詞庫里“干咳”不是詞它把單字拆開當成兩個獨立詞處理。自定義詞典里雖然加了“干咳”但沒有控制詞頻字典優(yōu)先級沒覆蓋到。解決在自定義詞典里把“干咳”詞頻調高比如寫成“干咳 100 nz”。jieba.load_userdict對相同前綴詞的切分優(yōu)先級說到底還是看詞頻。另一個方法是在實體識別后加一次“最長詞優(yōu)先”的合并遍歷候選實體集合看某個詞是否作為另一個更長詞的后綴是的話就丟棄短詞。這兩招配合“干咳”和“頭痛”“胃痛”這類復合癥狀基本都能保住。6. 評估與調優(yōu)用離線指標和Bad Case把系統(tǒng)調到可以交付6.1 用Precision1把問答效果變成可比較的數(shù)字問答系統(tǒng)最怕“感覺還行”四個字。我一般會攢一份100條左右的評測集每一條包含問句、期望意圖、期望實體集合。跑完一輪后統(tǒng)計實體命中率和意圖命中率作為每次迭代的基準線。def evaluate(test_set, qa_pipeline): total len(test_set) intent_hit 0 entity_hit 0 for case in test_set: result qa_pipeline.run(case[question]) if result.intent case[expected_intent]: intent_hit 1 if set(result.entities) set(case[expected_entities]): entity_hit 1 print(fintent_acc{intent_hit/total:.2f}, entity_acc{entity_hit/total:.2f})評測集每加一條Bad Case就跑一遍這個腳本看準確率有沒有變化。實體命中率是更敏感的指標因為實體錯了后面所有環(huán)節(jié)都會跟著錯。意圖準確率到0.85以上實體準確率到0.9以上這個問答系統(tǒng)才有交付給業(yè)務方試用的底氣。6.2 上線前必須補的功課同義詞表擴充、知識重建、兜底回答跑通只是第一步真正見得光的系統(tǒng)至少還要做三件事。第一把評測集里所有“實體識別對但歸一化錯”的樣本全部補齊到同義詞表不要今天加兩條明天加三條。第二建一個定時任務每月或每季度重跑一遍數(shù)據(jù)清洗和入庫腳本讓新藥品、新說明書能進圖否則知識會隨時間過期。第三給unknown意圖和空答案設計兜底話術讓用戶感覺系統(tǒng)“知道自己的邊界”而不是怒懟一個“查無此人”。這套系統(tǒng)的價值不在于模型多深而在于工程結構清晰。把實體、意圖、模板三個模塊解耦之后換模型、換數(shù)據(jù)都只影響局部血淚經(jīng)驗告訴我這比追求單一環(huán)節(jié)的“更聰明”更重要。希望幫到你。本文還有配套的精品資源點擊獲取