实践历程:三个阶段的演进 零幻觉问答并非一开始就设计完备而是在成本、延迟和准确率的拉扯中逐步演进的。下面按时间顺序回顾三个阶段便于理解当前架构为何长成这样。慢、贵、长书不准丢细节、仍偏慢当前方案阶段一全文直塞阶段二LLM 提取关键句阶段三片段索引 Tool 检索淘汰淘汰零幻觉 可溯源阶段一全文直塞 Context最简单也最先暴露问题做法用户打开一本书提问时将提取出的全部正文放进 System Prompt 或 User 消息交给对话模型作答。若全书超过约40 万字符则硬截断——只保留前面一段后续章节对模型不可见。优点实现成本极低几乎不需要预处理短书、结构简单的文档效果尚可——模型确实「看到了整本书」交互简单问就能答没有「请先等待分析」的等待态。缺点很快变得不可接受响应慢每次提问都要把海量文本送进模型首 Token 延迟和总耗时随书长线性恶化Token 成本高同一本书每问一次就重复付一遍全文的输入费用长书严重失真超过 40 万字符后被截断后半本、附录、结论章节等于不存在且 UI 往往没有明确告知已截断检索粒度为零模型要在几十万字里「大海捞针」容易漏细节也更容易产生看似合理、实则无据的概括——阅读场景最忌讳这类幻觉。阶段一适合验证 MVP不适合作为产品级方案。阶段二用轻量 LLM 提取关键句压缩 Context但压缩得太狠做法在提问前或首次打开书时用成本更低的模型对正文做一轮预处理按 Spine 分章或整书分段抽取关键句输出时保留[f文件-起始-结束]形式的位置标记再将摘录拼成较短文本作为后续问答的 Context。典型链路是Extract → Cache → Chat先离线或按需跑一遍提取并落库之后每次提问复用同一份「关键句合集」。这与很多文档 QA 原型里「先压缩文档、再拿压缩结果做 QA」的思路相同也是我们在阶段二实际采用过的路线。优点每次提问送入模型的文本明显缩短单次 Token 消耗较阶段一显著下降预处理结果可缓存同一本书不必每次提问都重新提取已引入位置标记为后续溯源打下基础。缺点长书场景下依然扛不住细节大量丢失「关键句」由模型主观筛选论证链上的限定条件、反例等容易被丢掉答案容易「正确但片面」长书 Context 仍然偏大大部头作品即便只留关键句拼接后的输入依然可观延迟和成本只是缓解没有根治双重 LLM 误差提取阶段可能漏选问答阶段又可能误读摘录错误会叠加静态 Context无论用户问的是某一章细节还是全书结构送进模型的都是同一份预提取文本无法按问题动态收窄范围。这一阶段的教训很明确问题不在「有没有压缩」而在「压缩是否按需、以及能否回到原文」。阶段三片段索引 Tool 按需检索 原文回传当前方案做法基本思路参考了 PageIndex 相对阶段二核心变化有三点预处理产物是结构化索引目录级摘要 精确字符 span而不是把摘录直接当作问答 Context每次提问由模型通过 Tool Calling 按需检索再拉取带位置标记的原文作答System Prompt 与前端联动约束引用格式并支持点击角标跳转、高亮原文。三阶段对比维度阶段一全文直塞阶段二关键句提取阶段三当前单次提问 Context全书或截断后的前半本预提取关键句合集仅与问题相关的少量原文片段长书准确性超 40 万字符后严重下降依赖提取质量易丢细节按目录/span 检索不受全书长度硬截断响应速度慢略好长书仍慢检索 短 Context明显更快Token 成本极高中等偏高预处理摊销 按需付费溯源能力弱难标注出处有位置标记但内容已是二次筛选角标对应真实原文 span工程复杂度低中高为何停在阶段三阅读场景的零幻觉关键不是「让模型看过尽量多的字」而是「作答前必须拿到与问题相关的原文证据」。阶段一、二都在 Context体积上做文章阶段三把链路拆成「索引预处理→ 检索Tool→ 取证原文→ 作答约束生成」才同时兼顾准确率、成本与可溯源性。下文展开阶段三的实现细节。二、问题定义阅读场景下幻觉比普通 Chat 更致命普通 ChatBot 偶发错误用户往往可以容忍。但在书籍 QA里幻觉的代价更高用户问的是这本书说了什么不是问模型的 parametric memory一句似是而非的「书中观点」可能误导笔记、引用甚至二次传播没有出处用户无法核实产品信任很难建立。因此「零幻觉」在工程上落地为三条可执行的规则书内问题必须先查书凡可能与当前书籍相关的问题模型必须先走检索Tool再组织答案答案必须可溯源关键结论附带原文位置标记前端可解析并跳转高亮查不到就说查不到书中没有的内容应明确告知而不是用通用知识冒充「书中观点」。下文按阶段三的数据流说明上述规则如何落地。三、整体架构预处理 → 工具检索 → 约束生成 → 可点击溯源生成与展示工具检索用户提问否是全书概览/书评具体事实/人物/章节离线/首次预处理按目录或长度切分全书LLM 生成片段摘要本地持久化 Segment 缓存用户输入问题已有 Segment 缓存?提取全文 / 询问是否预处理注册 Tool Calling问题类型get_full_book_segment_summariesget_related_segment_summariesLLM 从摘要目录中选相关片段 ID按 span 拉取原文 位置标记拼接全书片段摘要Tool 结果回传模型System Prompt 约束引用格式流式输出答案 位置角标渲染可点击引用角标点击 → 预览原文 → 跳转高亮核心思路可以概括为不让模型「凭记忆答题」而是让它「先取证、再作答、并标注出处」。四、预处理把整本书变成可检索的「片段索引」若每次提问仍采用阶段一的全文 Context长书必然爆 Token检索粒度也过粗。阶段三的解法是用户首次对某本书发起 AI 对话时后台异步跑片段摘要任务按目录结构或文本长度将全书切成若干Segment为每个片段生成摘要并持久化到本地IndexedDB。每个Segment在数据结构上包含摘要与正文物理位置字段含义startFileIndex/endFileIndexSpine 文件索引PDF 则每页一个文件startOffset/endOffset字符级起止偏移sequence线性阅读顺序title对应目录标题切分策略兼顾精度与成本单目录正文不超过约 20KB 时只总结该节点同级目录会合并成批15KB20KB再调用 LLM无目录的大块正文则按 34 万字符区间切段。摘要生成时的 System Prompt 会要求保留原文位置标记格式[f数字-数字-数字]以便后续 Tool 回传原文时位置信息与 spine 字符偏移一致。核心约束如下如果总结内容与原文某段相关须保留段末位置信息格式 [f数字-数字-数字]如 [f1-90-109]。 位置标记是整体禁止修改、合并或省略其中的任何字符或数值。预处理完成后问答不再依赖「整书 Context」而是依赖结构化片段索引——这是长书场景下零幻觉的工程前提。五、位置标记体系把「出处」编码进文本零幻觉不仅要求内容来自原文还要求出处可机器解析、可在 UI 中跳转。我们采用内联位置标记[f{fileIndex}-{startChar}-{endChar}]例如[f5-123-165]表示第 5 个 Spine 文件从 0 起算中字符偏移 123165 的文本区间。5.1 标记如何写入正文正文提取层在输出片段时为每个小段在段末写入[f{fileIndex}-{start}-{end}]。示意const position [f${fileIndex}-${absOffset}-${absOffset segment.length}]; fileLines.push(segment.text.trim() position);无论是预处理摘要还是 Tool 回传的原文摘录位置信息都与Spine 字符偏移对齐而不是让模型「估算页码」。5.2 对模型输出的约束在组装 System Prompt 时我们单独约定了[Position Citation Rules]核心五条标准格式必须使用[f_fileIndex-startChar-endChar]三段数字缺一不可只引用当前来源角标须原样复制自本轮 System/User 消息或 Tool 返回文本中的标记禁止伪造不得自行计算、修改或编造位置宁缺毋滥当前上下文没有合法标记时正常作答即可不要输出任何位置标记紧跟论述标记须紧跟相关句段禁止在文末堆砌引用清单。前端展示前还会过滤模型偶发输出的两段位非法标记如[f1-293]避免无效角标进入 UI。六、Tool Calling先检索再回答当对话绑定某本书存在resourceId且chatType chat时每次生成前会向模型注册两个 Tool并挂载对应的 executor。整体遵循 OpenAI 兼容的function calling 循环。6.1get_related_segment_summaries—— 针对具体问题查片段