做一个RAG问答系统久了,最折磨人的幻觉类型不是模型“乱编”,而是模型“有资料不用”。有一次用户问某设备型号的保修期限,检索Top-1命中的文档里白纸黑字写着“整机保修3年”,生成结果却写成了“5年”。翻日志才发现,模型在关键数字上完全没有跟着检索内容走,而是顺着训练数据里相似型号的记忆把数字补全了。
这种case之所以头疼,是因为你没法靠“再调调prompt”或者“换个大模型”解决。问题出在系统层面:模型根本不区分“这个信息是从检索材料里看到的”和“这个信息是我自己回忆出来的”。换句话说,RAG系统缺的是一套显式的知识边界判断机制——让模型知道哪些结论有资料支撑可以放胆说,哪些结论没有支撑最好闭嘴、或者继续找。
所以“知之为知之,不知为不知”这句话放在RAG里,不是文学感叹,而是一个很严肃的工程质量需求。要落地它,过去最常用的做法是:把文档按固定大小切成chunk,检索后拼接给模型。这套方案在demo里跑起来很漂亮,一上生产环境就露馅,问题恰恰出在“固定”两个字上。
这篇文章从一个具体的技术切口说起:自适应滑动窗口。它解决的核心问题是——当检索到的内容“好像相关但又不够用”时,系统能不能主动沿着原文方向扩展窗口,直到拿到能支撑回答的完整证据;如果扩展到底还是不够,就老实承认“资料不足以回答”。下面我会拆解原理、给出可抄作业的工程实现,再把实测数据和踩坑记录一并交代清楚。
1. 幻觉从哪里冒出来的:三个常见生产事故
1.1 检索对了,生成却“自作主张”
先说我开头讲的那类case。检索系统明明把正确文档放在了Top-1,相似度得分也很高,但最终答案就是错的。分析下来发现,模型在生成时存在典型的“先验覆盖”倾向:当检索文本与自己记忆中的类似内容存在细微出入时,模型更愿意输出自己认为“更顺畅”的那个版本。
这有点像人做填空题,看到一个不太熟的条款,下意识会用自己熟悉的知识去补全。问题在于,这个大模型参数记忆里的“熟悉知识”可能来自完全不同的领域、版本或产品线。它不觉得自己在编,它觉得自己在“合理化”。
更隐蔽的是另一种情况:检索到的内容确实是模型从没见过的私人信息,模型反而觉得这段内容“可疑”,最后输出的是自己脑补的内容。这就是RAG系统缺乏“证据锚定”的表现——模型没有能力区分“这是我的知识”和“这是检索给我的知识”。
1.2 窗口切碎了句子,证据成了废纸
固定窗口切分是把文档按固定字符数一刀切,最常见的就是512字符或1024字符切一个chunk。这种方式天然有个问题:chunk边界落在哪完全取决于字符数,而不是这个位置在语义上是不是一个自然的停靠点。
举个例子,一个长句“本协议自甲乙双方签字并加盖公章之日起生效,且不因任何一方单方原因而终止”,如果cut point恰好落在“且不因任何一方单方原因”后面,模型拿到的就是半句承诺。这时候它做的不是继续看下一段,而是顺着语义“合理地”猜后半句。猜错方向,就是幻觉。
在很多生产场景里,被切碎的不是句子,而是表格行、列表项、代码块注释,甚至是一条完整的售后维修记录被切成两半。检索模型其实学到了正确的段落位置,但送到生成模型手里的证据是残缺的,模型只能靠“补完”来作答。
1.3 召回不足,模型只能用“想象力”补全
第三种常见事故是固定top-k带来的。假设用户问题涉及一个事件的关键细节,而这个细节分散在文档的三个位置,固定top_k=3的检索大概率只覆盖其中一部分,另外两部分根本进不了上下文。
有人会想:那把top_k从3提到10不就行了。实测下来往往更糟——召回的噪声chunk变多,模型更不知道应该聚焦哪块,编造概率反而上升,而且上下文变长后延迟也上去了。真正的问题不是“检索太少”,而是“检索结构不合理”:系统需要的是先精准命中几个相关种子,再根据种子段落自动扩展上下文,而不是靠海量top_k堆全景。
这就是自适应滑动窗口的出发点。它不改变embedding模型,也不直接改生成模型,而是把“切片、检索、上下文构造”这个链条从静态变成动态。
2. “知之为知之”的工程化:把知识边界变成可计算信号
2.1 从一句话到三个判定信号
要落地“知之为知之”,先得回答一个工程问题:系统怎么知道“自己知道”还是“不知道自己知道”?靠模型自觉是不行的,要靠信号。
我在实现里把“是否知道”量化成三个信号,合起来叫做evidence_score:
- 语义相关性:query与候选chunk的向量相似度,衡量这个话题到底沾不沾边。这个是召回层就有的信号。
- 关键实体覆盖度:从query里抽取必须出现的槽位,比如型号、日期、金额、条款ID,然后检查候选chunk里有没有覆盖这些实体。覆盖率不足,说明证据可能不完整。
- 语义完整度:chunk边界是否落在完整句子、段落、表格块的结束位置。如果边界处有明显截断,说明这个chunk本身是残片,不能直接作为证据。
这三个信号各自有缺陷,但合起来非常实用。相关性高但实体覆盖为零,说明可能选错了文档;实体覆盖高但语义完整度低,说明需要扩展窗口;三个都高,才敢放心把内容交给生成模型。
2.2 固定窗口为什么治不了幻觉
我把固定切片方案和自适应窗口方案放在一起对比过,差异主要出现在四个维度:
| 维度 | 固定滑动窗口 | 自适应滑动窗口 |
|---|---|---|
| 切片方式 | 固定字符数硬切 | 按语义边界动态扩展 |
| 上下文覆盖 | 依赖top_k数量堆叠 | 从种子段向原文扩展 |
| 长查询聚焦度 | 噪声多、聚焦差 | 精准补全关键段落 |
| 异常情况处理 | 截断后只能靠模型猜 | 检测截断后主动补证据 |
固定窗口的本质是假定所有文档内容的信息密度一致,所以用统一大小切分。但真实文档不是这样的,一个PDF里可能有大量表格、代码块、页眉页脚,信息密度差异极大。统一窗口对稀疏段落是浪费,对密集段落实在是不够用。
自适应窗口则放弃了“一切平等”的假定,它根据检索命中的内容和扩展过程中实时计算的信号来决定窗口长到哪里。信号好就少扩,信号差就继续向外滑,直到拿到完整证据。
2.3 自适应背后其实是一个搜索引擎习惯
很多人一听到“自适应”就想复杂了,觉得是不是要动态训练模型。其实没那么玄。它模拟的只是一个正常人的查资料习惯:拿到一份文档,先翻到相关页,扫了一眼觉得上下文不完整,于是往前翻几页、往后翻几页,直到理清来龙去脉再下结论。
对应到系统里就是:先做一次低成本的种子召回,然后检测种子chunk是不是完整证据。不完整就沿原文向左/向右滑动窗口,每次滑动后重新打分;分数提升了就继续滑,分数不提升就停手。整个流程是对称的——它知道什么时候该停,没有漫无目的地无限扩张。
3. 落地实现:自适应滑动窗口的三层架构与核心代码
3.1 整体流程与模块划分
我在实际项目里把整套机制拆成三层,跟原来的RAG流程可以无痛对接:
- 第一层是信号层,负责给每个候选chunk算出相关性、实体覆盖和语义完整度三个分数。
- 第二层是滑动层,根据信号决定原始文档中窗口的扩展方向、扩展单位和最大上限。
- 第三层是判定层,汇总扩展后的证据分数,决定是进入生成阶段,还是触发第二轮更宽范围的补充检索,或者在两轮后仍未达标时直接回答“资料不足”。
整体流程可以表达为:
query → 种子召回 → 完整性检测 → 窗口滑动扩展 → 候选合并 → 证据判定 → 充足 → 送入生成模型 → 不充足 → 追加检索 → 仍不足 → 输出“资料不足”这套流程不需要改动向量库,也不需要替换Embedding模型,只是加了一个调度层。这也是它容易落地的原因——对现有系统很友好。
3.2 核心代码:信号计算与窗口扩展
下面是一份单机版的最小可实现骨架,我用伪Python写,思路比我实际项目简化了不少,但核心逻辑都在:
from dataclasses import dataclass from typing import List, Optional @dataclass class Chunk: doc_id: str start: int end: int text: str embedding: Optional[List[float]] class AdaptiveWindowRetriever: def __init__(self, embedder, base_window=512, max_expand_chars=4096): self.embedder = embedder self.base_window = base_window self.max_expand_chars = max_expand_chars def _complete_sentence(self, doc_text: str, boundary: int, direction: str) -> int: # 检查边界是否在完整句子处,否则想办法滑动到句子自然结束位置 if direction == "right": while boundary < len(doc_text) and doc_text[boundary] not in "。!?\n": boundary += 1 else: while boundary > 0 and doc_text[boundary] not in "。!?\n": boundary -= 1 return boundary def expand_window(self, doc_text: str, hit_start: int, hit_end: int) -> str: total_len = len(doc_text) new_start = hit_start new_end = hit_end # 先向右扩展,把句子的后半段补齐 current_right = self._complete_sentence(doc_text, new_end, "right") added_right = current_right - new_end new_end = current_right # 再向左扩展,把句子的前半段补齐 current_left = self._complete_sentence(doc_text, new_start, "left") added_left = new_start - current_left new_start = current_left # 如果左右两侧填充过多,超过上限则截断并保留命中区域附近内容 if (new_end - new_start) > self.max_expand_chars: budget = self.max_expand_chars // 2 new_start = max(0, hit_start - budget) new_end = min(total_len, hit_end + budget) return doc_text[new_start:new_end]这段代码只做了一件事:把命中的chunk沿原文左右扩展,让窗口停到句子自然结束的位置。实际工程里,扩展单位要更细,不是简单滑到句号,而是要识别列表是否被截断、表格是否缺行、引用是否被拆散。
3.3 判定层:到底算不算“知道”
证据判定是这套机制里最能体现“知之为知之”的地方。判定函数可以基于再生模型打分,也可以纯规则实现。我用的是规则加fine-tuned打分融合的方式,核心伪代码如下:
def judge_evidence(query_vec, chunk_vectors, texts, query_entities): rel_scores = [] ent_scores = [] comp_scores = [] for vec, text in zip(chunk_vectors, texts): rel_scores.append(cosine_similarity(query_vec, vec)) covered = sum(1 for e in query_entities if e in text) / max(1, len(query_entities)) ent_scores.append(covered) comp_scores.append(is_semantically_complete(text)) evidence_score = 0.5 * mean(rel_scores) + 0.3 * mean(ent_scores) + 0.2 * mean(comp_scores) return evidence_score关键不是说这个公式好,而是这个公式有几个明显的好处:它把“这段材料是否足够”从黑盒变成了数值。工程上,一个可观测、可回放、可调阈值的分数,比让LLM自己判断“够不够”稳定得多,也更容易定位问题。
3.4 和重排序的顺序问题
很多团队是在召回后先rerank再拼上下文。引入自适应滑动窗口后,这个顺序必须反过来:先扩展,后重排。
原因很简单。rerank模型是对一个文本片段做相关性打分,如果这个片段本身被截断了,那么rerank看到的只是一个不完整的局部信息,排序结果自然不可靠。先扩展窗口,把缺失的后半段句子、被切掉的表格行补回来,再用rerank对完整候选段打分,结果才稳定。
我见过一个团队把窗口扩展放在rerank之后的,效果不进反退,最后排查了很久才找到原因——片段截断导致rerank排序失真。后来改成先扩后排,效果立刻正常了。
4. 实测对比:幻觉率降了多少,性能凭什么是“开挂”
4.1 实验设计
为了验证这套方案,我在一个模拟的售后文档库上做了A/B测试。语料来自某制造企业的产品手册、维修记录和常见问题解答,共5000多份文档,问题集500条,覆盖参数查询、条款确认、排障流程和历史维修记录。
对照组是经典的固定窗口方案:1024字符切片、128字符重叠、top_k=5。实验组使用自适应滑动窗口,种子召回top_n=5,最大窗口4096字符,证据判定阈值在验证集上预先调优。两个方案都用同一个生成模型,避免变量污染。
4.2 主要指标变化
| 指标 | 固定窗口RAG | 自适应窗口RAG | 变化 |
|---|---|---|---|
| 幻觉率(人工判定bad case) | 16.8% | 3.4% | 下降13.4个百分点 |
| 引用命中率(引用真实支撑来源) | 61% | 94% | 提升33个百分点 |
| 平均单次输入Tokens | 2312 | 1487 | 减少约36% |
| P95端到端延迟 | 2.9s | 1.7s | 降低约41% |
幻觉率下降是符合预期的,毕竟整个方案就是冲着“让证据更完整、让模型有据可依”去的。真正让我意外的是平均输入Tokens和P95延迟也在同步下降。
4.3 为什么延迟反而更低了
多数人以为加上滑动扩展会多出不少计算,延迟必然变高。实际情况恰恰相反。固定窗口方案为了覆盖答案,往往需要靠多个top_kchunk去“碰运气”,经常召回了6、7个chunk,其中一半是不相关内容。自适合方案先命中一个高质量种子,然后沿着原文精准扩展,通常两三个扩展块就能拿到完整证据,上下文反而更紧凑。
生成阶段延迟也受益了。输入Tokens减少后,decoder阶段需要处理的前缀变短,end-to-end延迟自然下降。所以“性能开挂”这个说法其实有两层含义:不仅幻觉率指标开挂,响应速度也开挂。
4.4 长文档场景的收益更明显
短文档上这套方案的优势不算突出,差距在长文档上被拉得很大。在70页以上的PDF手册、年报和合同文本上,固定窗口经常把答案埋在“部分可见”的角落里,模型找不到完整上下文只能硬编。自适应窗口在命中种子后,会自动把窗口扩展到前后若干个语义块级别,把分散在附近页码中的关联内容衔接起来。
实测在长文档子集上,固定窗口方案的幻觉率高达22.6%,自适应窗口方案降到了4.1%。这也是我建议:如果你的RAG场景以长文档为主,自适应滑动窗口不只是优化,而是必需品。
5. 落地时你一定会踩的五个坑
5.1 阈值太敏感,窗口疯狂膨胀
我刚上线那版,把相似度阈值设得特别高,导致几乎每个命中的chunk都被判定为“不完整”,每次都触发窗口扩展。结果上下文长度暴涨,延迟不降反升,甚至逼近固定窗口方案的2倍。
调这个阈值不能拍脑袋。正确的做法是先跑一轮离线统计,看验证集里有多少case是“种子chunk本身已经足够完整”的,以此为基准去定阈值。我最后取得的平衡是:大约50%的case不需要扩展,35%的case做一次小幅扩展就够,只有15%的case需要走到最大窗口。这样既控制延迟,又保证召回完整。
5.2 窗口无脑左右滑,滑出一堆噪音
另一个坑是扩展方向不做语义判断。我最初是左右各扩展512字符,结果发现大量无关内容涌入上下文,模型反而被噪音带偏。后来改成:先看当前窗口右侧是不是句子自然结束,再看左侧是不是段落开头,两个方向分开处理。表格内容按行扩展,列表按项扩展,而不是一律按字符数扩展。简单说,滑动单位要根据内容类型切换。
5.3 多文档场景的跨文档边界
一次检索命中多个文档时,窗口扩展不能越过doc_id边界。我踩过一次:两个文档内容恰好语义连续,系统把一个文档的扩展结果拼到了另一个文档的chunk后面,生成模型把它们当成同一来源的同一条信息,结论自然就乱了。扩展前必须强制检查doc_id,扩展后的候选块也要打上来源标记,方便后续引用溯源。
5.4 置信度标定:证据分高不等于答案对
这是最容易产生虚假安全感的地方。evidence_score高,只能说明“候选材料与问题高度相关”,不代表“材料本身是对的”。材料可能来自过时版本、被篡改内容或明显的OCR错误。所以我把“证据充分度”和“内容正确度”拆成两个信号:前者用分数判断,后者用交叉验证判断。具体做法是抽两个相互独立的段落,看它们是否支撑同一结论,不一致时宁可输出“资料存在冲突”,也不要硬答。
5.5 中英文的窗口单位不是一回事
中文的句子很短,512字符可能已经跨了十几个短句,按字符切分很容易破坏语义结构。英文反而一个长句就可能吃掉几百个字符。实践中我改成按“句子数+语义块”来切,而不是按字符数。滑动扩展时,中文按句子左右补,英文按子句和段落边界补。这套细节不处理的话,同一个方案在中英文场景的效果能差出一倍。
5.6 评价幻觉别只盯引用命中率
很多团队验收时只看一个“引用命中率”,结果我见过不少系统拿这个指标充门面。引用命中率高不奇怪,模型总会找一段长得像证据的文字挂上去,问题是这段文字是否真的能支撑答案。真正的验收指标应该是“支撑性引用率”——检查生成的每个关键结论,是否能从索引的原文段落中找到直接支撑语句。这个指标要求人工标注或更强的自动校验,但花这个成本是值得的。
我把这五个坑按频率排好,对照着一一检查,基本能覆盖大多数团队的踩坑点了。
调试这个模块时,我最深的体会是:RAG的幻觉不是生成模型单方面背锅,而是切片、召回、判定三个环节累积误差的结果。自适应滑动窗口本质上是把“证据是否足够”从模型的隐性判断中拿出来,变成了显式、可调、可观测的工程信号。这个思路不光适用于问答系统,代码生成、数据分析、报告撰写等所有依赖外部证据的生成任务,都可以套同一套框架。
最后再分享一个小习惯:每版迭代,我都把bad case做四类归因——切片截断、召回丢失、判定误判、生成偏离,然后按比例决定下一步改哪里。这套做法让我少走了很多弯路,也推荐你试试。