首页 / 资讯中心 / 文章详情

从小说入库到RAG问答:完整实践与五个硬伤

从小说入库到RAG问答:完整实践与五个硬伤 ★ FEATURED ARTICLE
上周我干了件挺有意思的事把一整部长篇小说塞进数据库然后基于它搭了一个RAG问答系统。念头其实很直接——小说太长LLM的上下文窗口根本塞不下那就先存起来用的时候再捞。系统跑通之后我才发觉“能用”和“好用”之间隔着一条鸿沟能答上来的问题没几个倒是暴露出来一长串硬伤。这篇就把从入库到检索的完整过程以及踩出来的五个大坑原原本本记录下来。这玩意儿的定位是一个私人阅读辅助工具面向的是“想快速回忆剧情、查人名、理时间线”的需求。适合谁看一类是想上手RAG但还没找到合适业务场景的开发者另一类是和我一样拿小说、文档做实验想搞清楚检索增强到底哪里会翻车的人。看完之后你至少能避开我踩过的这些坑。1. 项目缘起与整体设计1.1 为什么非要用数据库而不是直接让LLM硬读一开始我也偷懒想过直接把整本小说拼成一段提示词丢给模型让它回答不就行了实际上完全不可行。一本80万字的小说按一个汉字约等于1.5到2个token估算光文本就得120万到160万token市面上任何模型的上下文窗口都扛不住。就算强行塞进去注意力机制在超长文本上的表现也会明显退化模型只记得开头和结尾中间细节基本丢光。数据库在这里承担的角色不是“存储”而是“索引”。它把文本切成小块让每一块都有地址有向量能被检索。用户问一个问题系统先到库里捞几块最相关的原文再把这些原文塞给LLM生成答案。这其实就是RAG的基本链路召回、拼接、生成。数据库负责的是“先找到可能含答案的地方”LLM负责的是“基于这些片段把答案说出来”。顺着这个思路我制定了几个硬性目标支持按原文片段回答回答能追溯到具体章节支持人物、地点、事件等实体的快速定位能够处理“某件事发生在什么时候”“某人做过什么”这类跨章节问题全程本地可跑不依赖外部收费API1.2 从文本到向量的完整链路整个流程拆开看是这么几步文本清洗、分章节、切块、向量化、入库、检索、生成。听起来不复杂但每一步都有坑尤其是“切块”这一步直接决定了后面检索质量的好坏。文本清洗这步最容易被忽略。网络小说源文件里常混着章节序号、乱码符号、作者的话、排版空格不处理干净后面切出来的块全是噪音。我用了一个简单的规则把空行压缩把“第X章”单独抽出来作为章节标记把正文中连续的对话段落保留原样但去掉多余的空格和不可见字符。清洗之后再按章节粒度切分每个章节作为一个document附带章节号、标题等元数据。向量化这一步我选的是BGE-M3的中文模型。原因有两个一是它在中文语义匹配上的表现比较稳二是它可以处理不同长度的输入最长支持8K token对于小说段落足够用了。嵌入维度是1024维也就是每切出来一个块在数据库里就是一行“原文1024个浮点数”。入库的时候我做了两套存储向量库负责存嵌入向量和原文切片MySQL负责存章节结构、原始文本和元数据。为什么搞两套因为向量检索适合“模糊找”但要是想按章节号查、按角色筛选还是结构化查询更直接。后面我会讲到这套双存储设计在出问题的时候也成了排查的重要依据。2. 核心环节实现与参数选择2.1 切块参数为什么不能直接按固定字数切切块是RAG里面最“反直觉”的一步。我一开始图省事直接按512个字符切一块结果问题很快暴露一句话刚说一半就被腰斩下一句话的开头跑到了下一块里。LLM拿到这种残缺片段连“男主被人捅了一刀”和“但是没死”这种前后关系都拼不出来自然只能胡说。后来换成按语义边界切具体做法是这样的from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size512, chunk_overlap128, separators[\n\n, \n, 。, , , ], keep_separatorTrue ) chunks text_splitter.split_text(chapter_text)参数含义是优先按段落切段落太长就按句号、感叹号、问号这些句子边界切实在不行才硬切。chunk_overlap设为128字符相当于两块之间重叠一小段避免一个完整句子的尾部信息被丢到下一块开头而检索不到。这个重叠值不是拍脑袋定的而是根据512字符块128重叠估算的如果一句话恰好落在边界上最多有128字符的缓冲基本能覆盖中文长句的长度。一部80万字的小说按这个参数切出来大约2100个块。这个数量对向量检索来说非常小但每个块的内容是否“完整”才是关键。2.2 Embedding模型与向量库选型中文小说里昵称多、代称多比如主角叫“林远”有时候又叫“那小子”“白衣青年”embedding模型能不能把这些指代关联起来直接影响召回效果。我实测了三个模型text2vec-base-chinese轻量速度快但对长句和跨段语义捕捉一般BGE-M3中文效果好支持多语言但显存占用比前者高M3E-base体积小效果居中适合CPU环境跑最后我选了BGE-M3跑在GPU上。为什么不用更重的模型因为小说问答是一个不要求极致精度但要求响应速度的场景BGE-M3已经在“召回正确片段”这个任务上做到了够用的水平。如果你是在CPU上跑我建议用M3E-base速度差距会很明显。向量库选型上我比较了Chroma和Milvus。Chroma胜在部署简单一个库文件就能跑适合轻量项目。Milvus功能更全支持标量过滤和混合检索但部署和运维成本明显更高。我最后选了Chroma因为2100个块这个量级根本用不着分布式向量库杀鸡用牛刀反而拖慢调试速度。真要上了百万级文本再迁移到Milvus也不迟。2.3 检索问答链路检索的时候我先把用户问题向量化然后去Chroma里做相似度搜索取出top_k个候选片段。top_k我一开始设的是3后来发现3个太抠门换成5个之后回答完整度明显提高但噪音也随之增加。这里有一组实测数据top_k回答正确率人工评估回答完整度明显噪音片段比例1约55%低低3约68%中中5约74%高略高10约70%高高top_k从1涨到5正确率有提升但涨到10之后错误片段把正确答案淹没整体效果反而下降。所以最终停在5这个数字就是拍出来的调参结论不同文本类型得重新试。检索完成后系统把候选片段按原始章节顺序排好拼进提示词再交给Qwen2.5-7B生成答案同时要求模型在回答末尾标注引用的章节号方便我人工核对。3. 五个硬伤逐一拆解系统跑通之后我拿着几十个问题去测评。问题分三类事实型“男主的师父叫什么”、全局型“男主一共收过几个徒弟”、推理型“反派为什么一开始就要害男主”。结果三类问题各有各的翻车方式。我把它们总结成五个硬伤按严重程度排了个序。3.1 硬伤一机械切块毁掉了叙事连续性这算是所有硬伤里最致命的一个。小说的叙事是线性连续的上一章结尾可能是一个高潮下一章开头立刻接解密。但切块不管这些它只认字符数。我举一个实际翻车的例子小说某一章的最后一句话是“她纵身跃下了悬崖”下一章的第一句话是“三天后林远在崖底找到了奄奄一息的她”。我切块的时候“跃下悬崖”和“三天后被找到”被硬生生分到了两个块里。用户问“女主跳崖之后怎么样了”向量检索到的只有“跳崖”那一块LLM根本不知道后面发生了什么只能编一个“女主在崖底生活了一段时间”的离谱答案。这个问题本质上是切块策略和叙事结构之间的失配。固定大小切块适合新闻、文档这类信息相对独立的文本但对小说这种依赖上下文的前后文结构极其不友好。解决办法倒是简单粗暴——加大chunk_size让一个块尽量覆盖完整的场景转换。我后来把chunk_size调到了1024重叠加到256剧情连续性明显改善。但代价是单块变长后检索精度下降一个块里只有一小段和问题相关其他全是噪音。3.2 硬伤二向量检索的“半吊子召回”第二个硬伤出在召回环节。向量检索找到的片段经常是“看起来相关实际没答到点子上”。问“男主的师兄是谁”系统可能召回一段“师兄站在门口神色复杂地看着林远”这段确实提到了师兄但答案“陈墨”两个字出现在前文不在召回的片段里。我把这个现象叫做“半吊子召回”语义相似不等于答案存在。向量模型擅长匹配“话题相同的文本”但小说里的人物昵称、代称、指代关系会让“话题相同”和“答案存在”严重脱节。那段时间我反复调相似度阈值从0.5调到0.3召回率上来了但噪音也多了回答里经常混入不相关的情节。后来我加了一步关键词兜底除了向量检索同时用BM25做关键词检索再把两路结果合并去重。这个办法虽然土但对人名、地名这种专有名词特别有效。用户问“张三丰的徒弟是谁”向量检索可能找不到但BM25能稳稳地把所有出现过“张三丰”的段落捞出来。3.3 硬伤三没有元数据问不了全局性问题这个问题在问“全书一共有多少角色出场过”“这本小说的世界观设定是怎么演变的”“时间线大概是怎么走的”这类跨章节统计问题时彻底暴露。因为我的索引里只有“文本块”和“向量”没有“章节号”“出现过的角色名单”“时间标记”这些结构化信息系统在回答这类问题时只能抓几个孤立的段落凑数完全没有全局视角。举个例子用户问“男主在第三卷一共打败了几个对手”。正确的做法是先按“第三卷”做章节过滤再统计“战斗场景”的数量最后列出对手。但在我的RAG里“第三卷”这个信息并不存在于任意一个文本块中向量检索根本找不到一个代表“第三卷”的块。系统最终只抓到几个碰巧提到“打败”的片段数出来的答案完全对不上。这里就是热词里ontology RAG、知识图谱这些东西发挥价值的地方了。如果能在入库阶段就把“卷-章-节-段落”的层级结构、每段出场人物、涉及的时间标记全部抽取出来作为元数据检索时先做结构化过滤再向量匹配这类全局问题就能被拆解成“先查元数据再做局部推理”的流程。3.4 硬伤四长线因果链条怎么拼也拼不完整小说里很多关键信息是层层铺垫的一个原因可能在第10章埋下在第200章才揭示。比如我问“反派最开始为什么要害男主”正确答案是“因为男主父亲当年在战场上坑过反派”这个信息在第15章就提过。但向量检索召回的是当前问题最相似的段落大概率是“反派正在害男主”的近期章节而“父亲坑反派”这种埋藏极深的历史信息相似度被排到了非常靠后的位置根本不会被top_k选中。我试过把top_k从5调到10但这会让拼接出来的上下文变得前后矛盾一会儿是“反派在谋划”一会儿是“反派被男主打败”LLM根本无法从中提炼出一条因果链。这是RAG在叙事文本上最根本的局限检索单元是局部片段但因果链是全局结构。纯粹靠“捞几块文本喂给LLM”的套路解决不了这个问题。3.5 硬伤五知识只进不出更新与纠错全靠重灌最后一个硬伤比较隐蔽是数据库层面的。小说文本并不是一成不变的章节会修订作者会补写番外甚至会因为版权原因把某些章节整段替换。我最初设计的数据入库流程是“全量清空再重灌”一套流程跑下来清洗、切块、向量化、写入80万字大约需要四十分钟。一开始还能忍但第三次因为一个小修订就要全量重跑的时候我开始认真考虑增量更新的问题了。更麻烦的是“纠错”场景假如我发现某个片段向量化之后语义不对或者切块切坏了某句话想单独修这个块数据库本身没有提供“一行增删改查”的能力——因为向量库里的文本块和MySQL里的章节记录没有做关联映射我改完章节文本向量库里对应的块还在用旧向量参与检索答案永远是旧的。这其实暴露了一个很本质的问题我用数据库但没有把数据库当成一个可维护的系统来设计。关系和索引都没有做向量表和文本表没有通过外键关联改一处就要全量重来。这也是后来我研究agentic RAG的原因——一个真正可用的RAG应该有能力感知到“哪些数据更新了”“哪里需要重新embedding”而不是每次从零开始。4. 踩坑实录与排查技巧这一节我按“肉眼可见的症状”来记录方便你直接对照排查。4.1 命中率上不去先查“句子被切成了两半”症状用户问的问题明明在小说里有对应的原文但系统就是检索不到。排查步骤先打开数据库看看和问题最相似的前10个块长什么样。如果发现候选块里有大段残缺句子基本可以断定是切块参数的问题。尤其是直接按固定长度切块的情况候选块常常是“上句不接下句”的。解决方法是改用递归切分器强制以标点作为边界。我最初命中率大概只有58%调整切块参数后肉眼可见跳到了74%。如果你用的是LangChain直接上RecursiveCharacterTextSplitter重点调两个参数chunk_size和chunk_overlap。中文文本建议把“。”、“”、“”都加入分隔符列表这样每个块至少是完整的句子。4.2 相似度阈值命中率和准确率你选哪个调阈值是个让人头大的事。阈值高了检索不到低了全是噪音。我试了很多组合最后得到一个粗糙的结论在中文小说这个场景里阈值设在0.3到0.4之间比较合适同时把top_k固定在5再让LLM自己判断哪些片段有用。但阈值只是候选策略真正有效的是加一个“重排”步骤。先用低阈值多召回一些候选比如20个再用一个rerank模型或者更精确的相似度算法把这20个重新排序取前5个。这种做法相当于初筛加精排命中率比单纯调阈值稳定很多。4.3 连接池和事务向量库和MySQL的一致性我最初把向量库和MySQL分开部署结果出了个很诡异的bug文本表已经写入新章节了向量库里还是旧数据导致问答系统拿新章节的引用去查旧向量答出来的内容前后矛盾。排查下来是写入流程的原子性问题——先写MySQL后写向量库中途任何一个失败都会导致两边不一致。解决办法是在代码里加了一个简单的事务补偿先写向量库成功之后再写MySQL如果MySQL写失败了反查向量库并把刚才写入的向量删掉。或者更简单一点两边都写完后再做一次计数校验对不上就报警重跑。连接池这块我遇到的是另一个问题向量检索用完之后连接没有及时释放导致并发查询稍微上来之后数据库连接直接被占满系统假死。如果你用Chroma的话建议把PersistentClient常驻不要每次查询都新建客户端用MySQL的话连接池上限至少设到20并加上空闲回收策略。4.4 中文小说的人名问题专用名词是检索盲区中文小说的人名尤其是那种“姓称号”混着来的写法是向量检索的重灾区。单一模型经常无法把“林远”“那小子”“白衣青年”“宗主”这几个称呼关联到同一个人身上。我的土办法是在入库之前先把全文的称呼替换成统一的规范人名再做embedding原文保留一份不动用于最后的展示引用。效果立竿见影人物相关的问答命中率提升了差不多15%。另一个思路是做一个“称呼映射表”存在数据库里检索的时候先用正则或规则把用户问题里的昵称映射成规范名再去向量库查询。这个方案更轻不用重新embedding全文调试起来更方便。5. 如果重做一次从硬伤反推的改进方案5.1 带元数据的入库设计重做的话第一件事就是给每个文本块加上可过滤的元数据所属卷号、章节号、出场人物列表、时间标记、场景类型。Chroma本身就支持metadata过滤检索时可以先按章节范围过滤再在过滤后的集合里做向量匹配这样“第三卷有哪些战斗场景”这类问题就能先通过过滤缩小范围大幅减少噪音。5.2 混合检索加重排上一节提过混合检索加重排是我认为性价比最高的改进方案。不用上特别复杂的系统就在现有RAG流程里加两样东西BM25索引和rerank模型。初召回的时候向量召回20条BM25召回20条合并去重剩30条左右再用rerank排序取前5条。这个方案能把命中率从74%拉高到85%以上我认为值得折腾。5.3 面向小说明细的结构化知识层对标ontology RAG的思路小说其实非常适合建立一个“实体—关系”图谱。人物、门派、地名、法宝是实体“隶属于”“击败过”“暗恋”是关系。有了这个图谱很多硬伤就能转化为图查询问“男主一共打败过几个人”直接查“击败”关系就能统计问“反派的动机”先查“反派—关联事件—主角父亲—历史冲突”这条路。这套东西本质上就是知识图谱RAG比单纯塞文本块要强大得多当然建设成本也高得多。5.4 从被动检索走向Agentic RAG最后一个改进方向是把“问一个问题-捞几块-生成答案”的被动流程改成“先分析问题类型-规划检索路径-多步检索-综合答案”的主动流程。也就是热词里说的Agentic RAG。比如用户问“反派的动机是什么”Agent先判断这是一个因果推理问题再去查人物图谱找出反派与主角的冲突节点然后带着这些节点去向量库检索具体情节片段最后再生成答案。这比现在的单轮检索能承载的逻辑复杂度高一个量级。我踩完这些坑最强烈的感受是RAG不是“塞一个数据库进去就能用”的解决方案它在非结构化文本上的表现高度依赖数据设计和检索策略。小说这种文本恰好把RAG的短板都暴露了出来——长程依赖、全局统计、跨章节因果每一类问题都需要不同的解法。如果你的项目里也有类似复杂文本建议先从元数据和混合检索开始这两步投入产出比最高。
阅读完成 · 觉得有帮助?
咨询建站