基于《天龙八部》的RAG知识库问答系统实战:从切块到Graph RAG

📅 发布时间:2026/9/10 5:28:56
基于《天龙八部》的RAG知识库问答系统实战:从切块到Graph RAG
先问你一个不一定好回答的问题用户问“天龙八部里虚竹的师父到底是谁”手边的通用大模型能稳定答对吗大概率会把无崖子、天山童姥、玄慈方丈混在一块。RAG检索增强生成就是为这类问题设计的先在知识库里检索相关资料再让大模型基于资料作答。今天我就用《天龙八部》做知识库从原理、切块、向量检索、重排一路讲到 Graph RAG、Agentic RAG完整过一遍 RAG 知识库问答系统的落地路线。这篇文章适合算法工程师、后端开发也适合想自己搭一个私有知识库问答的爱好者。1. RAG 到底是什么先搞懂这套流水线1.1 为什么大模型需要“外挂知识”大模型再大它的知识也停留在训练数据截止那一刻而且内部参数很难改。拿《天龙八部》这种小说来说通用模型大概知道主线剧情但你要问“阿紫眼睛是谁弄瞎的”“段正淳的五个情人分别是谁”它很可能会一本正经地编。这是大模型的固有缺陷参数化记忆不可控也说不清来源。RAG 的思路很直白不逼模型背知识而是让它在回答之前先去外部知识库里查资料。你可以把它理解成“开卷考试”模型不用把所有答案记在脑子里只要知道怎么快速翻书然后照着书上的内容组织答案。这个“书”就是我们的知识库可能是小说、企业文档、财务报告、政策法规随便什么。这样做有三个直接好处答案可以追溯到具体原文减少凭空捏造的幻觉知识库可以随时更新不用重新训练模型领域内容越专业、越垂直收益越明显。所谓 RAG 增强 LLM增强的不是参数而是信息获取能力。1.2 RAG 的五个核心环节标准的 RAG 流程可以拆成五个环节文档加载与清洗把原始文件转成干净文本。切块Chunking把长文本切成适合检索的小片段。向量化用 Embedding 模型把文本片段变成向量。检索用户问题经过同样的向量化在向量库中找到最相关的片段。生成把检索到的片段和问题一起塞进 Prompt交给大模型生成回答。很多教程把 RAG 讲得很玄其实就是这条链路。你踩过的坑绝大多数都发生在 2 和 4 之间也就是切块和检索策略。后面我会用天龙八部的文本逐段讲。2. 知识库搭建先把天龙八部切成好用的索引积木2.1 文档获取与清洗从电子书到干净文本我这里默认你已经有《天龙八部》的电子版 TXT 文件。网上流传的版本可能会带有目录、章节名、分卷信息甚至夹杂乱码或广告段。如果不处理就直接切块向量库里会混进去一堆废话问“乔峰在哪学的降龙十八掌”检索到的却是一段“感谢某某网整理”的冗余文字回答自然废掉。我一般先用 Python 做两层清洗import re def clean_text(content: str) - str: # 统一换行符去掉连续空行 content content.replace(\r\n, \n).replace(\r, \n) content re.sub(r\n{3,}, \n\n, content) # 去掉常见网页/整理者信息按自己文本情况补充规则 lines content.split(\n) clean_lines [] for line in lines: if 整理 in line or 下载 in line or 入库 in line: continue clean_lines.append(line.strip()) return \n.join(line for line in clean_lines if line)清洗之后我会先用正则把“第...回”这种标题抽出来做成一个结构化列表。比如天龙八部通常是五十回第01回 青衫磊落险峰行第02回 玉壁月华明……把回目标题当成文档结构后面切块会很省事。提示TXT 来源不同编码可能是 GBK 或 UTF-8读取时用encodingutf-8报错就换errorsignore或者用chardet自动检测。这一步看着基础实际能省下一堆脏数据带来的定位时间。2.2 切块策略为什么不能直接按 1000 字硬切切块是知识库问答里最容易被低估的一步。你问“乔峰和慕容复谁强”如果切块把“乔峰”和“慕容复”拆到不同片段或者一段内容被拦腰截断后半段的关键信息丢失检索自然失败。我踩过不少坑总结下来有三类常见切法固定字数硬切实现最简单按 500 字一刀切但会切开语义单元不适合小说这种长文本。按章节切符合自然边界但一章动辄几千上万字超出模型上下文也容易混入多个主题。递归结构切先用章节做粗切再按段落和句子边界细切配合重叠overlap效果最稳。以天龙八部为例我会先按“第?十?回”切出一章然后把一章内部用递归字符切分器切成 400~600 字的小块重叠 60~80 字。LangChain 里可以直接用RecursiveCharacterTextSplitterfrom langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n\n, \n, 。, , , , , ], ) chunks splitter.split_text(chapter_text)这里的separators很关键它决定系统“优先按什么边界切”。先按段落切再按句号、叹号、问号切实在不行才在逗号处断开。这样能最大限度保证每一片是相对完整的语义块。重叠的 80 个字相当于给了每个片段一点“上下文缓冲”避免边界处句子被劈成两半。切完之后我会顺手给每个块加上chapter_title和chunk_index之类的元数据。后面生成回答时可以让模型引用“出自《天龙八部》第几回”这个细节对体验提升非常明显。2.3 Embedding 选型中文向量模型怎么选切好的文本片段需要编码成向量。选模型时别只看名字要看它是否适合中文、是否支持长文本、是否支持稠密向量和稀疏向量。我常用的是 BGE-M3多语言、中文效果好而且同时产出 Dense Vector 和 Sparse Weight做 Hybrid RAG 非常顺手。如果机器资源有限text2vec-base-chinese也能用但效果会差一截。如果选 OpenAI 的text-embedding-3-large效果不错但要注意两个问题一是中文长文本费用不便宜二是数据要出网企业对隐私敏感场景往往不乐意。自己搭私有知识库尽量用开源的 Embedding 模型配合本地向量库整套系统才能做到可控。向量维度和存储资源也要提前算好。BGE-M3 默认输出 1024 维一部《天龙八部》切成 4000~6000 块用 Float32 存大约需要 25MB 左右实际加上索引倍数还是很小。但换成企业级百万级文档这个量级就会影响选型要么降维要么换更省内存的量化索引。做项目前先把块数量估算清楚再定硬件。3. 检索侧实战多路召回、Rerank 与查询优化3.1 向量检索的“召回-精排”两段式新手最容易犯的错误是用户问题一来直接top_k5取最相近的 5 个块丢给大模型。问题是向量相似度不完全等于语义相关度Top 5 里经常混着好几条似是而非的内容。正确思路是“宽召回精重排”。第一步先用向量检索召回 20~50 条候选第二步再用 Rerank 模型精排取前 3~5 条进入 Prompt。这样既保证不遗漏真正相关的块又避免把噪音全部塞给模型。我这里用 Milvus 做向量库检索可以写成from pymilvus import Collection collection Collection(tianlong) collection.load() query_embedding embed_model.encode([question]) res collection.search( dataquery_embedding, anns_fieldembedding, param{metric_type: COSINE, params: {nprobe: 16}}, limit30, output_fields[text, chapter_title], )nprobe是 HNSW 或 IVF 索引的搜索参数调大一点召回更全但延迟会涨。实操时我会先设 30 条候选观察精排后的效果再调整。3.2 多路召回向量 关键词 BM25 的 Hybrid 组合Embedding 擅长“意思相近”但不擅长“字面精确”。比如问“易筋经是少林寺哪本经书”如果知识库里原文写的是“《易筋经》系少林派内功秘笈”向量检索一般能对上但如果你问“扫地僧是谁”这句话在原文里可能只是“那老僧”或“无名老僧”纯向量检索就很容易漏。所以要做多路召回Hybrid RAG一路走向量一路走 BM25 这种稀疏检索。BM25 吃关键词适合人名、招式名、专有名词向量检索吃语义适合换一种说法问同一个事。两路结果合在一起再去重、再重排。在 Milvus 2.4 里可以用内置的稀疏向量和混合检索稠密向量用 Dense 字段稀疏字段用 BM25 或 learned sparse。伪代码大概是res collection.hybrid_search( reqs[ {anns_field: embedding, data: query_dense, metrics: COSINE}, {anns_field: sparse, data: query_sparse, metrics: BM25}, ], limit30, )如果项目里已经用了 Elasticsearch也可以把向量字段和 BM25 查询一起做。现在主流的 RAG 框架比如 LangChain、LlamaIndex、Dify都支持这种混合检索配置。查“乔峰聚贤庄”这种既有明确人名又有关键词的问题混合检索效果明显好于单路。3.3 Rerank 重排精排模型如何“去伪存真”多路召回拿到 30 条候选后必须重排。很多人会直接按相似度分数排序这其实不够。向量检索用的是 Bi-Encoder把问题和文档分别编码成向量再算相似度速度快但交互信息少。而 Rerank 模型如 BGE-Reranker是 Cross-Encoder把“问题文档”拼起来一起过模型能捕捉更细的关系分数更接近真实相关性。用起来很简单from sentence_transformers import CrossEncoder rerank_model CrossEncoder(BAAI/bge-reranker-base) pairs [(query, chunk_text) for chunk_text in candidate_texts] scores rerank_model.predict(pairs) # 按分数排序取前 5 top_indices scores.argsort()[::-1][:5]注意Rerank 不是越高越好需要观察分数分布。我见过同一篇文档里两个块都能正确回答问题但分数差距只有 0.02。这种时候不要迷信阈值反而应该在 Prompt 里多给两条候选让模型自己综合判断。Rerank 的主要价值是把“完全无关”的噪音压下去而不是在高度相似的候选里帮你挑“唯一标准答案”。3.4 查询改写、多轮对话与权限卡控真实用户不会老老实实一次把问题说全。比如上一句问“段誉怎么追的王语嫣”下一句问“那他爹同意了吗”——这里的“他”指段誉“爹”指段正淳还是段延庆直接把第二句拿去检索什么都查不到。所以多轮对话场景要先做查询改写把“当前问题 历史对话”交给一个轻量模型整理成一个独立的、完整的查询词。还有权限卡控。企业级知识库里不是所有文档所有人都能看RAG 的检索结果必须按用户权限过滤。具体做法是在切块阶段给每个文档打permission元数据检索时先过滤再走召回res collection.search( dataquery_embedding, filterfpermission IN {user_permissions}, limit30, )拿天龙八部举例如果知识库分“公开版”和“内部考据版”那么普通用户问“段誉身世”时只能检索到公开章节而不能命中内部考据版的内容。权限卡控不在 Prompt 层做必须在检索层做否则大模型可能通过上下文泄露内容。4. 进阶路线Graph RAG、Agentic RAG 与多模态4.1 Graph RAG把人物关系变成一张图普通 RAG 是“先找片段再总结”但像天龙八部这种人物关系错综复杂的内容问“乔峰的结拜兄弟有谁”“段正淳的所有情人包括哪些”答案分散在几十个地方靠向量检索容易漏。Graph RAG 的思路是把知识库里的实体和关系抽取出来构建成一张图查询时直接用图搜索找关联路径。比如我可以把天龙八部的核心实体抽出来人物乔峰、段誉、虚竹、门派少林、丐帮、逍遥派、武功降龙十八掌、六脉神剑、关系师承、结拜、情侣。存进 Neo4j 后用 Cypher 查MATCH (p:Person {name: 段誉})-[:结拜]-(brother) RETURN brother.name图搜索能给出精确的关系链。实际落地时不需要把所有文本都转成图更常见的做法是对部分高频实体构建图谱图谱结果和向量检索结果做一个融合。这也是现在 Ontology RAG 的玩法先用本体定义好人物、门派、武功这些 schema再按 schema 抽取实体关系让图结构更规整。Graph RAG 很适合“关系密集型”知识库但也费时费力先用向量 RAG 顶上等遇到明显的关系缺失问题再上图谱性价比更高。4.2 Agentic RAG让模型自己决定查什么传统的 RAG 流程是固定的一个问题查一次返回片段生成答案。但有些问题没法靠一次检索解决。比如“比较乔峰和段誉的实战能力”要么分别查乔峰战绩和段誉战绩然后再综合要么先查“乔峰”发现需要对比再补查“段誉”。Agentic RAG 就是让 LLM 当“调度员”自己拆解问题、决定检索动作、观察结果再决定是否需要二次检索。在工程上可以基于 LangGraph 或 ReAct 模式给它几个工具search_tianlong、search_character、search_relationship。模型先规划再调用工具。比如问“段誉的六脉神剑为什么时灵时不灵”模型可能先搜索“六脉神剑”再搜索“段誉内力”把两次结果合并后回答。这种灵活性把 RAG 从“一次问答”升级成了“会查资料的助理”。代价也很明显延迟变高、token 消耗更多、更难调试。所以我的建议是简单的固定知识库问答用普通 RAG 就够碰到需要多轮规划、工具调用的场景再升级到 Agentic RAG。4.3 多模态 RAG 与落地扩展如果把天龙八部的旧版插图、人物画像、扫描页也接入知识库那就涉及到多模态 RAG。流程是先用 OCR 把图片里的文字提出来再用图像模型生成图片描述和文字块一起向量化。用户问“丐帮帮主信物长什么样”系统会检索到对应图片描述返回相关图片路径。多模态 RAG 在企业里更常见合同扫描件、财务报表截图、产品手册里的架构图纯文本检索根本覆盖不到。落地时不用一上来就全套多模态最省力的方式是“OCR VLM 描述 文本向量化”把视觉信息先翻译成文本再接传统 RAG 链路。效果够用成本也低。5. 从 0 到 1 的实战流水线技术栈与核心代码5.1 技术栈选型我不想把项目搞复杂所以只选一套低成本、能快速跑通的技术栈语言Python 3.10文档处理LangChain 的文本切分器EmbeddingBAAI/bge-m3本地部署向量库Milvus 2.4支持 Dense SparseRerankBAAI/bge-reranker-base生成模型Qwen 或其他支持 OpenAI 兼容接口的大模型框架直接写代码不重度依赖特定框架方便后续排查对比一下主流 RAG 框架LangChain 功能全但抽象层多出问题不好定位LlamaIndex 对文档索引很友好适合索引逻辑重的项目Dify 偏向产品化适合快速搭建界面但不适合深度定制。我的建议是学习阶段可以先用 Dify 跑通全流程真正做项目还是自己写数据管线。5.2 核心实现从切块到生成整个流程的核心代码其实不长。我先建一个简单的文档结构体然后用RecursiveCharacterTextSplitter切块给每块补齐chapter_title元数据再用BGE-M3编码向量插入 Milvus。class Chunk: text: str chapter: str source: str # 已清洗好的全文按章节切好 all_chunks [] for chapter_title, chapter_text in chapters.items(): pieces splitter.split_text(chapter_text) for idx, piece in enumerate(pieces): all_chunks.append({ text: piece, chapter: chapter_title, source: tianlong.txt, index: idx, })然后查询链路query 乔峰为什么被称为北乔峰 # 1. 向量召回 q_vec embed_model.encode([query]) candidates collection.search(dataq_vec, limit30) # 2. 关键词召回如果走混合检索把结果合并 bm25_candidates search_by_bm25(query) # 3. 合并去重后 rerank merged merge_and_dedup(candidates, bm25_candidates) top5 rerank_and_select(query, merged, top_k5) # 4. 拼 Prompt context \n\n.join([c[text] for c in top5]) prompt f基于以下知识库内容回答问题。如果内容中找不到答案请直接说明不知道不要编造。 知识库 {context} 问题{query} answer llm.chat(prompt)这里最容易被忽略的是 Prompt 设计。我见过不少案例检索没问题但模型回答还是瞎编原因就是 Prompt 里没有强制约束“仅依据知识库内容、不要外推”。所以我都会在 Prompt 里写死两句话一是“禁止使用常识或训练知识作答”二是“回答时尽量引用原文或指出出自第几回”。效果立竿见影。5.3 评估指标怎么知道系统到底好不好RAG 系统不是“能跑就行”要能度量。我最常用的评估分三层检索层Hit Rate正确答案是否在召回结果中、MRR正确答案排名有多靠前、Recallk。生成层忠实度回答是否严格来自检索到的文档、相关性是否答非所问、有害性。端到端用户主观满意度、平均回答长度、延迟。手动构造测试集很痛苦但很值得。我一般从天龙八部里挑 40~50 个问题分成三类事实类“虚竹的师父是谁”、关系类“乔峰和阿朱什么关系”、推理类“段誉的六脉神剑为什么时灵时不灵”。每类问题都要标注标准答案来自哪一章。跑完一版后Hit Rate 至少要达到 0.8否则说明切块或检索有问题。重排后的生成质量可以用 LLM-as-judge 自动打分让一个强模型对照“参考答案 检索片段 模型回答”来打分。虽然不是绝对准确但省人力适合迭代时快速发现问题。6. 常见问题与排查技巧实录6.1 检索结果不对先看召回再看重排只要回答出错第一步永远是“看检出来的原始片段”。不要把锅甩给大模型因为大多数情况下问题出在检索侧。我一般会打印出 Top 5 的片段和分数人工看一眼片段里本身就没答案说明召回失败检查切块是否太大或太小、Embedding 模型是否合适。片段里有答案但模型没用说明 Prompt 约束不够或者重排把正确答案挤掉了。片段对但来源不对可能是不同章节相似内容冲突需要元数据过滤。我还碰到过一个经典场景问“阿紫的眼睛是谁弄瞎的”向量检索召回的片段是在讲阿紫的悲惨遭遇但后半句才提到“被萧峰用箭射瞎”。因为切块被硬切断了导致信息缺失。最后我把 chunk_size 从 800 降到 400问题就解决了。所以遇到奇怪的问题先从切块下手。现象可能原因排查建议检索结果完全无关问题表述太口语化向量检索不敏感加查询改写或关键词召回内容相关但不完整切块把信息切断调小 chunk_size增加 chunk_overlap重排后正确答案被挤掉rerank 模型不适用于该领域换 reranker 或用更大模型回答有幻觉Prompt 没有约束来源强制“仅根据知识库回答”模型不知道就说不知道6.2 幻觉控制与引用溯源RAG 的最大价值就是追踪来源。我会让模型在回答末尾加上引用比如“出自《天龙八部》第二十四回”。如果模型在知识库里找不到信息就直接回答“我没找到相关资料”而不是硬编一个答案。为了做到这一点前面切块时保留章节名和原文片段就特别重要。还有一个细节如果多个片段之间信息冲突比如 “段誉生父” 在不同章节有不同描述我会让 Prompt 提示模型“优先采用后出现的信息”或“明确指出存在多种说法”。这样既能压制幻觉也能让回答更严谨。6.3 性能与成本优化RAG 的性能瓶颈通常在 Embedding 和 LLM 生成。批量处理时先把全库文本一次性 Embedding 入库查询时的计算量很小。向量库里启用 HNSW 索引并在检索时调小nprobe可以明显降低延迟。如果 QPS 很高可以用 Embedding 服务缓存查询向量避免重复编码。成本方面生成模型的输入 token 数是最大头。假设每次回答塞 5 个块每块 500 字约 2500 字大概是 3500 个 token加上问题和历史差不多 4000 token。如果一天一万次问答那 token 花费会很可观。优化办法是在重排后只保留 3 个块把 Prompt 模板精简并给相似问题做缓存。实测下来输出质量不会差太多成本能省三分之一。还有一个小技巧如果知识库经常不变化可以把检索出来的“标准答案片段”做离线缓存用户问同样问题时直接返回缓存结果彻底省掉生成这一步。最后再说几句话我做了好几个 RAG 项目之后最大的体会是RAG 的效果天花板不在模型而在知识库的处理质量。切块、元数据、混合检索和重排每一项都值得花时间调优。特别是切块千万别图省事按固定字数硬切《天龙八部》这种中等长度的文本已经能暴露很多问题换成企业文档问题只会更明显。最后分享一个我觉得最实用的小技巧每次改完切块策略或检索参数先跑一遍固定的 20 个测试问题把召回结果打印出来人工扫一眼再回到代码里调。这套反馈循环看起来笨但比任何高级框架都管用。RAG 从来不是装上一个框架就能跑它需要你像调试老系统一样一步步把每个环节调到顺手为止。