极简RAG知识库系统实战:从FastAPI封装到zip分发避坑指南
2026/8/26 11:55:54 网站建设 项目流程

简介:RAG(检索增强生成)技术能有效提升知识库问答的准确性与可解释性,其核心原理在于将文档切块、向量化存储,并在回答前检索最相关的片段作为上下文。对于内部资料、离线场景或轻量化应用,一个极简的本地知识库系统往往比引入重型框架更务实。这种方案利用Python生态中的轻量组件即可实现完整闭环,并通过FastAPI快速封装成可调用的服务接口。然而,将系统打包分发给同事时,常见问题如“file is not a zip file”或“could not find eocd”往往源于文件传输损坏或非标准压缩包,而非代码本身。这篇文章从零拆解极简RAG的构建思路,包括依赖选型、文档切块、向量检索与调优记录,并重点总结zip分发时的踩坑经验,帮助开发者在十分钟内搭建并分发一套可用的本地知识库问答系统。 先说个结论:如果你只是想给一堆本地 PDF、Word、Markdown 搭一个问答机器人,真没必要一上来就是 LangChain + Chroma + OpenSearch 全家桶。上个月我给自己的项目笔记做了个“Python 极简 RAG 知识库系统”,压缩成 zip 后发给同事,同事解压、装依赖、跑起来,整个过程没超过十分钟。这个项目没有复杂的框架,核心代码量很小,但检索、问答、知识库更新这几个环节全都有。这篇文章就把这个 zip 里面的思路完整拆开讲:极简 RAG 怎么搭、哪些依赖必须装、FastAPI 怎么包一层接口、最后打包成 zip 分发时踩过的那些坑,特别是“file is not a zip file”这类经典报错。

1. 极简RAG到底“简”在哪:四件套和它们的边界

先看一遍这个项目最终长什么样。解压 zip 之后,目录结构是这样的:

rag_system/ ├── requirements.txt ├── README.md ├── app.py # FastAPI 服务入口 ├── rag/ │ ├── __init__.py │ ├── loader.py # 文档读取 │ ├── chunker.py # 文本切块 │ ├── embedder.py # 向量化与索引 │ ├── searcher.py # 检索 │ └── generator.py # 调用 LLM 生成回答 ├── models/ # 模型文件目录(GGUF 等) └── data/ # 知识库原始文档

我不放任何数据库配置,没有 Redis、没有消息队列,连向量数据库都是用一个本地 Faiss 索引文件替代的。为什么敢这么干?因为 RAG 的本质其实就四步:加载文档、把文本切块、把每一块转成向量存起来、用户提问时检索相关块喂给大模型。知道了这一步,你就明白哪些组件是省不掉的,哪些可以砍。

  • 文档加载:pypdf 读 PDF,内置的 open() 读 txt 和 markdown,够用。
  • 文本切块:自己写一个 splitter,几十行代码,不需要 LangChain 的 TextSplitter。
  • 向量化:sentence-transformers 加载一个中文 embedding 模型。
  • 向量存储与检索:faiss-cpu,保存在内存里就行,不需要单独部署服务。
  • 大模型生成:llama.cpp 的 Python 绑定加载 Qwen2-7B 的 GGUF 模型,纯本地推理。

有人可能会问:为什么不直接用 ChatGPT API?因为知识库属于内部资料,而且很多场景要求本地离线运行。用本地模型是“极简”的另一个含义:不依赖外网、不额外花钱、数据不出内网。这个系统能在只有 CPU 的机器上跑,虽然慢一点,但能完整跑通链路。

这套东西看起来很“简陋”,但它恰恰覆盖了一个知识库问答系统的最小闭环。后续你想加权限、加增量更新、加多用户隔离,都是在这个闭环上做扩展。先把最小闭环跑通,比一开始就设计十来个微服务要靠谱得多。

2. 依赖选型:不用 LangChain 也能跑,但 embedding 和 LLM 不能省

