:從文檔加載到API封裝的知識庫問答系統(tǒng))
這次我們來看一個 RAG 檢索增強生成問答系統(tǒng)的完整實戰(zhàn)。重點不是重復(fù)“RAG 是什么”的概念而是把整條鏈路跑起來文檔加載、中文分塊、BM25 稀疏檢索、稠密向量檢索、RRF 倒數(shù)排名融合、Prompt 拼接、調(diào)用大模型生成答案最后封裝成 API 和批量任務(wù)。整篇文章會帶代碼適合已經(jīng)了解 RAG 基本概念、想親手實現(xiàn)一個最小知識庫問答系統(tǒng)的讀者。這套方案的硬件門檻并不高。檢索和融合環(huán)節(jié)主要吃 CPU 和內(nèi)存不需要單獨的大顯存嵌入模型可以選小尺寸中文模型生成環(huán)節(jié)可以接本地大模型比如 Ollama 服務(wù)的 Qwen、DeepSeek也可以接 OpenAI 兼容接口。換句話說即使沒有獨立顯卡把生成模型換成遠端 API整套流程依然可以跑通。文章會繞開 LangChain 這種重量級框架先用最直觀的方式把底層邏輯拆明白確認每一步都理解之后再考慮要不要遷移到成熟框架。最后給出批量問答和 FastAPI 接口封裝方便直接接入真實業(yè)務(wù)。1. RAG 核心能力速覽能力項說明項目類型RAG 檢索增強生成問答系統(tǒng)實戰(zhàn)技術(shù)梳理核心技術(shù)文檔加載、文本分塊、BM25 稀疏檢索、稠密向量檢索、RRF 倒數(shù)排名融合、LLM 生成主要依賴Python 3.9、jieba、rank_bm25、sentence-transformers、requests、FastAPI硬件門檻檢索與融合部分 CPU 即可運行嵌入模型可選用小尺寸中文模型生成模型按參數(shù)規(guī)模選擇本地 GPU 或遠端 API支持平臺Windows / Linux / macOS啟動方式命令行腳本運行或通過 FastAPI 暴露 HTTP 服務(wù)是否支持 API支持可封裝為/qa接口是否支持批量任務(wù)支持可批量文檔入庫、批量問題問答適合場景私有知識庫問答、企業(yè)文檔助手、RAG 學習實驗、接口集成這里要強調(diào)一句RAG 項目沒有固定公式不同場景對分塊粒度、檢索路數(shù)、融合策略、生成模型的要求都不一樣。本文給的是最小可運行基線后面所有參數(shù)都可以按實際數(shù)據(jù)調(diào)整。2. RAG 整體架構(gòu)與關(guān)鍵環(huán)節(jié)RAG 的完整流程可以拆成四個階段數(shù)據(jù)準備、索引構(gòu)建、檢索召回、生成回答。下面這條鏈路是當前工業(yè)界比較通用的形態(tài)。知識庫文檔 - 加載解析 - 文本分塊 - 向量化與索引構(gòu)建 | 用戶問題 - 查詢短語處理 - 雙路檢索 --------| v RRF 融合排序 | Prompt 拼接 | 大模型生成 | 最終答案拆開看每個環(huán)節(jié)的職責文檔加載把 PDF、Word、Markdown、TXT 等非結(jié)構(gòu)化數(shù)據(jù)轉(zhuǎn)成純文本。這一步容易出問題的是 PDF 排版錯亂、表格被拆散、頁眉頁腳混入正文。文本分塊把長文檔切成適合檢索和模型輸入的片段。分塊太短會丟失上下文太長會引入噪聲還容易超出模型上下文窗口。向量化與索引將文本片段轉(zhuǎn)換成向量。這里有兩種常見路線一種是稠密向量用深度模型把文本映射成固定維度向量另一種是稀疏向量用 BM25、TF-IDF 這類基于詞頻統(tǒng)計的方法。檢索召回用戶提問后從知識庫中找到最相關(guān)的若干片段。融合排序多路檢索結(jié)果合并最常用的方案之一就是 RRF 倒數(shù)排名融合。Prompt 拼接把檢索片段和用戶問題組織成結(jié)構(gòu)化提示詞。大模型生成將完整 Prompt 輸入大模型輸出答案。這個流程看著不算長但每一層都有參數(shù)和工程細節(jié)。下面從環(huán)境準備開始逐步實現(xiàn)。3. 環(huán)境準備與依賴安裝3.1 創(chuàng)建虛擬環(huán)境建議使用獨立虛擬環(huán)境避免項目依賴污染系統(tǒng) Python。python -m venv rag-demo # Windows rag-demo\Scripts\activate # Linux / macOS source rag-demo/bin/activate3.2 安裝 Python 依賴核心依賴包含分詞的 jieba、BM25 實現(xiàn)的 rank_bm25、向量化編碼的 sentence-transformers、數(shù)值計算的 numpy以及用于 API 調(diào)用的 requests。pip install jieba rank_bm25 sentence-transformers numpy requests如果還要做 API 服務(wù)再安裝 FastAPI 相關(guān)依賴。pip install fastapi uvicorn pydantic如果涉及 PDF 解析額外安裝 pypdf。pip install pypdf需要特別注意的是 sentence-transformers 會連帶安裝 PyTorch。如果你的機器沒有配置好 CUDA建議先安裝 CPU 版 PyTorch避免自動下載一個很大的 CUDA 依賴。pip install torch --index-url https://download.pytorch.org/whl/cpu pip install sentence-transformers有獨立顯卡且已裝好 CUDA 的機器這一步可以跳過直接讓 pip 自動匹配 GPU 版本。4. 文檔加載與分塊策略數(shù)據(jù)清洗和分塊是 RAG 系統(tǒng)里最容易被低估的一環(huán)。很多人把時間花在調(diào)大模型接口上結(jié)果檢索召回一堆廢話問題往往就出在文檔沒有處理好。4.1 文本文件加載先用一個簡單的文本讀取函數(shù)做通用模板。真實項目中路徑和編碼需要按實際情況調(diào)整。def load_txt(path: str) - str: with open(path, r, encodingutf-8) as f: return f.read()4.2 PDF 文件加載PDF 解析比純文本麻煩一些常見坑是掃描版 PDF 需要 OCR、表格被錯誤換行、多欄排版讀取順序錯亂。先用 pypdf 做最基礎(chǔ)的解析。from pypdf import PdfReader def load_pdf(path: str) - str: reader PdfReader(path) pages [] for page in reader.pages: text page.extract_text() if text: pages.append(text) return \n.join(pages)如果你的知識庫主要是掃描件這個方案不夠需要接 OCR 服務(wù)如果以 Word、HTML 為主可以使用 python-docx、BeautifulSoup 等工具。替換讀取函數(shù)即可。4.3 中文文本分塊分塊策略直接影響檢索效果。常見方案有三種。第一固定窗口分塊簡單粗暴但容易切斷語義第二遞歸分隔符分塊按標題、段落、句子的優(yōu)先級逐級切分LangChain 的 RecursiveCharacterTextSplitter 就是這個思路第三語義分塊用嵌入模型判斷句子邊界效果更好但代價更高。給出一個適合中文的輕量分塊方案先按句號、問號、感嘆號、分號切句再按最大長度合并同時保留重疊片段減少切分帶來的信息丟失。import re def split_into_sentences(text: str): parts re.split(r(?[。]), text) return [p.strip() for p in parts if p.strip()] def split_into_chunks(text: str, chunk_size: int 300, overlap: int 50): sentences split_into_sentences(text) chunks [] buffer for sent in sentences: if len(buffer) len(sent) chunk_size: if buffer: chunks.append(buffer) if overlap 0: buffer buffer[-overlap:] sent else: buffer sent else: buffer sent if buffer: chunks.append(buffer) return chunks這里 chunk_size 和 overlap 都不是固定值。一般先用 200 到 500 字做實驗看檢索召回效果再調(diào)整。如果文檔有明確的標題結(jié)構(gòu)優(yōu)先按標題層級切分再對每個章節(jié)做句子級切分效果通常會更好。5. 雙路檢索BM25 稀疏檢索 稠密向量檢索RAG 的檢索階段通常不會只依賴一條路。純稠密向量檢索在語義理解上很擅長但在精確匹配人名、編號、設(shè)備型號、特殊參數(shù)時表現(xiàn)不如關(guān)鍵詞檢索純 BM25 又沒有辦法處理同義改寫情況。所以更穩(wěn)的方案是雙路檢索最后用融合算法合并結(jié)果。先準備一小批演示文檔模擬一個知識庫。documents [ RAGRetrieval-Augmented Generation檢索增強生成通過在生成前檢索外部知識庫把相關(guān)內(nèi)容作為上下文補充給大模型。, 稀疏向量檢索的代表算法是 BM25。BM25 基于詞頻和逆文檔頻率對文檔打分適合精確關(guān)鍵詞匹配。, 稠密向量檢索使用深度神經(jīng)網(wǎng)絡(luò)將文本映射為固定維度向量通過余弦相似度衡量語義相關(guān)性。, RRFReciprocal Rank Fusion倒數(shù)排名融合算法輸入多路檢索結(jié)果輸出按融合分數(shù)排序的最終結(jié)果列表。, 常見 RAG 框架包括 LangChain、LlamaIndex、Dify 等。Dify 提供可視化工作流適合快速搭建知識庫應(yīng)用。, 中文文檔分塊建議優(yōu)先按段落、標題、句子邊界切分避免把一個完整語義單元拆散。, 大模型幻覺問題可以通過 RAG 引入外部事實來緩解前提是檢索結(jié)果要準確、上下文要完整。, 向量數(shù)據(jù)庫常見選型包括 FAISS、Milvus、Chroma、Qdrant。FAISS 輕量適合本地實驗。, 構(gòu)建 RAG 知識庫時文檔質(zhì)量直接影響檢索效果。清洗格式、去除無關(guān)頁眉頁腳是關(guān)鍵步驟。, 評估 RAG 系統(tǒng)通常關(guān)注檢索召回率、上下文相關(guān)性、答案準確率和回答可溯源四個維度。, ]5.1 BM25 稀疏檢索實現(xiàn)BM25 的核心思想是詞在文檔中出現(xiàn)得越多文檔得分越高但這個詞如果在整個文檔集合中出現(xiàn)得越頻繁它的權(quán)重就要下調(diào)。簡單說既看重詞頻又懲罰普遍出現(xiàn)的詞。代碼使用 rank_bm25 庫中文先做 jieba 分詞。import jieba import numpy as np from rank_bm25 import BM25Okapi tokenized_docs [list(jieba.cut(doc)) for doc in documents] bm25 BM25Okapi(tokenized_docs) def sparse_search(query: str, top_k: int 3): query_tokens list(jieba.cut(query)) scores bm25.get_scores(query_tokens) top_indices np.argsort(scores)[::-1][:top_k] return [(int(idx), float(scores[idx])) for idx in top_indices]返回結(jié)果是不定長數(shù)組因為np.argsort(scores)[::-1]在 scores 全部相同時會給出從大到小的索引。top_indices轉(zhuǎn)換成 int 后可以直接用于 documents 列表索引。5.2 稠密向量檢索實現(xiàn)稠密向量這部分選一個對中文友好的小尺寸嵌入模型。BAAI/bge-small-zh-v1.5 是值得先試的模型體積小語義表現(xiàn)穩(wěn)定。首次運行會自動下載模型文件下載時間取決于網(wǎng)絡(luò)條件。from sentence_transformers import SentenceTransformer embedder SentenceTransformer(BAAI/bge-small-zh-v1.5) doc_vecs embedder.encode(documents, normalize_embeddingsTrue) def dense_search(query: str, top_k: int 3): query_vec embedder.encode(query, normalize_embeddingsTrue) sims np.dot(doc_vecs, query_vec) top_indices np.argsort(sims)[::-1][:top_k] return [(int(idx), float(sims[idx])) for idx in top_indices]注意normalize_embeddingsTrue后向量點積就等于余弦相似度后續(xù)不需要再手動計算余弦公式直接 np.dot 即可。5.3 對比兩種檢索效果可以自己跑幾組查詢對比感受。查詢“BM25 和稠密向量有什么區(qū)別”BM25 更容易命中帶“BM25”“稠密向量”字樣的片段稠密向量檢索則可能召回“語義相關(guān)性”“向量化”相關(guān)的片段查詢“如何評估 RAG 系統(tǒng)”BM25 可能因“評估”一詞精準命中而稠密向量也能通過語義關(guān)聯(lián)找到“評估 RAG 系統(tǒng)”相關(guān)片段。兩條路的結(jié)果不完全一樣這正是需要融合的原因。6. RRF 倒數(shù)排名融合算法原理與實現(xiàn)6.1 RRF 核心公式RRF 的全稱是 Reciprocal Rank Fusion倒數(shù)排名融合。它的核心思想不依賴具體分數(shù)而是只依賴排名位置。公式如下。score(d) Σ 1 / ( k rank_i(d) )其中rank_i(d) 表示文檔 d 在第 i 路檢索結(jié)果中的排名從 0 開始計k 是平滑常數(shù)原論文中通常取 60Σ 表示對多路檢索結(jié)果求和。使用排名而不是原始分值好處很明顯不同檢索器輸出的分數(shù)尺度可能完全不同向量相似度可能是 0.6 到 0.9BM25 分數(shù)可能是幾到幾十直接加權(quán)平均很不公平。RRF 把每路結(jié)果統(tǒng)一成“第幾名”用排名參與計算天然規(guī)避了分數(shù)尺度不一致的問題。6.2 RRF 代碼實現(xiàn)實現(xiàn)非常短。def rrf_fusion(ranked_lists, k: int 60): fused {} for ranked in ranked_lists: for rank, doc_id in enumerate(ranked): fused[doc_id] fused.get(doc_id, 0) 1.0 / (k rank 1) return sorted(fused.items(), keylambda x: x[1], reverseTrue)enumerate(ranked)從 0 開始所以分母用k rank 1。如果某個文檔只在其中一路出現(xiàn)它只獲得這一路的分數(shù)兩路都出現(xiàn)且排名靠前的文檔融合分數(shù)會明顯更高。6.3 融合效果驗證用一個例子手動算一遍。假設(shè)某文檔 A 在稀疏檢索中排第 1 名在稠密檢索中排第 3 名k 取 60。A 的融合分數(shù) 1/(601) 1/(603) 0.01639 0.01587 0.03226另一篇文檔 B 在稀疏檢索中排第 2 名在稠密檢索中排第 1 名。B 的融合分數(shù) 1/(602) 1/(601) 0.01613 0.01639 0.03252可以看到 B 的融合分數(shù)略高因為它拿到了一個“第 1 名”和一個“第 2 名”整體排名質(zhì)量稍好。把雙路檢索接在一起def hybrid_search(query: str, top_k: int 3): sparse_top sparse_search(query, top_k5) dense_top dense_search(query, top_k5) sparse_ids [doc_id for doc_id, _ in sparse_top] dense_ids [doc_id for doc_id, _ in dense_top] fused rrf_fusion([sparse_ids, dense_ids]) return fused[:top_k]這里檢索時先各取 5 條再融合取前 3 條。之所以多取一些再截斷是因為融合階段有時會出現(xiàn)“單路排名第 6 但兩路都出現(xiàn)”的文檔綜合排序可能比“單路第 3”更靠前。7. 大模型生成與完整問答流程檢索到相關(guān)片段后下一步就是組裝 Prompt 并調(diào)用大模型。7.1 Prompt 設(shè)計RAG 的 Prompt 設(shè)計核心是三點明確角色、提供資料、限制編造。SYSTEM_PROMPT 你是一個嚴謹?shù)闹R庫問答助手。請根據(jù)提供的資料回答問題。如果資料中沒有相關(guān)信息直接說不知道不要編造。 def build_prompt(question: str, context_chunks) - str: context \n\n.join( [f[片段{i1}] {documents[doc_id]} for i, (doc_id, _) in enumerate(context_chunks)] ) return f資料\n{context}\n\n問題{question}\n\n請基于資料回答context_chunks是hybrid_search返回的結(jié)果每個元素是(doc_id, score)元組。這里把文檔原文拼進 Prompt并保留片段編號方便后續(xù)做答案溯源。7.2 調(diào)用大模型大模型接入方式有很多種。本地推薦用 Ollama它啟動后提供 OpenAI 兼容接口。這里以 Ollama 為例模型名需要按本機實際拉取的模型替換。import requests LLM_BASE_URL http://127.0.0.1:11434/v1 LLM_MODEL qwen2.5:7b-instruct LLM_API_KEY ollama def generate_answer(question: str, context_chunks, temperature: float 0.3) - str: prompt build_prompt(question, context_chunks) resp requests.post( f{LLM_BASE_URL}/chat/completions, headers{Authorization: fBearer {LLM_API_KEY}}, json{ model: LLM_MODEL, messages: [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: prompt}, ], temperature: temperature, max_tokens: 512, }, timeout120, ) resp.raise_for_status() data resp.json() return data[choices][0][message][content]如果本機 Ollama 版本不支持 OpenAI 兼容端點也可以調(diào)用原生/api/chat接口把LLM_BASE_URL換成http://127.0.0.1:11434/api/chat參數(shù)格式略有不同。接云端大模型時只需要改成對應(yīng)的接口地址、密鑰和模型名。7.3 完整問答腳本把前面的臨時環(huán)境變量和函數(shù)整合成一個單文件腳本方便跑通全流程。這個腳本只是演示正式項目里建議把索引構(gòu)建和查詢服務(wù)拆成兩個模塊。import jieba import numpy as np import requests from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer documents [...] # 上一節(jié)定義的演示文檔 # BM25 索引 tokenized_docs [list(jieba.cut(doc)) for doc in documents] bm25 BM25Okapi(tokenized_docs) # 稠密向量索引 embedder SentenceTransformer(BAAI/bge-small-zh-v1.5) doc_vecs embedder.encode(documents, normalize_embeddingsTrue) def sparse_search(query, top_k5): tokens list(jieba.cut(query)) scores bm25.get_scores(t