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 |
判断方法:
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 质量的关键:
| 分辨率 | |
| 预处理 | |
| 语言 | |
| 版面分析 |
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,下期见。