我在一开始写的时候其实纠结过要不要用 LangChain。最后放弃了。不是 LangChain 不好,而是它对你理解 RAG 原理没什么帮助,反而把链路包了一层又一层。出了问题,你要在抽象层之间跳来跳去,排查起来很痛苦。极简项目的原则是:每个组件都应该是你能看懂且能单独替换的

依赖我用的是这几样:

组件选型备注
文档解析pypdf纯 Python,适合 PDF;txt/md 直接读
文本切块自写 chunker避免引入重依赖
Embeddingsentence-transformers + BAAI/bge-small-zh-v1.5中文效果不错,维度 512
向量索引faiss-cpuIndexFlatIP,内存索引,简单直接
LLMllama-cpp-python + Qwen2-7B-Instruct GGUF本地推理,CPU/GPU 均可
API 层FastAPI + uvicorn轻量,带 Swagger 文档

先说 embedding 模型。中文场景我直接选了 BGE 系列中的 small 版本,512 维。相比更大的 bge-large,它的好处是内存占用低、速度快,而且对 CPU 机器友好。如果知识库文档超过十万块,再考虑用更大模型提升精度。实际测试下来,对中文技术文档,BGE-small 的 top-5 召回率已经很能打了。

再说 LLM 部分。llama-cpp-python 有一个特点:安装方式会因为硬件条件不同而不同。比如 CPU 版直接pip install llama-cpp-python就行;如果有 Nvidia 显卡,建议先用CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python装带 CUDA 的版本。我当时图省事装了 CPU 版,Qwen2-7B 用 Q4_K_M 量化,16G 内存的机器也能跑,一个 200 字的问题大概要等十几秒。如果换成 1.5B 或 3B 的模型,延迟能降到 2 秒以内,极简场景下其实更合适。

还有个容易被忽略的坑:requirements.txt里最好把版本锁死。因为llama-cpp-pythonsentence-transformersfaiss-cpu这几个库更新很快,不锁版本可能出现“今天能装,下周装不上”的情况。我的 requirements 大致是:

fastapi==0.109.0 uvicorn==0.23.2 pypdf==3.17.4 sentence-transformers==2.2.2 faiss-cpu==1.7.4 llama-cpp-python==0.2.26 numpy==1.24.4

注意numpy版本不要太高,因为 faiss-cpu 1.7.x 对 numpy 2.x 的兼容性有点问题。这个不是我瞎说,我第一次在全新环境安装时就因为 numpy 自动升到 2.0 导致 faiss 导入报编译错误。

3. 主流程代码:从 PDF 切块到向量检索再到 Qwen 回答

这块是项目的核心,也是我建议你照着敲一遍的部分。我把它拆成几个小模块来写。

3.1 文档加载:不要只看扩展名

loader.py 里面我处理了 pdf、txt、md 三种格式。为什么不处理 docx?因为极简项目里,我一般会建议把 Word 转成 PDF 或 txt 再导入。加了 python-docx 又多一个依赖,收益不大。代码很简单:

from pypdf import PdfReader from pathlib import Path def load_document(path): path = Path(path) suffix = path.suffix.lower() if suffix == ".pdf": reader = PdfReader(str(path)) text = "\n".join(page.extract_text() for page in reader.pages) elif suffix in (".txt", ".md"): text = path.read_text(encoding="utf-8") else: raise ValueError(f"不支持的文档格式: {suffix}") return text

这里请留个心:extract_text()对扫描版 PDF 几乎是废的,因为里面是图片不是文字。如果你要处理扫描件,得先用 OCR,但这会破坏“极简”的定位,所以我没加。

3.2 切块策略:最简单的重叠窗口

切块是整个 RAG 链路里最容易被低估的环节。切太碎,语义不完整;切太大,检索出来上下文太长,喂给大模型又浪费 token。我用的是固定长度加重叠窗口:

def chunk_text(text, chunk_size=500, overlap=50): if len(text) <= chunk_size: return [text] chunks = [] start = 0 while start < len(text): end = start + chunk_size chunks.append(text[start:end]) if end >= len(text): break start = max(end - overlap, start + 1) return chunks

