夜雨聆风学习资料网

ARTICLE · 1084073

从零搭建 AI 知识库(二):文档解析与清洗——80% 的 RAG 问题从这里开始

从零搭建 AI 知识库(二):文档解析与清洗——80% 的 RAG 问题从这里开始

大家好,我是老 J。

上期我们聊了 RAG 效果差的六大根因,排在第一位的就是文档解析。

很多团队花大量时间调检索参数、换模型,却忽略了最基础的一步——如果文档解析出来就是错的,后面所有环节都是在垃圾上做优化。

这期我们专门讲文档解析与清洗。

一、为什么文档解析是 RAG 的第一道坎?

1.1 一个真实的翻车案例

某团队把公司的产品手册(PDF)导入 RAG,用户问“产品A的保修期是多久”,RAG 回答“3年”。

实际上产品手册里写的是“产品A保修期:2年”。

为什么?因为 PDF 解析时,表格被解析成了这样:

产品A 保修期 2年
产品B 保修期 1年
产品C 保修期 5年

变成了:

产品A 保修期
2年 产品B 保修期
1年 产品C 保修期
5年

表格的行列关系丢失了,语义完全错乱。

1.2 文档解析的三大挑战

┌─────────────────────────────────────────────────────────────────┐
│                   文档解析的三大挑战                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   ① 格式多样                                                     │
│   ├── PDF(扫描版/文字版/混合版)                               │
│   ├── Word(.doc/.docx)                                        │
│   ├── HTML(网页/邮件)                                         │
│   ├── Markdown                                                  │
│   ├── Excel/CSV                                                 │
│   └── 图片(截图/照片)                                         │
│                                                                  │
│   ② 结构复杂                                                     │
│   ├── 多级标题                                                   │
│   ├── 表格(合并单元格、跨页)                                  │
│   ├── 图片 + 图注                                                │
│   ├── 公式                                                       │
│   └── 页眉页脚                                                   │
│                                                                  │
│   ③ 质量参差                                                     │
│   ├── 扫描版 PDF 需要 OCR                                       │
│   ├── 排版错乱                                                   │
│   ├── 乱码                                                       │
│   └── 水印干扰                                                   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

二、PDF 解析:最大的坑

2.1 两种 PDF

类型
特征
解析方式
文字版 PDF
可以直接选中文字
直接提取文本
扫描版 PDF
文字是图片
需要 OCR

判断方法:

publicbooleanisScannedPdf(String pdfPath)throws IOException {
try (PDDocumentdocument= PDDocument.load(newFile(pdfPath))) {
PDFTextStripperstripper=newPDFTextStripper();
Stringtext= stripper.getText(document);
// 如果提取出的文字很少,大概率是扫描版
return text.trim().length() < 100;
    }
}

2.2 文字版 PDF 解析:Apache PDFBox

@Component
publicclassPdfParser {

public String parse(String filePath)throws IOException {
try (PDDocumentdocument= PDDocument.load(newFile(filePath))) {
PDFTextStripperstripper=newPDFTextStripper();
// 保留段落结构
            stripper.setSortByPosition(true);
            stripper.setLineSeparator("\n");
            stripper.setParagraphStart("\n");
            stripper.setParagraphEnd("\n");
return stripper.getText(document);
        }
    }
}

关键配置:

配置
作用
setSortByPosition(true)
按位置排序,避免文字顺序错乱
setLineSeparator("\n")
设置换行符
setParagraphStart/End
设置段落标记

2.3 扫描版 PDF:OCR 处理

扫描版 PDF 需要先转成图片,再用 OCR 识别文字。

@Component
publicclassOcrPdfParser {

public String parse(String filePath)throws Exception {
// 1. PDF 转图片
        List<BufferedImage> images = pdfToImages(filePath);

// 2. 逐页 OCR
StringBuilderresult=newStringBuilder();
try (Tesseracttesseract=newTesseract()) {
for (BufferedImage image : images) {
Stringtext= tesseract.doOCR(image);
                result.append(text).append("\n");
            }
        }
return result.toString();
    }

private List<BufferedImage> pdfToImages(String filePath)throws IOException {
        List<BufferedImage> images = newArrayList<>();
try (PDDocumentdocument= PDDocument.load(newFile(filePath))) {
PDFRendererrenderer=newPDFRenderer(document);
for (inti=0; i < document.getNumberOfPages(); i++) {
// 300 DPI 保证清晰度
BufferedImageimage= renderer.renderImageWithDPI(i, 300);
                images.add(image);
            }
        }
return images;
    }
}

OCR 质量的关键:

因素
建议
分辨率
300 DPI 以上
预处理
去噪、二值化、倾斜校正
语言
指定中文 + 英文
版面分析
分栏、表格单独处理

2.4 PDF 表格解析:最大的难点

表格是 PDF 解析中最难的部分。推荐用 Camelot 或 Tabula(Python),Java 侧可以用 Tabula-Java。

@Component
publicclassPdfTableParser {

public List<String> parseTables(String filePath) {
        List<String> tables = newArrayList<>();

try (PDDocumentdocument= PDDocument.load(newFile(filePath))) {
// 使用 Tabula 提取表格
for (intpage=1; page <= document.getNumberOfPages(); page++) {
// Tabula 提取逻辑
// 表格转 Markdown 格式,保留结构
StringmarkdownTable= convertToMarkdown(table);
                tables.add(markdownTable);
            }
        } catch (Exception e) {
            log.error("表格解析失败", e);
        }
return tables;
    }
}

表格转 Markdown 的好处:

| 产品 | 保修期 | 价格 |
|------|--------|------|
| 产品A | 2年 | 299元 |
| 产品B | 1年 | 199元 |
| 产品C | 5年 | 599元 |

