RAG 第一步:多格式文档加载与文本预处理实战

为什么文档预处理如此重要?

一个真实的教训

某团队在构建 RAG 系统时遇到了一个诡异的问题:同样的代码,有时候跑得完美,有时候却会遗漏关键数据

经过层层排查,问题源头竟然是:

  1. PDF 中的表格同时存在"结构化表格"和"被打散成文本的乱码"两个版本
  2. 向量检索时,两个版本都被召回
  3. 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)需要转换成统一的文本格式,以便后续处理。

typescript 复制代码
// 统一后的文本格式示例
{
  content: "这是文档的纯文本内容...",
  metadata: {
    source: "technical_guide.pdf",
    page: 42,
    title: "第三章:核心概念"
  }
}

2. 内容干净

去除对检索和生成无帮助的噪声内容:

噪声类型 示例 处理方式
页眉页脚 "机密文档·第3页" 正则匹配删除
特殊字符 \u0000\ufffd 替换或删除
多余空白 连续空格、空行过多 规范化
乱码字符 编码错误导致的� 过滤或修复

3. 语义完整

切分文档时,确保每个块在语义上是相对完整的:

typescript 复制代码
// ❌ 错误:在语义边界外截断
const badChunk = "闭包是指函数能够记住并访问它的词法作"; // 句子被截断

// ✅ 正确:保持语义完整
const goodChunk = "闭包是指函数能够记住并访问它的词法作用域,即使这个函数在它的词法作用域之外执行。";

常用文档加载器使用详解

支持的文档格式

LangChain 支持 20+ 种文档格式,前端开发最常用的是:

格式 加载器 前端应用场景
TXT TextLoader 日志文件、配置文件
Markdown UnstructuredMarkdownLoader 技术文档、README
CSV CSVLoader 数据导出、报表
PDF PDFLoader 用户手册、合同文档

加载器使用示例

typescript 复制代码
// 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}`);
  }
}

元数据的重要性

加载器会自动提取文档的元数据,这对后续的溯源至关重要:

typescript 复制代码
// 加载后的 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

完整清洗函数实现

typescript 复制代码
// 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 实现:

html 复制代码
// 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>

文档内容预览组件

html 复制代码
// 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", "。", ","] 优先按段落、句子边界切分

分割器实现

typescript 复制代码
// 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 需要保留对话连贯性

代码实战与效果展示

完整文档处理流水线

typescript 复制代码
// 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:" 前缀、日期模式、货币格式密度等特征
typescript 复制代码
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

解决方案:添加字符集过滤

typescript 复制代码
function sanitizeText(text: string): string {
  // 只保留常用字符集
  return text.replace(/[^\u4e00-\u9fa5a-zA-Z0-9\s\n.,;:!?()\-_+=\[\]{}|\\/]/g, '');
}

问题 3:元数据丢失导致无法溯源

现象:AI 回答后无法告诉用户答案来自哪个文档

解决方案:在整个处理流程中保留并传递元数据

typescript 复制代码
// 切分时继承原始元数据
const chunks = await splitter.splitDocuments(docs);
// 每个 chunk 都会保留原始文档的 metadata
console.log(chunks[0].metadata.source); // 原始文件名

问题 4:文档过大导致内存溢出

现象:尝试加载 50MB+ 的 PDF 文件时内存不足

解决方案:分块读取 + 流式处理

结语

通过这篇教程,我们系统学习了 RAG 系统的文档加载与预处理技术。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
程序员黑豆1 小时前
全新系列开启:AI 全栈开发
前端·后端·全栈
小小小小宇1 小时前
Partial Clone
前端
小小小小宇1 小时前
git sparse-checkout(稀疏检出)
前端
AI兴球2 小时前
2026 AI编程能力实测排名
ai编程
ZC跨境爬虫2 小时前
跟着 MDN 学JavaScript day_9:字符串方法实战挑战与解题思路
开发语言·前端·javascript
夜焱辰2 小时前
WebMCP 的正确打开方式:只注册 2 个工具,代理 N 个——CreatorWeave 的 On-Demand 实践
前端
千云2 小时前
ClaudeCode Skill生成教学培训文档,助力新人快速学习项目
人工智能·后端·ai编程
用户7459571748402 小时前
Fabric:Python SSH 远程执行利器
前端
用户288391927473 小时前
Elasticsearch DSL:用 Python 对象写查询,不用再手写 JSON
前端