说实话,我一开始也以为搞个个人 RAG 知识库,就是把一堆 PDF 扔进去,然后对着聊天窗口问问题就完事了。但真正上手做了一版之后才发现,如果只是追求"能聊",那这玩意儿和搜狗搜索引擎没区别,知识库的价值完全发挥不出来。尤其是当你手里的资料是几百篇论文、技术文档、会议纪要,甚至还有多个版本互相覆盖的时候,单纯"上传 PDF 聊天"根本撑不住——你要的是版本可追溯、检索能命中细节、回答还能带着出处。
这篇文章我想聊的就是我在搭建个人 RAG 知识库时真正花时间打磨的几个核心环节:版本治理、父子分块、混合检索,以及可引用回答。不是泛泛而谈概念,而是把每个环节为什么这么做、怎么落地、踩过哪些坑,全部摊开来讲。
1. 整体设计与思路拆解
1.1 先想清楚:知识库到底在解决什么问题
做 RAG 知识库之前,我反复问自己一个问题:我为什么不直接用 ChatGPT 或者直接在本地开一个 Web UI 聊天?答案其实很直接——我手头这些资料是我多年积累的私有文档,里面有大量具体的技术参数、项目历史决策、数据指标,这些东西模型没见过,网上也搜不到。我需要的不是模型"自由发挥",而是让它基于我指定的资料来回答问题,并且告诉我答案来自哪一篇文档的哪一段。
所以 RAG 的核心价值不是"聊天",而是"检索+生成"的组合。检索决定了模型能看到什么,生成决定了模型怎么说。如果你的检索环节做得稀烂,后面模型再强也白搭。
基于这个思路,我给自己定了几条设计原则:
- 文档要能追溯:任何一条回答都能定位到原始文档和具体段落,不能是"模型编的"。
- 检索要能命中细节:用户问一个很具体的问题,比如"去年某某项目的存储容量方案是什么",只靠全文向量匹配很容易跑偏,必须配合关键词精确匹配。
- 知识库要能演进:文档会更新、会有新版本,旧版本不能悄无声息地消失,得能回溯。
- 答案要能验证:模型生成的回答旁边必须附上引用片段,让用户自己判断可信度。
1.2 技术选型:为什么不用现成的 All-in-One 方案
市面上确实有不少现成的 RAG 方案,比如 Dify、FastGPT、MaxKB 这些,开箱即用,界面也挺漂亮。但我的场景是个人深度使用,不是做一个 Demo 给别人看,所以我有几个更挑剔的需求:
- 我想完全掌控分块逻辑,而不是用系统内置的"固定字数切分"。
- 我需要版本治理,大部分现成方案对"文档更新"的处理就是删除旧的、插入新的,没有版本历史。
- 我要混合检索(向量+关键词),不少方案只做向量检索,或者混合得很粗糙。
- 我要可引用回答,回答里必须带来源标记。
综合考虑下来,我决定自建一套,组件选择如下:
| 组件 | 选型 | 理由 |
|---|---|---|
| 向量库 | Milvus Lite / Chroma | 个人使用量级不大,Chroma 轻量够用,Milvus Lite 兼容性好 |
| 文档解析 | MarkItDown + PyMuPDF | 兼顾 PDF 文本抽取和 Markdown 结构化 |
| Embedding 模型 | BGE-M3 或 text-embedding-v3 | 中文效果稳,支持多语言和长文本 |
| 关键词检索 | BM25 (RankBM25) | 精确匹配能力强,不受向量语义偏移影响 |
| 重排序 | bge-reranker-v2-m3 | 对检索结果做精排,提升 TopK 质量 |
| LLM | 本地化部署或 API 均可 | 看个人对数据隐私的敏感度 |
这套组合的逻辑是:每个环节都选"单项最强"的,然后自己把它们串起来。虽然初期搭建成本高一点,但后面调参、修 bug 都方便。
2. 版本治理:知识库也要有 Git
2.1 没有版本治理,文档更新就是一场灾难
我先说一个真实踩过的坑。最开始我把一份产品需求文档切块后存进向量库,过了一个星期文档更新了,我直接删掉旧内容重新导入。结果用户问"之前的处理办法是什么",系统回答的全是新版本内容,旧方案完全消失了。那一刻我才意识到,知识库的内容管理和代码管理是一样的道理——你不能直接把文件覆盖掉,你得知道每一次改动是什么、什么时候改的、为什么要改。
版本治理不是让你保留所有历史切片浪费存储,而是要在知识库层面建立一个"文档生命周期"的管理模型。
2.2 版本治理的四个核心动作
我最终的实现方案分四步:
第一步,导入文档时计算文件哈希值。我用的是 SHA-256,每次导入前先算哈希,如果和库里的某个版本哈希完全一致,就跳过导入,避免重复劳动。
第二步,文档元数据里带上版本号。我的元数据格式大致是这样:
{ "doc_id": "prd-2024-001", "version": "v2.3", "content_hash": "6b8f0a2c1d...", "created_at": "2024-11-20T10:30:00Z", "modified_at": "2024-11-28T14:22:00Z", "chunk_strategy": "parent_child", "source_file": "product_requirement_v2.3.pdf" }第三步,文档更新时,旧版本不物理删除,而是标记为superseded,新版本以新的version字段写入。查询的时候默认只检索最新版本,但如果你显式指定版本号,就能查历史版本的内容。
第四步,提供"版本回滚"能力。假设 v2.30 导入后发现解析效果极差(比如 PDF 表格被切得乱七八糟),一条命令把 v2.29 恢复为 superseded 状态,新版本下线。
2.3 版本治理带来的检索逻辑改变
有了版本之后,检索逻辑也要跟着调整。我在查询层加了一个"版本过滤"的预处理器,流程是这样的:
- 先解析用户查询,看看里面是否包含版本关键字(如"v2.1""上个版本""历史方案")。
- 如果包含版本关键字,就放宽版本过滤条件,允许检索所有版本的切片。
- 如果不包含,就默认只检索最新版本。
举个例子,用户问"v2.0 的时候那个定价策略是怎么定的",你的向量检索条件里就会带上version == v2.0的过滤,这样就不会被 v2.3 的内容干扰。
注意:版本标记用
superseded而不是直接删除,还有一个隐藏的好处——你可以追踪"知识库演进轨迹",比如对比不同版本之间切片内容的变化,甚至能做语义 diff。这个能力后面做项目复盘的时候特别好用。
3. 父子分块:让检索既有上下文又有精度
3.1 分块的经典矛盾:太大检索不准,太小上下文丢失
在做分块设计时,我遇到了一个很典型的两难问题。如果每块切 1000 token,检索时容易命中一个大块,但块里可能包含多个主题,回答时内容过于宽泛、不够聚焦;如果每块切 200 token,检索精度确实高了,但是模型只能看到很小的一段上下文,经常"答非所问",或者缺失关键背景信息。
"父子分块"就是为了解决这个矛盾诞生的。
3.2 父子分块的结构设计
我的实现思路是:把一个文档先按语义段落划分成多个"父块",每个父块大概覆盖 800-1200 token 的内容;然后把每个父块再细分成多个"子块",每个子块大概 200-300 token。
检索时,我用子块去匹配用户的查询(高精度),但把命中的子块连同它的父块一起返回给模型(高上下文)。这样模型既能精确定位到相关句子,又能看到完整的段落背景。
具体来说,数据结构是这样的:
class RagChunk(BaseModel): chunk_id: str doc_id: str version: str parent_chunk_id: Optional[str] # 如果这是父块,此处为空 child_chunk_ids: List[str] # 如果是父块,记录子块列表 content: str token_count: int embeddings: List[float]分块实现时,我没有用 LangChain 的TextSplitter无脑按字符切,而是做了两步:
第一步,用文档结构识别器(基于版面分析)把 PDF 或 Markdown 分成"自然块"。每个自然块尽量是完整的章节、小节或表格区块。这一步很关键,因为直接按字符切会把表格、代码块、列表切得稀碎。
第二步,对每个自然块判断长度:如果块本身小于 300 token,就让它单独作为一个父块和一个子块;如果块大于 1200 token,我再按句子边界递归切分成多个子块,同时保留父块的完整内容。
3.3 子块索引、父块回传的具体实现
这里给一段简化但可跑的伪代码,展示我的索引逻辑:
def index_document(doc): natural_blocks = split_by_layout(doc) # 基于版面分析 for block in natural_blocks: tokens = tokenize(block) if len(tokens) <= 300: # 小块直接作为父块+子块 parent = create_chunk(block, is_parent=True) child = create_chunk(block, parent_id=parent.id, is_parent=False) store_to_vector_db(child) elif len(tokens) <= 1200: parent = create_chunk(block, is_parent=True) for sentence_group in split_by_sentence(block, max_tokens=300): child = create_chunk(sentence_group, parent_id=parent.id) store_to_vector_db(child) else: # 超长块,先切子块,再合并成父块 sub_blocks = split_by_sentence(block, max_tokens=300) parent = create_chunk(merge_text(sub_blocks), is_parent=True) for sub in sub_blocks: child = create_chunk(sub, parent_id=parent.id) store_to_vector_db(child)检索的时候,我用子块去向量搜索,拿到 TopK 之后,取每个子块的parent_chunk_id,把父块内容加载进来,拼装成大上下文。
这里有一个细节值得注意:子块要存独立的 embedding,父块也要存 embedding,但父块的 embedding 只有在"父块需要独立检索时"才用。我的场景中子块是主要检索入口,父块只是上下文来源,所以父块可以不用 embedding,直接存原文即可。这样可以省不少向量存储空间。
3.4 父子块的参数调优心得
关于分块大小,我实测下来有几个经验值(以中文场景为准):
- 子块 200-300 token 是最舒服的区间。再小容易切碎语义,再大就会让父块失去存在的意义。
- 父块 800-1200 token 比较合适。太大则一个大块包含太多无关内容,检索命中父块后模型容易被带偏;太小则上下文不够完整。
- 重叠区(overlap)我控制在 10%-15%,主要是为了处理边界语义割裂的问题。但是注意,重叠区只在子块切分时加,父块不要加。
我最初用的是固定 512 token 切块,后来换成父子分块后,检索命中率(Hit Rate)从 68% 提升到了 84% 左右,回答引用准确率也明显上升。所以这个设计绝对不是花架子,是真的能改变效果的。
4. 混合检索:向量 + 关键词的组合拳
4.1 为什么单独用向量检索不够
向量检索的本质是"语义相似度匹配"。它理解"如何提升服务器性能"和"服务器卡顿怎么优化"内容相近,却对"端口号 3306"这种精确数字的匹配无感。向量模型在遇到具体代码变量名、函数名、端口号、专有名词时,经常给出相似但不精确的结果。
我举一个很现实的例子:我的知识库里有几百篇技术笔记,里面充斥着nginx.conf、worker_processes、503 Service Unavailable这种关键词。向量检索查"nginx 502"时,返回的内容在语义上确实和"nginx 错误处理"相关,但未必精准命中那个真正写"502 解决方法"的片段。而关键词检索(BM25)在这种场景下几乎是 100% 击中。
所以,混合检索不是"锦上添花",而是"必须"。
4.2 混合检索的实现:BM25 + 向量 + RRF 融合
我的实现方案是:向量检索(使用 embedding 模型召回 Top50)+ BM25 关键词检索(召回 Top50),然后用 RRF(Reciprocal Rank Fusion)算法合并两边的排序结果,最后用 cross-encoder 重排序模型精排 Top10。
RRF 的公式特别简单:
score(d) = sum( 1 / (k + rank_i(d)) )其中rank_i(d)是文档 d 在第 i 路检索中的排名,k 是个平滑常数(我设的是 60)。这个算法的优点是不需要把向量相似度和 BM25 分数做归一化,两种分数体系完全不同也能直接融合。
融合后再用 bge-reranker-v2-m3 做交叉编码重排。重排序这一步非常关键,因为 RRF 只是粗融合,它能保证"两路都命中"的文档排前面,但它没法理解查询和候选文档之间的深层语义关系。cross-encoder 两两计算查询和文档的匹配分,做精排,效果才稳。
我的检索流水线完整流程:
- 查询预处理:提取关键词、识别版本过滤条件、识别是否包含表格/代码查询意图。
- 向量召回:query embedding 在向量库中搜 Top50。
- 关键词召回:query 经分词后走 BM25 搜 Top50。
- RRF 融合:合并两路结果,得初步排序。
- 精排:bge-reranker 对融合结果前 30 条打分。
- 返回 TopN(我常用 Top5)作为上下文。
- 父子块处理:将 Top5 子块映射到父块,父块去重。
- 生成回答:把父块内容发给 LLM。
4.3 混合检索中必须注意的分词问题
中文检索最大的坑是分词。BM25 好不好用,完全取决于分词器的质量。我一开始用 jieba 默认模式,发现"知识库"会被分成"知识"和"库","混合检索"会被分成"混合"和"检索"。这些问题单独看不大,但在 BM25 里会导致文档得分偏低,有时甚至召回不到正确答案。
我的解决办法是自定义分词词典,把领域内的高频专用词提前注入:
import jieba # 自定义知识库领域词典 domain_words = [ "知识库", "父子分块", "混合检索", "版本治理", "可引用回答", "重排序", "向量召回", "RAG", "chunk", "reranker" ] for word in domain_words: jieba.add_word(word, freq=20000)同时,对代码、URL、版本号等 token,我做了正则保护,不让分词器切开它们。这个细节别看小,对检索精度的提升非常明显。
5. 可引用回答:从"生成"到"可验证"
5.1 RAG 不解决幻觉,它只是给幻觉装上安全带
很多人以为 RAG 解决了幻觉问题,我实际用下来的体会是:RAG 只能降低幻觉概率,不能根除。即便你把上下文精准喂给模型,模型在组织语言时仍然可能漏掉细节、错误拼接、过度推断。
解决这个问题靠的是一个朴素机制——每个回答都必须带引用来源。用户看到回答后,能点开引用、对一下原文、自己判断模型说得对不对。这个"能验证"比"生成正确"更重要,因为 LLM 天生不是确定性系统,你无法保证每次都答对,但你可以让错误暴露得明明白白。
5.2 引用来源的三个层级
我的引用系统分三个层级:
- 元数据级:回答下面的引用标注
[1]指向某篇文档的 doc_id 和 version。 - 切片级:点击引用后,高亮显示命中的子块片段,精确到句子。
- 上下文级:同时展示该子块所属的父块全文,让用户看到完整的上下文,判断模型是否断章取义。
实现起来,核心是把"生成回答时用到了哪些 chunk"记录下来。我用的方法是给 LLM 的 prompt 里每个片段加上标记,让模型在引用时输出对应的 chunk_id:
以下是从知识库中检索到的相关片段,请基于这些片段回答问题。 每个片段都有编号,回答时请在引用内容后标注编号。 <chunk id="c-0001" doc_id="prd-2024-001" version="v2.3"> 内容... </chunk> <chunk id="c-0002" doc_id="tech-note-2024-007" version="v1.1"> 内容... </chunk>然后在生成阶段,我做了后处理解析器,从模型输出中提取[c-0001]这种标记,再映射回doc_id和原文片段,最终渲染成带引用的 Markdown。
5.3 引用回答的渲染与交互
前端渲染我采用的是:"回答正文 + 引用角标 + 底部引用列表 + 点击展开原文面板"的结构。
用 Markdown 呈现:
该方案的存储容量规划主要考虑三个因素[1]:数据增长速率、备份保留周期、以及索引存储开销。 ## 引用来源 [1] 《技术架构规划 v2.3》 第3.2节 · 关于存储容量估算的描述需要注意的是,模型在生成回答时可能会引用同一个片段多次,也可能引用不存在的编号。我做了一个清洗逻辑:
- 引用编号必须存在于本次检索返回的 chunk 集合中,否则丢弃。
- 同一个 chunk 的引用合并为一个引用列表项。
- 如果模型回答完全没带引用,提示"该回答可能包含模型推断内容,请谨慎参考"。
这个"无引用警告"的做法是我加了之后觉得特别值的一个设计。它倒逼模型在不确定的时候要么引用原文,要么明说"检索内容中未找到相关信息"。
6. 实操避坑实录:那些文档里不写的细节
6.1 文档解析阶段:PDF 表格是最容易翻车的点
我相信很多人建 RAG 知识库时,第一步就栽在 PDF 解析上。PDF 本来就不是为"内容提取"设计的格式,它记录的是"渲染位置"。表格更是重灾区——通过 PyMuPDF 抽取出来的文本,表格内容经常是错位的,不同的单元格文本混在一起。
我的做法是这样的:能拿到 Markdown 或 Word 源文件的,优先处理源文件;只有 PDF 的情况下,使用版面分析工具(如 PyMuPDF 的get_text("blocks")加自写的表格重建逻辑),把表格内容转成 Markdown 表格格式再入库。
import fitz # PyMuPDF def extract_pdf_blocks(pdf_path): doc = fitz.open(pdf_path) blocks = [] for page in doc: page_dict = page.get_text("dict") for block in page_dict["blocks"]: if block["type"] == 0: # 文本块 lines = [] for line in block["lines"]: text = "".join(span["text"] for span in line["spans"]) lines.append(text) blocks.append("\n".join(lines)) elif block["type"] == 1: # 图片块 # 图片可以存路径,或用多模态模型补充描述 blocks.append(f"[图片块: page {page.number}]") return blocks这段代码只是基础版,但至少能保证文本块按排版顺序输出。真正的表格解析,我强烈建议单独用一套表结构识别逻辑,不要和普通文本混在一起切块。
6.2 向量库选型:Chroma 和 Milvus 之间怎么选
个人知识库的规模,我判断标准很简单:十万级 chunk 以下用 Chroma 足够,超过这个量级再考虑 Milvus。Chroma 的优势是零配置、本地文件持久化、API 简单,适合个人折腾。但 Chroma 的元数据过滤性能一般,如果你像我一样在元数据里塞了大量版本号和文档属性,查询时频繁做where过滤可能会慢。
Milvus Lite 则需要在本地起一个服务,配置稍微复杂一点,但查询性能和元数据索引强不少。我最后是迁移到了 Milvus Lite,因为我的版本治理逻辑严重依赖元数据过滤。如果你不想折腾,也可以考虑用 SQLite + sqlite-vec 这种轻量方案,关键看你的过滤复杂度。
6.3 Rerank 到底值不值得加
我自己的实测对比:不加 rerank,Top5 里有效片段大概 2-3 个;加了 rerank 之后,Top5 里有效片段能到 4-5 个。这个差距在复杂查询下非常明显。而且 rerank 模型(bge-reranker-v2-m3)单次推理延迟大概 50ms 级别,对个人 KB 场景完全可以接受。
所以我的建议是:如果你的 RAG 系统延迟预算在 2 秒以内,把 rerank 加上。这是整个 RAG 管线里性价比最高的一个环节。
6.4 向量维度与存储规划
Embedding 模型输出的向量维度各有不同,BGE-M3 是 1024 维。1024 维的 float 向量在 Milvus 中一个 chunk 大概占 4KB 原始存储,加上索引开销再翻倍。假设你有 5 万个 chunk,向量存储大概 400MB 左右,这还蛮可观的。
我后面做了一个优化:把子块的 embedding 维度降为 256 维(用降维模型),父块不存 embedding。这样存储直接减少 4 倍。要注意,降维后检索精度会有轻微损失,但我实测对 Top10 召回的影响在可接受范围内。如果你想保守一点,先不降维,等库大了再考虑。
6.5 常见问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 检索命中但回答完全不对 | 上下文里混入太多无关父块 | 减小 TopN、加强 rerank 阈值过滤 |
| 关键词检索不到内容 | 分词器切开专有名词 | 自定义词典、正则保护 |
| 版本更新后老数据还能搜到 | 没有版本过滤或用成了 OR 逻辑 | 默认查询加version == latest过滤 |
| PDF 表格内容乱序 | 文本抽取按坐标输出而非阅读顺序 | 用版面分析或转 Markdown |
| 引用标注失效 | 模型编造引用编号 | 后处理过滤 + 无引用警告 |
| 回答太长超出上下文 | 父子块拼接后过大 | 限制父块长度、滑动窗口取父块子集 |
| 第一次查询很慢,后续变快 | 缺少缓存 | 对相同查询做缓存,命中后直接返回 |
6.6 一个是容易被忽略的细节:查询改写
检索和生成之间,其实还有一步很多人都跳过了——查询改写。用户的问题往往是口语化的,比如"这个方案后来改过什么"、"我记得有个地方说阈值设成了多少"。直接拿原始问题去做向量检索,效果通常一般,因为口语问题缺少完整上下文。
我自己加了一个轻量级的查询改写模块:先把用户问题和历史对话拼接,然后让一个小模型生成检索子问题(类似于分解式查询)。比如用户问"对比一下 v2.0 和 v2.3 的存储方案差异",拆解为两个检索子问题:"v2.0 存储方案细节原文","v2.3 存储方案细节原文"。每个子问题分别走混合检索,然后合并结果。
这个步骤看着多了一步,但对回答质量的提升非常明显。
7. 一点个人心得
整个系统从零到一跑通,花了大概两周的业余时间。回头复盘,最难的不是技术实现,而是能不能忍住"先跑起来再说"的冲动。如果你只是想演示,那随便用一个现成的 RAG 框架就行;但如果想让知识库真正成为一个可靠的工作工具,版本治理、父子分块、混合检索、可引用回答这四件事,每一件都省不得。
附:一些可参考的技术栈组合
| 用途 | 推荐选型 | 备选 |
|---|---|---|
| 文档解析 | MarkItDown + PyMuPDF | Unstructured |
| 分块 | 自写规则 | LangChain RecursiveCharacterTextSplitter |
| Embedding | BGE-M3 | text-embedding-v3,OpenAI Embedding |
| 关键词检索 | RankBM25 | Elasticsearch BM25 |
| 向量存储 | Milvus Lite | Chroma,sqlite-vec |
| 重排序 | bge-reranker-v2-m3 | Cohere Rerank |
| LLM | Qwen2.5-32B 或 GPT 系列 | 按喜好选即可 |
最后再分享一个小技巧:不要一次把全部文档灌入知识库。先挑 10 篇左右的有代表性文档,跑通流程,验证检索效果,再逐步扩容。这样你能在早期就发现分块策略、检索策略的问题,而不是等几千篇文档入库之后才发现要重来。