这段逻辑很直白:每次前进chunk_size - overlap个字符,保证两个相邻切块中间有 50 个字符是重叠的。为什么需要重叠?因为如果某个知识点刚好被拦腰切断,重叠窗口能大概率让这个知识点出现在至少一个完整切块里。我在自己的技术笔记上试过,chunk_size 在 300-500 之间,overlap 在 50-100 之间,效果比较稳定。

3.3 向量化与 Faiss 索引

embedder 模块里我保存了一个全局的 Faiss 索引和对应的原文列表。注意 Faiss 存的是向量,不存原文,所以必须有一个chunk_store列表把向量的顺序和切块原文对应起来。

import faiss import numpy as np from sentence_transformers import SentenceTransformer _model = SentenceTransformer("BAAI/bge-small-zh-v1.5") _dim = 512 _index = faiss.IndexFlatIP(_dim) _chunk_store = [] def add_document(text): chunks = chunk_text(text) vectors = _model.encode(chunks, normalize_embeddings=True) _index.add(np.array(vectors, dtype=np.float32)) _chunk_store.extend(chunks) return len(chunks) def save_index(path): faiss.write_index(_index, path) def load_index(path): global _index if Path(path).exists(): _index = faiss.read_index(path)

这里有几个细节需要注意。第一,normalize_embeddings=True很重要,它把向量归一化到单位长度,配合IndexFlatIP内积索引,算出来的分数其实就是余弦相似度,值域大概在 -1 到 1 之间。第二,如果重启服务想保留之前的索引,可以save_index到本地,启动时再load_index。这也是极简方案里“持久化”的讨巧写法。

3.4 检索:不要只看相似度排序

searcher 模块负责把 query 转成向量,然后在 Faiss 里搜 top-k 并返回原文片段:

def search(query, top_k=5): vec = _model.encode([query], normalize_embeddings=True) scores, indices = _index.search(np.array(vec, dtype=np.float32), top_k) results = [] for score, idx in zip(scores[0], indices[0]): if idx < 0 or idx >= len(_chunk_store): continue results.append((_chunk_store[idx], float(score))) return results

索引里没有足够数据时,Faiss 可能返回 -1,所以要做一次范围判断。另外我会在实际使用中过滤掉分数很低的结果:比如相似度低于 0.45 的切块,基本可以认为和问题无关。宁可回答“知识库中找不到相关内容”,也不要硬凑一段垃圾上下文给大模型。

3.5 生成:把检索结果塞进 Prompt

generator 里加载 Qwen2-7B 的 GGUF 文件,然后构造一个简单的中文提示词模板。这里不需要任何复杂框架,就是把检索到的文本拼接起来:

from llama_cpp import Llama _llm = Llama( model_path="models/qwen2-7b-instruct-q4_k_m.gguf", n_ctx=4096, n_threads=8, verbose=False, ) def generate(query, context_text): system_prompt = "你是一个严谨的知识库问答助手。请仅根据提供的资料内容回答问题,如果资料中找不到答案,请明确说明。" prompt = f"{system_prompt}\n\n参考资料:\n{context_text}\n\n问题:{query}\n回答:" output = _llm(prompt, max_tokens=512, temperature=0.2, stop=["<|im_end|>"]) return output["choices"][0]["text"]

Qwen2 的 chat 格式一般带<|im_start|><|im_end|>,如果你用的是官方 instruct 版本,建议用它的 chat template,而不要像我上面这样直接拼接。上面这段代码只是一个简化的可运行版本,实际发布时我按 Qwen 的模板封装了一层format_chat,在完整源码里能看到。

temperature=0.2是我在问答场景下的经验值。低于 0.2 会显得机械,高于 0.5 容易跑题。知识库问答要的是稳定,不是创造性。

4. FastAPI 外壳:几分钟把知识库变成可调用的问答服务

光有推理逻辑还不够,要让同事快速用起来,我得给这个系统加一个 HTTP 接口。FastAPI 在这里很合适,它自带 Swagger 文档,浏览器打开/docs就能测试接口,不需要额外写前端。

