搭建个人RAG知识库:版本治理、父子分块与混合检索实战
说实话我一开始也以为搞个个人 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. 版本治理知识库也要有 Git2.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_parentTrue) child create_chunk(block, parent_idparent.id, is_parentFalse) store_to_vector_db(child) elif len(tokens) 1200: parent create_chunk(block, is_parentTrue) for sentence_group in split_by_sentence(block, max_tokens300): child create_chunk(sentence_group, parent_idparent.id) store_to_vector_db(child) else: # 超长块先切子块再合并成父块 sub_blocks split_by_sentence(block, max_tokens300) parent create_chunk(merge_text(sub_blocks), is_parentTrue) for sub in sub_blocks: child create_chunk(sub, parent_idparent.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然后用 RRFReciprocal 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, freq20000)同时对代码、URL、版本号等 token我做了正则保护不让分词器切开它们。这个细节别看小对检索精度的提升非常明显。5. 可引用回答从生成到可验证5.1 RAG 不解决幻觉它只是给幻觉装上安全带很多人以为 RAG 解决了幻觉问题我实际用下来的体会是RAG 只能降低幻觉概率不能根除。即便你把上下文精准喂给模型模型在组织语言时仍然可能漏掉细节、错误拼接、过度推断。解决这个问题靠的是一个朴素机制——每个回答都必须带引用来源。用户看到回答后能点开引用、对一下原文、自己判断模型说得对不对。这个能验证比生成正确更重要因为 LLM 天生不是确定性系统你无法保证每次都答对但你可以让错误暴露得明明白白。5.2 引用来源的三个层级我的引用系统分三个层级元数据级回答下面的引用标注[1]指向某篇文档的 doc_id 和 version。切片级点击引用后高亮显示命中的子块片段精确到句子。上下文级同时展示该子块所属的父块全文让用户看到完整的上下文判断模型是否断章取义。实现起来核心是把生成回答时用到了哪些 chunk记录下来。我用的方法是给 LLM 的 prompt 里每个片段加上标记让模型在引用时输出对应的 chunk_id以下是从知识库中检索到的相关片段请基于这些片段回答问题。 每个片段都有编号回答时请在引用内容后标注编号。 chunk idc-0001 doc_idprd-2024-001 versionv2.3 内容... /chunk chunk idc-0002 doc_idtech-note-2024-007 versionv1.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 到底值不值得加我自己的实测对比不加 rerankTop5 里有效片段大概 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 PyMuPDFUnstructured分块自写规则LangChain RecursiveCharacterTextSplitterEmbeddingBGE-M3text-embedding-v3OpenAI Embedding关键词检索RankBM25Elasticsearch BM25向量存储Milvus LiteChromasqlite-vec重排序bge-reranker-v2-m3Cohere RerankLLMQwen2.5-32B 或 GPT 系列按喜好选即可最后再分享一个小技巧不要一次把全部文档灌入知识库。先挑 10 篇左右的有代表性文档跑通流程验证检索效果再逐步扩容。这样你能在早期就发现分块策略、检索策略的问题而不是等几千篇文档入库之后才发现要重来。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →