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