4.1 先写两个接口

接口设计得尽量简单:一个用于上传文档,一个用于提问。

from fastapi import FastAPI, UploadFile, File from pydantic import BaseModel import io app = FastAPI(title="极简RAG知识库") class Question(BaseModel): q: str @app.post("/upload") async def upload(file: UploadFile = File(...)): content = await file.read() # 兼容 PDF 和 txt if file.filename.endswith(".pdf"): from pypdf import PdfReader reader = PdfReader(io.BytesIO(content)) text = "\n".join(page.extract_text() for page in reader.pages) else: text = content.decode("utf-8") n = add_document(text) return {"message": f"成功添加 {n} 个切块"} @app.post("/query") async def query(question: Question): results = search(question.q) context_text = "\n\n".join([r[0] for r in results]) answer = generate(question.q, context_text) return {"answer": answer, "evidence": [{"text": r[0], "score": r[1]} for r in results]}

这里我故意把/query的返回里带上 evidence 字段。为什么?因为在知识库问答里,用户需要知道答案是哪些资料支撑的。验证 RAG 效果最直接的方法,就是看返回的 evidence 是不是真的和问题相关。你去调 ChatGPT 只会拿到一个答案,但在这里你还能看到模型是从哪一段文本里找到的。这也是自建知识库系统比直接用通用大模型强的地方。

4.2 前端演示页

为了给同事做演示,我还在static/里放了一个极简单的 HTML 页面,就是两个 textarea 加一个按钮,上传文档和提问都在同一个页面。不要把前端做复杂,这个系统的重点是后端链路。

启动方式很简单,在项目根目录执行:

uvicorn app:app --host 0.0.0.0 --port 8000

然后浏览器访问http://127.0.0.1:8000,就能看到上传和问答的页面。如果你不想在服务器上暴露端口,可以只监听127.0.0.1,这样只有本机能访问。

4.3 内存索引的边界

这个设计有个明显边界:Faiss 索引是存在内存里的,一旦服务重启,没保存过的索引就丢了。所以我在实际使用中给系统加了个“先建索引再启动”的脚本:首次启动时扫描data/目录下所有文档,全部向量化后把索引写到index.faiss,后面再启动就直接加载。整个过程大概是这样:

if Path("index.faiss").exists(): load_index("index.faiss") else: for doc in Path("data").glob("*.pdf"): text = load_document(doc) add_document(text) save_index("index.faiss")

这样做的好处就是:知识库的内容在第一次建立索引后固化下来,之后新增文档就通过/upload接口动态加入,不会把内存撑爆。

5. 分发 zip 才是难点:解压报错、conda 安装和文件损坏排查

我打包成 zip 的原因是:同事机器性能一般,不期望他装 Git、拉仓库、配环境,只想让他解压后直接pip install -r requirements.txt。但正是这个“zip 分发”过程,让我撞见了一连串问题,每一个都值得单独拿出来说。

5.1 打包命令和注意点

在 Linux 或 macOS 下,我一般这样打包:

zip -r rag_system.zip rag_system -x "rag_system/models/*.gguf" -x "rag_system/__pycache__/*"

为什么排除 GGUF 模型文件?因为模型文件动辄 4-5 GB,走邮件或聊天软件根本发不出去。我会把模型单独放到网盘或内网共享目录,给同事一个下载链接。所以 zip 里只包含代码、requirements.txt和 README,模型让使用者自己放到models/目录。如果是在 Windows 上,用 PowerShell 的Compress-Archive -Path rag_system -DestinationPath rag_system.zip也能生成 zip,但压缩出来的文件结构会多一层目录,README 里要写清楚解压路径。

5.2 经典报错:file is not a zip file

这是同事们遇到最多的一个问题,分为两种形态。第一种是在解压工具里直接弹“file is not a zip file”;第二种是在 Java/Python 等代码里看到类似的 “invalid zip archive: could not find eocd”。

