庫(kù)問(wèn)答系統(tǒng)實(shí)戰(zhàn):從文檔預(yù)處理到檢索增強(qiáng)生成)
簡(jiǎn)介這是一套基于RAG檢索增強(qiáng)生成技術(shù)的私有知識(shí)庫(kù)問(wèn)答系統(tǒng)完整項(xiàng)目面向計(jì)算機(jī)相關(guān)專(zhuān)業(yè)需要完成畢業(yè)設(shè)計(jì)、期末大作業(yè)或課程設(shè)計(jì)的學(xué)生也適合想學(xué)習(xí)RAG落地實(shí)踐的開(kāi)發(fā)者參考。項(xiàng)目代碼注釋詳細(xì)經(jīng)過(guò)嚴(yán)格調(diào)試簡(jiǎn)單部署即可運(yùn)行系統(tǒng)功能完善、界面美觀具備知識(shí)庫(kù)管理、智能檢索問(wèn)答等核心能力屬于導(dǎo)師認(rèn)可的高分畢設(shè)作品。資源包共545個(gè)文件約126MB以145個(gè)Python源碼文件為核心輔以96個(gè)pyc編譯文件、166個(gè)png界面截圖、23個(gè)md說(shuō)明文檔同時(shí)包含9個(gè)pdf文檔、35個(gè)js前端腳本及css、yaml、json等配置與依賴(lài)文件代碼、文檔、素材一應(yīng)俱全。目前已有1860人學(xué)習(xí)下載。源碼帶有清晰注釋新手也能讀懂可直接作為畢業(yè)設(shè)計(jì)或課程設(shè)計(jì)提交也可在此基礎(chǔ)上擴(kuò)展二次開(kāi)發(fā)對(duì)理解RAG檢索生成流程、私有化知識(shí)庫(kù)搭建具有很高的參考價(jià)值。1. 基于RAG的私有知識(shí)庫(kù)問(wèn)答系統(tǒng)畢業(yè)設(shè)計(jì)里最值得一試的“萬(wàn)能殼子”站在畢業(yè)設(shè)計(jì)的選題清單前大多數(shù)人的第一反應(yīng)是做一個(gè)XX管理系統(tǒng)但如果你盯上基于RAG的私有知識(shí)庫(kù)問(wèn)答系統(tǒng)要交付的就不是又一個(gè)CRUD而是一個(gè)能“看懂”你喂給它的文檔、并按提問(wèn)給出帶出處回答的系統(tǒng)。RAG檢索增強(qiáng)生成會(huì)把私有文檔切碎、向量化、存進(jìn)向量庫(kù)用戶提問(wèn)時(shí)先檢索最相關(guān)的片段再讓大模型基于這些片段作答。它直接解決一個(gè)真實(shí)痛點(diǎn)通用大模型不知道你內(nèi)部文檔里的內(nèi)容微調(diào)貴且周期長(zhǎng)RAG用最少的算力把“陌生文檔”變成“可回答的問(wèn)題”。這個(gè)方向適合想兼顧算法認(rèn)知和工程落地、又不想把畢業(yè)設(shè)計(jì)做成純調(diào)包演示的本科生和研究生源碼和文檔說(shuō)明寫(xiě)透了答辯時(shí)連“創(chuàng)新點(diǎn)”都能順著檢索鏈路往下講。2. 先把知識(shí)庫(kù)做成“能檢索的樣子”文檔歸一化、切塊與Embedding選型2.1 私有知識(shí)庫(kù)的常見(jiàn)來(lái)源PDF、Word、公眾號(hào)文章怎么歸一化“私有知識(shí)庫(kù)”聽(tīng)起來(lái)高大上落到文件系統(tǒng)里無(wú)非是幾十個(gè)PDF、若干Word/Pages文檔、Markdown筆記還有從微信讀書(shū)或公眾號(hào)里存下來(lái)的網(wǎng)頁(yè)。常見(jiàn)做法是先把所有格式歸一成純文本后續(xù)的切塊、向量化才有統(tǒng)一入口。PDF用pdfplumberWord用python-docxHTML用BeautifulSoup這是我在多個(gè)項(xiàng)目里試下來(lái)最穩(wěn)的組合。from pathlib import Path import pdfplumber from docx import Document from bs4 import BeautifulSoup def load_document(path: str) - str: p Path(path) suffix p.suffix.lower() if suffix .pdf: text [] with pdfplumber.open(path) as pdf: for page in pdf.pages: text.append(page.extract_text() or ) return \n.join(text) if suffix .docx: doc Document(path) return \n.join(para.text for para in doc.paragraphs) if suffix in (.html, .htm): soup BeautifulSoup( p.read_text(encodingutf-8, errorsignore), html.parser ) for tag in soup([script, style]): tag.decompose() return soup.get_text(\n) return p.read_text(encodingutf-8, errorsignore)這段代碼做三件事按后綴分發(fā)解析器PDF每頁(yè)單獨(dú)提取并按頁(yè)拼接HTML先刪掉script和style再取文本。邏輯上要注意extract_text()對(duì)掃描件返回空字符串所以給它兜底o(hù)r 避免整頁(yè)內(nèi)容在后續(xù)處理里靜默丟失Word只取段落文本表格里的內(nèi)容會(huì)丟掉如果知識(shí)庫(kù)里有表格型制度文件要看后面避坑章節(jié)的處理方案。把公眾號(hào)文章存進(jìn)知識(shí)庫(kù)我一般不是去爬而是直接復(fù)制全文到Typora或Obsidian里存成Markdown再走上面的Markdown分支。這樣做的好處是保留了小標(biāo)題結(jié)構(gòu)后面按標(biāo)題切塊時(shí)能拿到干凈的語(yǔ)義邊界也天然避開(kāi)了網(wǎng)頁(yè)里導(dǎo)航、廣告等噪聲文本的干擾。2.2 Embedding模型選型中文私有文檔的四個(gè)候選檢索質(zhì)量的上限由Embedding模型決定不是由大模型決定。這個(gè)順序很多同學(xué)搞反先定LLM然后隨便用一個(gè)Embedding檢索效果差就開(kāi)始調(diào)Prompt這是本末倒置。建議先做一輪Embedding選型再回去定生成模型。下面這組對(duì)比是我在中文制度類(lèi)文檔上常用的候選模型維度是否可本地部署中文效果備注BAAI/bge-large-zh-v1.51024是好密集檢索基線需要配合查詢指令前綴BAAI/bge-m31024是好支持多語(yǔ)言和多粒度內(nèi)存占用偏大text2vec-base-chinese768是中資源占用低適合老舊電腦跑演示云端API百煉、千帆等不等否好零部署但要把文檔內(nèi)容傳到外部私有場(chǎng)景慎用私有知識(shí)庫(kù)的核心訴求恰好在“私有”二字上一般優(yōu)先推薦bge-m3或bge-large-zh-v1.5。前者在長(zhǎng)文檔和混合語(yǔ)言場(chǎng)景更穩(wěn)后者單機(jī)幾個(gè)GB內(nèi)存就能跑。選型時(shí)有一個(gè)容易踩的坑不同Embedding模型產(chǎn)出的向量維度不一樣切換模型之后必須重建向量索引否則FAISS加載時(shí)直接把進(jìn)程崩掉這屬于“換了模型忘了索引”的低級(jí)事故避坑章節(jié)里我會(huì)展開(kāi)講。另外知識(shí)圖譜型知識(shí)庫(kù)KG與RAG知識(shí)庫(kù)是兩條不同的路線。KG適合實(shí)體關(guān)系密集的場(chǎng)景比如公安案件、企業(yè)供應(yīng)鏈關(guān)系查詢但構(gòu)建圖譜的標(biāo)注和抽取成本很高RAG知識(shí)庫(kù)更適合制度文本、操作手冊(cè)這類(lèi)“一段話講清楚一件事”的內(nèi)容。畢業(yè)設(shè)計(jì)里如果選的是制度問(wèn)答老老實(shí)實(shí)走RAG就夠了別為了湊創(chuàng)新點(diǎn)硬上KG。2.3 圖片和表格要不要進(jìn)知識(shí)庫(kù)熱搜里常有人問(wèn)“RAG知識(shí)庫(kù)能存儲(chǔ)圖片嘛”。純文本RAG處理不了圖片像素但有兩種變通一是用多模態(tài)Embedding把圖片和文字一起向量化二是先把圖片轉(zhuǎn)成文字描述再入庫(kù)。畢業(yè)設(shè)計(jì)不建議引入多模態(tài)顯得復(fù)雜且答辯不好講。更實(shí)用的做法是把圖片中的文字用OCR識(shí)別出來(lái)跟圖片的上下文文本拼在一起表格則按行轉(zhuǎn)成鍵值對(duì)文本再入庫(kù)。常見(jiàn)做法是把表格轉(zhuǎn)成“列名: 值; 列名: 值”的拼接文本例如“姓名: 張三; 部門(mén): 研發(fā)部”而不是直接喂原始表格。原因在于切塊和Embedding對(duì)純排版結(jié)構(gòu)不敏感鍵值對(duì)文本的語(yǔ)義密度遠(yuǎn)高于帶空格的表格原樣。這一步雖然土但能顯著減少后端生成的幻覺(jué)。數(shù)據(jù)清洗時(shí)再順手做兩步去掉連續(xù)空行和亂碼字符按標(biāo)題把一篇長(zhǎng)文檔預(yù)切成小節(jié)這樣切塊器工作的目標(biāo)粒度從“整本手冊(cè)”變成“一個(gè)章節(jié)”召回效果會(huì)明顯提升。3. 用Python把“檢索生成”串起來(lái)最小可跑的RAG流水線3.1 本地向量庫(kù)FAISS bge-m3 的建庫(kù)與檢索一個(gè)能拿來(lái)答辯的RAG系統(tǒng)最核心的代碼量其實(shí)只有兩百行左右。我強(qiáng)烈建議不要一上來(lái)就套LangChain的完整流程畢業(yè)設(shè)計(jì)里要先自己手寫(xiě)一遍Embedding和檢索哪怕后面用框架重構(gòu)成產(chǎn)品形態(tài)答辯時(shí)也能講清楚“向量從哪來(lái)、索引長(zhǎng)什么樣、檢索怎么算相似度”。下面這套最小可跑的組合直接用SentenceTransformer加載本地中文Embedding模型用FAISS建密排索引。from sentence_transformers import SentenceTransformer from langchain_text_splitters import RecursiveCharacterTextSplitter import faiss import numpy as np model SentenceTransformer(BAAI/bge-m3) splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , ], ) def load_documents(file_paths: list[str]) - list[str]: # file_paths 來(lái)自上一章 load_document 的歸一化結(jié)果 return [load_document(fp) for fp in file_paths] def build_index(docs: list[str]): chunks [ c for doc in docs for c in splitter.split_text(doc) ] vectors model.encode( chunks, normalize_embeddingsTrue ).astype(float32) index faiss.IndexFlatIP(vectors.shape[1]) index.add(vectors) return index, chunks def search(index, chunks, query, k4): query_vec model.encode( [query], normalize_embeddingsTrue ).astype(float32) scores, idx index.search(query_vec, k) return [ (float(scores[0][i]), chunks[idx[0][i]]) for i in range(k) ]代碼邏輯分三塊切塊器把每個(gè)文檔切成500字的小段段間重疊50字模型把每段變成1024維向量normalize_embeddingsTrue讓向量落在單位球面上檢索用IndexFlatIP也就是內(nèi)積相似度歸一化之后的內(nèi)積等價(jià)于余弦相似度。參數(shù)上chunk_size500對(duì)制度條款類(lèi)中文文本是安全起點(diǎn)overlap50能避免句子被從中間腰斬。這里有個(gè)容易忽略的細(xì)節(jié)split_text返回的是字符串列表RecursiveCharacterTextSplitter的順序是先按“\n\n”切再按句號(hào)切最后按空格這意味著Markdown的段落邊界會(huì)被優(yōu)先保留比固定長(zhǎng)度硬切合理得多。如果裝不上langchain-text-splitters也可以手寫(xiě)一個(gè)按標(biāo)點(diǎn)切塊的函數(shù)但多級(jí)分隔符的優(yōu)先級(jí)順序要保留。3.2 生成環(huán)節(jié)Ollama 本地大模型 帶出處約束的Prompt檢索拿到top-k片段之后生成環(huán)節(jié)的任務(wù)是“忠實(shí)復(fù)述”不是“自由發(fā)揮”。我用Ollama跑本地模型比如qwen2.5:7b通過(guò)OpenAI兼容接口調(diào)用這樣一個(gè)Client可以在本地和云端API之間平滑切換。Prompt里的核心約束是只能依據(jù)給定資料作答資料里沒(méi)有的內(nèi)容要明說(shuō)不知道。from openai import OpenAI client OpenAI( base_urlhttp://127.0.0.1:11434/v1, api_keyollama, # Ollama 本地服務(wù)不校驗(yàn) key占位即可 ) def ask(query: str, hits: list[tuple[float, str]], top_k: int 3): context \n\n.join([ f片段{i1}{text} for i, (_, text) in enumerate(hits[:top_k]) ]) prompt ( 你是一個(gè)嚴(yán)謹(jǐn)?shù)膯?wèn)答助手。請(qǐng)只根據(jù)下面的資料回答問(wèn)題。\n 資料中沒(méi)有的信息請(qǐng)回答“資料中未找到相關(guān)內(nèi)容”不要編造。\n 回答時(shí)標(biāo)注你引用的片段編號(hào)。\n\n f資料\n{context}\n\n f問(wèn)題{query} ) resp client.chat.completions.create( modelqwen2.5:7b, messages[{role: user, content: prompt}], temperature0.2, max_tokens512, ) return resp.choices[0].message.content這里temperature0.2是為防止模型在事實(shí)問(wèn)答里“文采飛揚(yáng)”從而引入幻覺(jué)max_tokens512對(duì)大多數(shù)制度問(wèn)答夠用。讓模型標(biāo)注片段編號(hào)有兩個(gè)作用一是給用戶提供出處答辯時(shí)這是很好的加分項(xiàng)二是當(dāng)模型引用了不存在的片段或半句話你能從編號(hào)反查是切塊問(wèn)題還是檢索問(wèn)題。實(shí)際演示時(shí)一個(gè)常見(jiàn)的翻車(chē)現(xiàn)場(chǎng)是模型滔滔不絕地把檢索片段之外的知識(shí)也講出來(lái)所以Prompt里“不要編造”這五個(gè)字往往比調(diào)大top_k更管用。生成這一步還有個(gè)小技巧把之前的問(wèn)答歷史拼進(jìn)上下文實(shí)現(xiàn)多輪追問(wèn)。簡(jiǎn)單做法是維護(hù)一個(gè)messages列表把前一輪的user/assistant內(nèi)容追加進(jìn)去但要注意上下文膨脹超過(guò)模型窗口后反而把檢索片段擠掉。我一般只保留最近兩輪對(duì)話再多就截?cái)唷?.3 手寫(xiě)流水線 vs Dify/開(kāi)源知識(shí)庫(kù)畢業(yè)設(shè)計(jì)怎么選熱搜里頻繁出現(xiàn)Dify知識(shí)庫(kù)和開(kāi)源知識(shí)庫(kù)很多同學(xué)會(huì)糾結(jié)畢業(yè)設(shè)計(jì)要不要直接用Dify拉流水線我的看法是Dify適合做產(chǎn)品原型和現(xiàn)場(chǎng)演示但如果論文要求“實(shí)現(xiàn)一個(gè)系統(tǒng)”直接拖節(jié)點(diǎn)很容易被答辯老師問(wèn)住——你寫(xiě)進(jìn)論文的“創(chuàng)新點(diǎn)”會(huì)變成“配置點(diǎn)”。手寫(xiě)代碼的另一個(gè)好處是能精確監(jiān)控每個(gè)環(huán)節(jié)的耗時(shí)和丟失率Dify里的“知識(shí)庫(kù)排隊(duì)中”這類(lèi)黑匣子狀態(tài)到了答辯現(xiàn)場(chǎng)很難解釋。不過(guò)這并不意味著不該了解Dify。它的知識(shí)庫(kù)本質(zhì)就是文檔集、切塊策略、向量庫(kù)、檢索參數(shù)四件套你完全可以在論文的“系統(tǒng)設(shè)計(jì)”章節(jié)畫(huà)同樣的四層結(jié)構(gòu)再用自己的Python代碼逐層落地。開(kāi)源知識(shí)庫(kù)也可以拿來(lái)對(duì)比選型但源碼級(jí)研究還是回到FAISS與SentenceTransformer這條主線最省力。底線是界面可以用現(xiàn)成框架套但檢索和生成的鏈路代碼必須能在自己機(jī)器上從零跑通否則論文的“系統(tǒng)實(shí)現(xiàn)”站不住。4. 讓回答從“能跑”到“靠譜”三個(gè)必調(diào)參數(shù)、混合檢索與Reranker4.1 三個(gè)必調(diào)參數(shù)chunk_size、chunk_overlap、top_k把系統(tǒng)跑通只需半天調(diào)參卻可能花掉一周因?yàn)槊總€(gè)參數(shù)都直接影響檢索命中率而命中率又決定生成質(zhì)量。三個(gè)必調(diào)參數(shù)是chunk_size切塊大小、chunk_overlap切塊重疊、top_k送入生成模型的片段數(shù)量。chunk_size語(yǔ)義上等于“模型一次能看到的資料粒度”。設(shè)置太小比如200一個(gè)完整的條款被攔腰拆散檢索到的是殘句設(shè)置太大比如1000向量里混入太多無(wú)關(guān)信息相似度被稀釋。中文制度文檔一般從400到600之間起步。chunk_overlap是防止句子被從中間切開(kāi)導(dǎo)致語(yǔ)義斷裂的緩沖經(jīng)驗(yàn)值是chunk_size的10%到20%。top_k則需要在“上下文完整”和“上下文干凈”之間平衡top_k2時(shí)上下文很干凈但容易漏關(guān)鍵出處top_k6時(shí)召回高但Prompt會(huì)塞進(jìn)噪聲。我常用top_k4做默認(rèn)值再配合重排序修正。調(diào)參不能靠手感要寫(xiě)一個(gè)離線評(píng)估腳本把幾十個(gè)“問(wèn)題-標(biāo)準(zhǔn)答案”喂進(jìn)去算召回率。def evaluate_params( docs: list[str], qa_pairs: list[dict], chunk_size: int, overlap: int, top_k: int, ) - float: splitter RecursiveCharacterTextSplitter( chunk_sizechunk_size, chunk_overlapoverlap, separators[\n\n, \n, 。, , , ], ) chunks [ c for doc in docs for c in splitter.split_text(doc) ] # 注意chunk_size 變了之后必須重建索引 vectors model.encode(chunks, normalize_embeddingsTrue).astype(float32) index faiss.IndexFlatIP(vectors.shape[1]) index.add(vectors) hit 0 for item in qa_pairs: hits search(index, chunks, item[question], ktop_k) texts [text for _, text in hits] if any(item[golden] in text for text in texts): hit 1 return hit / len(qa_pairs)這段代碼把“問(wèn)題-包含答案的片段原文”作為測(cè)試集統(tǒng)計(jì)top_k召回里是否出現(xiàn)答案所在片段。如果數(shù)據(jù)里沒(méi)有一塊完整片段包含答案說(shuō)明chunk_size太小把答案切碎了。這個(gè)“答案落在一個(gè)chunk里”的比率就是檢索系統(tǒng)的底氣比率低于70%先別急著調(diào)Prompt回來(lái)調(diào)切塊。參數(shù)說(shuō)明上chunk_size和overlap在切塊時(shí)就決定了chunk集合的形狀改完必須重建索引top_k只影響檢索讀取多少片段不需要重建索引可以快速掃幾個(gè)值。所以調(diào)參順序是先固定top_k4用腳本掃chunk_size和overlap再固定最優(yōu)切塊掃top_k。批量跑的時(shí)候把結(jié)果記錄成CSV方便寫(xiě)進(jìn)論文的“實(shí)驗(yàn)與分析”章節(jié)。很多人工標(biāo)注問(wèn)答對(duì)的同學(xué)會(huì)忽略一點(diǎn)測(cè)試集要和調(diào)參過(guò)程分離留出至少10個(gè)“沒(méi)用來(lái)調(diào)參”的問(wèn)題做最終驗(yàn)證否則調(diào)參過(guò)程會(huì)過(guò)擬合到自己這批題上。4.2 混合檢索BM25補(bǔ)上精確匹配的盲區(qū)向量檢索用語(yǔ)義相似度找“意思相近”的片段但對(duì)專(zhuān)有名詞、工號(hào)、公文編號(hào)這類(lèi)精確字符串并不敏感。比如你問(wèn)“請(qǐng)找出編號(hào)為ZD-2024-001的制度文件”語(yǔ)義向量很可能把它匹配到“制度文件”上而丟掉編號(hào)?;旌蠙z索的常見(jiàn)做法是對(duì)同一批chunk同時(shí)建BM25倒排索引和向量索引檢索時(shí)分別取結(jié)果再用帶權(quán)重的分?jǐn)?shù)融合合并。from rank_bm25 import BM25Okapi # 中文按字切分最穩(wěn)按詞切分容易切錯(cuò)專(zhuān)有名詞 tokenized_chunks [list(chunk) for chunk in chunks] bm25 BM25Okapi(tokenized_chunks) def hybrid_search(query: str, k: int 4, alpha: float 0.5): vec_scores, vec_idx index.search( model.encode([query], normalize_embeddingsTrue).astype(float32), k * 2, ) bm25_scores bm25.get_scores(list(query)) max_bm25 max(bm25_scores) or 1.0 vec_norm [float(s) for s in vec_scores[0]] max_vec max(vec_norm) or 1.0 combined {} for pos, i in enumerate(vec_idx[0]): combined[i] alpha * (vec_norm[pos] / max_vec) for i in range(len(chunks)): bm25_part (1 - alpha) * (bm25_scores[i] / max_bm25) combined[i] combined.get(i, 0.0) bm25_part top_indices sorted( combined, keylambda i: combined[i], reverseTrue, )[:k] return [(combined[i], chunks[i]) for i in top_indices]上面的權(quán)重融合先把向量分?jǐn)?shù)和BM25分?jǐn)?shù)各自歸一化到0到1再用alpha做線性加權(quán)否則兩類(lèi)分?jǐn)?shù)量綱不同相加沒(méi)有意義。中文場(chǎng)景我建議按單字切分而不是按詞因?yàn)榉衷~器對(duì)制度文本里的新詞很容易切錯(cuò)。alpha控制語(yǔ)義和精確匹配的側(cè)重制度編號(hào)多的文檔alpha取0.4以下口語(yǔ)化問(wèn)答多alpha取0.6以上?;旌蠙z索不是越復(fù)雜越好但BM25插件的成本極低且在答辯里屬于“檢索策略有改進(jìn)”的加分點(diǎn)。4.3 用Cross-Encoder做重排序粗排精排的經(jīng)典打法向量檢索的第一階段是雙塔結(jié)構(gòu)速度快但精度有限第二階段用Cross-Encoder把查詢和每個(gè)候選片段拼起來(lái)打分精度高但慢。RAG流水線里常見(jiàn)做法是向量和BM25各召回幾十分交給重排序模型統(tǒng)一打分再取top_k送入生成。這個(gè)“粗排精排”的思路在搜索引擎里是標(biāo)配放到RAG里一樣成立。from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch tokenizer AutoTokenizer.from_pretrained(BAAI/bge-reranker-v2-m3) reranker AutoModelForSequenceClassification.from_pretrained( BAAI/bge-reranker-v2-m3 ) reranker.eval() def rerank(query: str, candidates: list[tuple[float, str]], top_k: int 3): pairs [(query, text) for _, text in candidates] inputs tokenizer( pairs, paddingTrue, truncationTrue, max_length512, return_tensorspt, ) with torch.no_grad(): scores reranker(**inputs).logits.squeeze().tolist() scored sorted( zip(scores, candidates), keylambda x: x[0], reverseTrue, ) return [item for _, item in scored[:top_k]]Cross-Encoder一次只能處理一對(duì)文本所以它只能在“候選數(shù)量已經(jīng)很小”的環(huán)節(jié)用。我一般讓向量檢索先召回30個(gè)候選重排序后取4個(gè)進(jìn)入生成這樣既保住了檢索的廣度又把生成環(huán)節(jié)的上下文噪聲壓到了最低。這個(gè)設(shè)計(jì)在代碼上成本不高但能在論文里放一張“加Reranker前后回答準(zhǔn)確率對(duì)比”的圖屬于投入產(chǎn)出比很高的優(yōu)化點(diǎn)。需要注意bge-reranker-v2-m3按查詢與片段對(duì)打分分?jǐn)?shù)含義是相關(guān)性不能直接和向量相似度比較。5. 避坑指南最常翻車(chē)的五個(gè)地方現(xiàn)象、原因、解決5.1 PDF表格整段消失檢索永遠(yuǎn)命不中現(xiàn)象喂進(jìn)知識(shí)庫(kù)的PDF里有幾張帶邊框的表格任何關(guān)于表格內(nèi)容的問(wèn)題都檢索不到。 原因pdfplumber的extract_text()只提取文字流邊框表格的單元格文本會(huì)被漏掉或亂序。 解決表格頁(yè)面先用page.find_tables()拿到單元格坐標(biāo)逐格取文本再按行合并也可以直接用page.extract_tables()但合并單元格會(huì)拆成空值。畢業(yè)設(shè)計(jì)數(shù)據(jù)量通常不大我一般優(yōu)先人工整理成Markdown表格再入庫(kù)既不丟信息又省調(diào)錯(cuò)時(shí)間。如果表格實(shí)在太多再用腳本處理別上來(lái)就全自動(dòng)否則你會(huì)花一下午查數(shù)據(jù)去哪了。5.2 切塊把句子攔腰截?cái)啻鸢赣肋h(yuǎn)差后半句現(xiàn)象檢索命中的片段總是“半句話”模型生成時(shí)只能說(shuō)一半怎么調(diào)Prompt都沒(méi)用。 原因切塊器按固定字?jǐn)?shù)切分沒(méi)有感知句號(hào)逗號(hào)separators里沒(méi)放中文標(biāo)點(diǎn)。 解決RecursiveCharacterTextSplitter的separators按順序嘗試邊界要把“?!焙汀啊狈旁诳崭袂懊嫒绻袎K器不支持多級(jí)分隔符就在切之前先給文本做預(yù)分段再按chunk_size聚合。檢查方法是打印前20個(gè)chunk看有多少以句號(hào)結(jié)尾。以我的經(jīng)驗(yàn)中文文本如果chunk里句號(hào)結(jié)尾占比低于80%切塊策略就該調(diào)了。5.3 檢索到了但回答“未找到”P(pán)rompt把答案憋壞了現(xiàn)象檢索出的片段明明包含答案模型卻回答“資料中未找到相關(guān)內(nèi)容”。 原因兩個(gè)常見(jiàn)原因一是top_k2導(dǎo)致答案片段被截到上下文之外二是Prompt要求模型“只能根據(jù)資料回答”時(shí)模型過(guò)于保守或者max_tokens太小把答案截?cái)嗔恕?解決先打印送入生成模型的context確認(rèn)答案是否真的在context里。若不在調(diào)高top_k若在問(wèn)題出在生成環(huán)節(jié)把Prompt改為“優(yōu)先依據(jù)資料回答資料不足時(shí)請(qǐng)說(shuō)明”并調(diào)大max_tokens。這條排查順序非常重要檢索問(wèn)題先于生成問(wèn)題不要在沒(méi)看context時(shí)瞎調(diào)Prompt。5.4 換Embedding模型后進(jìn)程崩潰索引維度寫(xiě)死了現(xiàn)象上午用bge-large-zh建好庫(kù)下午換bge-m3程序啟動(dòng)直接段錯(cuò)誤退出。 原因FAISS索引在創(chuàng)建時(shí)確定了向量維度IndexFlatIP(dim)已經(jīng)寫(xiě)死新模型維度不同index.add()會(huì)內(nèi)存越界。 解決每次切換模型后把索引文件和chunk文本一起重建。建議把模型名和維度寫(xiě)進(jìn)索引文件名比如index_bge-m3_1024.faiss加載時(shí)先校驗(yàn)維度再加載。FAISS索引文件本身可刪可重建真正怕的是chunk文本和索引順序?qū)Σ簧纤运饕募cchunks的序列化文件要成對(duì)保存。5.5 檢索出“相似但不相關(guān)”的片段長(zhǎng)尾問(wèn)法搞不定現(xiàn)象用戶用自然口語(yǔ)提問(wèn)檢索結(jié)果卻總是另一份相似文檔的片段。 原因雙塔Embedding對(duì)字面差異敏感對(duì)語(yǔ)義改寫(xiě)不夠魯棒或者測(cè)試集里這一類(lèi)問(wèn)題本身就少切塊與模型都沒(méi)針對(duì)性優(yōu)化。 解決混合檢索里的BM25能兜住字面匹配Reranker能修正語(yǔ)義排序二者疊加后此類(lèi)現(xiàn)象大幅減少。另一個(gè)有效手段是給查詢加一個(gè)重寫(xiě)步驟把口語(yǔ)問(wèn)題改寫(xiě)成規(guī)范問(wèn)句再檢索比如“工資什么時(shí)候發(fā)”改寫(xiě)成“工資發(fā)放時(shí)間”。這個(gè)邏輯可以寫(xiě)成一個(gè)小函數(shù)也可以讓生成模型在檢索前代為改寫(xiě)但畢業(yè)設(shè)計(jì)階段用規(guī)則或Few-shot最省錢(qián)可控。6. 進(jìn)階用離線評(píng)估和鏈路快照給答辯交一份能復(fù)現(xiàn)的數(shù)字6.1 三個(gè)離線指標(biāo)替代“我覺(jué)得變好了”把系統(tǒng)調(diào)到位之后最容易被忽略的是評(píng)估。答辯最怕老師說(shuō)“這個(gè)效果是你自己調(diào)出來(lái)的換一批文檔還能用嗎”。我最后做的一道工序是把評(píng)估腳本固化召回率k、答案中含golden片段的比例、單次問(wèn)答的生成時(shí)長(zhǎng)中位數(shù)。每改一個(gè)參數(shù)命令行跑一遍輸出對(duì)比表。這里的關(guān)鍵是測(cè)試集要留出一部分“沒(méi)參與調(diào)參”的問(wèn)題防止過(guò)擬合到自己這批文檔上。6.2 把一次問(wèn)答的完整鏈路dump下來(lái)另一個(gè)實(shí)用技巧是把每次問(wèn)答的chunk片段、Prompt、模型輸出全部存成JSON文件翻車(chē)時(shí)對(duì)著JSON看是哪一環(huán)出了問(wèn)題而不是盯著終端猜測(cè)。鏈接口返回異常、檢索為空、生成超時(shí)分別記不同的錯(cuò)誤碼答辯現(xiàn)場(chǎng)出了問(wèn)題也能說(shuō)“這是哪一層的日志”。def save_trace(question, hits, final_answer, pathtrace.jsonl): import json record { question: question, hit_chunks: [text[:100] for _, text in hits], answer: final_answer, } with open(path, a, encodingutf-8) as f: f.write(json.dumps(record, ensure_asciiFalse) \n)這段代碼把當(dāng)前問(wèn)題的檢索片段截?cái)嗪笈c最終答案一起落盤(pán)。字段刻意只保留前100字避免日志文件膨脹你要做的就是每改一次參數(shù)后跑一批問(wèn)題對(duì)比兩次的JSON記錄看答案是變好了還是只是換了個(gè)說(shuō)法。我最深的教訓(xùn)是第一次做RAG時(shí)花了三周調(diào)大模型最后發(fā)現(xiàn)70%的問(wèn)題來(lái)自切塊和Embedding而不是LLM。所以我強(qiáng)烈建議你先跑通離線評(píng)估再去做界面和包裝。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取