Skip to content
AI 工程
RAG 文档处理流程:解析、切片、Embedding 与索引构建
RAG 文档处理流程:解析、切片、Embedding 与索引构建 的概念、用法、示例和注意点
2026/03/2112 分钟AI 工程
RAG 文档处理流程:解析、切片、Embedding 与索引构建
概述
检索增强生成(RAG)需要先把外部文档转换成可检索的形式。这个转换过程可以看作一条文档处理管道:输入是 PDF、Word、HTML、Markdown、TXT 等原始文件,输出是一个支持相似度检索的索引,以及查询时所需的文本块和元数据。
管道包含四个主要阶段:
- 解析:读取文件内容,提取文本与结构。
- 切片:将长文档切成适合检索和作为上下文的文本块。
- Embedding:把每个文本块映射成向量。
- 索引:把向量写入索引结构,并把文本块、元数据与向量关联。
查询阶段同样需要经过 Embedding。用户问题先转换成查询向量,再从索引中检索相关文本块。因此,Embedding 模型在写入和查询两个阶段都会被使用。
整体管道与模块衔接
数据流可以表示为:
text
原始文件 -> 文档解析 -> 清洗后的文本 -> 文档切片 -> 文本块
文本块 -> Embedding -> 向量 -> 向量索引 -> 元数据存储
查询文本 -> Embedding -> 查询向量 -> 检索 -> 命中的文本块每一步的输入输出应当保持明确的模块边界。后续评估中,可能需要单独调整切片方式或索引参数,而不重写整个管道。
文档解析与清洗
常见格式与解析工具
不同文档格式的解析方式差异较大:
- PDF 可能是文本型,也可能是扫描图像型。文本型 PDF 可以直接提取字符流,扫描型需要 OCR。
- DOCX 本质是一个 ZIP 包,内含 XML 文件,正文在
document.xml中。 - HTML 需要解析 DOM,剔除脚本、样式和导航链接。
- Markdown/TXT 是纯文本,但 Markdown 的标题和列表是后续切片的重要结构信息。
Node.js 生态中可用的解析库有多种。处理 PDF 时可以选择能输出文本块的库,处理 DOCX 时可以选择转换为干净 HTML 或纯文本的库,处理 HTML 时可以使用 DOM 解析库,处理 Markdown 时可以使用语法树解析工具。选择依据主要是两点:输出是否保留结构信息,以及是否支持后续的清洗和切片。
文本提取、表格与多栏版面
PDF 文本提取最容易遇到的问题不是文字识别,而是阅读顺序。多栏版面的 PDF 在物理层面由字符和坐标组成,直接按文本流导出,可能会把两栏的文字交错在一起。
处理方向包括:
- 使用版面分析识别栏区域,再按栏的顺序拼装文本。
- 表格单独提取为 HTML 或 CSV;如果只按空白分词,表格中的列名和单元格内容会失去对应关系。
- 保留标题的位置信息,作为章节层级。
参考工具 [1] 是一个 Docker 化的 PDF 版面分析服务,可对 PDF 页面中的文本、标题、图片、表格进行分割与分类,支持表格提取为 HTML、公式提取为 LaTeX,并输出 JSON、Markdown、HTML。这类服务适合作为解析层的独立组件。
扫描件 OCR 与版面分析
扫描件没有文本层,需要先经过 OCR。常见流程是:先检测版面区域,再对文字区域做识别,最后按坐标重组段落。OCR 引擎需要语言包支持,中文和英文通常使用不同的模型。识别结果容易出现同音字、标点丢失等问题,因此清洗步骤很重要。
OCR 和版面分析通常需要较大的模型和依赖空间,对计算资源也有要求。文档量大时,这部分会成为管道中耗时最高的阶段。
编码识别、去噪与规范化
文本解析后的字符串可能包含 BOM、控制字符、全角空格、连续空白和多余的换行。规范化目标是让后续切片得到相对干净的单位。
js
function cleanText(raw) {
return raw
.replace(/^\uFEFF/, '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
.replace(/[ \t]+/g, ' ')
.replace(/ *\n */g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}行为说明:
\uFEFF是 UTF-8 BOM,通常出现在文件开头。\r\n和\r统一转换为\n,避免 Windows、Unix 和旧 Mac 换行符混用。- 控制字符中保留
\n,其他不可见字符被移除。 - 连续空格折叠为一个空格。
- 换行前后的空格被清理。
- 连续三个以上换行被压缩为两个,保留段落边界。
需要注意,不要把所有空白都去掉。段落和换行是切片的重要边界。
文本切片策略
切片粒度对检索和生成的影响
切片是检索的最小单位。如果文本块过大,包含太多无关信息,向量会被“稀释”;如果过小,可能只覆盖一个句子的片段,缺少主语或上下文。
切片粒度还会影响生成阶段。模型只能接受有限长度的上下文,多个切片拼接后可能超出窗口,因此不能把整篇文档交给模型。好的切片应当是一个语义相对完整的段落或小节。
固定长度切分与递归字符切分
固定长度切分按字符数或 token 数切块,实现简单,但容易在句子中间切断。
js
function fixedSizeChunks(text, size, overlap = 0) {
if (overlap >= size) {
throw new Error('overlap must be less than size');
}
const chunks = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + size, text.length);
chunks.push(text.slice(start, end));
if (end === text.length) break;
start = end - overlap;
}
return chunks;
}参数校验保证 overlap 必须小于 size,否则步长为零或为负,可能导致死循环。这里使用 end - overlap 作为下一步起点,当 overlap 为 0 时起点为 end,正常推进。
递归字符切分使用一系列分隔符。先尝试按段落切分;如果某一块仍然太长,再在下一级分隔符处切分。这样可以尽量保持句子和段落的完整性。
js
const SEPARATORS = ['\n\n', '\n', '。', '. '];
function splitRecursive(text, maxLen, seps = SEPARATORS) {
if (text.length <= maxLen) return [text];
const sep = seps[0];
if (!sep) {
const first = text.slice(0, maxLen);
return [first, ...splitRecursive(text.slice(maxLen), maxLen, seps)];
}
const parts = text.split(sep);
const chunks = [];
let current = '';
for (const part of parts) {
const candidate = current ? `${current}${sep}${part}` : part;
if (candidate.length <= maxLen) {
current = candidate;
} else {
if (current) chunks.push(current);
chunks.push(...splitRecursive(part, maxLen, seps.slice(1)));
current = '';
}
}
if (current) chunks.push(current);
return chunks;
}这是一个简化实现,用于说明递归切分的基本行为:先尽量保留段落,段落过长时再按句子切,最后按字符兜底。
结构感知切分与语义切分
结构感知切分利用文档本身的标题层级。Markdown 的 # 标题、HTML 的 h1/h2、PDF 版面分析给出的标题区域,都是天然边界。切片时可以把标题保存在当前块的元数据中。
js
function splitByMarkdownHeadings(md) {
const lines = md.split('\n');
const chunks = [];
let current = [];
let heading = '';
for (const line of lines) {
if (/^#{1,6}\s/.test(line)) {
if (current.length) {
chunks.push({ heading, text: current.join('\n') });
}
heading = line.replace(/^#{1,6}\s/, '');
current = [line];
} else {
current.push(line);
}
}
if (current.length) {
chunks.push({ heading, text: current.join('\n') });
}
return chunks;
}语义切分通过计算句子向量的相邻相似度,在语义转折处切分。这种方法比规则切分成本高,适合主题变化明显的文档。切分阈值需要在具体数据集上验证。
切片重叠与元数据保留
重叠指相邻切片共享部分文本,用来减少边界截断造成的实体丢失。重叠会带来重复内容,但通常可以接受。
切片元数据至少应当包含:
- 文档 ID。
- 切片 ID。
- 来源文件路径或 URL。
- 所在章节标题。
- 页码。
- 文档版本号。
切片 ID 应基于完整内容和文档信息生成,避免只取开头片段造成碰撞。
js
const crypto = require('crypto');
function chunkId(docId, text, index) {
return crypto
.createHash('sha1')
.update(`${docId}:${index}:${text}`)
.digest('hex');
}文本嵌入
Embedding 模型选型:中英文与领域适配
Embedding 模型将文本映射到高维向量。选型时首先看语言范围:中英文混合场景需要双语模型。其次看领域:法律、医疗等领域专有名词多,通用模型可能无法充分表达领域语义。
同一个模型通常会规定最大输入 token 数,切片长度不应超过这个限制。向量维度也不是越高越好:维度高能表达更多信息,但存储和计算成本也会增加。常见向量维度从数百维到数千维不等,具体以模型为准。
Batch Embedding、向量维度与成本
对大量切片逐条调用嵌入服务,网络开销会很大。大多数模型服务支持批量传入文本。批次大小受模型输入长度和内存限制。
js
class EmbeddingClient {
constructor(batchSize = 16) {
this.batchSize = batchSize;
}
async embed(texts) {
const vectors = [];
for (let i = 0; i < texts.length; i += this.batchSize) {
const batch = texts.slice(i, i + this.batchSize);
const result = await this.request(batch);
vectors.push(...result);
}
return vectors;
}
async request(batch) {
// 调用远程或本地模型服务
// 返回与 batch 顺序一致的向量数组
throw new Error('not implemented');
}
}这是一个示例类,实际使用时需要实现 request 方法,对接具体的模型服务。批量服务必须保证返回顺序与输入顺序一致。如果返回乱序,向量会与文本错位,后续索引和检索都会被污染。
相似度度量:余弦、内积与欧氏距离
给定两个向量 a 和 b,常用度量方式有三种:
- 余弦相似度:
cos = (a·b) / (|a||b|),取值范围是[-1, 1],适合比较文本语义。 - 内积:
a·b,向量未归一化时,长度会影响结果。 - 欧氏距离:
sqrt(Σ(a_i - b_i)^2),度量绝对距离。
如果 Embedding 模型输出做过归一化,内积和余弦相似度等价。HNSW 索引在声明 cosine 空间时,通常会在内部做归一化。
js
function cosineSimilarity(a, b) {
let dot = 0;
let na = 0;
let nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}返回值越大表示向量方向越接近。cosine 是向量索引库中的常见空间名称,具体实现以所用库文档为准。
向量索引构建
索引类型:FLAT、IVF、HNSW
向量索引用来加速“查找最近邻”的过程。三种常见类型:
- FLAT:线性扫描全部向量,精度最高,数据量增大后查询耗时会线性增长。
- IVF:先对全部向量做聚类,查询时只扫描最相近的若干聚类。
nlist控制聚类数量,nprobe控制查询时扫描的聚类数量。 - HNSW:构建多层近邻图。查询从顶层开始,逐层向下,搜索宽度由
efSearch控制。
选择上没有绝对标准。数据量小、要求精确时,FLAT 足够;数据量中等、需要低延迟时,HNSW 比较常见;数据量很大且能接受精度损耗时,IVF 值得考虑。
内存索引与磁盘索引
HNSW 和 FLAT 可以常驻内存,查询快,但数据量受内存限制。磁盘索引把向量和元数据写入持久化存储,进程重启后不需要重新构建,但会增加磁盘 I/O。
数据量不大时,单机内存索引通常可以满足需求。当数据量超过单机容量时,才需要考虑分布式向量数据库。
HNSW 参数与 IVF 参数配置
HNSW 的关键参数:
M:每个节点的最大连接数。M越大,图越稠密,召回越高,但内存和构建耗时也增加。efConstruction:构建时的动态候选数。越大构建越慢,但图质量越高。efSearch:查询时的动态候选数。越大召回越好,查询越慢。
IVF 的关键参数:
nlist:聚类数量。过大时每类样本少,聚类不稳定;过小时每类样本多,查询扫描量大。nprobe:查询时检查的聚类数。增大nprobe会提升召回,但增加查询耗时。
这些参数没有固定配置,需要根据数据集规模、召回目标和查询延迟进行实验。
以下示例使用 hnswlib-node 库。API 名称以该库的版本为准。
js
const { HierarchicalNSW } = require('hnswlib-node');
const dim = 384;
const maxElements = 10000;
const M = 16;
const efConstruction = 200;
const efSearch = 64;
const index = new HierarchicalNSW('cosine', dim);
index.initIndex(maxElements, M, efConstruction, 100);
index.setEf(efSearch);说明:
new HierarchicalNSW(space, dimension):创建索引对象。initIndex(maxElements, M, efConstruction, randomSeed):初始化容量和构建参数。setEf(efSearch):设置查询阶段的候选数。maxElements是索引能容纳的最大向量数,达到上限后需要新建索引或扩展容量。
添加向量:
js
await index.addPoint(vector, id);搜索:
js
const res = await index.searchKnn(queryVector, 10);
console.log(res.neighbors);id 必须是整数,且不能超过 maxElements。文档 ID 需要单独映射。addPoint 是异步还是同步取决于具体绑定版本,如果对应版本提供 addPointSync,可以改为同步调用。
增量更新、删除与去重
向量索引一般支持增量插入。删除有两种处理方式:
- 索引本身支持删除。
- 使用逻辑删除:把向量标记为不可用,检索后过滤对应元数据。
如果索引不支持删除,或者删除后碎片较多,定期重建索引是更简单的方式。
去重可以在解析阶段对文本内容计算哈希。如果哈希已存在,就跳过写入。哈希相同基本可以认为文本相同,但文本不同也可能语义相同,这是两个层面的问题。
混合检索索引
向量索引与 BM25/稀疏索引
向量检索能匹配语义相近但用词不同的文本。BM25 擅长精确词项匹配,对专有名词、代码、型号等效果较好。两者互补,组合使用可以提高检索覆盖率。
混合检索通常维护两个索引:
- 稠密索引:文本向量。
- 稀疏索引:词项与词频,用于 BM25。
查询时两个索引分别返回 top-N 文档,再用融合算法合并。
RRF 融合原理与实现要点
RRF(Reciprocal Rank Fusion)融合的是排名,不是原始分数。一个文档在多个结果列表中的排名越靠前,融合分越高。
公式:
text
score(d) = Σ 1 / (k + rank(d, r))其中 r 是每个检索列表,rank 从 1 开始,k 是平滑常数。
js
const listA = ['a', 'b', 'c', 'd'];
const listB = ['b', 'c', 'a', 'e'];
function rrfScore(rank, k = 60) {
return 1 / (k + rank);
}
const scores = new Map();
for (const [rank, id] of listA.entries()) {
scores.set(id, (scores.get(id) || 0) + rrfScore(rank + 1));
}
for (const [rank, id] of listB.entries()) {
scores.set(id, (scores.get(id) || 0) + rrfScore(rank + 1));
}
const result = Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.map(([id]) => id);
console.log(result);
// 这里 d 和 e 同分,稳定排序会保留插入顺序
// 输出为 ['b', 'a', 'c', 'd', 'e']计算过程:
b:在 listA 第 2 位,在 listB 第 1 位,得分1/62 + 1/61。a:在 listA 第 1 位,在 listB 第 3 位,得分1/61 + 1/63。c:在 listA 第 3 位,在 listB 第 2 位,得分1/63 + 1/62。d:只在 listA 第 4 位,得分1/64。e:只在 listB 第 4 位,得分1/64。
因此排序是 b, a, c, d/e。
实际检索中,两组结果列表的文档 ID 体系必须一致。如果向量检索和 BM25 使用不同 ID,需要先统一。
Node.js/ES6 最小实现
Pipeline 模块划分
最小实现可以分成四个模块:
parser:文件读取、文本提取、清洗。chunker:文本切片。embedder:文本向量化。indexer:向量写入与检索。
模块之间通过普通对象传递数据。
js
class Pipeline {
constructor({ parse, chunk, embed, index }) {
this.parse = parse;
this.chunk = chunk;
this.embed = embed;
this.index = index;
}
async run(filePath) {
const parsed = await this.parse(filePath);
const chunks = this.chunk(parsed.text);
const vectors = await this.embed(chunks.map(c => c.text));
await this.index.add(vectors, chunks, parsed.meta);
return { chunks: chunks.length };
}
}四个模块各自可以独立替换。比如替换切片函数不会影响 Embedding 和索引模块。
解析与切片示例
解析示例读取纯文本文件,然后清洗:
js
const fs = require('fs/promises');
async function parseTextFile(path) {
const raw = await fs.readFile(path, 'utf8');
return {
text: cleanText(raw),
meta: { source: path }
};
}切片示例按 Markdown 标题和递归字符切分组合:
js
function chunkMarkdown(md, maxLen = 800) {
const sections = md.split(/^(?=#{1,3} )/m).filter(Boolean);
const chunks = [];
for (const section of sections) {
const lines = section.split('\n');
const title = lines[0] || '';
const body = lines.slice(1).join('\n');
const pieces = splitRecursive(body, maxLen);
pieces.forEach((text, i) => {
chunks.push({
id: chunkId('', text, i),
title,
text
});
});
}
return chunks;
}这里的 id 是精简示例,实际使用时应传入文档 ID,或改用数据库自增 ID。
Batch Embedding 与索引写入示例
将前面的 EmbeddingClient 和 VectorIndex 组合起来:
js
const { HierarchicalNSW } = require('hnswlib-node');
class VectorIndex {
constructor(dim, maxElements, { M = 16, efConstruction = 200, efSearch = 64 } = {}) {
this.index = new HierarchicalNSW('cosine', dim);
this.index.initIndex(maxElements, M, efConstruction, 100);
this.index.setEf(efSearch);
this.chunks = [];
}
async add(vectors, chunks) {
for (let i = 0; i < vectors.length; i++) {
const id = this.chunks.length + i;
await this.index.addPoint(vectors[i], id);
}
this.chunks.push(...chunks);
}
async search(queryVector, k = 10) {
const res = await this.index.searchKnn(queryVector, k);
return res.neighbors.map(id => this.chunks[id]);
}
}说明:
this.chunks与向量 ID 按插入顺序一一对应。searchKnn返回neighbors和distances。neighbors是 ID 数组,distances是对应的距离数组。- 如果只取距离,可以读取
res.distances。
调用流程:
js
const embedder = new EmbeddingClient();
const index = new VectorIndex(384, 10000);
const pipeline = new Pipeline({
parse: parseTextFile,
chunk: chunkMarkdown,
embed: (texts) => embedder.embed(texts),
index
});
await pipeline.run('./notes.md');
const queryVector = await embedder.embed(['什么是切片重叠?']);
const hits = await index.search(queryVector[0], 3);
console.log(hits.map(h => h.title));这里的 EmbeddingClient 需要由实际模型服务实现。查询语句在进入检索前也需要做同样的清洗和规范化。VectorIndex 是内存索引,进程重启后数据会丢失;如果需要持久化,需要把向量和 chunks 导出或改用数据库。
文档处理质量评估
切片有效率与命中率
切片有效率衡量“切片内容是否值得作为检索结果”。可以人工检查一小批检索结果,统计包含有效信息的切片比例。无效切片可能来自噪声、错误 OCR、清洗过度等。
命中率是另一个简单指标:对每个测试问题,查看 top-10 或 top-20 结果中是否存在人工标注的相关切片。如果相关切片存在但排名靠后,说明索引排序需要优化。
召回率与检索效果评估
可以用标注数据集计算 Recall@k。相关文档集合由人工确定。
js
function recallAtK(retrievedIds, relevantIds, k) {
const topK = retrievedIds.slice(0, k);
const hit = topK.filter(id => relevantIds.has(id)).length;
return relevantIds.size === 0 ? 0 : hit / relevantIds.size;
}示例:
js
const retrieved = [10, 20, 30, 40];
const relevant = new Set([20, 40]);
console.log(recallAtK(retrieved, relevant, 2)); // 0.5
console.log(recallAtK(retrieved, relevant, 4)); // 1Recall@2 只看前 2 个结果,这里命中了 20,相关集合大小为 2,所以是 0.5。Recall@4 命中了 20 和 40,所以是 1。
评估时应固定切片方式和 Embedding 模型,只改变索引参数,否则无法判断影响来自哪个环节。
评估对生成效果的影响
检索效果和生成效果并非完全一致。检索结果的排序、相关性、上下文完整度都会影响最终回答。通常,文档处理管道质量越高,检索质量越高,生成回答包含正确信息的概率也越高。
端到端评估可以用一批问题和参考答案,比较生成回答与参考答案的相似度,或由人工打分。这种评估成本较高,适合在需要确认整体效果时进行。
注意点与工程边界
文档来源多样性与异常输入
实际输入中会出现空文件、加密 PDF、只含图片的扫描件、损坏的 DOCX、超长表格和混合语言文本。解析层应当返回明确错误,而不是中断整批任务。
js
async function safeParse(path) {
try {
return await parseTextFile(path);
} catch (err) {
return {
text: '',
meta: { source: path, error: err.message }
};
}
}出错文档可以单独记录,并继续处理后续文档。
元数据设计与版本管理
每个文档至少应保留:
- 唯一文档 ID。
- 来源路径。
- 内容哈希。
- 解析时间。
- 解析版本。
当文档内容更新后,旧切片应标记为过期,或直接用新版本覆盖。如果索引不支持删除,可以先检索旧文档的 ID 列表,再从元数据层过滤。
数据更新的工程约束
更新文档时不能只插入新向量,还需要处理旧向量:
- 全量重建:删除旧索引,重新解析所有文档。简单但耗时。
- 增量更新:判断文档是否变化,只更新变化的文档。
- 逻辑删除:通过元数据字段过滤旧版本。
这些约束来自向量索引和数据存储本身的限制。在设计管道前,应先确认索引是否支持删除和动态扩容。
参考链接
相关推荐
2026/03/17 · 12 分钟Embedding 模型原理:文本向量化与语义检索机制
Embedding 模型原理:文本向量化与语义检索机制 的概念、用法、示例和注意点
2026/02/07 · 9 分钟LangChain.js 应用:构建带 RAG 与工具的问答应用LangChain.js 应用:构建带 RAG 与工具的问答应用 的概念、用法、示例和注意点
2026/05/30 · 9 分钟assistant-ui 架构分析:现代 AI Chat UI 的组件设计assistant-ui 架构分析:现代 AI Chat UI 的组件设计 的概念、用法、示例和注意点
2026/05/24 · 13 分钟AI Chat Runtime 设计:消息状态管理、上下文维护与交互架构AI Chat Runtime 设计:消息状态管理、上下文维护与交互架构 的概念、用法、示例和注意点