我排查完后发现原因几乎都相同:这个文件根本不是标准的 zip 文件。最常见的场景是,我用网盘分享链接,同事在浏览器里点击下载,结果网络不稳定,文件下载到一半中断,或者下载出来的是一个 HTML 错误提示页,但文件名后缀仍然是.zip。zip 格式的文件头固定是PK(十六进制50 4B),如果文件头不是PK,那它大概率不是 zip。

Linux 下直接看:

file rag_system.zip

如果输出是HTML document,那就说明你下载到了一个网页而不是压缩包。Windows 下可以看文件大小:一个代码项目的 zip 通常至少几十 KB,如果下载下来只有几 KB,那基本就是错误页面。

Python 也可以做一个快速检查:

import zipfile def check_zip(path): try: with zipfile.ZipFile(path) as zf: print(f"zip 有效,包含 {len(zf.namelist())} 个文件") except zipfile.BadZipFile as e: print(f"Bad zip: {e}")

could not find eocd里的 EOCD 是 zip 文件末尾的中央目录结束标记。一个完整的 zip 在最末尾必须有这段记录,如果下载不完整,或者有人强行给一个损坏文件改了后缀,就会出现这个报错。处理方式很简单:重新下载,换个下载工具或用浏览器自带的下载功能,尽量不要用不稳定的下载器。另外我建议我这边再发一次 zip 的 MD5 校验值,同事下载完可以自己校验,避免文件传输过程中被拦截或截断。

5.3 在 conda base 环境里安装失败怎么办

“github 下载的 zip 如何安装在 conda base 环境中”这类问题也常有人问。我的态度是:不要直接装进 conda base。conda base 是你 Python 环境的本底,里面可能有各种项目依赖,直接pip install -r requirements.txt很容易把 base 搞得一团糟。正确做法是给这个 RAG 项目单独建一个环境:

conda create -n rag python=3.10 conda activate rag pip install -r requirements.txt python -c "from rag.embedder import _index; print('index ok')"

如果确实想装进 conda base,倒也不是不行,但你要做好心理准备。因为sentence-transformers依赖的torch版本可能和你 base 里已有的torch冲突,轻则版本被覆盖,重则其他项目跑不了。另外要注意,conda 安装包时如果不指定 pip 的--no-cache-dir,可能因为缓存原因安装某些库后出现“导入失败 caused by invalid zip archive: could not find eocd”这种诡异问题。解决办法是清掉 pip 缓存重新装:

pip install --no-cache-dir -r requirements.txt

5.4 密码保护和解压路径

我不建议在打包 zip 时加密码。因为 zip 的加密本质上只是对文件名和内容做简单 AES/ZipCrypto 加密,密码一复杂接收方容易忘,密码一简单等于没加密,而且很多 Linux 默认解压工具并不支持带密码的 zip。如果项目里有敏感数据,我建议把敏感数据单独拿出去,不要和代码一起打包。如果别人给你发了一个带密码的 zip,最靠谱的办法还是找分发人要密码,或者用支持 AES 解密的软件如 7-Zip 来解。

6. 你以为跑通就结束了吗:切块、检索和模型参数的调优记录

第一次跑通的时候,我认为只要“能回答问题”就万事大吉了,实际一测才发现,回答质量离“能用”还有不少距离。这个章节记录了我调优的一些经验,按重要性排序。

6.1 切块长度和重叠比例怎么定

我一开始用chunk_size=1000, overlap=100,测试结果很一般。问题出在:1000 个中文字符对很多技术文档来说太长了,一个问题背景往往只涉及其中一两百字,检索时相似度会被无关字符稀释。后来我改成chunk_size=400, overlap=80,召回内容明显更聚焦。

另一个细节是,不要对所有文本用同一个切块函数。如果你的文档有明确的一二三级标题,优先按标题切分,把每一个标题下的内容作为一个候选块,再对特别长的段落做二次切分。这比纯字符窗口更“懂”文档结构。我的 chunker 里加了一个笨办法:如果文本中出现连续两个换行,就在那个位置尝试断开,优先保证每个切块能落在自然段边界上。

