1. 为什么“上传 PDF 聊天”根本不算 RAG 知识库
我见过太多人把“上传 PDF 然后对着它提问”当成 RAG 知识库的终点。说实话,这连起点都算不上。你上传一份合同、一份技术白皮书、一份产品手册,模型能回答几个问题,你就觉得“成了”——但只要文档一更新,或者你想知道答案到底来自哪一页、哪一段,整个系统立刻露馅。更别提当知识库从 10 份文档膨胀到 1000 份时,检索命中率断崖式下跌,回答开始胡编乱造,你连排查的入口都找不到。
这就是我动手做个人 RAG 知识库版本治理的起点。核心诉求很明确:知识库要像代码仓库一样有版本、有 diff、有回滚;检索要能同时吃语义和关键词;回答必须能指回原文的具体位置。这三个需求分别对应版本治理、混合检索、可引用回答,而父子分块是串起它们的底层结构。整套东西跑在本地,用 Ollama 做推理,不依赖任何外部服务,数据不出机器。
适合谁来参考?如果你已经用 LangChain 或类似框架搭过一个“能跑”的 RAG demo,但被更新、检索质量、答案溯源这三个问题卡住,那这篇就是写给你的。如果你还没搭过,建议先跑通一个最小闭环再回来,因为下面很多设计决策是建立在“你已经踩过基础坑”的前提上的。
我自己的技术栈是 Python + LangChain + Ollama + ChromaDB + BM25(rank_bm25 库),嵌入模型用nomic-embed-text,生成模型用qwen2.5:7b。选这套的理由后面会细说,先给结论:本地、可控、每个环节都能替换。不追求开箱即用的 SaaS 体验,追求的是出问题时我知道该动哪一行。
2. 整体架构设计与核心思路拆解
2.1 从“文档集合”到“版本化知识库”的思维转变
大多数人建 RAG 知识库的默认心智模型是“一个文件夹里放一堆 PDF”。这个模型的问题在于:文档之间没有关系,更新没有记录,删除没有痕迹。你无法回答“这份文档上周改了什么”“为什么同一个问题昨天答对了今天答错了”。
我的做法是把知识库当成一个Git 仓库来管。每份文档是一个被追踪的对象,每次导入生成一个版本快照,快照之间可以对比。具体来说,我设计了三层结构:
- 文档层(Document):一份原始文件,有唯一 ID、标题、来源路径、当前版本号。
- 版本层(Version):每次内容变更生成一个新版本,记录时间戳、内容哈希、变更摘要。
- 块层(Chunk):版本内的实际检索单元,每个块携带
doc_id、version_id、chunk_id、parent_id等元数据。
这样设计的好处是,检索时我可以选择“只搜最新版本”或“搜所有历史版本”,回答时能精确引用到“某文档某版本某块”。版本治理不是锦上添花,它是可引用回答的前提——你连答案来自哪个版本都不知道,引用就是假的。
2.2 父子分块:解决“检索粒度”与“上下文完整性”的矛盾
分块是 RAG 里最容易被低估的环节。块太大,检索精度下降,因为一个块里混了太多主题;块太小,上下文丢失,模型拿到碎片拼不出完整意思。我试过固定 512 token 切分,结果一份技术文档里的“配置参数表”被拦腰截断,检索到了但回答缺一半。
父子分块的核心思路是检索用小块,生成用大块。具体做法:
- 子块(Child Chunk):按语义或固定长度切成 200-300 token 的小块,用于向量化和 BM25 索引,保证检索精度。
- 父块(Parent Chunk):子块所属的更大上下文,通常是 1000-1500 token 的段落或章节,存储在单独的文档存储里。
- 映射关系:每个子块记录
parent_id,检索命中子块后,通过parent_id取出父块内容送给生成模型。
这样检索时匹配的是精细语义单元,生成时拿到的是完整上下文。实测下来,同一个问题用父子分块比纯固定分块的回答完整度提升明显,尤其是涉及多步骤操作或参数说明的场景。
2.3 混合检索:为什么单一向量检索不够用
向量检索擅长语义相似,但对精确匹配很弱。比如你问“max_retries参数默认值是多少”,向量检索可能返回一堆讲“重试机制”的段落,但就是找不到那个写着max_retries=3的配置表。反过来,BM25 擅长关键词精确匹配,但对同义表达无能为力,你问“怎么设置重试次数”它可能匹配不到“retry configuration”。
混合检索就是把两者结果融合。我的做法是:
- 向量检索取 Top 20,BM25 取 Top 20。
- 用Reciprocal Rank Fusion(RRF)融合排序,公式是
score = Σ 1/(k + rank),k 取 60。 - 融合后取 Top 8 送入重排序(可选),最终取 Top 5 给生成模型。
RRF 的好处是不需要调权重,对两路检索的分数尺度不敏感。我试过加权求和,光调权重就花了一下午,效果还不稳定。RRF 直接上,省事且鲁棒。
2.4 可引用回答:让每个答案都能指回原文
可引用回答不是简单地在末尾加个“来源:xxx.pdf”。它要求:
- 答案中的每个关键陈述都能对应到具体的块。
- 引用信息包含文档名、版本、页码或章节、块 ID。
- 如果多个块支撑同一个陈述,全部列出。
实现上,我在生成 prompt 里明确要求模型用[1][2]这样的标记标注引用,然后在后处理阶段把标记替换成实际的块元数据。同时,我会把检索到的块按doc_id + version_id分组,确保引用不会跨版本混淆。
这套设计下来,整个系统的数据流是:文档导入 → 版本快照 → 父子分块 → 双路索引 → 混合检索 → 重排序 → 生成 + 引用标注。每个环节都有明确的输入输出和元数据传递,出问题时能逐段排查。
3. 核心细节解析与实操要点
3.1 版本治理的数据模型设计
版本治理的难点不在技术,在于数据模型设计。我一开始想简单点,每份文档存一个updated_at字段就完事。但很快发现不行:我需要知道“这个块属于哪个版本”,否则检索到旧版本的块,引用就错了。
最终的数据模型是这样的(用 SQLite 存元数据,ChromaDB 存向量):
# 文档表 class Document(BaseModel): doc_id: str # UUID title: str source_path: str current_version: int created_at: datetime updated_at: datetime # 版本表 class Version(BaseModel): version_id: str # doc_id + version_num doc_id: str version_num: int content_hash: str # SHA256 of raw content chunk_count: int created_at: datetime change_summary: str # 自动生成或手动填写 # 块表(存在 ChromaDB metadata 里) { "chunk_id": "xxx", "doc_id": "xxx", "version_id": "xxx", "parent_id": "xxx", "chunk_type": "child", # or "parent" "text": "...", "page": 12, "section": "3.2 配置参数" }关键点:每个块都携带version_id。检索时可以加过滤条件version_id == current_version,确保只搜最新版本。如果想搜历史版本,去掉过滤即可。content_hash用于判断文档是否真的变了——有时候文件时间戳变了但内容没变,没必要生成新版本。
注意:
change_summary我一开始想用 LLM 自动生成,后来发现成本高且不稳定。改成简单规则:对比新旧版本的块集合,输出“新增 X 块,删除 Y 块,修改 Z 块”。够用了。
3.2 父子分块的切分策略与参数选择
分块策略直接决定检索质量。我试过三种方案:
| 方案 | 子块大小 | 父块大小 | 优点 | 缺点 |
|---|---|---|---|---|
| 固定长度 | 256 token | 1024 token | 实现简单 | 语义边界被破坏 |
| 按段落 | 1 段 | 3-5 段 | 语义完整 | 段落长度不均 |
| 语义分块 | 动态 | 动态 | 语义最优 | 计算成本高 |
最终我选了混合策略:先用RecursiveCharacterTextSplitter按\n\n、\n、。递归切分,保证子块在 200-300 token 之间;然后按文档结构(标题层级)聚合父块,父块控制在 1000-1500 token。如果文档没有明显结构,就按固定窗口聚合,窗口大小 1200 token,重叠 200 token。
参数选择依据:
- 子块 200-300 token:太小则语义不完整,太大则检索精度下降。我实测 256 token 是个甜点,嵌入模型
nomic-embed-text的上下文窗口是 8192,256 完全够用。 - 父块 1000-1500 token:生成模型
qwen2.5:7b的上下文窗口是 32k,但实际使用时我限制在 4k 以内,因为太长的上下文会稀释注意力。1200 token 的父块加上问题和其他块,总上下文控制在 3k 左右,效果稳定。 - 重叠 200 token:防止关键信息正好落在切分边界上被截断。
from langchain.text_splitter import RecursiveCharacterTextSplitter child_splitter = RecursiveCharacterTextSplitter( chunk_size=256, chunk_overlap=32, separators=["\n\n", "\n", "。", ";", ",", " ", ""], length_function=len, ) parent_splitter = RecursiveCharacterTextSplitter( chunk_size=1200, chunk_overlap=200, separators=["\n## ", "\n### ", "\n\n", "\n", "。"], )实操心得:中文文档的 separators 一定要加中文标点,否则切分效果很差。我一开始只用了英文标点,结果中文段落被硬切,语义断裂严重。
3.3 混合检索的实现细节与 RRF 融合
混合检索的工程实现有几个坑:
第一,BM25 的索引要单独维护。ChromaDB 只存向量,BM25 需要自己建索引。我用rank_bm25库,每次版本更新时重建对应文档的 BM25 索引。为了支持增量更新,我按doc_id分片存储 BM25 索引,检索时只加载相关分片。
第二,中文分词。BM25 默认按空格分词,中文不行。我用jieba做分词,建索引和查询时都先分词。
import jieba from rank_bm25 import BM25Okapi # 建索引 tokenized_corpus = [list(jieba.cut(chunk["text"])) for chunk in chunks] bm25 = BM25Okapi(tokenized_corpus) # 查询 query_tokens = list(jieba.cut(query)) scores = bm25.get_scores(query_tokens)第三,RRF 融合。两路检索各返回一个有序列表,RRF 按排名融合,不依赖分数绝对值。
def rrf_fusion(vector_results, bm25_results, k=60): scores = {} for rank, item in enumerate(vector_results): scores[item["chunk_id"]] = scores.get(item["chunk_id"], 0) + 1 / (k + rank + 1) for rank, item in enumerate(bm25_results): scores[item["chunk_id"]] = scores.get(item["chunk_id"], 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)注意:RRF 的 k 值我试过 10、30、60、100,60 最稳。k 越小,排名靠前的结果优势越大;k 越大,越平滑。60 是原论文的推荐值,实测确实好用。
3.4 可引用回答的 Prompt 设计与后处理
生成阶段的 prompt 我改了十几版,最终稳定下来的结构是:
你是一个知识库助手。请根据以下检索到的文档片段回答问题。 要求: 1. 每个关键陈述后用 [数字] 标注来源,数字对应片段编号。 2. 如果片段中没有相关信息,直接说“根据现有资料无法回答”。 3. 不要编造片段中没有的内容。 检索片段: [1] {chunk_1_text} [2] {chunk_2_text} ... 问题:{query} 回答:后处理阶段,我用正则提取[数字],然后替换成实际的引用信息:
import re def postprocess_answer(answer, chunks): citations = re.findall(r'\[(\d+)\]', answer) for cite in set(citations): idx = int(cite) - 1 if idx < len(chunks): chunk = chunks[idx] ref = f"[{cite}] {chunk['doc_title']} v{chunk['version_num']} 第{chunk['page']}页" answer = answer.replace(f"[{cite}]", ref) return answer这样最终回答里每个引用都带着文档名、版本号、页码,读者可以直接去原文核对。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
整套系统跑在本地,硬件要求不高:16GB 内存、有 GPU 更好但非必须。我用的是 MacBook Pro M2 16GB,Ollama 跑 7B 模型流畅。
# 安装 Ollama(略,官网有安装包) ollama pull qwen2.5:7b ollama pull nomic-embed-text # Python 依赖 pip install langchain langchain-community chromadb rank_bm25 jieba pypdf sqlalchemy提示:
nomic-embed-text的嵌入维度是 768,ChromaDB 默认用余弦距离,建集合时指定hnsw:space: cosine。
4.2 文档导入与版本快照生成
导入流程我写成了一个 CLI 工具,核心逻辑:
def import_document(file_path, title=None): # 1. 读取内容 raw_text = extract_text(file_path) # PDF 用 pypdf,Markdown 直接读 content_hash = hashlib.sha256(raw_text.encode()).hexdigest() # 2. 检查是否已存在 doc = get_document_by_path(file_path) if doc and doc.content_hash == content_hash: print("内容未变化,跳过") return # 3. 生成新版本 version_num = (doc.current_version + 1) if doc else 1 version_id = f"{doc_id}_v{version_num}" # 4. 父子分块 parent_chunks = parent_splitter.split_text(raw_text) child_chunks = [] for p_idx, parent in enumerate(parent_chunks): parent_id = f"{version_id}_p{p_idx}" children = child_splitter.split_text(parent) for c_idx, child in enumerate(children): child_chunks.append({ "chunk_id": f"{parent_id}_c{c_idx}", "parent_id": parent_id, "text": child, "doc_id": doc_id, "version_id": version_id, }) # 5. 存入 ChromaDB 和 SQLite store_chunks(child_chunks, parent_chunks) save_version_metadata(doc_id, version_num, content_hash)关键点:先算哈希再决定是否生成新版本。我一开始每次导入都生成新版本,结果版本号涨到 50 多,全是重复内容。加上哈希判断后,版本号干净多了。
4.3 检索流程的完整实现
检索入口是一个函数,接收 query 和可选的版本过滤条件:
def retrieve(query, top_k=5, version_filter=None): # 1. 向量检索 query_embedding = ollama.embeddings(model="nomic-embed-text", prompt=query)["embedding"] vector_results = collection.query( query_embeddings=[query_embedding], n_results=20, where={"version_id": version_filter} if version_filter else None, ) # 2. BM25 检索 bm25_results = bm25_search(query, top_k=20, version_filter=version_filter) # 3. RRF 融合 fused = rrf_fusion(vector_results, bm25_results) # 4. 取 Top K,通过 parent_id 取父块 final_chunks = [] for chunk_id, score in fused[:top_k]: child = get_chunk(chunk_id) parent = get_chunk(child["parent_id"]) final_chunks.append({ "text": parent["text"], "doc_title": get_doc_title(child["doc_id"]), "version_num": get_version_num(child["version_id"]), "page": child.get("page", "N/A"), "score": score, }) return final_chunks实操心得:向量检索的
n_results我设 20,BM25 也取 20,融合后取 8 做重排序,最终取 5。这个比例是试出来的——取太少会漏,取太多会引入噪声。8 进 5 出是个平衡点。
4.4 生成与引用标注的完整链路
生成阶段把检索到的父块拼成 prompt,调用 Ollama 生成,然后后处理引用:
def answer(query): chunks = retrieve(query, top_k=5) context = "\n\n".join([ f"[{i+1}] {chunk['text']}" for i, chunk in enumerate(chunks) ]) prompt = f"""你是一个知识库助手。请根据以下检索到的文档片段回答问题。 要求: 1. 每个关键陈述后用 [数字] 标注来源。 2. 如果片段中没有相关信息,直接说“根据现有资料无法回答”。 3. 不要编造片段中没有的内容。 检索片段: {context} 问题:{query} 回答:""" response = ollama.generate(model="qwen2.5:7b", prompt=prompt) raw_answer = response["response"] # 后处理引用 final_answer = postprocess_answer(raw_answer, chunks) return final_answer实测下来,qwen2.5:7b对引用标注的遵循度不错,大概 90% 的情况下会正确标注。偶尔会漏标或标错,后处理阶段可以加一个校验:如果答案里有陈述但没有引用标记,就追加一个提示“部分内容未标注来源,请核实”。
5. 常见问题与排查技巧实录
5.1 检索命中率低的排查思路
检索命中率低是最常见的问题。我的排查顺序是:
- 先看分块:把检索到的块打印出来,看内容是否完整。如果块被切得七零八落,问题在分块策略。
- 再看嵌入:用同一个 query 手动算嵌入,和库里的块算相似度,看 Top 10 里有没有相关块。如果没有,可能是嵌入模型不适合你的领域。
- 最后看融合:分别打印向量检索和 BM25 的结果,看融合后是否把相关块排到了后面。如果是,调 RRF 的 k 值或调整两路检索的取数。
我遇到过一次典型问题:一份技术文档里的参数表,向量检索完全找不到,BM25 能找到但排名靠后。原因是参数表里全是key=value格式,嵌入模型对这种结构化文本的语义捕捉很弱。解决办法是在分块时把参数表单独提取出来,作为独立块存储,并在 metadata 里标记chunk_type: table,检索时对这类块加权。
5.2 版本更新后检索结果混乱的处理
版本更新后,如果旧版本的块没有清理,检索时可能同时返回新旧版本的块,导致回答矛盾。我的处理方式是:
- 每次生成新版本时,把旧版本的块标记为
deprecated: true,而不是直接删除。 - 检索时默认加过滤
deprecated != true。 - 如果需要查历史版本,显式指定
version_id。
这样既保证了检索干净,又保留了历史数据可追溯。
5.3 引用标注不准确的修复方法
引用标注不准确通常有两个原因:一是检索到的块本身不相关,二是模型没有正确遵循 prompt。排查时先看检索结果,如果块不相关,问题在检索;如果块相关但标注错,问题在 prompt 或后处理。
我试过在 prompt 里加 few-shot 示例,效果提升明显。比如加一个“问题:xxx,回答:yyy [1]”的示例,模型对格式的遵循度会高很多。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 检索不到相关块 | 分块太碎/太大 | 打印块内容 | 调整 chunk_size |
| 检索到但不相关 | 嵌入模型不匹配 | 手动算相似度 | 换嵌入模型 |
| 回答缺上下文 | 只取了子块 | 检查 parent_id 映射 | 确保取父块 |
| 引用标注错误 | prompt 不明确 | 检查 prompt | 加 few-shot 示例 |
| 版本混乱 | 旧块未清理 | 检查 metadata | 加 deprecated 标记 |
| BM25 中文效果差 | 未分词 | 检查分词 | 用 jieba 分词 |
避坑技巧:每次修改分块或检索参数后,建一个固定的测试问题集(20-30 个问题),跑一遍看命中率和回答质量。不要凭感觉调参,要有量化对比。
6. 工具选型与本地部署的取舍
6.1 为什么选 Ollama 而不是其他推理方案
本地推理方案我试过 llama.cpp、vLLM、Ollama。最终选 Ollama 的理由很简单:模型管理省心。ollama pull一条命令搞定下载和加载,API 兼容 OpenAI 格式,切换模型只需要改一个字符串。vLLM 性能更好但部署复杂,llama.cpp 更轻但模型格式转换麻烦。对于个人知识库这种低频、非并发的场景,Ollama 的便利性远大于性能差异。
嵌入模型选nomic-embed-text是因为它在中文上的表现比all-minilm好,而且 768 维不算大,检索速度可以接受。如果你有 GPU,可以换bge-m3,效果更好但需要额外部署。
6.2 ChromaDB 与向量库的选型对比
| 向量库 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| ChromaDB | 轻量、Python 原生、易上手 | 大规模性能一般 | 个人知识库 |
| FAISS | 性能好、Facebook 出品 | 需要自己管 metadata | 中等规模 |
| Qdrant | 功能全、支持过滤 | 需要单独部署 | 生产环境 |
| Milvus | 大规模、分布式 | 重、部署复杂 | 企业级 |
个人知识库我推荐 ChromaDB,因为它和 LangChain 集成好,metadata 过滤方便,持久化简单。数据量超过 10 万块再考虑换。
6.3 本地部署的硬件与成本考量
整套系统跑在 16GB 内存的机器上没问题。Ollama 跑 7B 模型大概占 5-6GB 内存,嵌入模型占 1GB 左右,ChromaDB 和 BM25 索引占 1-2GB。如果内存只有 8GB,建议用 3B 模型,或者把嵌入模型换成更小的。
成本方面,本地部署没有 API 费用,但电费和硬件折旧算进去,其实不比用 API 便宜多少。选本地的核心理由是数据隐私和可控性,不是省钱。如果你的文档不敏感,用云端 API 其实更省事。
7. 后续扩展方向与个人经验
这套系统我跑了半年多,最大的体会是:RAG 的质量上限取决于数据治理,不是模型。同样的模型,分块策略改一改,命中率能从 60% 提到 85%。版本治理做得好,排查问题的时间能减少一半以上。
后续我打算扩展的方向有几个:一是加重排序模型,用bge-reranker对融合后的结果再排一次,实测能提升 5-10% 的命中率;二是加查询改写,用 LLM 把用户的口语化问题改写成更适合检索的形式;三是加多模态支持,把图片和表格单独处理,不依赖纯文本嵌入。
最后分享一个小技巧:建一个“黄金测试集”。我维护了 30 个问题和对应的标准答案,每次改完系统跑一遍,看命中率和回答质量的变化。没有这个测试集,调参就是盲人摸象。这个习惯让我避免了很多次“感觉变好了但实际变差了”的翻车。