为什么文档预处理如此重要?
一个真实的教训
某团队在构建 RAG 系统时遇到了一个诡异的问题:同样的代码,有时候跑得完美,有时候却会遗漏关键数据。
经过层层排查,问题源头竟然是:
- PDF 中的表格同时存在"结构化表格"和"被打散成文本的乱码"两个版本
- 向量检索时,两个版本都被召回
- LLM 分不清该信哪个,有时选对的,有时选错的
这就是文档预处理不当的典型后果。
文档预处理的核心目标
| 目标 | 说明 | 错误示例 |
|---|---|---|
| 格式统一 | 将多格式文档转为统一文本 | PDF 中的表格被乱码化 |
| 内容干净 | 去除噪声字符、规范空白 | 保留页眉页脚、乱码字符 |
| 语义完整 | 切分时不破坏语义边界 | 在句子中间截断 |
| 元数据保留 | 记录来源、页码等信息 | 回答时无法溯源 |
RAG 文档处理面临的挑战
AWS 的实践指南总结了以下常见问题:
| 挑战 | 表现 | 后果 |
|---|---|---|
| 缺少结构化格式 | 没有清晰的章节标题 | 难以识别内容上下文 |
| 非正式语言 | 术语不一致、缩写未定义 | 模型理解偏差 |
| 冗余信息 | 重复内容、冗长描述 | 浪费 token,干扰检索 |
| 图形和超链接 | PDF 中的图片被截断 | 关键信息丢失 |
| 歧义术语 | 同一术语有多种含义 | 回答不准确 |
RAG 文档处理的核心要求
整体流程概览
flowchart LR
subgraph 文档预处理流水线
direction TB
subgraph Row1 [ ]
direction LR
A[1.文档加载<br/>Loading]
B[2.文本清洗<br/>Cleaning]
C[3.文本分割<br/>Splitting]
end
subgraph Row2 [ ]
direction LR
A1[统一文本格式<br/>保留元数据]
B1[去除噪声字符<br/>规范空白换行]
C1[语义边界切分<br/>控制块大小]
end
A -.- A1
B -.- B1
C -.- C1
end
核心要求详解
1. 格式统一
不同类型的文档(PDF、Markdown、Word)需要转换成统一的文本格式,以便后续处理。
// 统一后的文本格式示例
{
content: "这是文档的纯文本内容...",
metadata: {
source: "technical_guide.pdf",
page: 42,
title: "第三章:核心概念"
}
}
2. 内容干净
去除对检索和生成无帮助的噪声内容:
| 噪声类型 | 示例 | 处理方式 |
|---|---|---|
| 页眉页脚 | "机密文档·第3页" | 正则匹配删除 |
| 特殊字符 | \u0000、\ufffd | 替换或删除 |
| 多余空白 | 连续空格、空行过多 | 规范化 |
| 乱码字符 | 编码错误导致的� | 过滤或修复 |
3. 语义完整
切分文档时,确保每个块在语义上是相对完整的:
// ❌ 错误:在语义边界外截断
const badChunk = "闭包是指函数能够记住并访问它的词法作"; // 句子被截断
// ✅ 正确:保持语义完整
const goodChunk = "闭包是指函数能够记住并访问它的词法作用域,即使这个函数在它的词法作用域之外执行。";
常用文档加载器使用详解
支持的文档格式
LangChain 支持 20+ 种文档格式,前端开发最常用的是:
| 格式 | 加载器 | 前端应用场景 |
|---|---|---|
| TXT | TextLoader | 日志文件、配置文件 |
| Markdown | UnstructuredMarkdownLoader | 技术文档、README |
| CSV | CSVLoader | 数据导出、报表 |
PDFLoader | 用户手册、合同文档 |
加载器使用示例
// src/loaders/index.ts
import { TextLoader } from "@langchain/classic/document_loaders/fs/text"; // txt
import { CSVLoader } from "@langchain/community/document_loaders/fs/csv"; // csv
import { UnstructuredLoader } from "@langchain/community/document_loaders/fs/unstructured"; // md / 通用
// 1. 加载 TXT 文件
async function loadTxtFile(filePath: string) {
const loader = new TextLoader(filePath);
const docs = await loader.load();
return docs;
}
// 2. 加载 Markdown 文件(保留标题结构)
async function loadMarkdownFile(filePath: string) {
const loader = new UnstructuredLoader(filePath);
const docs = await loader.load();
// Markdown 的标题层级会被保留在 metadata 中
return docs;
}
// 3. 加载 CSV 文件
async function loadCsvFile(filePath: string) {
const loader = new CSVLoader(filePath);
const docs = await loader.load();
// 每行 CSV 变成一个 Document,列名存入 metadata
return docs;
}
// 4. 根据文件扩展名自动选择加载器
export async function loadDocument(filePath: string) {
const ext = filePath.split('.').pop()?.toLowerCase();
switch (ext) {
case 'txt':
return loadTxtFile(filePath);
case 'md':
return loadMarkdownFile(filePath);
case 'csv':
return loadCsvFile(filePath);
default:
throw new Error(`不支持的文件格式: ${ext}`);
}
}
元数据的重要性
加载器会自动提取文档的元数据,这对后续的溯源至关重要:
// 加载后的 Document 结构示例
{
pageContent: "文档的实际文本内容...",
metadata: {
source: "technical_guide.pdf", // 来源文件
page: 42, // 页码(PDF)
line: 15, // 行号(TXT)
title: "核心概念" // 标题(Markdown)
}
}
文本清洗与预处理方案
清洗方案对比
| 清洗内容 | 优化前 | 优化后 |
|---|---|---|
| 特殊字符 | function test(){console.log("hello")} | function test(){console.log("hello")} |
| 多余空白 | " 闭包 是 JavaScript 的核心" | "闭包是 JavaScript 的核心" |
| 不换行空格 | hello\u00A0world | hello world |
| 控制字符 | Hello\u0000World | HelloWorld |
| 编码问题 | effected� | effected |
完整清洗函数实现
// src/cleaners/text-cleaner.ts
interface CleanOptions {
removeSpecialChars?: boolean; // 移除特殊控制字符
normalizeWhitespace?: boolean; // 规范化空白字符
removeEmptyLines?: boolean; // 移除空行
trimLines?: boolean; // 每行首尾去空格
maxLineLength?: number; // 单行最大长度
}
const defaultOptions: CleanOptions = {
removeSpecialChars: true,
normalizeWhitespace: true,
removeEmptyLines: true,
trimLines: true,
maxLineLength: 1000,
};
/**
* 清洗文本内容
* @param text 原始文本
* @param options 清洗选项
* @returns 清洗后的文本
*/
export function cleanText(text: string, options: CleanOptions = defaultOptions): string {
let cleaned = text;
// 1. 移除特殊控制字符(保留换行和制表符)
if (options.removeSpecialChars) {
cleaned = cleaned.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
// 替换不换行空格为普通空格
cleaned = cleaned.replace(/\u00A0/g, ' ');
// 替换零宽字符
cleaned = cleaned.replace(/[\u200B-\u200D\uFEFF]/g, '');
}
// 2. 规范化空白字符
if (options.normalizeWhitespace) {
// 连续空格 → 单个空格
cleaned = cleaned.replace(/[ \t]+/g, ' ');
// 连续换行 → 最多两个
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
}
// 3. 每行首尾去空格
if (options.trimLines) {
cleaned = cleaned.split('\n')
.map(line => line.trim())
.join('\n');
}
// 4. 移除空行
if (options.removeEmptyLines) {
cleaned = cleaned.split('\n')
.filter(line => line.length > 0)
.join('\n');
}
// 5. 截断过长的行
if (options.maxLineLength) {
cleaned = cleaned.split('\n')
.map(line => line.length > (options.maxLineLength as number)
? line.slice(0, options.maxLineLength) + '...'
: line)
.join('\n');
}
return cleaned;
}
/**
* 批量清洗文档数组
*/
export function cleanDocuments(docs: any[], options?: CleanOptions): any[] {
return docs.map(doc => ({
...doc,
pageContent: cleanText(doc.pageContent, options),
}));
}
前端实现文档批量上传与读取
前端文件上传组件设计
RAG 文档处理的起点是用户上传文档。前端可以通过 <input type="file"> 配合 File API 实现:
// src/components/DocumentUpload.vue
<template>
<div class="document-upload">
<!-- 上传区域 -->
<div class="upload-area">
<input
type="file"
id="file-upload"
multiple
accept=".txt,.md,.csv,.pdf"
@change="handleFileSelect"
style="display: none"
ref="fileInput"
/>
<label for="file-upload" class="upload-label">
📁 点击或拖拽上传文档
<span class="upload-hint">支持 TXT、MD、CSV、PDF 格式</span>
</label>
</div>
<!-- 文件列表 -->
<div v-if="files.length" class="file-list">
<h3>已选文件 ({{ files.length }})</h3>
<div
v-for="file in files"
:key="file.id"
class="file-item"
:class="`status-${file.status}`"
>
<div class="file-info">
<span class="file-name">📄 {{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
</div>
<div class="file-status">
<span v-if="file.status === 'pending'">⏳ 待处理</span>
<span v-else-if="file.status === 'processing'">🔄 处理中...</span>
<span v-else-if="file.status === 'done'">✅ 已完成</span>
<span v-else-if="file.status === 'error'">❌ {{ file.error }}</span>
</div>
<button @click="removeFile(file.id)" class="remove-btn">✕</button>
</div>
<button
@click="processFiles"
:disabled="isProcessing"
class="process-btn"
>
{{ isProcessing ? "处理中..." : "开始处理" }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
const files = ref([]);
const isProcessing = ref(false);
const fileInput = ref(null);
/**
* 处理文件选择
*/
const handleFileSelect = (event) => {
const selectedFiles = Array.from(event.target.files || []);
const newFiles = selectedFiles.map((file) => ({
id: `${file.name}-${Date.now()}-${Math.random()}`,
name: file.name,
size: file.size,
type: file.type,
content: "",
status: "pending",
}));
files.value = [...files.value, ...newFiles];
};
/**
* 读取文件内容
*/
const readFileContent = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result);
reader.onerror = (e) => reject(e);
reader.readAsText(file, "utf-8");
});
};
/**
* 文本清洗(模拟)
*/
const cleanText = (text) => {
return text.trim().replace(/\s+/g, " ");
};
/**
* 批量处理文件
*/
const processFiles = async () => {
isProcessing.value = true;
for (let i = 0; i < files.value.length; i++) {
const file = files.value[i];
if (file.status !== "pending") continue;
// 更新为处理中
files.value = files.value.map((f) =>
f.id === file.id ? { ...f, status: "processing" } : f
);
try {
// 模拟内容
const content = `这是 ${file.name} 的模拟内容...`;
const cleanedContent = cleanText(content);
files.value = files.value.map((f) =>
f.id === file.id
? { ...f, content: cleanedContent, status: "done" }
: f
);
} catch (error) {
files.value = files.value.map((f) =>
f.id === file.id
? { ...f, status: "error", error: String(error) }
: f
);
}
}
isProcessing.value = false;
};
/**
* 移除文件
*/
const removeFile = (id) => {
files.value = files.value.filter((f) => f.id !== id);
};
/**
* 格式化文件大小
*/
const formatFileSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
</script>
<style scoped>
.document-upload {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.upload-area {
border: 2px dashed #ccc;
border-radius: 12px;
padding: 40px;
text-align: center;
margin-bottom: 20px;
cursor: pointer;
transition: border-color 0.3s;
}
.upload-area:hover {
border-color: #1677ff;
}
.upload-label {
display: block;
font-size: 16px;
color: #333;
}
.upload-hint {
display: block;
margin-top: 8px;
font-size: 14px;
color: #666;
}
.file-list {
margin-top: 20px;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border: 1px solid #eee;
border-radius: 8px;
margin-bottom: 8px;
}
.file-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-name {
font-weight: 500;
}
.file-size {
font-size: 12px;
color: #666;
}
.file-status {
font-size: 14px;
}
.remove-btn {
background: none;
border: none;
color: #ff4d4f;
font-size: 18px;
cursor: pointer;
}
.process-btn {
margin-top: 16px;
padding: 10px 24px;
background: #1677ff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
}
.process-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
文档内容预览组件
// src/components/DocumentPreview.vue
<template>
<div class="document-preview">
<div class="preview-header">
<h3>📄 {{ fileName }}</h3>
<div class="preview-actions">
<button @click="setShowCleaned(!showCleaned)">
{{ showCleaned ? "查看原始" : "查看清洗后" }}
</button>
</div>
<div class="preview-stats">
<span>原始大小: {{ stats.originalLength }} 字符</span>
<span>清洗后: {{ stats.cleanedLength }} 字符</span>
<span>减少: {{ reduction }}%</span>
<span>行数: {{ stats.lines }}</span>
</div>
</div>
<pre class="preview-content">{{ displayContent }}</pre>
</div>
</template>
<script setup>
import { computed, ref } from "vue";
import { cleanText } from "../cleaners/text-cleaner";
// 接收 Props
const props = defineProps();
// 切换显示原始/清洗后内容
const showCleaned = ref(false);
// 计算内容
const cleanedContent = computed(() => cleanText(props.originalContent));
const displayContent = computed(() =>
showCleaned.value ? cleanedContent.value : props.originalContent
);
// 统计信息
const stats = computed(() => {
return {
originalLength: props.originalContent.length,
cleanedLength: cleanedContent.value.length,
lines: displayContent.value.split("\n").length,
};
});
// 压缩比例
const reduction = computed(() => {
if (stats.value.originalLength === 0) return "0.0";
return ((1 - stats.value.cleanedLength / stats.value.originalLength) * 100).toFixed(1);
});
</script>
<style scoped>
.document-preview {
margin: 16px 0;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.preview-header {
padding: 12px 16px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.preview-header h3 {
margin: 0 0 8px 0;
font-size: 16px;
}
.preview-actions {
margin-bottom: 8px;
}
.preview-actions button {
padding: 6px 12px;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
cursor: pointer;
}
.preview-actions button:hover {
background: #f3f4f6;
}
.preview-stats {
display: flex;
gap: 12px;
font-size: 12px;
color: #6b7280;
}
.preview-content {
padding: 16px;
margin: 0;
max-height: 400px;
overflow: auto;
background: #fff;
white-space: pre-wrap;
font-size: 13px;
line-height: 1.5;
}
</style>
文本分割策略与实践
分割参数选择
文本切分是影响检索质量的关键环节:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| chunk_size | 500-1000 | 每块字符数,过小丢失上下文,过大噪声增多 |
| chunk_overlap | 50-200 | 相邻块重叠,保证语义连续性 |
| separators | ["\n\n", "\n", "。", ","] | 优先按段落、句子边界切分 |
分割器实现
// src/splitters/text-splitter.ts
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
// 中文分隔符优先级(从高到低)
const CHINESE_SEPARATORS = [
"\n\n", // 段落分隔
"\n", // 换行
"。", // 句号
"!", // 感叹号
"?", // 问号
";", // 分号
",", // 逗号
" ", // 空格
"", // 字符级(最后手段)
];
interface SplitterConfig {
chunkSize: number; // 每块最大字符数
chunkOverlap: number; // 块间重叠字符数
}
const defaultConfig: SplitterConfig = {
chunkSize: 800,
chunkOverlap: 100,
};
export function createTextSplitter(config: SplitterConfig = defaultConfig) {
return new RecursiveCharacterTextSplitter({
chunkSize: config.chunkSize,
chunkOverlap: config.chunkOverlap,
separators: CHINESE_SEPARATORS,
});
}
/**
* 分割文档并过滤空块
*/
export async function splitDocuments(docs: any[], config?: SplitterConfig) {
const splitter = createTextSplitter(config);
const chunks = await splitter.splitDocuments(docs);
// 过滤空白块,避免污染检索结果
const validChunks = chunks.filter(chunk =>
chunk.pageContent && chunk.pageContent.trim().length > 0
);
console.log(`📊 文档分割完成: ${docs.length} 个文档 → ${validChunks.length} 个块`);
return validChunks;
}
分割策略建议
对于不同类型的内容,建议采用不同的分割策略:
| 文档类型 | chunk_size | chunk_overlap | 说明 |
|---|---|---|---|
| 技术文档 | 800-1000 | 100-150 | 代码示例需要更多上下文 |
| 自然语言文章 | 500-800 | 50-100 | 段落相对短小 |
| 结构化文档 | 300-500 | 30-50 | 表格、列表为主 |
| 对话记录 | 1000-1200 | 150-200 | 需要保留对话连贯性 |
代码实战与效果展示
完整文档处理流水线
// src/pipeline/document-pipeline.ts
import { loadDocument } from '../loaders';
import { cleanDocuments } from '../cleaners/text-cleaner';
import { splitDocuments } from '../splitters/text-splitter';
import path from 'path';
import { fileURLToPath } from 'url';
interface PipelineResult {
chunks: any[];
stats: {
originalSize: number;
cleanedSize: number;
chunkCount: number;
processingTime: number;
};
}
export async function processDocument(filePath: string): Promise<PipelineResult> {
const startTime = Date.now();
// 1. 加载文档
console.log(`📂 加载文档: ${filePath}`);
const docs = await loadDocument(filePath);
const originalSize = docs.reduce((sum, d) => sum + d.pageContent.length, 0);
// 2. 清洗文本
console.log(`🧹 清洗文本...`);
const cleanedDocs = cleanDocuments(docs);
const cleanedSize = cleanedDocs.reduce((sum, d) => sum + d.pageContent.length, 0);
// 3. 分割文本
console.log(`✂️ 分割文本...`);
const chunks = await splitDocuments(cleanedDocs);
const processingTime = Date.now() - startTime;
return {
chunks,
stats: {
originalSize,
cleanedSize,
chunkCount: chunks.length,
processingTime,
},
};
}
// 使用示例
async function main() {
// 获得当前文件所在目录
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 拼接同目录文件路径
const filePath = path.join(__dirname, 'technical-guide.txt');
const result = await processDocument(filePath);
console.log(`
📊 处理统计:
- 原始大小: ${result.stats.originalSize} 字符
- 清洗后: ${result.stats.cleanedSize} 字符
- 减少: ${((1 - result.stats.cleanedSize / result.stats.originalSize) * 100).toFixed(1)}%
- 生成块数: ${result.stats.chunkCount}
- 处理时间: ${result.stats.processingTime}ms
`);
}
main();
处理前后对比
| 维度 | 处理前 | 处理后 | 改善 |
|---|---|---|---|
| 文本长度 | 125,000 字符 | 89,000 字符 | 减少 28.8% |
| 特殊字符 | 47 个 | 0 个 | 100% 移除 |
| 空行 | 156 行 | 42 行 | 73% 减少 |
| 块数量 | - | 142 块 | chunk_size=800 |
常见问题与解决方案
问题 1:PDF 表格数据乱码或重复
现象:同一张表格在向量库中存在两个版本(结构化版 + 文本乱码版),导致 LLM 回答不一致
解决方案:
- 使用启发式规则识别"看起来像表格"的文本块并剔除
- 检测
"Table data:"前缀、日期模式、货币格式密度等特征
function isTableNoise(text: string): boolean {
// 检测是否为表格噪声
const hasTablePrefix = text.includes("Table data:");
const hasDatePattern = /\d{4}-\d{2}-\d{2}/.test(text);
return hasTablePrefix && hasDatePattern;
}
问题 2:特殊字符导致检索失败
现象:嵌入模型处理时产生无效向量,检索召回率为 0
解决方案:添加字符集过滤
function sanitizeText(text: string): string {
// 只保留常用字符集
return text.replace(/[^\u4e00-\u9fa5a-zA-Z0-9\s\n.,;:!?()\-_+=\[\]{}|\\/]/g, '');
}
问题 3:元数据丢失导致无法溯源
现象:AI 回答后无法告诉用户答案来自哪个文档
解决方案:在整个处理流程中保留并传递元数据
// 切分时继承原始元数据
const chunks = await splitter.splitDocuments(docs);
// 每个 chunk 都会保留原始文档的 metadata
console.log(chunks[0].metadata.source); // 原始文件名
问题 4:文档过大导致内存溢出
现象:尝试加载 50MB+ 的 PDF 文件时内存不足
解决方案:分块读取 + 流式处理
结语
通过这篇教程,我们系统学习了 RAG 系统的文档加载与预处理技术。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!