6.2 检索 top-k 和分数阈值

top_k 选多少?我试过 3、5、8。选 3 时上下文太短,模型经常答不全;选 8 时无关内容太多,模型容易被带偏。最后停在 5。另外一个更好用的是“截断策略”:先取 top-10,然后只看相似度在最高分 0.85 以上的那些,这样做比固定 top_k 更鲁棒。在我写的 searcher 里,实际逻辑和你看到的简化版有一点点不同:

def search(query, top_k=10, min_ratio=0.85): vec = _model.encode([query], normalize_embeddings=True) scores, indices = _index.search(np.array(vec, dtype=np.float32), top_k) results = [] valid_scores = [] for score, idx in zip(scores[0], indices[0]): if idx < 0: continue results.append((_chunk_store[idx], float(score))) valid_scores.append(float(score)) if not valid_scores: return [] max_score = max(valid_scores) results = [r for r in results if r[1] >= max_score * min_ratio] return results

这个min_ratio=0.85是我调出来的经验值,并不绝对。如果知识库内容覆盖比较密,可以调到 0.9;如果文档量少、问题跨度大,0.75 更合适。

6.3 模型量化和线程数

Qwen2-7B 的 GGUF 量化版本很多,我实测过几个:Q8_0 回答质量最好,但内存占用逼近 8GB,CPU 机器慢得让人焦虑;Q4_K_M 是质量和性能的折中;Q2_K 不太推荐,中文理解能力下降太明显。如果你只想验证流程,可以先用 1.5B 的模型,几分钟就能跑通整个链路,之后再决定要不要换 7B。

还有n_threads并不是越大越好。在我的 8 核 CPU 上,n_threads=4反而比n_threads=8更快,因为内存带宽会成为瓶颈。这个参数最好在你自己机器上对比几次再定。

6.4 embedding 模型的知识库适配

用 BGE-small 的时候要注意它的输入长度限制是 512 token。如果你的 chunk_size 是 400 个字符,那没问题;如果调到 800 个中文字符,embedding 模型会自动截断,被截掉的语义就丢了。这也是我最后选择chunk_size=400的原因之一,必须让切块长度和 embedding 模型的 max_seq_length 匹配。如果你想增大单块信息量,可以考虑换用 bge-base 或 bge-large,它们的最大长度可能更高,但依赖库的安装和显存需求也会跟着上去。

6.5 一个反直觉的问题:检索不到就硬答

很多人以为 RAG 的难点在生成,其实大部分失败案例死在检索。我遇到一次很离谱的情况:问“项目什么时候上线”,模型答了一个看似合理的日期,但我翻 evidence 发现根本没有相关文档。原因是 LLM 自己“脑补”了答案。后来我在 prompt 里加了严格约束“如果资料中没有明确信息,请回答‘知识库中未找到相关信息’”,并且把temperature降到 0.2,这种情况才消失。所以,prompt 不是随便写的,它直接决定了 RAG 系统的鲁棒性。

7. 写在最后:这个 zip 留给我的几个经验

打包这个“极简 RAG 知识库系统” zip 的过程,比写 RAG 核心代码本身更让我有收获。第一点体会是,把项目发给别人用之后,我才真正理解“可分发性”是什么意思:代码能跑只是第一步,依赖锁版本、模型单独分发、README 里写清目录结构,这些才是别人愿意用你项目的关键。第二点体会是,遇到file is not a zip filecould not find eocd这类报错时,不要先怀疑解压工具,先检查文件本身是不是完整的 zip,用file命令或者 Python 检查一下头部字节,五分钟就能定位问题。

我在这个项目后续的版本里还做了一些顺手的扩展:支持批量上传多个 PDF、给每个文档设定独立的命名空间、用增量索引的方式避免全量重建。如果你想把 Zip 里的这个极简系统再往外推一步,我建议先加一个“原文溯源”功能,就是让回答返回时带上对应的文档名和页码。这是最直接能提升知识库系统可信度的功能,比盲目上各种 RAG 框架都更有用。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询