做知识问答系统,最头疼的从来不是模型能力不够,而是模型“一本正经地胡说八道”。你问它《三国演义》里“关羽的青龙偃月刀第一次出现是在哪一回”,大模型大概率会给你一个看起来合理、实际上张冠李戴的答案。要解决这个问题,最务实的路径就是上 RAG(检索增强生成):先把原著切成片段、做成向量索引,用户提问时先检索最相关的原文片段,再把这些片段塞给大模型让它照着回答。这篇博文就完整记录一次以《三国演义》为测试语料的 RAG 全流程搭建过程,核心链路是“混合切分 + Qdrant 向量库 + BGE 中文向量模型”,最终把召回率 Recall@5 做到了 0.8。整个项目在普通开发机上就能跑,代码量不大,但里面涉及的文本切分策略、向量化细节、检索参数调优,都是真实业务里每天都会踩的坑,值得完整复现一遍。
1. 项目整体设计与技术选型
1.1 为什么拿《三国演义》当测试语料
很多人看到标题第一反应是:直接拿一本公版书做向量检索,是不是有点大材小用?恰恰相反,《三国演义》这种六七十万字的大部头古籍,是做中文 RAG 实验的绝佳样本。
原因有三点。第一,它足够长。六七十万字的体量,意味着如果切分策略设计得不好,检索效果会非常明显地下滑,你很容易看出问题出在哪个环节。第二,它是半文半白的语言风格,有大量“某曰”“当下”“却说”这类古语表达,跟现代中文的句子边界、语义密度差异很大,直接套用现代文本的固定窗口切分,效果一定拉胯,这就逼着你去认真设计切分方案。第三,很多人对三国故事本身就比较熟,构建评测集的时候,你能手工判断“这个片段到底该不该被召回”,方便人工标注 golden chunk。
一句话总结:这项目表面是在搭一个 RAG 管道,实际上是在练一套“古董级中文文本怎么进向量库”的方法论。数据库里存的是三国故事,方法论迁移到任何垂直领域文档上都成立。
1.2 RAG 的整体链路拆解
标准的 RAG 流程是固定的四段式,很多人一上来就急着写代码调模型,其实先把链路画清楚,后面每一步才知道自己在干嘛:
文本加载与预处理:拿到原始文本,清洗格式、去空行、统一标点,必要时做编码转换。切分(Chunking):把长文本切成适合向量化的片段,既要控制长度,又要尽量保住语义完整性。向量化(Embedding):用向量模型把每个片段编码成一个稠密向量。存储与检索:把向量写入向量数据库,查询时用同样的向量模型编码问题,在库里做相似度搜索,返回 top-k 片段。
这四步里,切分策略直接决定了向量化的输入质量,检索参数决定了“搜得准不准”,向量模型决定了“语义距离算得对不对”。任何一个环节偷懒,最终召回率都会给你颜色看。
1.3 技术选型背后的取舍逻辑
这次选型不是随手拍的,每一项都是对比过替代方案后的结果。
Qdrant是目前做中规模 RAG 项目很顺手的一个向量数据库。它用 Rust 写的,性能好,Docker 一键启动,自带 Web Dashboard,客户端 API 也足够简洁。相比 FAISS 这类纯库方案,Qdrant 自带持久化存储、payload 过滤和 RESTful 接口,不需要你自己管理索引生命周期。相比 Milvus 这类重级方案,Qdrant 的部署成本低得多,一台开发机就能跑得很舒服。小步快跑的场景下,Qdrant 是省心之选。
BGE(BAAI General Embedding)是中文语义向量模型里口碑很稳的一系,社区开源,有多个尺寸版本,对中文长文本的语义理解能力明显强于通用多语言模型。我这次用的是 large 版本,向量维度 1024 维,效果与性能的平衡度很好。如果用 base 版本(768 维)速度更快但精度略降,如果你的数据量特别大、对延迟敏感,base 也可以接受。
Recall@5=0.8 这个目标定得很实在。它的意思是:每次检索返回前 5 个片段,人工标注的正确片段出现在这 5 个结果里的概率是 80%。0.8 这个数字对知识问答场景来说是一个可用的及格线,再往上走边际收益会明显下降。
2. 环境准备与实验设施搭建
2.1 开发环境与依赖清单
整个项目的运行环境非常朴素,一台 16G 内存的普通开发机就够了。如果要用 GPU 跑向量模型,建议至少 6G 显存;没有 GPU 也能跑,就是 embedding 那一步会慢不少,但文本量只有六七十万字,CPU 硬扛也就是多等十几分钟的事。
我用的环境是这样的:
- Python 3.10+
- transformers
- sentence-transformers
- qdrant-client
- 中文分词辅助库(用于切分策略里的统计信息)
安装命令一条龙:
pip install transformers sentence-transformers qdrant-client如果你是第一次接触向量模型,建议先安装 sentence-transformers,它的封装比直接用 transformers 调模型更友好,几行代码就能完成文本向量化。
2.2 Qdrant 的启动姿势
Qdrant 的启动方式非常“傻瓜式”,官方镜像一把梭:
docker run -p 6333:6333 -p 6334:6334 \ -v $(pwd)/qdrant_storage:/qdrant/storage \ qdrant/qdrant这里 6333 端口是 HTTP 接口,6334 是 gRPC 接口,-v参数把数据目录挂载到宿主机上,防止容器重建后向量数据丢失。
启动之后,浏览器打开http://localhost:6333/dashboard,你就能看到 Qdrant 自带的 Web 界面,可以查看 collection 状态、执行简单的查询,甚至直接在里面调试 payload 过滤条件。
提示:Qdrant 的 Dashboard 是排查问题的利器。写入数据后,先在 Dashboard 里手动执行几个检索,看看返回的片段是否符合直觉,再上评测脚本。跳过这一步直接跑指标,容易把“数据库没写对”和“模型不行”混为一谈。
2.3 语料来源与清洗处理
《三国演义》作为公版书,网上有很多 TXT 版本。但公版书有个特点:整理质量参差不齐。有的版本每回前面有大段注释,有的版本回目里混入了“毛宗岗评点”之类的内容,有的则直接缺章少节。
所以拿到原始文本之后,第一件事不是切分,而是清洗。我的清洗步骤是:
- 统一换行符:把
\r\n统一替换为\n,避免 Windows 和 Linux 混用导致的换行混乱。 - 去掉多余空行:连续空行压缩为单空行。
- 识别回目标记:《三国演义》每回有明确的“第X回”标题,正则是
第[一二三四五六七八九十百千0-9]+回,以此作为第一层切分的锚点。 - 过滤杂质内容:把“版权所有”“整理自网络”这类无关文本剔除。
import re text = open("sanguo.txt", encoding="utf-8").read() text = re.sub(r"\r\n", "\n", text) text = re.sub(r"\n{3,}", "\n\n", text) text = re.sub(r"版权所有.*?整理自网络", "", text, flags=re.S)清洗的意义很多人会忽略,但向量模型的输入是“文本整体语义”,如果文本里混入大量与正文无关的注释、评点,切分出来的 chunk 会夹带噪声,检索时容易被干扰。这里多花十分钟,后面省两个小时调优。
3. 混合切分策略的设计与实现
3.1 固定窗口切分为什么不够用
初次做 RAG 的人很容易犯一个错:直接按固定字符数切分,比如每 500 字一刀切,完全不考虑语义边界。这种做法的后果,用大白话说就是:你很可能把一个完整的故事场景拦腰截断,甚至一句话被切到两个 chunk 里。
《三国演义》里到处都是长句和复杂嵌套结构。举个例子,“玄德与关、张来投吕布,却不知吕布已先入城,此时正与太守相谈甚欢”这种句子,如果一刀切在两个字符窗口的边界上,前半段说的是“刘备去找吕布”,后半段说的是“吕布已经进城”,检索“刘备投奔吕布的结果”时,这两个碎片都只包含部分答案,向量表示的语义都偏了。
固定窗口最大的问题在于它无视文本结构。对现代白话文,句子平均长度短,标点密集,固定窗口误伤率还可以接受。但古籍文本句子长、标点疏、语义密度高,固定窗口的误伤几乎是灾难级的。
3.2 混合切分的三层架构
我的方案是“三层混合切分”,每一层解决不同粒度的问题。核心思想非常简单:先按结构粗切,再按语义细切,最后用固定长度兜底。
第一层:回目级粗切。先把全书按“第X回”切成 120 个回目文档。这一步是结构性锚点,保证后续所有切分都不会跨回目。因为《三国演义》的每一回是一个独立的故事单元,跨回切分会把两个不同场景的语义混在一起。
第二层:自然段合并与拆分。对每一回的内容,先按空行拆成自然段。然后做两个判断:
- 如果相邻两个自然段都很短(比如每段少于 50 字),且语义主题相近(可以用简单规则:上一段结尾的实体在下一段开头再次出现),就合并成一个段组。
- 如果一个自然段特别长(超过 800 字),就按句号、问号、感叹号等句末标点拆成更小的语义单元。
这一步的目的是消除“文本中人为换行带来的随机性”。很多 TXT 版的换行位置根本不是语义边界,而是排版折行,只有经过自然段合并与拆分,才能得到相对干净的语义块。
第三层:固定长度兜底与重叠控制。经过前两层处理后,大部分语义块的长度已经落在合理范围。但总有一些块过长,或者块之间长度差异悬殊。此时再设定一个目标长度(比如 500 字)和一个重叠窗口(比如 80 字),对超长块做滑动窗口切分。
三层的逻辑关系是:结构层保证不跨回目,语义层保证不切断完整情节,长度层保证向量模型输入尺寸均匀。这样切出来的 chunk,既有语义完整性,又有长度一致性。
3.3 切分的核心参数计算过程
参数不是乱设的,我给出我的推导逻辑,你可以根据自己的语料性质调整。
向量模型的最大输入长度:BGE large 中文模型的默认最大序列长度是 512 token。中文里一个 token 大约对应一个字到两个汉字,所以稳妥起见,单个 chunk 的字符数控制在 350~500 字之间比较安全。超过 512 token 的内容会被截断,尾巴上的语义直接丢失,这是很多人召回率上不去的隐形原因。
重叠窗口长度:我定的是 80~100 字符。为什么要重叠?因为如果两个语义相关的片段正好被切在边界两侧,不重叠的话,这个跨边界的语义关系就断了。重叠相当于给检索加了一层保险,让边界附近的句子有更大几率出现在两个 chunk 里。但重叠也不能太多,否则一个片段被重复切进多个 chunk,既浪费存储,又容易在检索时返回大量重复内容。
合并短段的阈值:我设的是 50 字。短段合并的本质是在“语义碎片化”和“上下文污染”之间找平衡。太长的合并会把两个不相关的事件绑在一个 chunk 里,太短的合并又会让 chunk 因为信息量不足而向量化不充分。
def hybrid_chunking(text, max_len=450, overlap=80): chapters = re.split(r"(第[一二三四五六七八九十百千0-9]+回)", text) chunks = [] for i in range(1, len(chapters), 2): chapter_title = chapters[i].strip() chapter_body = chapters[i + 1] if i + 1 < len(chapters) else "" # 按自然段拆分 paragraphs = [p.strip() for p in re.split(r"\n\s*\n", chapter_body) if p.strip()] # 短段合并 merged = [] for p in paragraphs: if len(p) < 50 and merged and len(merged[-1]) + len(p) < max_len: merged[-1] += p else: merged.append(p) # 超长段按句末标点切分并控制长度 for p in merged: if len(p) <= max_len: chunks.append((chapter_title, p)) else: # 按句子拆开后再尽量拼回接近 max_len 的大小 sentences = re.split(r"(?<=[。!?])", p) buf = "" for s in sentences: if len(buf) + len(s) > max_len: if buf: chunks.append((chapter_title, buf.strip())) buf = s else: buf += s if buf: chunks.append((chapter_title, buf.strip())) return chunks切完之后,务必对每个 chunk 做去重和空文本过滤。我在实践中发现,公版书文本里的重复标语(比如“话说天下大势,分久必合,合久必分”这类套话)会被切出多个几乎一样的 chunk,检索的时候它们会占据 top-k 名额,把真正相关的片段挤出去。
3.4 切分效果的验证方法
切分完不能直接开跑,得先抽样验证。抽 10 个 chunk,逐个人工过一遍,回答三个问题:
- 这个 chunk 内部讲的是同一个事件吗?
- 有没有一句话被拦腰截断?
- 相邻两个 chunk 的首尾语义是否断裂?
这一步非常朴素,但对排查问题极其有效。如果抽样里有三成 chunk 语义混乱,后面向量化做得再好都白搭。我给当时的抽样统计留个表:
| 切分方案 | 抽样数 | 语义完整 | 句子被截断 | 跨场景混杂 |
|---|---|---|---|---|
| 固定 512 字 | 10 | 4 | 3 | 3 |
| 混合切分 | 10 | 9 | 0 | 1 |
抽样结果一目了然。混合切分不是理论上的“感觉更好”,而是实际效果显著优于固定窗口。
3.5 为什么回目信息必须保留进元数据
切分的过程中很多新手会忽略一件事:切出来的 chunk 要带上元数据。这里的元数据是指每个 chunk 属于第几回、回目标题是什么、在原文中的位置。
为什么必须带?因为向量检索只是第一步,真实的知识问答系统在拿到 top-k 片段后,还需要把答案的出处展示给用户,或者基于原文段落做进一步的逻辑校验。如果 chunk 里不带元数据,你最终只能给用户回一句“我也不知道这是哪一节的原文”,项目就没法用了。
另外,元数据也是后续做过滤检索的基础。比如用户限定“只查诸葛亮出场之后的章节”,你就可以在 Qdrant 里用 payload 过滤条件直接按回目标题筛,不用做向量相似度计算,速度快得多。
payload 设计长这样:
{ "chapter_title": "第二十七回 美髯公千里走单骑 汉寿侯五关斩六将", "chapter_index": 27, "chunk_id": "chunk_027_003" }4. 向量化:把文本变成语义坐标
4.1 BGE 模型的基础参数
BGE 全称 BAAI General Embedding,是社区开源的中文向量模型家族。我这次用的 large 版本,关键参数是这些:
- 输出维度:1024 维
- 最大输入长度:512 token
- 检索方式:余弦相似度
- 查询端需要拼接特定的指令前缀
很多人用 BGE 只把模型加载出来就直接 encode 全文,漏掉了一个关键细节:BGE 的查询文本和文档文本的编码方式不一样。文档端直接 encode 原文即可,但查询端必须加上指令前缀。这个指令前缀在不同版本的模型里措辞可能略有差异,large 中文版通常用的是为这个句子生成表示以用于检索相关文章:。
为什么要加这个前缀?因为 BGE 在训练的时候用了指令微调,模型习惯了在指令语境下理解查询文本。不加前缀,查询向量和文档向量的语义空间可能会有一点偏移,导致相似度计算不准确。我实测下来,就这么一个前缀,能把 top-5 检索质量从“随机”拉到“可用”级别,影响远比你想象的大。
4.2 模型加载与向量生成的完整代码
我用的是 sentence-transformers 封装。直接上完整代码:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-large-zh-v1.5") model.max_seq_length = 512 def encode_documents(docs): # 文档端:直接编码,不做归一化也可以,Qdrant 的 cosine 距离会自己处理 embeddings = model.encode( docs, batch_size=32, show_progress_bar=True, normalize_embeddings=True, ) return embeddings def encode_query(query): # 查询端:必须拼接指令前缀 instruction = "为这个句子生成表示以用于检索相关文章:" embeddings = model.encode( [instruction + query], normalize_embeddings=True, ) return embeddings[0]这里有两个细节值得单独说。
第一个是normalize_embeddings=True。归一化之后,向量的模长变成 1,余弦相似度退化为点积,检索时可以走更快的点积计算路径。Qdrant 的 COSINE 距离在内部也已经做了处理,但提前归一化能保证检索过程中数值计算更稳定。
第二个是batch_size 不要贪大。在 16G 内存的机器上,batch_size=32 是比较稳的选择。一次性塞太多文本进模型,显存或内存吃紧时,进程会被直接杀掉,前面白跑半天。跑长文本任务,稳定压倒一切,宁可多循环几次,也别一次性梭哈。
4.3 向量规模与内存估算
整个《三国演义》混合切分下来,最终 chunk 数量大概是 2500~3000 个左右(具体数量取决于切分参数)。每个 chunk 的向量是 1024 维浮点数,也就是1024 * 4 bytes = 4KB的原始数据量。
3000 个 chunk 全部向量化,原始数据大约是 12MB 左右。再加上 Qdrant 的索引结构开销,总量也就几十 MB 级别。这个体量对 Qdrant 来说属于“小菜一碟”,CPU 就能扛。你的实际业务如果数据量远大于这个量级,再认真考虑 GPU 加速和分片部署。
5. Qdrant 配置与向量写入
5.1 创建 Collection:给向量建一个“仓库”
在 Qdrant 里,向量数据存放在 collection 里。创建 collection 时必须指定两个核心参数:向量维度和距离函数。
维度必须与向量模型输出维度一致。我用的 BGE large 是 1024 维,所以 collection 的 size 就是 1024。距离函数选 Cosine,因为语义相似度用余弦相似度是最直观的度量方式。
from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams client = QdrantClient(host="localhost", port=6333) COLLECTION_NAME = "sanguo_rag" client.recreate_collection( collection_name=COLLECTION_NAME, vectors_config=VectorParams(size=1024, distance=Distance.COSINE), )5.2 HNSW 索引参数的角色分配
Qdrant 默认使用 HNSW(Hierarchical Navigable Small World)图索引做向量近邻搜索。HNSW 有两个关键参数:m和ef_construct。
m是图中每个节点的最大连接数,越大图越稠密,检索精度越高,但内存和构建时间也越大。ef_construct是构建索引时考虑的候选节点数量,越大索引质量越高,构建越慢。
我的配置是m=16、ef_construct=200。这个组合对 3000 个向量的小集合来说非常宽裕,几乎没有构建压力,同时能保证召回质量。
这里有个常见问题:很多人看到 HNSW 参数就头大,其实对中小规模数据集来说,默认参数基本够用。HNSW 的真正调优是在千万级甚至亿级向量场景下才需要扣的细节。小数据集上,把切分和向量化做好,收益远大于折腾索引参数。
5.3 批量写入与 payload 管理
写入时把向量和 payload 一起传进去:
from qdrant_client.models import PointStruct points = [] for idx, chunk in enumerate(chunks): doc_text = chunk[1] embedding = encode_documents([doc_text])[0] payload = { "chapter_title": chunk[0], "text": doc_text, "chunk_id": f"chunk_{idx:04d}", } points.append(PointStruct(id=idx, vector=embedding.tolist(), payload=payload)) # 分批写入,避免一次性提交过大 for i in range(0, len(points), 64): client.upsert( collection_name=COLLECTION_NAME, points=points[i:i+64], )写入之后做一次完整性检查:查一下 collection 里的点数,跟本地的 chunk 数量对得上吗?检查时要特别留意payload里有没有存原文文本。很多人图省事只存向量不存原文,导致检索结果出来了还要回源数据库二次查询,平白多一次 IO 开销。在小项目里直接把原文放在 payload 里,查询时一次就全拿出来了。
5.4 写入后的快速验证
写入完成不代表万事大吉。先跑几条人工查询,看看返回结果是否符合直觉。
hits = client.search( collection_name=COLLECTION_NAME, query_vector=encode_query("关羽温酒斩华雄"), limit=3, ) for hit in hits: print(hit.payload["chapter_title"]) print(hit.score) print(hit.payload["text"][:120])这一步很关键。如果“关羽温酒斩华雄”都搜不到“第五回”相关的片段,问题一定出在前面切分或向量化环节,别急着调检索参数。
6. 检索链路与召回率评测
6.1 查询的完整数据流
检索链路本身不复杂,但每个环节都有隐藏细节:
- 查询预处理:清洗用户输入,去掉多余空格、统一中英标点。
- 查询向量化:拼接 BGE 指令前缀,用与文档端相同的模型编码。
- 相似度搜索:在 Qdrant 里用余弦距离做 top-k 搜索。
- 后处理:按分数排序,过滤掉分数过低的片段,返回候选项。
def search(query, top_k=5): query_vector = encode_query(query) hits = client.search( collection_name=COLLECTION_NAME, query_vector=query_vector, limit=top_k, score_threshold=0.5, # 低于这个分数说明语义关联太弱 ) results = [] for hit in hits: results.append({ "score": hit.score, "text": hit.payload["text"], "chapter": hit.payload["chapter_title"], }) return resultsscore_threshold=0.5这个阈值是我多次测试后的经验值。BGE 生成的向量对语义相似度比较敏感,真正相关的片段分数通常在 0.65~0.85 之间;完全不相关的片段分数会断崖式下跌到 0.3 以下。中间这层“模糊地带”就是调参会重点观察的区域。
6.2 评测集的构建:最花精力的一步
要算 Recall@5,先得有“标准答案”。这一步没有任何捷径,老老实实人工标注。
我构建了 40 个问题,覆盖三类场景:
直接事实型:比如“赤壁之战中,谁献了连环计?”答案是庞统,标准片段是包含“庞统”和“连环计”的那一段。跨章节关联型:比如“刘备三顾茅庐的详细经过”,标准片段可能分布在好几回,评测时要记录多个 golden chunk。模糊语义型:比如“诸葛亮和司马懿在哪些战役中交锋”,这类问题涉及到多个事件,标准答案更分散。
对每个问题,手工翻阅原文,找出正确答案出现的所有 chunk 编号,作为 golden set。这个环节极其枯燥,但它是整个评测体系的基石。如果 golden set 本身标注错了,后面算出来的 Recall@5 再好看也是自欺欺人。
6.3 Recall@5 的计算逻辑
假设一个问题 Q,它的 golden set 里有 2 个正确 chunk(答案跨了两个片段)。检索系统返回 top-5 的片段。计算逻辑是这样的:
- 如果这 5 个片段里包含 golden set 里任意一个 chunk,记为一次命中。
- 如果 5 个片段里一个 golden chunk 都没出现,记为零命中。
- 最终 Recall@5 = 命中问题数 / 总问题数。
注意:不是要求返回的 5 个片段全都正确,只要“正确答案至少出现在 top-5 里”,就算命中。这个指标的语义是“系统有没有把正确答案捞回来”,至于捞回来之后怎么用,那是下一个环节(比如摘要、问答生成)的事。
def recall_at_k(results, golden_set, k=5): hit = 0 for doc in results[:k]: if doc["chunk_id"] in golden_set: hit = 1 break return hit6.4 首次评测的惨痛现实
说实话,第一次完整跑通评测,我拿到的 Recall@5 只有 0.25。这个分数意味着:四分之三的问题,正确答案根本没被捞回来。
排查之后发现最大的问题出在切分上。我用的还是早期的固定 512 字切分方案,很多 chunk 语义破碎,向量表示被“稀释”了。比如有一道题问“赵云七进七出救阿斗”,标准答案在原文里是一整段连续的叙述,但固定切分把它劈成了两半,两半各自向量化之后,跟问题向量的相似度都不高,双双跌出 top-5。
这个现象非常典型:不是模型不行,而是输入给模型的文本片段本身就不完整。切分策略是 RAG 管道的“地基”,地基不稳,上层建筑再好也白搭。
7. 从 0.25 到 0.8 的调优过程
7.1 切分策略替换带来的质变
把固定切分换成混合切分之后,Recall@5 直接跃升到了 0.62。这个跳变印证了一个判断:对古籍长文本,语义完整的 chunk 是检索精度的第一决定因素。
换切分之后我做的第二件事是把 BGE 的查询指令前缀加上。指令前缀本身不改变文档向量,但显著改善了查询向量的语义对齐,Recall@5 又从 0.62 升到了 0.72 左右。
这里想强调一下:加指令前缀这件事很容易被忽略,因为文档端和查询端用的是同一个模型,很多人默认“同一个模型编码出来的向量肯定在同一个空间里”。实际上 BGE 的指令微调让查询端和文档端有不同的预期输入格式,漏掉指令前缀等于让查询向量站错了队。
7.2 检索参数微调的增量收益
切分和向量化优化完,最后一步是检索参数的微调。我在 Qdrant 上重点调了两个参数:
第一个是search时的ef参数。HNSW 在搜索时也有一个ef参数,它控制搜索过程中检索的候选节点数量。默认值偏保守,我调大之后,top-5 结果的稳定性明显提升。对小型 collection,把ef设到 128 毫无压力,检索耗时的增加可以忽略。
第二个是score_threshold。阈值设得太低,top-5 里会混入一堆不相关片段;设得太高,又可能把正确的片段挡在门外。我通过跑一版完整评测,观察所有命中问题的分数分布,最终把阈值定在 0.5。
调优后的完整配置:
client.search( collection_name=COLLECTION_NAME, query_vector=query_vector, limit=5, score_threshold=0.5, search_params=SearchParams(hnsw_ef=128), )这一套组合拳打下来,Recall@5 终于站上了 0.8。
7.3 数据一致性暗坑:版本差异问题
调优过程中我还踩了一个很隐蔽的坑:语料版本混用。我最初的 TXT 文本是从一个网站下载的,中间发现某回内容疑似有缺漏,又换了另一个版本的文本补充,导致整个语料库存在两套风格差异明显的排版措辞。
问题不在于文本本身,而在于评测时标准答案和检索库的文本来源不一致。个别问题的 golden chunk 对应的文本,在检索库里根本不存在一模一样的版本,自然检索不出来。
这个坑的教训是:语料一旦确定,中途不要轻易换源。如果确实需要补全内容,必须重新走一遍清洗和切分流程,并且重新标注评测集,否则评测结果就会失真。
7.4 避免“虚高”的评测陷阱
做评测还有一个常见的自欺欺人操作:评测集里混入太多和原文高度重合的问题。比如直接把原文某句话改成疑问句作为测试问题,这种问题检索难度低,Recall@5 很容易虚高。
我最初有一版评测集 Recall@5 算出来是 0.85,但我复查时发现高分是因为十几个问题都直接复述了原文里的关键词。真正的知识问答场景,用户的问题是多样化的表述,不会那么“贴心”地跟原文保持一致。所以我后来重新设计了评测集,加入了大量同义改写、跨章节综合类问题,才把分数拉回真实的 0.8 水平。
评测集质量决定你调优的方向是否正确。在虚高的评测集上调优,调出来的参数在真实场景里大概率失灵。
8. 完整流程复盘与常见问题速查
8.1 一图看懂全流程数据流转
整个项目跑下来,核心链路可以用一条线串起来:
- 原始 TXT 文本 → 清洗去噪 → 回目级粗切 → 自然段合并与拆分 → 长度兜底切分
- 每个 chunk 带上回目标题、原文内容、chunk_id 等 payload
- BGE 模型对文档端直接编码,对查询端拼接指令前缀后编码
- Qdrant 建 collection,指定 1024 维和 Cosine 距离,批量写入向量与 payload
- 评测时人工标注 golden chunk,执行 top-k 检索,计算 Recall@5
- 根据评测结果反向调整切分参数、指令前缀、检索参数
每个环节的产出和反馈都是有序的。调优时如果效果不达预期,永远从上游开始排查,而不是盲目调模型或调数据库。
8.2 遇到的问题与解决对照表
| 现象 | 可能原因 | 解决动作 |
|---|---|---|
| Recall@5 极低 | 切分语义破碎 | 换混合切分,按章节和自然段锚定边界 |
| 查询结果与预期明显不符 | 漏加 BGE 查询指令前缀 | 查询端拼接检索指令前缀 |
| 返回片段重复内容多 | 文本里套话重复,多个相同 chunk | 切分时增加去重逻辑 |
| 正确答案在 golden 却搜不到 | 评测标注与语料版本不一致 | 统一语料来源,重跑标注 |
| 分数虚高但实战拉胯 | 评测问题与原文高度重合 | 重构评测集,加入改写和综合题 |
| 写入后查询报错 | collection 维度与模型不匹配 | 检查模型输出维度与 VectorParams.size |
| 内存溢出 | batch_size 过大 | 调低批量,分批写入 |
8.3 想迁移到自己的业务,这几点最关键
如果你不跑三国,想把这套流程用到自己的文档场景里,我给你几个迁移建议:
切分策略要跟着文本结构走。我的混合切分是“回目 + 自然段 + 长度兜底”三层结构。你的语料如果是 PDF 报告,那第一层粗切应该按章节标题来;如果是聊天记录,按对话轮次切;如果是代码文档,按函数定义切。核心思路不变:先找结构锚点,再处理语义边界,最后用长度兜底。
向量模型的指令前缀,每个模型都不同。我用的 BGE large 有固定的查询指令,换了别的模型要去看该模型的技术文档,确认查询端是否需要特殊的 prompt 格式,不要想当然沿用。
评测集要能反映真实使用场景。别用“和原文太像”的题目凑数,把你实际业务中用户最常问的那类问题拿来做评测集,哪怕数量少一点,也比自欺欺人的高分数有价值。
最后一个实操小技巧
这次项目里有一个很小但很实用的技巧,我放在最后说:每次调整切分或检索参数后,把旧的评测结果记录下来。我用一个简单的 CSV 文件,记录了每一次实验的问题编号、参数组合、top-5 返回结果、是否命中。调参的时候不要只盯着最终分数,还要看“哪些问题从 miss 变成了 hit,哪些问题反而退步了”。这种细粒度的变化比总分更能告诉你下一步往哪个方向使劲。
比如我发现某次调参后总分没变,但两个“跨章节关联型”问题从 miss 变成了 hit,说明新参数对这类问题的泛化能力更好,只是被另一些问题的波动抵消了。顺着这个线索继续调,往往能找到真正的瓶颈。RAG 调优说到底就是一轮一轮试出来的,把每一次实验的痕迹留下来,你才不会在反复试错中迷失方向。