療問答系統(tǒng):Python源碼解析與避坑指南)
簡(jiǎn)介這份資源是面向計(jì)算機(jī)相關(guān)專業(yè)學(xué)生與項(xiàng)目實(shí)戰(zhàn)學(xué)習(xí)者的醫(yī)療問答系統(tǒng)完整實(shí)現(xiàn)以RAG檢索增強(qiáng)生成結(jié)合大模型為核心技術(shù)路線適合用作畢業(yè)設(shè)計(jì)、課程大作業(yè)或進(jìn)階練手項(xiàng)目。壓縮包共75個(gè)文件約84.66MB包含10個(gè)Python源碼文件、7個(gè)Jupyter Notebook實(shí)驗(yàn)記錄、19個(gè)txt數(shù)據(jù)與說明、18張png界面截圖及json、yaml、md等配置文檔覆蓋數(shù)據(jù)處理、模型微調(diào)、知識(shí)圖譜構(gòu)建與Web交互等模塊。項(xiàng)目經(jīng)導(dǎo)師指導(dǎo)并通過評(píng)審源碼本地編譯可運(yùn)行調(diào)試完整。已有138人學(xué)習(xí)關(guān)注。讀者可獲得從醫(yī)療語料清洗、實(shí)體識(shí)別、圖譜搭建到LangChain與ChatGLM集成的完整方案并借助Notebook與截圖快速理解各環(huán)節(jié)實(shí)現(xiàn)邏輯與排錯(cuò)思路目錄結(jié)構(gòu)清晰便于按模塊拆解學(xué)習(xí)與二次開發(fā)。1. 醫(yī)療問答系統(tǒng)為什么不能只靠大模型硬答把一份「基于 RAG 與大模型的醫(yī)療問答系統(tǒng) Python 源碼和文檔說明」放到面前很多人第一反應(yīng)是不就是調(diào)個(gè)大模型 API把用戶問題丟進(jìn)去再把回答吐出來嗎真做過醫(yī)療場(chǎng)景的人會(huì)告訴你純靠大模型硬答翻車是遲早的事。醫(yī)療問答對(duì)事實(shí)準(zhǔn)確性、可追溯性、邊界控制的要求遠(yuǎn)高于閑聊模型一旦編造出看似專業(yè)的用藥劑量或診療建議后果不是「答得不好」而是「可能誤導(dǎo)」。RAG檢索增強(qiáng)生成要解決的正是這個(gè)痛點(diǎn)先從可信知識(shí)庫里檢索出相關(guān)片段再讓大模型基于這些片段組織答案把「憑記憶瞎說」變成「看著資料回答」。這套系統(tǒng)適合誰計(jì)算機(jī)相關(guān)專業(yè)的畢設(shè)同學(xué)需要一個(gè)有技術(shù)深度、能講清原理又能跑起來的題目也適合剛接觸大模型應(yīng)用開發(fā)、想搞明白 RAG 完整鏈路的工程師。它覆蓋的核心環(huán)節(jié)包括文檔解析、文本切分、向量化、向量檢索、Prompt 組裝、大模型調(diào)用和答案溯源。下面按「先立住原理、再動(dòng)手復(fù)現(xiàn)、最后講坑」的順序拆開講每一步都給到能直接抄的代碼和參數(shù)說明。2. 把醫(yī)療知識(shí)庫喂進(jìn) RAG文檔解析與切分2.1 醫(yī)療語料的特殊性決定了切分策略醫(yī)療知識(shí)庫的常見來源是藥品說明書、診療指南、醫(yī)學(xué)教材 PDF、FAQ 問答對(duì)。這些語料和通用網(wǎng)頁文本差別很大藥品說明書里「適應(yīng)癥」「用法用量」「禁忌」「不良反應(yīng)」是強(qiáng)結(jié)構(gòu)化的一段話往往就是一個(gè)完整語義單元診療指南里則經(jīng)常出現(xiàn)「一線用藥」「二線用藥」這種帶層級(jí)關(guān)系的表述。如果按固定字?jǐn)?shù)硬切很容易把「用法用量」和「禁忌」切到兩個(gè)塊里檢索時(shí)召回一個(gè)卻丟了另一個(gè)答案就不完整。我一般會(huì)按「標(biāo)題層級(jí) 段落」做切分而不是純按字符數(shù)。具體做法是先用解析庫把 PDF 或 Word 轉(zhuǎn)成帶層級(jí)標(biāo)記的文本再以標(biāo)題為邊界切大塊塊內(nèi)如果超過閾值再按句子切。切分塊大小控制在 300500 字比較穩(wěn)太小會(huì)丟上下文太大檢索精度下降。塊之間保留 5080 字重疊防止關(guān)鍵信息正好卡在邊界上被切斷。2.2 用 Python 把 PDF 和 Word 解析成結(jié)構(gòu)化文本import re from langchain_community.document_loaders import PyPDFLoader, Docx2txtLoader from langchain.text_splitter import RecursiveCharacterTextSplitter def load_medical_docs(file_path): 根據(jù)文件后綴選擇解析器返回原始文檔列表 if file_path.endswith(.pdf): loader PyPDFLoader(file_path) elif file_path.endswith(.docx): loader Docx2txtLoader(file_path) else: raise ValueError(僅支持 pdf / docx) docs loader.load() # 清洗頁眉頁腳等噪聲連續(xù)空白、頁碼行 for d in docs: d.page_content re.sub(r\n\s*\d\s*\n, \n, d.page_content) d.page_content re.sub(r[ \t], , d.page_content) return docs def split_docs(docs): 按語義邊界切分塊大小 400 字重疊 60 字 splitter RecursiveCharacterTextSplitter( chunk_size400, chunk_overlap60, separators[\n\n, \n, 。, , , ], length_functionlen, ) return splitter.split_documents(docs)這段代碼的邏輯是先按文件類型選解析器PDF 用 PyPDFLoaderWord 用 Docx2txtLoader解析后做一輪正則清洗把頁碼行和多余空格去掉。切分用 RecursiveCharacterTextSplitter它的 separators 順序很關(guān)鍵——優(yōu)先按空行切再按換行、句號(hào)、分號(hào)、逗號(hào)逐級(jí)降級(jí)這樣能盡量保證一個(gè)塊內(nèi)語義完整。chunk_size 設(shè) 400、chunk_overlap 設(shè) 60 是醫(yī)療語料比較通用的值如果你的文檔里單條藥品說明特別長(zhǎng)可以把 chunk_size 提到 600但別超過 800否則檢索時(shí)噪聲會(huì)明顯變多。提示解析完一定要人工抽查 510 個(gè)塊看看有沒有把「禁忌」和「用法」切散。這一步偷懶后面檢索再優(yōu)化也救不回來。2.3 向量化與入庫選模型和選庫的兩個(gè)決策切分完就要把文本轉(zhuǎn)成向量存進(jìn)向量庫。Embedding 模型的選擇直接決定檢索質(zhì)量。醫(yī)療領(lǐng)域如果追求中文效果常見做法是用 BGE 系列或 M3E 這類中文優(yōu)化模型維度一般是 768 或 1024。不要用英文為主的通用模型硬套中文醫(yī)療語料召回率會(huì)明顯掉。向量庫方面畢設(shè)規(guī)模幾千到幾萬塊用 FAISS 或 Chroma 就夠了本地跑、零依賴、不花錢如果要做企業(yè)大模型私有化部署那種規(guī)模再考慮 Milvus 或 Qdrant。from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS def build_vector_store(chunks, persist_dir./med_faiss): 把切分塊向量化并持久化到本地 embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-base-zh-v1.5, model_kwargs{device: cpu}, # 有 GPU 改 cuda encode_kwargs{normalize_embeddings: True}, # 歸一化提升余弦相似度穩(wěn)定性 ) db FAISS.from_documents(chunks, embeddings) db.save_local(persist_dir) return dbnormalize_embeddings 設(shè)為 True 是為了讓向量歸一化配合余弦相似度檢索更穩(wěn)定這是很多人會(huì)漏掉的一個(gè)參數(shù)。device 按機(jī)器實(shí)際情況選畢設(shè)機(jī)器沒顯卡就用 cpu幾萬塊向量建庫也就幾分鐘。存到本地后下次啟動(dòng)直接 load_local 加載不用重復(fù)向量化省時(shí)間也省 API 調(diào)用。3. 檢索與生成把召回片段變成可信答案3.1 檢索環(huán)節(jié)的三個(gè)必調(diào)參數(shù)檢索不是「相似度最高的那條直接拿來用」這么簡(jiǎn)單。top_k、相似度閾值、是否做重排這三個(gè)參數(shù)決定了召回的片段質(zhì)量和數(shù)量。top_k 太小可能漏掉關(guān)鍵信息太大又會(huì)把無關(guān)內(nèi)容塞進(jìn) Prompt 干擾模型。醫(yī)療問答我一般設(shè) top_k46配合一個(gè)相似度閾值過濾掉明顯不相關(guān)的塊。如果向量庫支持再加一層重排rerank模型把召回的塊按與問題的真實(shí)相關(guān)度重新排序取前 3 條進(jìn) Prompt效果比單純調(diào) top_k 好很多。def retrieve_context(db, query, top_k5, score_threshold0.35): 帶閾值過濾的檢索返回 (文本, 來源) 列表 results db.similarity_search_with_score(query, ktop_k) contexts [] for doc, score in results: # FAISS 返回的是 L2 距離越小越相關(guān)這里做簡(jiǎn)單歸一化判斷 if score score_threshold: contexts.append({ text: doc.page_content, source: doc.metadata.get(source, 未知), }) return contexts這里要注意不同向量庫返回的 score 含義不一樣FAISS 默認(rèn)返回 L2 距離越小越相關(guān)Chroma 返回的可能是余弦距離。閾值不能照搬得先打印幾條真實(shí)檢索結(jié)果看看分布再定。我見過有人直接把閾值設(shè)成 0.8結(jié)果一條都召不回來排查半天才發(fā)現(xiàn)是距離和相似度搞反了。3.2 Prompt 組裝讓大模型「看著資料說話」檢索回來的片段怎么塞給大模型直接決定答案質(zhì)量。核心原則有三條明確告訴模型只能基于給定資料回答、要求標(biāo)注信息來源、資料里沒有的內(nèi)容要明確說「不知道」。醫(yī)療場(chǎng)景尤其不能允許模型自由發(fā)揮寧可答「當(dāng)前資料未涵蓋」也不能編。MED_PROMPT 你是一個(gè)嚴(yán)謹(jǐn)?shù)尼t(yī)療知識(shí)問答助手。請(qǐng)嚴(yán)格根據(jù)下面提供的資料回答問題。 要求 1. 只使用資料中的信息不要引入資料之外的知識(shí)。 2. 如果資料不足以回答直接回復(fù)「根據(jù)現(xiàn)有資料無法回答該問題」。 3. 回答末尾用 [來源: 文件名] 標(biāo)注依據(jù)。 資料 {context} 問題{question} 回答 def build_prompt(contexts, question): ctx_text \n\n.join( f[{c[source]}]\n{c[text]} for c in contexts ) return MED_PROMPT.format(contextctx_text, questionquestion)Prompt 里把「只使用資料」「無法回答就直說」「標(biāo)注來源」寫成硬性要求是醫(yī)療問答可控性的關(guān)鍵。context 拼接時(shí)帶上來源文件名方便后面做答案溯源展示。如果你的大模型上下文長(zhǎng)度有限注意控制拼接后的總長(zhǎng)度一般留出 500800 token 給回答其余給資料。3.3 調(diào)用大模型并做答案溯源from openai import OpenAI client OpenAI(base_url你的模型服務(wù)地址, api_key你的key) def answer_question(db, question): contexts retrieve_context(db, question) if not contexts: return {answer: 根據(jù)現(xiàn)有資料無法回答該問題, sources: []} prompt build_prompt(contexts, question) resp client.chat.completions.create( model你的模型名, messages[{role: user, content: prompt}], temperature0.1, # 醫(yī)療場(chǎng)景壓低隨機(jī)性 max_tokens600, ) return { answer: resp.choices[0].message.content, sources: list({c[source] for c in contexts}), }temperature 設(shè) 0.1 是為了壓低隨機(jī)性醫(yī)療問答不需要「創(chuàng)造性」要的是穩(wěn)定復(fù)現(xiàn)。max_tokens 控制在 600 左右避免模型啰嗦。返回結(jié)構(gòu)里帶上 sources前端就能展示「本答案參考了哪些文檔」這是 RAG 相比純大模型最大的可信度優(yōu)勢(shì)。如果用的是免費(fèi)大模型 API 或本地部署模型接口協(xié)議兼容 OpenAI 格式的話這段代碼基本不用改只換 base_url 和 model 名即可。4. 避坑排查醫(yī)療 RAG 最容易翻車的 5 個(gè)地方4.1 檢索召回一堆但答案還是錯(cuò)現(xiàn)象明明知識(shí)庫里有正確內(nèi)容檢索也召回了相關(guān)塊但模型給出的答案仍然偏離。原因通常是召回塊里混入了語義相近但結(jié)論相反的片段比如同時(shí)召回了「某藥適用于 A 病」和「某藥禁用于 A 病并發(fā)癥」模型沒分清主次。解決辦法是在 Prompt 里要求模型對(duì)沖突信息做說明或者引入重排模型把最相關(guān)的塊排到最前減少干擾塊的影響。4.2 切分把關(guān)鍵信息切斷現(xiàn)象用戶問某藥的禁忌系統(tǒng)答不出但知識(shí)庫里明明寫了。原因多半是切分時(shí)「禁忌」標(biāo)題和內(nèi)容被切到了兩個(gè)塊檢索只召回了標(biāo)題塊或只召回了內(nèi)容塊。解決辦法是切分時(shí)把標(biāo)題和其下內(nèi)容綁定或者用帶標(biāo)題繼承的切分策略讓每個(gè)塊都帶上所屬章節(jié)標(biāo)題。4.3 相似度閾值設(shè)錯(cuò)導(dǎo)致空召回現(xiàn)象系統(tǒng)頻繁回復(fù)「無法回答」但知識(shí)庫覆蓋了這些問題。原因就是前面提到的把距離當(dāng)成了相似度閾值方向設(shè)反。解決辦法是先打印 20 條真實(shí)檢索的 score 分布確認(rèn)數(shù)值范圍后再定閾值別拍腦袋。4.4 Embedding 模型和語料語言不匹配現(xiàn)象中文醫(yī)療問題檢索英文語料或通用語料時(shí)召回率極低。原因是用了英文為主的 embedding 模型。解決辦法是換成中文優(yōu)化的 BGE 或 M3E 系列重新建庫。換模型必須重建向量庫不能只換模型不重建這是血淚經(jīng)驗(yàn)。4.5 大模型無視資料自由發(fā)揮現(xiàn)象資料里沒有的內(nèi)容模型照樣答得頭頭是道。原因是 Prompt 約束不夠硬或者 temperature 太高。解決辦法是把「只使用資料」「無法回答就直說」寫成明確規(guī)則temperature 壓到 0.1 以下并在后處理里檢查答案是否包含來源標(biāo)注沒有標(biāo)注的答案打回或提示存疑。5. 進(jìn)階技巧用混合檢索和答案校驗(yàn)把準(zhǔn)確率再抬一檔純向量檢索有個(gè)天然短板對(duì)專有名詞、藥品編碼、精確術(shù)語的匹配不如關(guān)鍵詞檢索。醫(yī)療場(chǎng)景里「阿司匹林腸溶片 100mg」這種精確表述向量檢索可能召回一堆語義相近但劑量不同的塊。我一般會(huì)加一路 BM25 關(guān)鍵詞檢索和向量檢索結(jié)果做融合這就是常說的混合檢索。融合策略用簡(jiǎn)單的加權(quán)或 RRF倒數(shù)排名融合都行實(shí)測(cè)對(duì)醫(yī)療問答的召回提升很明顯。from rank_bm25 import BM25Okapi import jieba def hybrid_retrieve(db, chunks, query, top_k5): 向量檢索 BM25 關(guān)鍵詞檢索RRF 融合 # 向量路 vec_results db.similarity_search(query, ktop_k) vec_ids [d.metadata.get(id) for d in vec_results] # 關(guān)鍵詞路 tokenized [list(jieba.cut(c.page_content)) for c in chunks] bm25 BM25Okapi(tokenized) scores bm25.get_scores(list(jieba.cut(query))) bm25_ids [chunks[i].metadata.get(id) for i in scores.argsort()[::-1][:top_k]] # RRF 融合 rrf {} for rank, cid in enumerate(vec_ids): rrf[cid] rrf.get(cid, 0) 1 / (60 rank 1) for rank, cid in enumerate(bm25_ids): rrf[cid] rrf.get(cid, 0) 1 / (60 rank 1) top_ids sorted(rrf, keyrrf.get, reverseTrue)[:top_k] return [c for c in chunks if c.metadata.get(id) in top_ids]RRF 里的 60 是經(jīng)驗(yàn)常數(shù)作用是削弱排名靠前項(xiàng)的絕對(duì)優(yōu)勢(shì)讓兩路結(jié)果更均衡。這段代碼要求每個(gè) chunk 在入庫時(shí)寫入唯一 id 到 metadata建庫時(shí)補(bǔ)上即可。融合后再走前面的 Prompt 組裝和生成流程整體準(zhǔn)確率會(huì)比單路向量檢索穩(wěn)不少。另一個(gè)值得做的進(jìn)階是答案校驗(yàn)生成答案后把答案和召回資料再喂給模型做一次一致性檢查問它「這個(gè)答案是否完全被資料支持」。這一步會(huì)增加一次調(diào)用開銷但對(duì)醫(yī)療這種高風(fēng)險(xiǎn)場(chǎng)景多一道校驗(yàn)就少一分誤導(dǎo)風(fēng)險(xiǎn)。我自己的習(xí)慣是任何涉及劑量、禁忌、診斷結(jié)論的回答都必須過校驗(yàn)這一關(guān)寧可慢一點(diǎn)也不能錯(cuò)。這套 RAG 加校驗(yàn)的鏈路才是醫(yī)療問答系統(tǒng)真正能拿得出手、也經(jīng)得起答辯追問的完整方案。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取