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