本地知识库问答答非所问,未必是模型不够强:如果召回片段本身没有答案,问题多半出在文档切分或检索;若片段相关,才该检查提示词与生成。文章从最小 RAG 管线出发,讲解按结构切分、调整块长与重叠、排查 top-k、引入 BM25 混合检索及选择中文嵌入模型,并建议用小型测试集量化每次改动。怎样判断该调哪一环,才能让答案更有依据?
— 此摘要由AI分析文章内容生成,仅供参考。
很多人第一次搭本地知识库问答都会卡在同一个地方:明明把文档都喂进去了,一问具体问题,模型要么答得牵强、找不到依据,要么直接把不相关的段落拼成一段看似合理实则离题的回答。更让人头疼的是,你改了一个参数,感觉"好像好了一点",但说不清到底好在哪,第二天换个问题又翻车。这篇教程就从"答案找不到依据、检索内容不相关"这个具体毛病出发,带你一步步调文档切分和向量检索,并用一个小小的测试集把每次改动的效果量化出来,而不是全凭感觉。
整个流程聚焦最基础的 RAG(检索增强生成,Retrieval-Augmented Generation)管线:整理文档、切分文本、建立向量索引、检索相关片段、拼进提示词交给大语言模型生成答案。我们不比较具体云服务,也不会宣称调完之后答案就绝对可靠——RAG 的本质是"让模型基于你给的材料回答",材料找得准不准,直接决定了答案的上限。

先搞清楚:答案不靠谱,到底是哪一环出了问题
在动手调参之前,得先分清症状。RAG 回答不好,通常是两类原因,排查方向完全不同:
- 检索阶段就没找对:发给模型的那几段文本本身就不包含答案,或者包含但被切碎了。这种情况下,模型再强也是巧妇难为无米之炊。
- 检索对了但生成跑偏:相关片段其实被召回了,但模型没好好用,或者提示词写得让它自由发挥。
区分这两类的方法很简单:把每次检索实际召回的文本片段打印出来看一眼。如果你肉眼都能从召回的片段里找到答案,那问题在生成和提示词;如果召回的片段压根不沾边,那问题在切分和检索。这篇文章重点解决后者,因为它是绝大多数"找不到依据"的根源。
准备条件:一个能跑起来的最小管线
调参的前提是你得先有个能跑的东西。最小配置包括:一个嵌入模型(embedding model,把文本转成向量)、一个向量索引、一份整理好的文档。本地跑的话,用 Ollama 加 LangChain 是常见组合,向量索引用 FAISS 这类轻量方案即可。
文档整理这一步容易被忽略,但它决定了后面所有环节的质量。建议做三件事:
- 统一格式。把 PDF、DOCX 转成纯文本或 Markdown。Markdown 的好处是标题层级、列表这些结构能保留下来,后面按结构切分时很有用。
- 去掉噪音。页眉页脚、页码、重复的版权声明、导航菜单,这些内容进了索引只会稀释检索质量,让不相关的片段更容易被召回。
- 保留来源信息。给每个片段打上
source之类的元数据,标明它来自哪个文件、哪一节。排查问题和给答案附引用时都靠它。
第一刀:切分长度和重叠怎么选
文档为什么要切块?因为大语言模型的上下文窗口有限,而且语义连贯的小段落比一整篇长文更容易被精准检索到。但切多大、重叠多少,是最影响效果又最容易拍脑袋的两个参数。
先看一段最基础的切分代码,用的是 LangChain 的字符切分器:
from langchain_text_splitters import CharacterTextSplitter
text_splitter = CharacterTextSplitter(
separator="nn", # 优先按空行(段落)切
chunk_size=500, # 每块最多 500 字符
chunk_overlap=50, # 相邻块重叠 50 字符
length_function=len,
)
chunks = text_splitter.split_text(content)
这里三个关键字段:
separator决定在哪里下刀。"nn"表示优先在段落边界切,尽量不破坏语义。对中文文档,如果段落里很少空行,可以考虑用递归切分器(RecursiveCharacterTextSplitter),它会按一组分隔符逐级尝试,先段落、再句子、最后才硬切。chunk_size是每块的最大长度。注意这里按字符计数,中文一个字就是一个字符,和英文的 token 不完全对应。chunk_overlap是相邻块之间重叠的部分,作用是防止一句话正好被切在两块之间,导致关键信息被割裂。
不同长度的取舍
切块长度没有万能值,它本质是"检索精度"和"上下文完整性"之间的权衡。块越小,向量越聚焦,检索越容易命中具体的点,但一个块可能装不下完整的因果或步骤;块越大,上下文越完整,但一个块里混了好几个主题,向量会被"平均"得很模糊,反而不容易被精确命中。
下面这张表给出常见文档类型的起步参考值,注意是起步值,最终还得靠测试集验证:
| 文档类型 | chunk_size(字符) | chunk_overlap | 说明 |
|---|---|---|---|
| 问答对、术语表、短条目 | 200–400 | 20–50 | 每条本身独立,切小更精准 |
| 技术文档、操作手册 | 400–800 | 50–100 | 保留完整步骤,重叠防止步骤被割裂 |
| 长篇文章、论文 | 600–1000 | 80–150 | 上下文更重要,适当加大 |
重叠一般取 chunk_size 的 10%–20% 比较稳妥。重叠太小,边界信息容易丢;重叠太大,会产生大量近乎重复的块,既占空间又让检索结果里塞满雷同内容。
一个实用的经验:如果你的文档结构性很强(比如 Markdown 带清晰标题),优先按标题结构切分,而不是死守固定字符数。按标题切能保证每块语义自洽,这往往比调字符数更有效。
第二刀:检索结果不相关时怎么排查
切好之后建索引、检索。如果打印出来发现召回的片段驴唇不对马嘴,按下面的顺序排查,基本能定位八成问题。
1. 先确认 top-k 够不够
检索时的 k 值(返回最相似的前几个片段)如果设得太小,比如只取 1,稍微有点偏差就全错。先把 k 调大一点,比如 5 或 8,看看正确的片段是不是其实排在第二第三位。如果是,说明召回没问题,是排序问题,可以考虑加重排(rerank)或混合检索。
2. 检查是不是切分破坏了语义
如果召回的片段都是半句话、从中间断开的内容,那是切分出了问题。回到上一步,换成按段落或标题的递归切分,或者适当加大重叠。被切碎的块向量语义不清,检索自然对不上。
3. 考虑纯向量检索的盲区
向量检索(稠密检索)擅长语义相近,但对精确关键词、专有名词、型号编号这类反而不敏感。比如你文档里有个产品代号"X-200",用户也问"X-200",纯向量未必能把它排到前面。这时候引入关键词检索(BM25 这类稀疏检索)做补充就很有用。
把两路结果融合的常见做法是 RRF(Reciprocal Rank Fusion,倒数排名融合)。它不看具体分数,只看每个片段在两路结果里的排名,按 1/(k+rank) 累加打分,k 是经验常数(常用 60,值越大两路结果越均衡)。这样既享受向量的语义泛化,又补上关键词的精确匹配。对"检索内容不相关"这个老毛病,混合检索往往是性价比最高的一招。
4. 别忘了嵌入模型本身
如果你的文档是中文,却用了一个主要在英文语料上训练的嵌入模型,检索效果会明显打折。换一个支持中文、维度合适的嵌入模型,有时候比反复调切分参数见效更快。嵌入模型就像检索的"眼睛",眼睛不行,切得再整齐也白搭。

