A19第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

文档解析与切分

文档解析与切分 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

读完本课你能把一份文档变成可检索的片段:先解析出纯文本,再按标题和窗口切分,并且说清为什么切分粒度直接决定检索质量。

问题场景

你把一份 2 万字的接口文档导入资料库,问「登录失败锁定多久」,检索器返回了整份文档——上下文塞进两万字,答案就在其中某一行。要么把上下文撑爆(A02 讲过上下文是稀缺资源),要么模型在长文里看走眼。整篇存不行,那就得切:把文档切成一段段带来源信息的小块,检索的最小单位从「文档」变成「片段」。

核心解释

本站的处理分两步,都在 lib/documents/parse.ts:

第一步:解析(extractText)——把各种格式变成纯文本。txt/md 直接读(顺手处理 BOM 头);PDF 用 pdf.js 提取文字层;扫描件没有文字层,明确报错「需要 OCR 能力,本站暂不具备」,而不是默默索引出一份空文档。解析失败的文档要给出人能看懂的原因。

第二步:切分(chunkText)——本站采用「标题分节 + 滑动窗口」:

  1. 按 Markdown 标题(# 到 ######)把文本分成节,每个片段记录它所属的 heading——这是后续引用定位的关键元数据(A22 会用到)。
  2. 节内容不超过约 800 字的 1.4 倍时整节成一块,避免小节被硬切。
  3. 超长节按 800 字窗口、100 字重叠 滑动切块,最后整体上限 500 块防失控。
// 切分核心逻辑(节选自 lib/documents/parse.ts 的 chunkText)
const WINDOW = 800;
const OVERLAP = 100;
for (const sec of sections) {
  if (sec.body.length <= WINDOW * 1.4) {
    chunks.push({ orderIndex: chunks.length, heading: sec.heading, text: sec.body });
    continue;
  }
  let start = 0;
  while (start < sec.body.length) {
    const end = Math.min(start + WINDOW, sec.body.length);
    chunks.push({
      orderIndex: chunks.length,
      heading: sec.heading,
      text: (start > 0 ? "…" : "") + sec.body.slice(start, end),
    });
    if (end >= sec.body.length) break;
    start = end - OVERLAP; // 回退 100 字,让跨界句子在两块里都完整出现
  }
}

为什么是这两个参数:窗口太小(如 200 字),一句话的上下文被切掉,检索到了也对不上问题;窗口太大(如 5000 字),又回到「命中即整篇」的老问题——检索粒度粗、上下文浪费。重叠解决的是句子被拦腰切断:关键句恰好跨在边界上时,因为重叠它在相邻两块里都完整存在,检索至少能命中一块。

如何验证:切完不是结束,要拿真实问题去检索,看命中片段是否「正好包含答案及其上下文」。

示例拆解

上传后落到库里的数据形状(真实表结构):

-- 切分结果存两处:doc_chunk 供 LIKE 兜底与展示,fts_doc 供全文索引
CREATE TABLE doc_chunk (
  document_id TEXT NOT NULL,      -- 来源文档(引用定位用)
  collection_slug TEXT NOT NULL,  -- 所属资料集(限定检索范围用)
  order_index INTEGER NOT NULL,   -- 片段顺序(可打开原文片段)
  heading TEXT,                   -- 所属标题(展示与定位)
  text TEXT NOT NULL              -- 片段正文
);

关键点:

  • 每个片段都带着「我从哪来」:document_id + order_index + heading。没有这些,检索命中后引用无从谈起。
  • 上传走 /api/documents 的 PUT(multipart 表单),服务端同步解析索引,document.status 从 pending 走到 indexed 或 failed,失败原因存在 error 字段里可查。
  • 同一文档重复导入会先删旧块再写新块(DELETE FROM doc_chunk WHERE document_id = ?),避免新旧片段混在一起。

动手任务

  1. 打开本课实验区,对样例文本分别用窗口 60 和 120 跑切分,数一数块数,想想为什么本站取 800 字(对照片段要「装得下一个完整问答上下文」)。
  2. 把重叠去掉(start = end),在输出里找一条恰好跨在边界上被切断的句子,说出它会带来什么检索后果。
  3. 把样例文本的标题行全删掉再切分,观察 heading 全为 null——想一下检索结果少了什么信息,引用还能定位到哪一级。

常见错误

  • 扫描件 PDF 静默导入成功:文字层提取出来是空的,索引了一堆空片段还不报错。必须检测「提取文字过短」并明确报错。
  • 按固定字数硬切、不留重叠:关键句被切断,检索命中了却答不对——症状是「片段明明相关但只差半句」。
  • 切分时丢掉标题等元数据:片段检索命中了,但引用只能给一串正文,用户无法定位来源。
  • 重复导入不清理旧块:同一文档出现两份片段,检索结果重复且引用错乱。

检查与复盘

自查清单:

  • 能说出解析阶段的边界(什么格式能处理、什么情况必须报错)
  • 能默写切分的两个关键参数与各自的作用(窗口长度、重叠)
  • 能解释「片段带 document_id / order_index / heading」分别服务于什么

参考资料

  • MDN:File API —— 上传文件的读取与类型判断
  • Mozilla pdf.js —— 本站 PDF 文字层提取所用库
  • 下一课 A20 讲另一条检索路线:把片段变成向量做语义检索。
动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码中等

阅读文档切分代码(超长节按窗口滑动切块)。关键行 start = end - OVERLAP 的作用是什么?

const WINDOW = 800;
const OVERLAP = 100;
while (start < sec.body.length) {
  const end = Math.min(start + WINDOW, sec.body.length);
  chunks.push({
    orderIndex: chunks.length,
    heading: sec.heading,
    text: (start > 0 ? "…" : "") + sec.body.slice(start, end),
  });
  if (end >= sec.body.length) break;
  start = end - OVERLAP; // 回退 100 字
}
Q2单选简单

用户上传了一份扫描件 PDF,导入显示成功,但之后怎么检索都查不到内容。最可能的原因与正确处理是?

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...