保留了行列关系,LLM 能正确理解。

三、其他格式解析

3.1 Word 文档:Apache POI

@Component
publicclassWordParser {

public String parse(String filePath)throws Exception {
try (XWPFDocumentdocument=newXWPFDocument(newFileInputStream(filePath))) {
StringBuilderresult=newStringBuilder();

// 遍历段落
for (XWPFParagraph paragraph : document.getParagraphs()) {
Stringstyle= paragraph.getStyle();
Stringtext= paragraph.getText();

if (text.isEmpty()) continue;

// 保留标题层级
if (style != null && style.startsWith("Heading")) {
intlevel= Integer.parseInt(style.replace("Heading", ""));
                    result.append("#".repeat(level)).append(" ").append(text).append("\n\n");
                } else {
                    result.append(text).append("\n\n");
                }
            }

// 遍历表格
for (XWPFTable table : document.getTables()) {
                result.append(parseTable(table)).append("\n\n");
            }

return result.toString();
        }
    }

private String parseTable(XWPFTable table) {
StringBuildersb=newStringBuilder();
for (XWPFTableRow row : table.getRows()) {
            sb.append("| ");
for (XWPFTableCell cell : row.getTableCells()) {
                sb.append(cell.getText()).append(" | ");
            }
            sb.append("\n");
        }
return sb.toString();
    }
}

3.2 HTML:Jsoup

@Component
publicclassHtmlParser {

public String parse(String html) {
Documentdoc= Jsoup.parse(html);

// 移除无用元素
        doc.select("script, style, nav, footer, header, aside").remove();

// 保留标题层级
for (Element h : doc.select("h1, h2, h3, h4, h5, h6")) {
intlevel= Integer.parseInt(h.tagName().substring(1));
            h.text("#".repeat(level) + " " + h.text());
        }

// 表格转 Markdown
for (Element table : doc.select("table")) {
            table.text(convertToMarkdown(table));
        }

return doc.body().text();
    }
}

3.3 Markdown:直接使用

Markdown 本身就是结构化的,直接读取即可:

public String parseMarkdown(String filePath)throws IOException {
return Files.readString(Paths.get(filePath));
}

四、文档清洗:解析后的第一道处理

解析出来的文本往往包含大量噪音,需要清洗:

@Component
publicclassDocumentCleaner {

public String clean(String text) {
Stringresult= text;

// 1. 去除页眉页脚
        result = removeHeadersFooters(result);

// 2. 去除多余空行
        result = result.replaceAll("\n{3,}", "\n\n");

// 3. 去除多余空格
        result = result.replaceAll("[ \t]{2,}", " ");

// 4. 去除特殊字符
        result = result.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]", "");

// 5. 规范化标点
        result = normalizePunctuation(result);

// 6. 去除乱码
        result = removeGarbledText(result);

return result.trim();
    }

private String removeHeadersFooters(String text) {
// 识别并移除重复出现的短行(页眉页脚的特征)
        String[] lines = text.split("\n");
        Map<String, Integer> lineCount = newHashMap<>();

for (String line : lines) {
Stringtrimmed= line.trim();
if (trimmed.length() > 0 && trimmed.length() < 50) {
                lineCount.merge(trimmed, 1, Integer::sum);
            }
        }

// 出现超过 5 次的短行,大概率是页眉页脚
        Set<String> headersFooters = lineCount.entrySet().stream()
            .filter(e -> e.getValue() > 5)
            .map(Map.Entry::getKey)
            .collect(Collectors.toSet());

return Arrays.stream(lines)
            .filter(line -> !headersFooters.contains(line.trim()))
            .collect(Collectors.joining("\n"));
    }
}

五、元数据提取:让检索更精准

解析文档时,同时提取元数据,对后续检索非常重要:

@Data
@Builder
publicclassDocumentMetadata {
private String source;        // 来源文件名
private String title;         // 文档标题
private String category;      // 分类
private LocalDate createDate; // 创建日期
private String author;        // 作者
private List<String> tags;    // 标签
private String section;       // 所在章节
private Integer pageNumber;   // 页码
}

元数据的用途:

  • • 过滤检索:只在特定分类中检索
  • • 权重调整:新文档权重更高
  • • 来源引用:回答时标注来源
  • • 权限控制:不同用户只能看到有权限的文档

六、完整的解析流水线

@Service
publicclassDocumentIngestionPipeline {

@Autowired
private PdfParser pdfParser;
@Autowired
private WordParser wordParser;
@Autowired
private HtmlParser htmlParser;
@Autowired
private DocumentCleaner cleaner;

public List<ParsedDocument> process(String filePath) {
Stringcontent= parseByType(filePath);
        content = cleaner.clean(content);
DocumentMetadatametadata= extractMetadata(filePath);
return splitIntoDocuments(content, metadata);
    }

private String parseByType(String filePath) {
Stringext= getExtension(filePath).toLowerCase();
returnswitch (ext) {
case"pdf" -> {
try {
yield pdfParser.parse(filePath);
                } catch (IOException e) {
thrownewRuntimeException(e);
                }
            }
case"docx", "doc" -> {
try {
yield wordParser.parse(filePath);
                } catch (Exception e) {
thrownewRuntimeException(e);
                }
            }
case"html", "htm" -> htmlParser.parse(readFile(filePath));
case"md", "markdown" -> readFile(filePath);
default -> thrownewIllegalArgumentException("不支持的文件类型: " + ext);
        };
    }
}

七、下期预告

从零搭建 AI 知识库(三):分块策略——切得好,才能找得到

  • • 固定长度分块
  • • 语义分块
  • • 递归分块
  • • 分块大小与重叠的权衡

我是老 J,下期见。

相关学习资料