关键一步:用小型测试集量化每次改动
前面说的所有调整,如果只靠"感觉变好了"来判断,很快就会陷入反复横跳。真正专业的做法是:建一个小测试集,每改一次参数就跑一遍,用数字说话。
测试集不需要很大,20 到 50 个问题就足够看出趋势。每个问题你要事先知道答案应该来自文档的哪个片段(或哪一节)。格式可以简单到这样:
test_set = [
{
"question": "产品 X-200 的最大工作温度是多少?",
"expected_source": "spec.md#温度参数", # 答案应来自的来源
},
{
"question": "如何重置管理员密码?",
"expected_source": "manual.md#账户管理",
},
# ... 更多问题
]
然后对每个问题跑检索,看返回的 top-k 片段里,是否包含了那个"应该命中"的来源。两个最实用的指标:
- 命中率 / 召回率@k:期望来源出现在前 k 个结果里的问题占比。这个指标直接回答"检索有没有把对的材料找回来"。
- 命中位置:对的片段平均排在第几位。位置越靠前,说明排序越好,生成时也越不容易被噪音干扰。
跑评估的核心逻辑可以简化成这样:
def evaluate(retriever, test_set, k=5):
hit = 0
for case in test_set:
docs = retriever.search(case["question"], k=k)
sources = [d.metadata["source"] for d in docs]
if case["expected_source"] in sources:
hit += 1
return hit / len(test_set) # 命中率@k
有了这个数字,调参就从玄学变成了实验。比如你把 chunk_size 从 500 改到 300,重跑一遍,命中率从 0.68 升到 0.81,这就是实实在在的进步;如果反而掉了,说明切太碎丢了上下文,果断退回去。每次只改一个变量,否则你分不清是切分起作用还是检索起作用。
下面是一个典型的调优记录,可以照着这个思路做自己的实验表:
| 实验 | chunk_size | overlap | 检索方式 | 命中率@5 |
|---|---|---|---|---|
| 基线 | 500 | 50 | 纯向量 | 0.68 |
| 调小块 | 300 | 40 | 纯向量 | 0.74 |
| 加混合检索 | 300 | 40 | 向量+BM25 | 0.86 |
| 按标题切 | 结构切 | — | 向量+BM25 | 0.90 |
(表中数字仅为示范调优过程的记录格式,不代表你的文档会得到相同结果。)
常见报错与坑
调试过程中几类高频问题,提前知道能省不少时间:
- 召回片段全是重复内容:十有八九是
chunk_overlap设得太大,或者文档本身有大量重复的页眉页脚没清理。先回去清噪音。 - 中文检索明显比英文差:检查嵌入模型是否支持中文,以及文本编码是否统一为 UTF-8,乱码的文本向量化后完全不可用。
- 改了文档却没生效:向量索引是构建时的快照,文档更新后必须重新切分并重建(或增量更新)索引,否则检索的还是旧内容。
- 精确词查不到:纯向量的典型短板,引入 BM25 混合检索,或在检索前对查询做关键词提取。
验证结果与下一步
走完这一轮,你应该能做到:遇到"答案找不到依据"时,先打印召回片段定位是检索还是生成的问题;针对检索问题,从切分长度、重叠、混合检索、嵌入模型四个方向逐一调整;每一次调整都用小测试集的命中率来验证,而不是凭感觉。
当命中率稳定到你满意的水平后,可以再往后走几步:给召回结果加一层重排模型,进一步提升命中位置;在提示词里要求模型"只根据给定材料回答,找不到就说不知道",减少编造;给答案附上来源片段,让每个回答都可追溯、可核对。基础 RAG 流程本身不复杂,难点从来不在于搭起来,而在于用测试集持续地、可复现地把检索效果磨准——这也是它和"看起来能用"之间最大的区别。

评论列表 (1条):
加载更多评论 Loading...