文档解析与知识入库
从文件进入系统开始,建立可更新、可删除、可追溯并带权限的知识索引。
建议先读:结构化输出与校验
本页内容
知识库质量从入库之前开始#
用户上传一份制度 PDF,问答系统却说“没有相关内容”。问题未必在模型,也可能是扫描件没有文字层、表格被打乱、章节标题丢失,或者更新后的文件没有进入可查询版本。本章把文档视为需要生命周期管理的数据资产。达标后,你应能设计解析、切块、索引、更新、删除的链路,并让任一回答追溯到具体版本与位置。
前置是文件读取、哈希、基础数据建模和结构校验。无需在本章部署向量数据库。离线例子只处理内存中的 Markdown 字符串,演示版本与索引切换;它不代表完整 PDF、Word 或 OCR 解析器。
文档不是一段无结构字符串#
不同输入有不同提取路径。网页需要去掉导航与广告并保留标题层级;Word 需要处理段落、表格与批注;有文字层的 PDF 可以读取文本项目,但项目顺序不一定等于自然阅读顺序;扫描 PDF 需要 OCR。PDF.js 的基础接口可以加载文档并访问页面,具体提取还要处理文本项目、坐标和布局。PDF.js 官方示例
解析后应有一个中间结构,例如 document、sections、blocks 与 sourceLocation,而不是立即拼接全文。表格的一行必须保留列名,否则“七天”可能不知道是退款期限还是开票时间。页眉页脚重复出现会污染检索,代码块和法律条款的边界也不宜随意打散。
质量检查应在进入索引前进行。一个二十页文件只提取出三十字,应标记“疑似扫描件”或“提取不足”。乱码比例、空页比例、重复块比例和表格结构丢失都可以成为告警条件。解析任务失败不应默默生成一份“空但成功”的知识库记录。
切块决定后面能看见什么#
切块的目标是让每段既能被检索,也足以独立理解。固定字符切块简单,但可能把前提与结论分开;按标题、段落和句子切块更贴近语义,不过长表格仍需额外处理。可以先按结构分段,再对超长段落做有边界的拆分。重叠部分能保留上下文,但也会产生重复结果与额外成本。
每个块应带文档 ID、版本、标题路径、页码或行号、原文偏移、内容哈希、解析器版本和权限信息。chunkId 不应只用“第一个块”之类的全局编号。更新文档后,旧块和新块不能混为同一证据。对于引用,用户需要打开原文对应位置,而不是看到无来源的相似片段。
字符长度与 token 预算需要分开。切块阶段可以使用字符限制做粗筛,但进入 embedding 和模型前仍需按对应模型计算。不要依赖某个固定“中文一字等于多少 token”的经验值处理所有文档。
完整示例:先构建新版本,再切换可见索引#
环境:Node.js 22;文件 ingestion.mjs;无需安装依赖,执行 node ingestion.mjs。本例的全部数据在内存,程序退出即消失。它演示幂等入库、版本替换、租户隔离和删除,不包括真实 embedding。
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。
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 字段同时表示全部状态。
参考答案:完整版本门禁与失败验证
Node.js 22,无需安装。保存为 publish-gate.mjs,执行 node publish-gate.mjs,预期输出 publish gate checks passed。这是同步 Map 对条件更新语义的模拟;真实数据库必须把比较与更新放入同一原子操作。
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');
sourceRevision 在开始构建时递增,active.revision 只有成功发布才改变,因此解析失败不会破坏旧服务。与此同时,新任务一旦被接受,旧任务就失去发布资格,即使新任务失败也不会意外恢复一个过时构建。若产品需要回退,应创建明确的回退操作及新版本,而不是让后台任务的完成顺序替用户决定。
测试故意先发布 fast 再发布 slow,模拟乱序完成;又在 running 构建后删除文档,模拟清理竞争。两组失败都返回 false,表示构建结果已过期,不应按普通网络失败无限重试。实际队列可以将其标为 superseded,停止后续付费计算,并清理只有这次构建引用的暂存块。
从任务队列走到可运营的入库服务#
持久化任务记录至少需要源对象、源版本、处理阶段、尝试次数、最后错误和已完成批次。重试整个任务最简单,但大文件的一小批向量失败可能造成大量重复计算。按确定性的批次键记录结果能够恢复进度,不过复用前必须比较内容哈希、模型配置与预处理版本,避免把旧维度向量塞进新索引。
嵌入接口的批量返回应按输入索引关联,不依赖并行请求完成顺序。网络失败不能证明服务端没有处理过这一批;应用要考虑重复成本和重复写入,同时让索引写入具有确定 ID。资源不足、格式不支持、文本为空和暂时服务故障应使用不同错误码,因为只有部分错误适合自动重试。前端可以据此显示“请换成可复制文本文件”或“系统稍后重试”。
删除完成也应该有可检查定义。逻辑删除已生效、搜索索引已清理、原始文件已清理、缓存已失效可能在不同时间发生。用户最先需要的是立即停止新查询返回资料;运维还需要知道物理清理是否积压。不要只看数据库文档记录消失,就宣称所有副本已经删除。对备份、审计保留和已发送给用户的答案,应按产品实际能力说明范围。
最终验收要从一条问题反向追到原文件:回答使用哪个块,块属于哪个构建,构建来自哪个源版本,解析器是否保留了正确位置。若链路某处只有一个字符串,事故排查就会依赖猜测。入库服务的价值不仅是产出向量,更是建立一个能持续更新、撤回、追溯和评测的证据基础。
最后一道一致性检查放在哪里#
示例的 Map 赋值只在同一进程中具有直观的顺序,不能替代数据库事务。真实发布可以使用带条件的更新语句,条件同时包含文档身份、期望源版本和未删除状态;只有受影响行数为一才算取得发布资格。若向量存储与文档目录不在同一事务中,可以先写不可见的构建空间,再切换目录指针,读取时以指针为准。清理孤立构建则成为后续可重试任务。
检索缓存不应只用问题文本作为键。它还要关联租户、可见权限版本、索引版本和必要的查询配置;否则权限变更后,缓存可能绕过刚刚更新的过滤器。缓存命中后展开原文时再次检查当前文档状态,是处理失效延迟的重要边界。若读取过程中权限再次变化,最终发布答案前也需要根据任务的一致性要求核验,下一章的问答流程会继续处理这个问题。
索引迁移也不只是替换一个模型名称。可以建立独立的新索引,完成全部必要块的向量计算,运行固定问题集,对比召回、延迟和成本后再切换查询配置。在迁移期间保留旧索引的清理计划,并记录每次回答使用的索引代号。只更新查询向量模型会产生空间不兼容;只迁移部分文档而不标记范围则可能让某些部门资料突然消失。
最后,质量告警需要包含分母。解析失败十份文件,在当天上传十份和一万份文件时含义不同;空块率正常,也可能掩盖关键表格解析全部失败。按格式、来源模板、语言和解析器版本分组观察,再抽查关键事实,可以把资料质量问题提前暴露在入库阶段,减少用户通过错误答案替系统发现问题的机会。
验收与三个自测#
验收包括重复上传不重复写、更新只查当前版本、解析失败保留旧版本、权限收紧立即生效、删除后不可检索,以及能从块定位原文。真实 PDF/OCR、数据库事务和向量索引一致性没有在本例执行,必须分别测试。
- 问:为什么块里保存页码还不够?答:还需要文档版本与更具体的位置,同一页在新版本可能已改变。
- 问:正文未变是否一定无需更新?答:不是,权限、解析器或 embedding 模型变化也可能要求重建。
- 问:为什么删除先撤销可见性?答:异步物理清理可能延迟,读取路径必须立即停止返回已删除内容。
本章示例验证记录#
已离线运行版本切换、原文偏移与发布门禁三个程序,覆盖解析失败保留旧内容、乱序完成、权限收紧、删除后的旧任务及原文切片一致性。示例不读取真实 PDF,不执行 OCR、embedding 或数据库原子操作。
官方资料#
查阅 PDF.js 示例、Node.js crypto 以及 OpenAI 检索与属性过滤。本章生命周期设计是教学实现,应按所选存储的一致性能力落实。