# 文档解析与知识入库

## 知识库质量从入库之前开始

用户上传一份制度 PDF，问答系统却说“没有相关内容”。问题未必在模型，也可能是扫描件没有文字层、表格被打乱、章节标题丢失，或者更新后的文件没有进入可查询版本。本章把文档视为需要生命周期管理的数据资产。达标后，你应能设计解析、切块、索引、更新、删除的链路，并让任一回答追溯到具体版本与位置。

前置是文件读取、哈希、基础数据建模和结构校验。无需在本章部署向量数据库。离线例子只处理内存中的 Markdown 字符串，演示版本与索引切换；它不代表完整 PDF、Word 或 OCR 解析器。

## 文档不是一段无结构字符串

不同输入有不同提取路径。网页需要去掉导航与广告并保留标题层级；Word 需要处理段落、表格与批注；有文字层的 PDF 可以读取文本项目，但项目顺序不一定等于自然阅读顺序；扫描 PDF 需要 OCR。PDF.js 的基础接口可以加载文档并访问页面，具体提取还要处理文本项目、坐标和布局。[PDF.js 官方示例](https://mozilla.github.io/pdf.js/examples/)

解析后应有一个中间结构，例如 document、sections、blocks 与 sourceLocation，而不是立即拼接全文。表格的一行必须保留列名，否则“七天”可能不知道是退款期限还是开票时间。页眉页脚重复出现会污染检索，代码块和法律条款的边界也不宜随意打散。

质量检查应在进入索引前进行。一个二十页文件只提取出三十字，应标记“疑似扫描件”或“提取不足”。乱码比例、空页比例、重复块比例和表格结构丢失都可以成为告警条件。解析任务失败不应默默生成一份“空但成功”的知识库记录。

## 切块决定后面能看见什么

切块的目标是让每段既能被检索，也足以独立理解。固定字符切块简单，但可能把前提与结论分开；按标题、段落和句子切块更贴近语义，不过长表格仍需额外处理。可以先按结构分段，再对超长段落做有边界的拆分。重叠部分能保留上下文，但也会产生重复结果与额外成本。

每个块应带文档 ID、版本、标题路径、页码或行号、原文偏移、内容哈希、解析器版本和权限信息。chunkId 不应只用“第一个块”之类的全局编号。更新文档后，旧块和新块不能混为同一证据。对于引用，用户需要打开原文对应位置，而不是看到无来源的相似片段。

字符长度与 token 预算需要分开。切块阶段可以使用字符限制做粗筛，但进入 embedding 和模型前仍需按对应模型计算。不要依赖某个固定“中文一字等于多少 token”的经验值处理所有文档。

## 完整示例：先构建新版本，再切换可见索引

环境：Node.js 22；文件 `ingestion.mjs`；无需安装依赖，执行 `node ingestion.mjs`。本例的全部数据在内存，程序退出即消失。它演示幂等入库、版本替换、租户隔离和删除，不包括真实 embedding。

```js
import { createHash } from 'node:crypto';

const documents = new Map();
const chunks = new Map();
const hash = text => createHash('sha256').update(text).digest('hex');

function ingest({ tenantId, documentId, text, allowedGroups }) {
  if (!text.trim()) throw new Error('拒绝空文档');
  if (!Array.isArray(allowedGroups) || allowedGroups.length === 0) {
    throw new Error('必须明确可见分组');
  }
  const key = JSON.stringify([tenantId, documentId]);
  // 权限也进入版本标识，避免同内容权限变更被误判为无事发生。
  const fingerprint = hash(JSON.stringify({
    text, allowedGroups: [...new Set(allowedGroups)].sort(), parser: 'paragraph-v1',
  }));
  const old = documents.get(key);
  if (old?.fingerprint === fingerprint) return 'unchanged';
  const version = (old?.version ?? 0) + 1;
  const blocks = text.split(/\n\s*\n/).map(value => value.trim()).filter(Boolean);
  const staged = blocks.map((content, index) => ({
    id: key + ':' + version + ':' + index,
    key, tenantId, documentId, version, content,
    location: { paragraph: index + 1 },
    allowedGroups: [...allowedGroups],
  }));
  if (staged.some(block => block.content.length > 500)) {
    throw new Error('段落过长，先补充结构切块策略');
  }
  // 同步内存段内切换；真实数据库需要事务或版本指针原子切换。
  for (const block of staged) chunks.set(block.id, block);
  documents.set(key, { version, fingerprint });
  for (const [id, block] of chunks) {
    if (block.key === key && block.version !== version) chunks.delete(id);
  }
  return 'indexed-v' + version;
}

function visible(tenantId, groups) {
  return [...chunks.values()].filter(block =>
    block.tenantId === tenantId &&
    block.allowedGroups.some(group => groups.includes(group)) &&
    documents.get(block.key)?.version === block.version);
}

function remove(tenantId, documentId) {
  const key = JSON.stringify([tenantId, documentId]);
  documents.delete(key); // 先撤销查询资格，再清理块。
  for (const [id, block] of chunks) if (block.key === key) chunks.delete(id);
}

const source = {
  tenantId: 't1', documentId: 'refund', allowedGroups: ['support'],
  text: '退款规则\n\n符合条件的订单可在七天内申请。',
};
console.log(ingest(source));
console.log(ingest(source));
console.log(ingest({ ...source, text: '退款规则\n\n符合条件的订单可在三天内申请。' }));
console.log('当前块数=' + visible('t1', ['support']).length);
console.log('其他租户块数=' + visible('t2', ['support']).length);
remove('t1', 'refund');
console.log('删除后块数=' + visible('t1', ['support']).length);
```

预期依次输出 indexed-v1、unchanged、indexed-v2、当前块数为二、其他租户为零、删除后为零。修改 allowedGroups 而保持正文不变，会形成新版本；输入超长段落时，原可用版本仍保留，因为错误发生在切换之前。

代码以租户与文档 ID 组合成键，避免两个租户上传同名资料相互覆盖。fingerprint 同时包含内容、权限和解析版本，说明“内容没变”不等于“索引无需更新”。staged 先构建完才进入可见状态，减少半份文档被检索的机会。visible 再检查当前文档版本，即使清理延迟也不会把旧块当现行资料。

## 更新、删除和权限变更是主流程

真实入库通常是异步任务：上传后返回任务 ID，状态从 received、parsing、indexing 到 ready 或 failed。前端应显示处理进度和错误原因，不能上传接口成功就显示“知识库已可用”。任务重试要复用文档身份；embedding 批次失败时应保存已完成进度，避免重复计费或重复块。

更新时最好保留旧可用版本，等新版本全部完成后原子切换。若直接删除旧块再慢慢写新块，用户会遇到空窗；若直接追加而不切换版本，旧新条款会同时出现。对于大型文档，可通过内容哈希复用未改变块，但仍需检查权限、解析策略和 embedding 模型是否变化。

删除需要处理原文件、解析文本、向量条目、检索缓存、答案缓存以及可能保存证据快照的会话。物理清理往往不是瞬时完成，因此先设置 tombstone 或撤销 activeVersion，让读取路径立刻拒绝已删除资料，再异步清理存储。产品需要明确删除后旧聊天引用如何展示，不能承诺已经生成到用户手中的文本自动消失。

权限过滤必须从用户身份在服务端生成，不能信任请求体带来的 tenantId 或 groups。本例手动传入只是演示函数边界。真实系统应把身份上下文传给检索服务，在召回前限制可见范围，并在打开原文时再次校验。只在前端隐藏来源按钮，不能防止答案泄露内容。

## 解析中间层为什么值得单独设计

实际接入十种文件格式时，最容易膨胀的是“统一提取文本”函数：PDF 分支返回字符串，Word 分支返回 HTML，网页分支已经去重，扫描件分支只有 OCR 行号。后面的切块代码不得不理解每一种输入。一个统一的中间结构可以把差异停在解析层，例如 block 保存 kind、text、sourceLocation、headingPath 与 qualityFlags；下游只处理已经定义好的内容单元。

中间结构不意味着把所有文件压成同一种外观。表格块可以保留列名、行值和合并单元格关系，代码块可以保留语言与原始缩进，图片块可以记录位置和 OCR 状态。某种格式暂不支持时，应在能力表中明确，而不是返回一个看似成功的空块。对用户来说，“此页需要人工确认表格”比一个流畅却缺半张表的答案更可信。

PDF 的阅读顺序尤其容易误判。页面上的字符有坐标，但双栏排版、浮动文本框、页眉脚注和跨页表格会让简单排序出现串行。一个看起来像“退款期限七日”的字符串，可能来自左栏“退款期限”和右栏“七日发货”。应保留页码与位置，抽样渲染对照原页，针对常见文档模板验证顺序。文本提取数量正常并不能证明语义顺序正确。

OCR 也不能只用总体准确率验收。普通句子错一个虚词通常不影响检索，产品编号、金额小数点或“不适用”的“不”错一个字符却可能改变结论。可以针对数字、表头、否定词与关键编号建立质量检查，把低可信块标成需要核对。生成阶段知道某证据来自低可信 OCR，仍不等于它会自动纠错；重要事实应要求原图核验或更可靠来源。

清理过程需要可追溯。若删除页眉、合并断行、统一全角半角后才计算位置，最终偏移已经不对应原文件。可以保存原始文本与规范化文本的映射，或使用解析器提供的页码、块坐标作为引用锚点。至少不要把规范化字符串的字符下标直接宣称为 PDF 原文位置。本章后面的偏移例子仅针对未改写的原始 Markdown。

## 示例二：保留标题和原文偏移的结构切块

环境为 Node.js 22，无第三方依赖。保存为 `source-chunks.mjs`，执行 `node source-chunks.mjs`，预期输出两个块、两个原文切片检查通过，以及长段落错误被捕获。它只支持以空行分隔段落的简化 Markdown，不处理列表嵌套、围栏代码或真实 PDF。

```js source-chunks.mjs
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';

function parseChunks(source, { documentId, revision, maxChars = 100 }) {
  if (typeof source !== 'string' || !documentId ||
      !Number.isInteger(revision) || revision < 1 ||
      !Number.isInteger(maxChars) || maxChars < 1) {
    throw new Error('INVALID_INPUT');
  }
  const headings = [];
  const chunks = [];
  // 每个匹配保留原串的下标，不先 trim 全文，以免位置偏移。
  const blocks = source.matchAll(/[^\n]+(?:\n(?!\n)[^\n]+)*/g);
  for (const match of blocks) {
    const text = match[0];
    const heading = /^(#{1,6}) (.+)$/.exec(text);
    if (heading) {
      const depth = heading[1].length;
      headings.length = depth - 1;
      headings[depth - 1] = heading[2];
      continue;
    }
    if (text.length > maxChars) throw new Error('BLOCK_TOO_LONG');
    const start = match.index;
    const end = start + text.length;
    const path = headings.filter(Boolean);
    const hash = createHash('sha256').update(text).digest('hex');
    chunks.push({
      id: documentId + ':' + revision + ':' + start,
      documentId, revision, start, end, text, headingPath: [...path],
      contentHash: hash,
      // 检索表示附带标题；原文定位仍指向未经改写的正文。
      embeddingText: path.join(' / ') + '\n' + text
    });
  }
  if (!chunks.length) throw new Error('NO_CONTENT');
  return chunks;
}
const source = '## 售后规则\n\nS-42 收货七日内可申请退货。\n\n### 例外\n\n已激活的授权不能退货。';
const chunks = parseChunks(source, { documentId: 'policy', revision: 2 });
assert.equal(chunks.length, 2);
for (const chunk of chunks) {
  assert.equal(source.slice(chunk.start, chunk.end), chunk.text);
}
assert.deepEqual(chunks[1].headingPath, ['售后规则', '例外']);
assert.throws(() => parseChunks('正文'.repeat(100),
  { documentId: 'policy', revision: 2, maxChars: 100 }), /BLOCK_TOO_LONG/);
console.log({ chunks: chunks.length, sourceSlicesVerified: chunks.length,
  secondPath: chunks[1].headingPath });
```

代码把标题保存在元数据里，正文保留原样。检索用 embeddingText 加入标题，引用用 start 与 end 定位原串，这两个表示有不同职责。若直接把带标题的表示当原文引用，就可能让用户看到一段原文件里不存在的拼接文本。实际系统可以显示“来自某章某节”的说明，但应明确它是上下文标签。

contentHash 用于识别内容是否改变，id 用文档、版本和位置标识本次证据。二者不应混用：两份文档可能包含完全相同的一句话，却有不同权限和法律效力；同一段话移动位置后，也可能仍复用向量计算结果，但引用锚点应更新。向量计算缓存可以按内容及模型版本复用，最终索引记录仍需保留各自来源身份。

程序遇到长段落直接失败，是明确的教学范围。上线可以增加按句子拆分、父子块或专门表格策略，但要为拆分后的完整性写样本。不能在失败分支悄悄截掉剩余文字，因为那会把资料缺失隐藏到问答阶段。错误中应包含文档与块位置，便于人工修复或选择更合适的解析策略。

## 切块大小、重叠和父子关系的取舍

小块更容易精确匹配一个问题，也减少无关材料进入模型；缺点是标题、定义和例外可能被分开。大块保留更多上下文，却可能同时谈多个主题，向量表示被平均，导致精确问题检索不佳。没有一个适合所有资料的固定长度。应按常见问题的事实跨度测试，比如产品参数表、制度条款和故障日志通常需要不同边界。

重叠能缓和边界损失，但不是免费提高质量。相邻十个块如果都重复同一段说明，召回结果可能被这一事实占满，必要的例外条款反而进不了最终预算。去重应理解来源关系：同一父块的重叠窗口可以合并，独立文件里内容相似但生效日期不同的条款不能直接合并。重复率和事实覆盖率应一起看。

父子块是一种折中：用较小的子块召回，命中后补充其父章节中的必要上下文。补充时仍要限制总预算、检查权限和版本，并避免直接塞入整章。父块里可能还有与当前产品无关的规定，甚至旧段落标记。检索命中只证明某处值得查看，不能证明整个父对象都应该成为答案证据。

切块策略改变也算数据版本变化。即使原文件没变，新策略可能让块编号、范围和引用位置都改变。保存 parserVersion、chunkerVersion、embeddingModel 与索引代号，才能解释某次回答为什么引用了一个现在找不到的块。让这些信息成为构建任务的一部分，也有助于在新策略评测不佳时切回旧索引。

## 并发更新的真正危险是旧任务晚完成

用户先上传版本一，随后上传版本二，版本二较短先处理完，版本一的 OCR 较慢后完成。如果发布逻辑只是“处理完就写 activeVersion”，旧任务会覆盖新资料。队列中先到先处理也不能保证完成顺序，重试和多工作进程会进一步放大问题。发布必须比较这次任务对应的源版本是否仍是当前期望版本。

删除与慢任务还有类似竞争。用户删除文档后，一个正在执行的 embedding 请求返回，工作进程若不检查删除标记，便会把块重新写回可见索引。仅从队列里移除未开始任务无法解决已运行任务。删除应推进文档世代或版本，并在发布时再次检查；旧任务可以清理自己的暂存数据，却不能撤销当前删除状态。

权限收紧通常比内容更新更急。内容新版本尚未完成时，旧可用内容可以继续服务，但不能继续采用旧的宽松权限。把权限只复制到向量块里会让可见性依赖重建速度。可采用当前文档权限作为最终判定来源，并让检索过滤使用可快速更新的权限元数据；旧块缓存和答案缓存也必须同步失效。下面的练习在读取时查当前目录，专门验证这条约束。

## 练习：阻止旧构建发布和删除后复活

实现一个内存文档目录：开始构建时取得源版本；发布时只接受仍匹配的版本；解析失败保留旧可用内容；删除后任何旧构建不能发布；权限收紧立即影响旧可用内容。提示是将“当前源版本”和“当前可读内容版本”分开，而不是用一个 status 字段同时表示全部状态。

<details><summary>参考答案：完整版本门禁与失败验证</summary>

Node.js 22，无需安装。保存为 `publish-gate.mjs`，执行 `node publish-gate.mjs`，预期输出 `publish gate checks passed`。这是同步 Map 对条件更新语义的模拟；真实数据库必须把比较与更新放入同一原子操作。

```js publish-gate.mjs
import assert from 'node:assert/strict';

const catalog = new Map();
function create(id, groups) {
  catalog.set(id, { sourceRevision: 0, deleted: false,
    groups: [...groups], aclRevision: 1, active: null });
}
function begin(id, text) {
  const doc = catalog.get(id);
  if (!doc || doc.deleted) throw new Error('NOT_FOUND');
  doc.sourceRevision += 1;
  return { id, sourceRevision: doc.sourceRevision, text };
}
function stage(job) {
  if (!job.text.trim() || job.text.length > 1000) throw new Error('PARSE_FAILED');
  return { ...job, chunks: job.text.split('\n\n').filter(Boolean) };
}
function publish(built) {
  const doc = catalog.get(built.id);
  if (!doc || doc.deleted || doc.sourceRevision !== built.sourceRevision) {
    return false; // 旧任务失效，不覆盖新状态。
  }
  doc.active = { revision: built.sourceRevision, chunks: [...built.chunks] };
  return true;
}
function setGroups(id, groups) {
  const doc = catalog.get(id);
  if (!doc || doc.deleted) throw new Error('NOT_FOUND');
  doc.groups = [...groups];
  doc.aclRevision += 1;
}
function remove(id) {
  const doc = catalog.get(id);
  if (!doc) return;
  doc.deleted = true;
  doc.sourceRevision += 1;
  doc.active = null;
}
function read(id, actorGroups) {
  const doc = catalog.get(id);
  if (!doc || doc.deleted || !doc.active ||
      !doc.groups.some(group => actorGroups.includes(group))) return [];
  return [...doc.active.chunks];
}

create('D1', ['support']);
assert.equal(publish(stage(begin('D1', '旧规则'))), true);
const broken = begin('D1', '长'.repeat(1001));
assert.throws(() => stage(broken), /PARSE_FAILED/);
assert.deepEqual(read('D1', ['support']), ['旧规则']);

const slow = begin('D1', '较早上传但处理很慢');
const fast = begin('D1', '最新规则');
assert.equal(publish(stage(fast)), true);
assert.equal(publish(stage(slow)), false);
assert.deepEqual(read('D1', ['support']), ['最新规则']);

setGroups('D1', ['admin']);
assert.deepEqual(read('D1', ['support']), []);
assert.deepEqual(read('D1', ['admin']), ['最新规则']);

const running = begin('D1', '删除之前开始的构建');
remove('D1');
assert.equal(publish(stage(running)), false);
assert.deepEqual(read('D1', ['admin']), []);
console.log('publish gate checks passed');
```

</details>

sourceRevision 在开始构建时递增，active.revision 只有成功发布才改变，因此解析失败不会破坏旧服务。与此同时，新任务一旦被接受，旧任务就失去发布资格，即使新任务失败也不会意外恢复一个过时构建。若产品需要回退，应创建明确的回退操作及新版本，而不是让后台任务的完成顺序替用户决定。

测试故意先发布 fast 再发布 slow，模拟乱序完成；又在 running 构建后删除文档，模拟清理竞争。两组失败都返回 false，表示构建结果已过期，不应按普通网络失败无限重试。实际队列可以将其标为 superseded，停止后续付费计算，并清理只有这次构建引用的暂存块。

## 从任务队列走到可运营的入库服务

持久化任务记录至少需要源对象、源版本、处理阶段、尝试次数、最后错误和已完成批次。重试整个任务最简单，但大文件的一小批向量失败可能造成大量重复计算。按确定性的批次键记录结果能够恢复进度，不过复用前必须比较内容哈希、模型配置与预处理版本，避免把旧维度向量塞进新索引。

嵌入接口的批量返回应按输入索引关联，不依赖并行请求完成顺序。网络失败不能证明服务端没有处理过这一批；应用要考虑重复成本和重复写入，同时让索引写入具有确定 ID。资源不足、格式不支持、文本为空和暂时服务故障应使用不同错误码，因为只有部分错误适合自动重试。前端可以据此显示“请换成可复制文本文件”或“系统稍后重试”。

删除完成也应该有可检查定义。逻辑删除已生效、搜索索引已清理、原始文件已清理、缓存已失效可能在不同时间发生。用户最先需要的是立即停止新查询返回资料；运维还需要知道物理清理是否积压。不要只看数据库文档记录消失，就宣称所有副本已经删除。对备份、审计保留和已发送给用户的答案，应按产品实际能力说明范围。

最终验收要从一条问题反向追到原文件：回答使用哪个块，块属于哪个构建，构建来自哪个源版本，解析器是否保留了正确位置。若链路某处只有一个字符串，事故排查就会依赖猜测。入库服务的价值不仅是产出向量，更是建立一个能持续更新、撤回、追溯和评测的证据基础。

## 最后一道一致性检查放在哪里

示例的 Map 赋值只在同一进程中具有直观的顺序，不能替代数据库事务。真实发布可以使用带条件的更新语句，条件同时包含文档身份、期望源版本和未删除状态；只有受影响行数为一才算取得发布资格。若向量存储与文档目录不在同一事务中，可以先写不可见的构建空间，再切换目录指针，读取时以指针为准。清理孤立构建则成为后续可重试任务。

检索缓存不应只用问题文本作为键。它还要关联租户、可见权限版本、索引版本和必要的查询配置；否则权限变更后，缓存可能绕过刚刚更新的过滤器。缓存命中后展开原文时再次检查当前文档状态，是处理失效延迟的重要边界。若读取过程中权限再次变化，最终发布答案前也需要根据任务的一致性要求核验，下一章的问答流程会继续处理这个问题。

索引迁移也不只是替换一个模型名称。可以建立独立的新索引，完成全部必要块的向量计算，运行固定问题集，对比召回、延迟和成本后再切换查询配置。在迁移期间保留旧索引的清理计划，并记录每次回答使用的索引代号。只更新查询向量模型会产生空间不兼容；只迁移部分文档而不标记范围则可能让某些部门资料突然消失。

最后，质量告警需要包含分母。解析失败十份文件，在当天上传十份和一万份文件时含义不同；空块率正常，也可能掩盖关键表格解析全部失败。按格式、来源模板、语言和解析器版本分组观察，再抽查关键事实，可以把资料质量问题提前暴露在入库阶段，减少用户通过错误答案替系统发现问题的机会。

## 验收与三个自测

验收包括重复上传不重复写、更新只查当前版本、解析失败保留旧版本、权限收紧立即生效、删除后不可检索，以及能从块定位原文。真实 PDF/OCR、数据库事务和向量索引一致性没有在本例执行，必须分别测试。

1. 问：为什么块里保存页码还不够？答：还需要文档版本与更具体的位置，同一页在新版本可能已改变。
2. 问：正文未变是否一定无需更新？答：不是，权限、解析器或 embedding 模型变化也可能要求重建。
3. 问：为什么删除先撤销可见性？答：异步物理清理可能延迟，读取路径必须立即停止返回已删除内容。

## 本章示例验证记录

已离线运行版本切换、原文偏移与发布门禁三个程序，覆盖解析失败保留旧内容、乱序完成、权限收紧、删除后的旧任务及原文切片一致性。示例不读取真实 PDF，不执行 OCR、embedding 或数据库原子操作。

## 官方资料

查阅 [PDF.js 示例](https://mozilla.github.io/pdf.js/examples/)、[Node.js crypto](https://nodejs.org/api/crypto.html) 以及 [OpenAI 检索与属性过滤](https://developers.openai.com/api/docs/guides/retrieval)。本章生命周期设计是教学实现，应按所选存储的一致性能力落实。