
簡介這是一套面向Python初學者與課程設計者的極簡RAG知識庫系統實現適用于期末大作業、畢業設計或人工智能方向課程實踐旨在幫助學習者快速掌握檢索增強生成RAG的核心流程與工程落地方法。資源包共48個文件含31個Python源碼覆蓋文檔加載、文本切分、嵌入生成、重排序、Elasticsearch集成及Web服務等核心模塊、3個YAML配置文件定義系統參數與服務配置、1個Dockerfile及配套容器化腳本支持一鍵部署另有README.md、Makefile、pyproject.toml等工程化配置文件完整體現現代Python項目開發規范。壓縮包僅152KB輕量易讀結構清晰——app/、service/、domain/、utils/等目錄劃分明確便于理解RAG各環節職責解耦。目前已有69人學習下載讀者可直接運行調試、修改配置適配自有數據源并基于現有框架拓展向量數據庫或大模型接口是入門RAG工程實踐的高性價比參考方案。 最近把手上一個RAG項目徹底清了一遍剪掉所有花哨的依賴和配置最后沉淀出一個Python極簡RAG知識庫系統。這個zip包里的代碼量不大但核心流程一條沒少文檔加載、文本切塊、向量化、檢索、拼接上下文、生成回答全鏈路跑通大概只要幾百行Python代碼。今天這篇就把這套系統的設計思路、關鍵代碼和踩坑記錄完整拆出來想自己做本地知識庫問答的同學可以參考著抄作業尤其是剛接觸RAG、不想一上來就上LangChain或LlamaIndex這種重框架的人這個項目應該能幫你把核心概念落到能跑的代碼上。先說我為什么要做這樣一個極簡版本。RAG這個詞最近被包裝得有點嚇人動輒就是知識圖譜、Agent、多路召回、重排序新人一看就勸退。但本質上RAG可以理解成“給大模型開卷考試”先從一個知識庫里把和問題相關的內容檢索出來再和大模型的問題拼在一起讓模型基于這些內容回答。理解了這層邏輯剩下的就是怎么把每一步做得更精細的問題。下面我從整體設計開始一步步拆開這個項目。1. 項目定位與整體設計思路1.1 RAG到底解決什么問題大模型有一個很要命的缺點就是它學完知識之后知識就固定在那了。你問它今年剛發生的事情它大概率會一本正經地編一個答案這就是所謂的“幻覺”。而知識庫的問題在于企業內部的大量文檔、規范、技術資料是模型訓練時根本沒見過的東西直接問模型等于白問。RAG的解決思路特別直白。你要回答一個問題之前先不去問大模型而是去你自建的知識庫里做一次檢索把和問題最相關的幾段文字撈出來然后把這些文字和問題一起交給大模型讓它照著這些材料念答案。這樣一來大模型不需要“記得”你的知識庫內容只需要“讀懂”你臨時塞給它的材料幻覺問題就大幅緩解了。這個項目和熱詞里反復出現的“rag是什么”“rag基礎原理”正好對上了。它解決的問題就是在沒有企業級基礎設施的情況下如何用最少的代碼搭出一個“能回答問題、能引用原文、能擴展”的最小閉環。1.2 極簡方案的邊界在哪里很多看到“極簡”兩個字的人會問那是不是性能很差、功能殘缺其實不是。極簡指的是技術棧和代碼量上的極簡而不是流程上的缺失。我在設計這個項目時給自己定了幾條硬約束不引入LangChain這類重框架核心邏輯全部用原生Python和輕量庫實現。向量數據庫不用Milvus、Weaviate這類分布式服務用FAISS本地索引文件就夠了。大模型部分不做微調通過OpenAI兼容接口對接本地或云端模型。整體代碼量控制在幾百行讓一個能看懂Python的人花半天時間就能讀完。這個方案的使用場景很明確個人知識庫、團隊內部文檔問答、原型驗證、教學演示。數據量在幾萬到幾十萬字符級別并發量不高一臺開發機就能跑。如果你的數據量到了百萬級文檔、需要多人高并發訪問那確實需要換Milvus、上分布式但那已經不是“極簡”該管的范圍了。為了讓大家更直觀地判斷我列一個對比表維度極簡方案本項目企業級方案向量存儲FAISS本地索引Milvus / Qdrant / ES框架依賴原生PythonLangChain / LlamaIndex文檔量級適合中小規模百萬級及以上部署方式單機腳本微服務 / K8s學習成本低半天能懂高需要理解一堆抽象概念適用場景個人知識庫、原型生產環境、高并發業務1.3 技術選型的邏輯這個項目里有幾個核心組件選擇我單獨說一下理由因為這些取舍直接決定了項目為什么能保持“極簡”向量存儲用FAISS而不是Chroma。Chroma也是一個很好的輕量向量庫而且自帶增刪改查的接口用起來更省事。但我最后還是用了FAISS原因是FAISS更貼近底層你能看到索引是怎么構建的、檢索是怎么算相似度的。對于想學習RAG原理的人來說這種“裸”一點的方式反而更友好。而且FAISS是Meta出品的性能和穩定性都有保障單機跑幾十萬向量完全沒問題。Embedding模型用BGE系列而不是OpenAI的text-embedding-ada-002。中文場景下本地Embedding模型的效果并不比API差而且不依賴網絡、不產生費用、沒有數據隱私問題。我選的是BAAI/bge-small-zh-v1.5維度512體積小普通CPU就能跑效果在中文語義搜索里屬于第一梯隊。生成端用OpenAI兼容接口。這樣設計的好處是無論是調云端模型還是用Ollama、vLLM、Xinference啟動的本地模型統一走一個接口代碼完全不用改。項目里默認演示的是對接本地Ollama服務因為這樣整個鏈路可以完全不依賴外網真正實現“本地知識庫”。2. 核心模塊拆解一個RAG系統最不能省的幾塊2.1 文檔加載與文本切塊策略文檔加載這一步沒什么技術含量但非常容易踩坑。PDF、Word、Markdown、TXT不同格式有不同的解析方式。我這里用pypdf來解析PDF用python-docx解析Word純文本直接按編碼讀。這里特別提醒一句PDF看著簡單實際解析起來是最麻煩的很多PDF的文本層是壞掉的或者排版是分欄的直接提取出來全是亂序。如果你的PDF是掃描件那必須接OCR這不是極簡項目該干的事所以我默認跳過了。真正有技術含量的是切塊Chunking。這一步決定了檢索的效果也決定了最終回答的質量。切塊的核心矛盾在于塊太小語義不完整檢索出來上下文碎片化塊太大向量表示的語義會被稀釋而且超過模型上下文窗口后會被截斷。我默認用的是chunk_size500chunk_overlap50這里的單位是字符不是token。中文場景下一個字符大概對應0.6到1個token500個字符大約就是300到500個token這個長度對大多數模型來說都是安全的。重疊的50個字符用來銜接前后文的語義避免一句話被硬生生切斷后后半句丟失了前半句的主語。不過這個策略只是兜底。我在項目里留了一個優化點切塊時優先在句號、換行符、問號這些自然邊界處切斷。具體做法是先暴力切成500字符的塊然后檢查這個塊的結尾是不是在句子中間如果是就往前退到最近的句號處。這樣切出來的塊語義完整性會好很多檢索的精度也會明顯提升。2.2 向量化與向量存儲文本切好之后下一步就是把每塊文本變成一串浮點數也就是Embedding向量。這里有個概念需要澄清很多人問“Embedding模型和普通NLP模型有什么區別”其實簡單說Embedding模型的任務是“把意思相近的文本映射到空間中相近的位置”所以它輸出的向量天然適合做相似度計算。我在項目里用sentence-transformers庫加載BGE模型一次把全部分塊編碼成向量。這里有個小細節編碼的時候一定要設置normalize_embeddingsTrue也就是對向量做L2歸一化。原因后面講檢索的時候再說。向量存儲我直接用了FAISS的IndexFlatIP這是最基礎的內積索引。構建方式很簡單把歸一化后的向量矩陣傳給index.add()就行然后把索引文件保存到本地。這里要注意FAISS的索引文件和分塊文本元數據是分開存的索引文件里只有向量數據沒有原文。所以我在旁邊還存了一個chunks.json里面按順序放著每一塊文本的原文內容這樣檢索出向量ID之后可以去JSON里把對應的文本找出來。2.3 檢索與相似度計算檢索的原理其實就是K近鄰搜索。用戶輸入一個問題先把問題用同一個Embedding模型轉成向量然后在向量索引里找出和這個向量最相似的K個向量返回對應的文本塊。這里解釋一下為什么編碼時要normalize_embeddingsTrue。因為FAISS的IndexFlatIP計算的是內積內積的大小受向量長度影響。如果兩個向量的模長差很多即使方向完全一致內積也會被模長帶偏。歸一化之后所有向量模長都是1內積就等于余弦相似度檢索結果就純粹由“方向一致性”決定也就是真正的語義相似度。默認top_k5也就是召回5個文本塊。這個數量不是拍腦袋定的。太少了可能漏掉關鍵信息太多了拼接起來的上下文會塞滿無關內容反而干擾模型的判斷。5個塊通常覆蓋500到2500個字符足夠回答大多數事實性問題。如果檢索結果的相似度普遍低于0.3基本可以斷定檢索失敗了。可能出現的原因是Embedding模型和知識庫內容領域不匹配、切塊大小不合理、查詢詞和文檔用詞差異太大。這些在后面的問題排查章節里細說。2.4 生成Prompt拼接與引用溯源檢索到相關內容之后最后一步就是拼Prompt交給大模型生成回答。這個環節看起來簡單但有一個極其重要的設計原則我在項目里反復強調一定要告訴模型“資料里沒有就回答不知道”。否則模型還是會仗著“自己懂很多”開始自由發揮RAG的防幻覺優勢就全沒了。項目里的Prompt模板是這樣的基于以下資料回答問題。如果資料里沒有相關信息請明確說不知道。 資料 {context} 問題{query} 回答這個模板里沒有花哨的系統和角色設定原因很簡單極簡項目不需要。真正的高手不會指望靠Prompt魔法讓模型變聰明而是靠檢索質量讓模型“不得不”從有限的上下文里找答案。關于引用溯源熱詞里提到的“rag的引用溯源與groundedness”是個進階話題。要讓回答有據可查一個很樸素的做法是檢索時把每個文本塊編號在資料里用[1]、[2]這樣的標簽標出來然后在Prompt里要求模型在引用到某個資料的信息時在句子后面標注對應的編號。輸出之后你再根據編號把原文附在回答末尾。這個功能我在極簡版本里沒有做得很復雜但保留了編號的接口后續擴展很方便。3. 實操復現從零跑通這個極簡系統3.1 環境準備先準備一臺有Python 3.9以上版本的機器建議直接用虛擬環境避免污染全局環境。我習慣在項目目錄里創建虛擬環境python -m venv .venv source .venv/bin/activate # Windows下是 .venv\Scripts\activate然后是安裝依賴。這個項目的依賴非常克制就這六個庫pip install faiss-cpu sentence-transformers pypdf python-docx openai這里說明一下幾個容易混淆的點。faiss-cpu是CPU版夠用如果你有NVIDIA顯卡可以裝faiss-gpu但日常使用差距不大因為FAISS檢索本身就是毫秒級的瓶頸主要在Embedding編碼上。openai庫是用來調用OpenAI兼容接口的不只是OpenAI官方服務才能用Ollama和大部分本地推理框架都兼容。python-docx在只處理純文本的場景下可以不用裝但考慮到Word文檔太常見我還是加上了。首先項目目錄結構是一個比較清晰的分離式結構rag_demo/ ├── requirements.txt # 依賴清單 ├── build_kb.py # 構建知識庫加載→切塊→向量化→存索引 ├── query.py # 查詢檢索→拼Prompt→調用LLM→輸出回答 ├── docs/ # 放原始文檔txt/md/pdf/docx └── index/ # 運行時自動生成存放faiss索引和chunks.json3.2 構建知識庫的完整代碼build_kb.py是第一步要運行的腳本它負責把docs/目錄下的所有文檔讀進來、切成小塊、編碼成向量、構建索引。import os import glob import json import numpy as np import faiss from pypdf import PdfReader from docx import Document from sentence_transformers import SentenceTransformer MODEL_NAME BAAI/bge-small-zh-v1.5 CHUNK_SIZE 500 CHUNK_OVERLAP 50 DOC_DIR docs INDEX_DIR index def read_document(path): if path.endswith((.txt, .md)): with open(path, r, encodingutf-8) as f: return f.read() elif path.endswith(.pdf): reader PdfReader(path) return \n.join(page.extract_text() or for page in reader.pages) elif path.endswith(.docx): doc Document(path) return \n.join(p.text for p in doc.paragraphs) return def chunk_text(text, chunk_sizeCHUNK_SIZE, overlapCHUNK_OVERLAP): chunks [] start 0 while start len(text): end min(start chunk_size, len(text)) if end len(text): last_period max(text.rfind(。, start, end), text.rfind(\n, start, end), text.rfind(, start, end), text.rfind(, start, end)) if last_period start chunk_size // 2: end last_period 1 chunks.append(text[start:end].strip()) if end len(text): break start end - overlap return [c for c in chunks if c] def main(): os.makedirs(INDEX_DIR, exist_okTrue) all_chunks [] infos [] for doc_path in glob.glob(os.path.join(DOC_DIR, **/*.*), recursiveTrue): if not os.path.isfile(doc_path): continue print(fprocessing: {doc_path}) text read_document(doc_path) if not text.strip(): print(f warning: empty content, skip {doc_path}) continue chunks chunk_text(text) for idx, chunk in enumerate(chunks): all_chunks.append(chunk) infos.append({ id: len(infos), source: doc_path, chunk_index: idx, text: chunk, }) print(ftotal chunks: {len(all_chunks)}) model SentenceTransformer(MODEL_NAME) embeddings model.encode(all_chunks, normalize_embeddingsTrue, show_progress_barTrue) embeddings np.asarray(embeddings, dtypefloat32) dimension embeddings.shape[1] index faiss.IndexFlatIP(dimension) index.add(embeddings) faiss.write_index(index, os.path.join(INDEX_DIR, kb.index)) with open(os.path.join(INDEX_DIR, chunks.json), w, encodingutf-8) as f: json.dump(infos, f, ensure_asciiFalse, indent2) print(build done.) if __name__ __main__: main()這段代碼里值得注意的地方有幾個。第一glob.glob用了遞歸模式docs/下的子目錄也能掃描到。第二切塊時的if last_period start chunk_size // 2這個條件是為了避免在一個塊的太靠前位置切那樣會導致塊特別短浪費上下文。第三show_progress_barTrue會在編碼時打印進度條文檔多的時候不至于讓你以為程序卡死了。3.3 查詢腳本與LLM對接query.py是第二個腳本負責接收用戶問題、檢索、調用大模型生成回答。import json import sys import numpy as np import faiss from openai import OpenAI from sentence_transformers import SentenceTransformer MODEL_NAME BAAI/bge-small-zh-v1.5 INDEX_DIR index TOP_K 5 LLM_BASE_URL http://localhost:11434/v1 # Ollama 默認地址 LLM_MODEL qwen2.5:7b # 換成你實際拉取的模型名 LLM_API_KEY not-needed # 本地服務一般不校驗 key def load_model(): model SentenceTransformer(MODEL_NAME) return model def load_index(): index faiss.read_index(f{INDEX_DIR}/kb.index) with open(f{INDEX_DIR}/chunks.json, r, encodingutf-8) as f: infos json.load(f) return index, infos def retrieve(model, index, infos, query, top_kTOP_K): q_vec model.encode([query], normalize_embeddingsTrue) q_vec np.asarray(q_vec, dtypefloat32) scores, ids index.search(q_vec, top_k) results [] for score, idx in zip(scores[0], ids[0]): if idx 0 and idx len(infos): results.append({ score: float(score), source: infos[idx][source], text: infos[idx][text], }) return results def build_prompt(query, retrieved): context_parts [] for i, r in enumerate(retrieved, start1): context_parts.append(f[{i}] {r[text]}) context \n\n---\n\n.join(context_parts) prompt f基于以下資料回答問題。如果資料里沒有相關信息請明確說不知道。 資料 {context} 問題{query} 回答 return prompt def main(): if len(sys.argv) 2: print(usage: python query.py \你的問題\) return query sys.argv[1] model load_model() index, infos load_index() retrieved retrieve(model, index, infos, query) print(\n retrieved chunks ) for i, r in enumerate(retrieved, start1): print(f[{i}] score{r[score]:.4f} source{r[source]}) print(r[text][:100].replace(\n, )) print() prompt build_prompt(query, retrieved) client OpenAI(base_urlLLM_BASE_URL, api_keyLLM_API_KEY) resp client.chat.completions.create( modelLLM_MODEL, messages[{role: user, content: prompt}], temperature0.1, ) print( answer ) print(resp.choices[0].message.content) if __name__ __main__: main()這個腳本有幾個設計細節。第一temperature0.1知識庫問答這種場景要的是事實準確性不是創造性所以溫度要低。第二我把檢索到的文本塊在拼接前打上了編號這樣做有兩個好處你可以直觀看到模型回答時到底參考了哪些資料后續加引用溯源也方便。第三retrieve函數里對idx做了邊界判斷防止FAISS返回-1這類空結果導致程序崩潰。3.4 運行驗證先往docs/目錄里放幾份測試文檔。建議第一輪先用你自己的技術方案、簡歷、公司內部制度這類內容做測試因為這些內容的準確性你自己心里有數能直觀判斷回答對不對。然后依次執行python build_kb.py python query.py 什么是RAG正常情況下你會看到腳本先打印檢索到的文本塊和對應的相似度分數再打印模型生成的回答。如果query.py報錯提示連接不上localhost:11434說明本地LLM服務沒啟動。我用的是Ollama先執行ollama pull qwen2.5:7b拉模型再執行ollama serve啟動服務。如果你有別的推理服務直接在LLM_BASE_URL里改地址即可。4. 常見問題與排查技巧實錄4.1 高頻問題速查表我在做RAG項目的過程中被問得最多的問題基本集中在下面幾類我整理成了一張表大家可以直接對照排查問題現象可能原因解決辦法檢索結果和問題完全不相關Embedding模型不適合你的領域換text2vec-base-chinese、bge-large-zh或使用領域微調的Embedding模型相似度分數普遍很低低于0.3切塊太小/太大或查詢詞和文檔表述差異大調整chunk_size在切塊時做句子邊界檢測FAISS報維度不匹配的錯誤構建索引和查詢時用了不同模型保證MODEL_NAME完全一致模型回答“不知道”但資料里明明有切塊把關鍵信息切碎了檢索時沒召回增大chunk_overlap或增加TOP_K模型回答答非所問像在自由發揮檢索到的內容本身不是答案模型被誤導先檢查檢索結果優化切塊和EmbeddingPDF內容提取出來全是亂的PDF是掃描件沒有文本層需要接入OCR比如PaddleOCR、Tesseract文檔一多構建索引很慢CPU編碼太慢或切塊太多換GPU跑Embedding或減少重疊字符回答沒有引用來源像在編Prompt里沒有強制要求基于資料回答在Prompt中加入“資料里沒有請說不知道”4.2 我踩過的幾個典型坑第一個坑是中文切塊時把語義切斷了。早期版本我用了一個特別簡單的按固定字符數切塊的邏輯結果一個完整的句子被從中間劈開比如“公司價值觀是”被切到一個塊的末尾下一塊開頭變成了“客戶第一”。檢索的時候這兩個塊雖然都有“公司價值觀”的表述但語義完整度都不夠模型看半天也拼不出完整答案。后來我加了句子邊界檢測效果立刻好了很多。如果你也自己寫切塊邏輯一定要記住這個教訓。第二個坑是向量歸一化問題。有一版我構建索引時沒有normalize_embeddingsTrue用的是IndexFlatL2查詢時手動算余弦相似度結果分數怎么調都不對。后來我統一改成IndexFlatIP加歸一化邏輯就通順了。這里想強調一個理念做檢索你只需要關心“方向一致性”不需要關心向量本身的長度。歸一化之后內積就是余弦相似度代碼更簡單結果也更好解釋。第三個坑是本地模型的能力上限。我用Qwen2.5-7B做測試發現它有時候會無視Prompt里的“資料里沒有請說不知道”強行編一個答案。后來我把temperature調到0.1同時在Prompt里把這句話換成了更生硬的版本“基于以下資料回答問題如果資料中沒有相關答案請直接回復資料中未找到相關信息。”效果好了很多。這說明Prompt的設計確實能影響模型的“服從度”。第四個坑是增量更新。最開始我想的是每次加一個新文檔就把索引全部重建文檔少的時候沒問題文檔多了之后構建一次要等好久。后來我才意識到這種極簡架構本來就不適合頻繁增量更新干脆改成“批量重建”策略一次性把文檔都丟進docs/然后跑一次build_kb.py。如果非要增量就要用FAISS的IndexIDMap維護文檔級別的ID映射但這會讓項目復雜度上一個臺階就需要權衡取舍了。5. 從“能跑”到“好用”幾個低成本優化方向極簡版跑通之后你會發現它“能用”但距離“好用”還有一段距離。這里我分享幾個性價比特別高的優化方向它們不會破壞項目的極簡性但能明顯提升體驗。先說說重排序Rerank。現在第一步召回5個文本塊但這里面可能有三塊是不相關的因為它只靠向量相似度。向量相似度擅長捕捉語義相關性但對“這個塊是否真的包含關鍵答案”這種精確匹配不敏感。重排的做法是先做一次寬松召回比如召回20個塊然后用一個專門的Rerank模型比如bge-reranker-base對這20個塊重新打分取前5個。這樣能極大壓縮無關內容模型看到的結果更干凈。再說說引用溯源。我在前面的Prompt里已經給文本塊打了編號但模型可能不會自動用編號。要真正實現“回答完能告訴你依據在哪”可以在Prompt里追加一句“當引用到某份資料的內容時在句子末尾標注對應的編號例如[1]。”然后在代碼里把編號映射回原始的文檔路徑和文本塊附加到回答末尾。這算是RAG落地時客戶和領導最喜歡問的東西因為他們要知道答案可不可信。然后是元數據過濾。如果知識庫里同時有產品文檔、技術文檔、管理制度查詢“有哪些產品功能”時可能會把技術文檔里的內容也撈出來。一個簡單的做法是在infos里保存每個塊所屬的文檔分類在檢索時先按分類過濾再算相似度。這在FAISS里可以用IndexIDMap配合元數據過濾來實現比繼續用裸的IndexFlatIP稍微復雜一點但很有必要。最后是Agentic RAG這個方向。熱詞里出現了很多次“agentic rag”在極簡項目的基礎上它并不神秘。比如當第一次檢索結果相似度都低于0.5時就說明知識庫里可能沒有直接答案這時候可以讓模型重新組織一個更寬泛的查詢詞做第二輪檢索再比如當用戶問的是“對比A和B”這種復合問題時可以先拆成兩個子問題分別檢索再把結果合并。這些邏輯本質上就是在RAG的外面包了一圈決策能力但核心的檢索和生成模塊完全不用改。根據我個人的實操經驗極簡版系統最值得投入精力的不是換更貴的模型也不是上更復雜的架構而是先把切塊和檢索調好。這兩個環節做好了哪怕生成端只是一個7B的本地模型效果也遠遠好過“檢索稀爛、硬上大模型”的方案。拿到這個zip包之后我建議你先往里丟幾份自己最熟悉的文檔把檢索結果逐條看一遍熟悉一下什么文本會被什么樣的檢索詞召回來這個手感建立起來之后后續所有的優化你就知道自己該往哪個方向使勁了。本文還有配套的精品資源點擊獲取