混合切分 + Qdrant + BGE:中文古籍 RAG 召回优化实战

发布时间:2026/10/17 16:22:27
混合切分 + Qdrant + BGE:中文古籍 RAG 召回优化实战 聊一个我最近跑顺的检索链路拿《三国演义》当测试语料把“混合切分 Qdrant BGE”这条 RAG 关键路径完整搭了一遍最终 Recall5 稳定在 0.8。这个数字放在现代新闻类语料里不稀奇但放在中文古籍这种长段落、强叙事、对话密集、人物指代复杂的文本里已经足够说明整套链路的基本盘是对的。这篇文章是一次完整复盘为什么选《三国演义》做测试混合切分具体怎么切BGE 模型怎么选Qdrant 集合怎么配以及那个 0.8 到底是怎么算出来的。适合正在搭知识库问答、做 RAG 召回优化、或者刚接触向量检索的朋友看完可以直接照抄思路。1. 项目定位为什么要拿一本章回体小说当检索试验田1.1 选《三国演义》做语料的真实原因很多人做 RAG 测试喜欢用新闻、百科、技术文档这类文本段落边界清楚、主题集中、一个段落往往只讲一件事检索难度天然偏低。我之所以选《三国演义》就是因为它足够“脏”章回体结构一回少则几千字多则上万字里面既有叙事、有对话、有书信、有诗词还有大量人物在不同章节反复出现。这种文本结构和真实业务场景里的合同、客服工单、医疗记录、历史档案非常像恰好能暴露检索链路的大多数问题。再说一个关键点它是公版文本可以合法、自由地用于本地实验。不需要考虑数据授权和数据脱敏模型怎么折腾都行这特别适合拿来验证工具链。如果你手头没有合适的实验语料用《三国演义》或者《红楼梦》《水浒传》这类公版古籍做压测是比较省事且可复现的选择。1.2 Recall50.8 到底意味着什么先解释一下 Recall5给定一个问题检索系统返回 5 条候选片段如果这 5 条里至少有一条包含答案或与答案段落语义一致就算一次命中命中次数除以问题总数就是 Recall5。简单说就是“前 5 条里能不能找到靠谱材料”的概率。0.8 这个目标不是拍脑袋定的。在 RAG 链路里召回是生成的上游召回模块把不相关的内容塞给大模型大模型再强也容易一本正经地胡说八道召回结果没有正确答案生成质量直接归零。如果前 5 条里命中率达到 80%说明检索模块已经能稳定地把正确位置捞到候选范围内接下来只需要在精排或提示词层面做优化。对知识库问答来说0.8 是一个相对靠谱的及格线低于 0.7 基本无法上线高于 0.9 则往往意味着测试集太简单或语料太规整。2. 混合切分别迷信“定长切分一刀切”2.1 三种切分方式的实测对比我最早跑了一组对照实验分别用整章切分、定长切分、自然段落切分去切同一本《三国演义》然后分别喂给同一个 BGE 模型和 Qdrant 检索。结果非常说明问题。整章切分最简单粗暴一章就是一个 chunk。优点是上下文完整缺点是向量化时信息太杂一个向量要“表达”好几件事。比如某回里前半段写军事调度后半段写人物对话这两个语义被压进同一个向量两方向互相稀释检索时哪个都不够精准。实测下来 Recall5 只有 0.4 出头。定长切分是很多教程里的“标准做法”比如固定 512 字、重叠 64 字。它在规整文本上有效但用在古籍上问题很突出定长边界会拦腰切断对话也会把一个人物的连续动作叙事切成两半。检索时经常出现“返回了 5 条每条都沾边但没有一条真正把答案讲全”的情况。而且古典白话里的“话说”“且说”“诗曰”这类套话会被无意义地切进不同块里进一步干扰语义。自然段落切分看起来最合理中文古籍里自然段落通常围绕一个事件或一次对话展开。但《三国演义》里也有大量超长段落一段几百上千字中间嵌套了好几层动作和对话。此时一个自然段依然太大直接在向量里形成“语义包”命中率还是上不去。2.2 混合切分的设计思路与实现所谓混合切分一句话概括先用章节边界框住语义范围再用自然段落做第一层切分最后对超长段落做带重叠的滑窗切分。这样既保留叙事上下文又让单个 chunk 的语义足够聚焦。具体规则如下按“回”读取全文以回目标题作为元数据不回切到上一回。在回内部按空行或者段落标记识别自然段。如果自然段长度小于阈值如 300 字直接作为一个 chunk保留章节和段落编号。如果自然段超过阈值再用滑窗切成多个片段片段之间保持 10% 到 20% 的重叠。每个 chunk 写入结构化元数据回目、段落序号、片段序号、文本内容、是否需要过滤如“诗曰”段落可单独标记。这套逻辑用代码实现并不复杂。我自己的处理脚本大致长这样max_len 400 overlap 64 chunks [] for chapter_id, chapter_text in enumerate(chapters): paragraphs split_paragraphs(chapter_text) for para_idx, para in enumerate(paragraphs): para clean_boilerplate(para) # 去掉开头的“话说”“且说”等固定套话 if len(para) max_len: chunks.append({ chapter: chapter_id, para_idx: para_idx, text: para }) else: start 0 seg_idx 0 while start len(para): end min(start max_len, len(para)) chunk_text para[start:end] chunks.append({ chapter: chapter_id, para_idx: para_idx, seg_idx: seg_idx, text: chunk_text }) seg_idx 1 if end len(para): break start end - overlap这里有一个经常被忽略的细节重叠不能太大。很多人喜欢把 overlap 设成 20% 甚至 30%觉得这样上下文衔接更稳。实际上重叠越大切出来的块越相似检索时一个正确答案会以多个高度雷同的片段霸占前 5 的位置反而让“有效候选”变少。我在 400 字块上做过对比overlap 从 64 调到 128Recall5 不但没涨反而因为重复内容挤压候选位而下降。2.3 切分参数为什么这么定chunk 大小和重叠不是凭感觉拍出来的。我在实验里对比了 256、400、512 三档块长结果 400 字最稳。原因可以从两个角度解释。从向量模型角度看BGE 这类中文向量模型虽然在技术上限上支持很长的输入但实际编码时真正起决定性作用的往往是前 200 到 300 个 token。过长的 chunk 里中段和后段信息会被严重压缩说白了就是“长文本里存在注意力稀释”。从检索场景看一个问题通常只对应一段具体描述比如“某次战役的兵力部署”对应原文可能就两三句话。chunk 太小则信息不全chunk 太大则噪音太多400 字恰好能覆盖一个完整战术动作加部分前后叙事。再说重叠 64 字。古代白话经常用“且说”“却说”做叙事转折转折往往发生在段落后半段。我用 64 字重叠相当于给下一个窗口预留了小半段上下文让“谁在说”“接哪段话”不丢。实测表明重叠从 0 调到 64 时召回提升最明显而超过 128 后提升趋零甚至为负。3. BGE 模型选型与向量化细节3.1 为什么选 BGE 而不是通用向量模型做中文检索模型选型直接影响指标上限。BGE 系列是国内开源的中文向量模型相比通用英文向量模型它对中文长句、文言白话混杂的文本理解更友好而且支持 8192 长度的输入对古籍这种长文本非常关键。实际对比也验证了选择同一批切分结果用 BGE-M3 编码后检索Recall5 比用通用英文向量模型高出 0.1 到 0.15。差距主要出现在两方面。一是人名、地名、古代官职名的语义匹配通用模型经常把“某路军马”和“某州”的相关性强拉到一起二是整句语义通用模型容易把古典白话里的倒装和省略结构理解偏。BGE 在这类中文语料上的训练数据更充分所以中文长句的整体表征明显更准。3.2 加载模型和编码时的关键操作BGE-M3 模型本身不小加载和推理需要做几个操作否则容易遇到内存爆掉或者推理速度慢的问题。我本地的加载方式是from sentence_transformers import SentenceTransformer model_path your_local_path/bge-m3 model SentenceTransformer(model_path, devicecuda) model.max_seq_length 8192 batch_texts [chunk[text] for chunk in chunks] embeddings model.encode( batch_texts, batch_size32, normalize_embeddingsTrue, show_progress_barTrue )几个需要注意的细节第一normalize_embeddingsTrue必须开。向量距离用什么计算方式取决于向量是否归一化。BGE 官方推荐用余弦相似度如果编码时不归一化后面在 Qdrant 里配了 COSINE 距离算出来的分数会偏掉影响阈值判断和后续精排。第二如果机器内存不够不要一次性把所有切块塞进encode可以分批处理比如每次编码 256 条然后增量写入向量库。BGE-M3 的显存占用在 fp16 下大概需要 6 到 8 GBCPU 推理也可以跑但速度会慢不少建议优先用 CUDA。第三chunk 文本在编码前最好统一清洗。古籍里有大量前后不一致的全角半角标点、繁体异体字统一转成简体并规范化标点后向量检索的稳定性会好很多。我在预处理时做了繁体转简体、全角转半角、剔除空段落这些基础操作比后续调模型参数带来的收益更直接、更稳定。4. Qdrant 集合配置与写入优化4.1 集合参数与索引配置向量库我选的是 Qdrant原因很简单开源、部署轻、Python 客户端友好、支持 payload 过滤和混合查询配合 BGE 做 RAG 检索刚好够用。第一次建集合时有几个参数需要格外注意。BGE-M3 输出的是 1024 维稠密向量所以集合的向量维度必须配成 1024。距离函数用 COSINE和前面编码时的归一化设置对应。集合配置代码如下from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams client QdrantClient(hostlocalhost, port6333) client.create_collection( collection_namesanguo_rag, vectors_configVectorParams(size1024, distanceDistance.COSINE), )Qdrant 默认会为向量建 HNSW 索引。对于这种规模的数据默认参数通常够用但如果你的业务数据到百万级可以关注两个参数m和ef_construct。m控制每个节点的最大连接数越大召回越准但内存越高ef_construct控制建索引时的搜索宽度越大索引质量越高。我这次只跑一本书的规模用了默认值没有额外调。需要注意的是Qdrant 的集合参数一旦创建就不能修改向量维度所以建集合之前必须确认模型输出维度。我一开始线建了一个 512 维的测试集合后来换 BGE-M3 时发现维度对不上只能删掉重建。这种错误排查起来很隐蔽因为检索不会报错只会返回全零的相似度。4.2 批量写入与 payload 设计写入 Qdrant 时我给每条 chunk 都配了一套结构化 payload。这步看似简单但对后续检索过滤和问题排查至关重要。我的 payload 结构大致如下{ chapter_id: 5, chapter_title: 某回目名称, para_idx: 12, seg_idx: 0, text: 原文片段内容, is_poem: false }写入时不要一条一条 upsert太慢。我按 256 条一批做批量写入效果比较理想from qdrant_client.models import PointStruct points [] for idx, emb in enumerate(embeddings): points.append(PointStruct( ididx, vectoremb.tolist(), payloadchunks[idx] )) if len(points) 256: client.upsert( collection_namesanguo_rag, pointspoints, waitFalse ) points [] if points: client.upsert(collection_namesanguo_rag, pointspoints, waitFalse)关于waitFalse它表示异步写入速度快很多。但异步写入后立刻去查询可能查不到刚写入的数据。如果需要“写入后立刻校验”比如小规模调试建议把wait设成True等写入完成再查批量导入阶段再用异步最后统一确认。4.3 查询侧的检索方式写入之后检索查询就不复杂了。核心代码如下hits client.query_points( collection_namesanguo_rag, queryquestion_embedding, limit5, with_payloadTrue ).points这里有一个实用技巧在召回阶段先不加任何过滤条件只按向量相似度取前 20 条然后再根据业务需要用 payload 里的章节、段落信息做二次过滤。比如某些问题明确涉及后半本的故事可以在查询时加一个chapter_id的范围过滤减少无关片段的干扰。from qdrant_client.models import Filter, FieldCondition, Range client.query_points( collection_namesanguo_rag, queryquestion_embedding, query_filterFilter( must[ FieldCondition( keychapter_id, rangeRange(gte30, lte50) ) ] ), limit5, with_payloadTrue )但这招要慎用。过滤条件会缩小候选范围如果问题本身跨越多个章节或者模型对问题定位不够准过滤反而会伤召回。我在实验里发现通过“先广后精”的策略也就是先取 20 条再本地按文本相似度重排往往比直接在向量库上加过滤更稳。5. 评估体系Recall5 是怎么算出来的5.1 测试集怎么构造评估向量检索效果最忌讳的是用“题目 标准答案段落”自己生成测试集再拿同一套数据调参容易过拟合。我采用的做法是先从《三国演义》全文中抽取了 60 个知识点每个知识点对应原文里一段明确描述然后手工编写成自然问句不参考切分结果设计问题。举个例子我从描述某场战役兵力调动的段落写成“这场战役中进攻方出动了哪些部队”这样的问题。注意问题不能直接使用原文里的原句否则测出来的只是“字符串匹配能力”而不是语义检索能力。测试集构造完成后再针对每条问题人工标注它对应的正确答案在原文中的位置。5.2 评估脚本与指标计算评估逻辑很简单对每个问题编码成向量调用 Qdrant 取前 5 条判断这 5 条中是否有一条的原文位置落在人工标注的正确答案区间里。如果命中了记为一次成功如果 5 条全是无关内容记为失败。最终把命中次数除以问题总数就得到 Recall5。脚本核心代码def evaluate(questions, gold_spans, client, model): hits 0 total len(questions) for q, gold_spans_one in zip(questions, gold_spans): q_emb model.encode([q], normalize_embeddingsTrue)[0] results client.query_points( collection_namesanguo_rag, queryq_emb.tolist(), limit5, with_payloadTrue ).points hit False for r in results: payload r.payload pid (payload[chapter_id], payload[para_idx], payload.get(seg_idx, 0)) if pid in gold_spans_one: hit True break if hit: hits 1 return hits / total我在实验里得到的指标是 Recall5 0.80MRR平均倒数排名约 0.61。MRR 的含义是正确结果排在第一位计 1 分第二位计 0.5 分第三位计 0.33 分取所有问题的均值。MRR 0.61 说明命中情况下正确答案多数落在第一、二位这对接大模型非常有利因为生成上下文越靠前模型越容易优先参考。5.3 结果分析与干扰因素0.8 的召回率并没有让我完全满意因此我进一步看了失败案例发现干扰主要来自三处。一是统称问题比如“某位谋士献策”这种表述出现频率太高模型常常检索到另一处的“献策”语义相似但原文位置不对。二是重叠分块带来的位置重叠问题如果正确答案跨了两个相邻 chunk人工标注区间可能只覆盖其中一个检索返回另一个时判成失败其实内容也相关。这种情况下评估脚本应该放宽条件把相邻 chunk 的区间也算命中。三是回目名称的干扰。《三国演义》每回的回目是对仗句比如“某武将单骑救主”这种高度概括的标题和正文内容语义距离较远如果问题恰好涉及该回检索很容易把回目标题配到正文前面。后来我在切分时把回目标题单独存成“标题型 chunk”并在评估时排除它们参与检索分数有所回升。6. 踩坑实录与排查速查6.1 重叠分块导致的候选重复第一次跑通全流程时我发现 Recall5 虚高到 0.87但人工看结果发现前 5 条里经常有 2 到 3 条来自同一个自然段的不同滑窗。这些片段高度相似虽然评估脚本按“至少命中一次”判断分数被撑高了但真实场景里如果生成模型拿到 3 条重复内容信息增量很小。正确做法是在查询后按“章节号 段落号”去重或者在建 chunk 时给重叠区域内的子片段降低权重。我调整后Recall5 回落到 0.8指标更诚实。6.2 古籍文本特有的噪音古代白话里常见的“话说”“且说”“却说”这类开场套话单独成块时几乎检索不到任何有效信息只会占向量库空间。我在预处理时写了一组简单规则把这些套话开头的段落做截断只保留有效内容。类似地“诗曰”“赞曰”后面的韵文虽然有一定的文学价值但作为检索单元干扰很大我会单独标记是诗在普通问答里过滤掉。6.3 Qdrant 与 BGE 联调时的高频问题版本不匹配是排查起来最花时间的坑。qdrant-client 版本差异会导致不同 API 风格早期版本用client.search新版本推荐client.query_points。如果照旧教程写代码很容易遇到接口被弃用的报错。建议统一按当前官方文档写不要混用旧版本代码。另外如果检索结果全是 0 相似度或者空列表优先怀疑向量维度和距离函数配置再检查编码时的归一化是否打开。内存问题也很关键。BGE-M3 加载后大概占 4 到 6 GB 显存Qdrant 进程单独占 1 到 2 GB 内存。如果本机总内存不大建议把 Qdrant 跑在 Docker 容器里限制内存上限或者换成 CPU 推理模式牺牲速度保障稳定。向量索引文件也不小写入后要及时确认磁盘剩余空间。6.4 本地复现这套流程的完整命令最后我整理了一下从零开始复现的完整操作顺序# 1. 准备好《三国演义》txt 文本 # 2. 安装依赖 pip install sentence-transformers qdrant-client # 3. 启动 Qdrant 容器 docker run -p 6333:6333 -v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant # 4. 运行预处理脚本输出 chunks.json # 5. 运行编码脚本生成向量并写入 Qdrant # 6. 运行评估脚本输出 Recall5 / MRR整个过程按照这个顺序跑遇到问题可以直接定位到对应环节不会出现“写了 500 行代码不知道哪里报错”的情况。7. 写在最后的个人体会整套流程走完我最大的感受是模型和向量库都不是这个项目里最卡脖子的环节真正决定上线效果的是分块策略和评估集的设计。很多人一上来就追求更强的模型、更高的向量维度却忽视了语料里“一段话讲三件事”的问题结果无论怎么换模型召回指标都卡在同一个水平线上。我这次用混合切分把《三国演义》这类长文本拆到“一段一句一义”的程度再配合 BGE 的中文语义理解和 Qdrant 的稳定检索才最终把 Recall5 抬到 0.8。最后再分享一个小技巧调参时不要一次改多个变量。我经常看到有人同时改 chunk_size、overlap、向量模型版本结果指标从 0.75 涨到 0.8根本说不清是哪个改动起的效果。正确做法是把每次实验的切分策略、编码配置、评估结果做成一张表一次只动一个变量这样你才能真正理解每个参数对检索质量的贡献。这套方法换个语料、换个领域照样能跑理解链路原理比记住某个参数值重要得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询