LangChain4j 的 DocumentLoader——各种文档格式的处理实践
LangChain4j 的 DocumentLoader——各种文档格式的处理实践
接手一个 RAG 项目的时候,前任同事留了一句注释让我印象深刻:
// TODO: 扫描版 PDF 解析结果是乱码,暂时跳过我点开那段代码,看到他用 PDFBox 直接提取文本,结果确实是乱码——因为那份 PDF 是扫描件,里面根本没有文字层,只有图片。
这个 TODO 在代码里挂了大概四个月,直到我来接手才处理掉。
文档处理看起来是个不起眼的环节,但它是整个 RAG 系统的入口。入口出了问题,后面的向量化、检索、生成全都白搭——垃圾进,垃圾出。
这篇文章我把我踩过的坑、解决过的问题,全部整理出来,包括 PDF、Word、Excel、HTML、Markdown 各种格式的处理实践,以及扫描版 PDF 用 OCR 处理的完整方案。
文档处理的复杂性
不同格式的文档,复杂度差很多。
Markdown:最简单,纯文本,格式规则标准,几乎不需要特殊处理。
HTML:需要提取正文,过滤掉导航栏、广告、脚本、样式。如果页面是动态渲染的(Vue/React),还需要先渲染再提取。
Word(.docx):Apache POI 处理一般够用,但有几个坑:
- 图片里的文字无法提取
- 复杂表格提取后格式混乱
- 带宏的文档处理要注意安全
Excel(.xlsx):结构化数据,提取成文本时要保留语义。不能把单元格内容直接拼接,要理解行/列的含义。
PDF:是所有格式里最复杂的。
- 文字型 PDF:用 PDFBox 提取,一般正常
- 扫描型 PDF:只有图片,必须用 OCR
- 混合型 PDF:部分页面是文字,部分是扫描图,需要逐页判断
- 带复杂排版的 PDF:多栏布局、表格、图文混排,提取出来顺序可能是乱的
统一文档解析接口设计
先定义接口,再做具体实现。这样业务代码不需要知道底层用什么库。
public interface DocumentParser {
/**
* 支持的文件类型
*/
Set<String> supportedExtensions();
/**
* 判断是否支持该文件
*/
default boolean supports(String filename) {
String ext = getExtension(filename).toLowerCase();
return supportedExtensions().contains(ext);
}
/**
* 解析文档
*/
ParseResult parse(InputStream inputStream, String filename, ParseOptions options)
throws DocumentParseException;
default String getExtension(String filename) {
int lastDot = filename.lastIndexOf('.');
return lastDot >= 0 ? filename.substring(lastDot + 1) : "";
}
}
@Data
@Builder
public class ParseResult {
private String text; // 提取的文本内容
private List<DocumentChunk> chunks; // 按逻辑分块(章节/段落)
private Map<String, Object> metadata; // 元数据(作者、创建时间等)
private List<ParseWarning> warnings; // 处理过程中的警告
private ParseQuality quality; // 解析质量评估
@Data
@AllArgsConstructor
public static class DocumentChunk {
private String content;
private int chunkIndex;
private Map<String, Object> chunkMetadata; // 章节标题、页码等
}
public enum ParseQuality {
HIGH, // 文字型 PDF 或 Office 文档,质量有保证
MEDIUM, // OCR 处理,可能有识别错误
LOW // 格式复杂,提取结果可能不完整
}
}
@Data
@Builder
public class ParseOptions {
private boolean enableOcr; // 是否启用 OCR(扫描 PDF)
private String ocrLanguage; // OCR 语言,默认 chi_sim+eng
private boolean extractTables; // 是否提取表格
private boolean preserveLayout; // 是否保留排版信息
private int maxPages; // 最大处理页数(0 = 无限制)
public static ParseOptions defaults() {
return ParseOptions.builder()
.enableOcr(true)
.ocrLanguage("chi_sim+eng")
.extractTables(true)
.preserveLayout(false)
.maxPages(0)
.build();
}
}格式路由:DocumentParserRouter
@Component
@Slf4j
public class DocumentParserRouter {
private final List<DocumentParser> parsers;
@Autowired
public DocumentParserRouter(List<DocumentParser> parsers) {
this.parsers = parsers;
}
public ParseResult parse(InputStream inputStream, String filename, ParseOptions options)
throws DocumentParseException {
DocumentParser parser = findParser(filename);
if (parser == null) {
// 尝试用 Apache Tika 做 fallback
log.warn("No specific parser for file: {}, using Tika fallback", filename);
return tikaFallback(inputStream, filename, options);
}
log.info("Parsing {} with {}", filename, parser.getClass().getSimpleName());
try {
return parser.parse(inputStream, filename, options);
} catch (DocumentParseException e) {
throw e;
} catch (Exception e) {
throw new DocumentParseException("Failed to parse document: " + filename, e);
}
}
private DocumentParser findParser(String filename) {
return parsers.stream()
.filter(p -> p.supports(filename))
.findFirst()
.orElse(null);
}
private ParseResult tikaFallback(InputStream inputStream, String filename, ParseOptions options) {
try {
Tika tika = new Tika();
String content = tika.parseToString(inputStream);
return ParseResult.builder()
.text(content)
.quality(ParseResult.ParseQuality.MEDIUM)
.warnings(List.of(new ParseWarning("Used Tika fallback parser for " + filename)))
.build();
} catch (Exception e) {
throw new DocumentParseException("Tika fallback also failed for: " + filename, e);
}
}
}PDF 解析:最复杂的部分
先判断是文字型还是扫描型
@Component
@Slf4j
public class PdfDocumentParser implements DocumentParser {
@Autowired(required = false)
private TesseractOcrEngine ocrEngine;
// 判断页面是否为扫描页(文字提取量少于阈值)
private static final int SCANNED_PAGE_TEXT_THRESHOLD = 50; // 少于 50 个字符认为是扫描页
@Override
public Set<String> supportedExtensions() {
return Set.of("pdf");
}
@Override
public ParseResult parse(InputStream inputStream, String filename, ParseOptions options)
throws DocumentParseException {
try {
byte[] pdfBytes = inputStream.readAllBytes();
PDDocument document = PDDocument.load(pdfBytes);
PDFTextStripper stripper = new PDFTextStripper();
stripper.setSortByPosition(true); // 按位置排序,改善多栏 PDF 的提取顺序
List<ParseResult.DocumentChunk> chunks = new ArrayList<>();
List<ParseWarning> warnings = new ArrayList<>();
StringBuilder fullText = new StringBuilder();
int ocrPageCount = 0;
int totalPages = document.getNumberOfPages();
int processPages = options.getMaxPages() > 0
? Math.min(totalPages, options.getMaxPages())
: totalPages;
for (int pageNum = 1; pageNum <= processPages; pageNum++) {
// 提取单页文本
stripper.setStartPage(pageNum);
stripper.setEndPage(pageNum);
String pageText = stripper.getText(document).trim();
// 判断是否为扫描页
if (isScannedPage(pageText)) {
if (!options.isEnableOcr()) {
warnings.add(new ParseWarning("Page " + pageNum + " appears to be scanned, OCR disabled, skipping"));
continue;
}
if (ocrEngine == null) {
warnings.add(new ParseWarning("Page " + pageNum + " appears to be scanned, but OCR engine not configured"));
continue;
}
// 用 OCR 处理
log.info("Page {} in {} is scanned, applying OCR", pageNum, filename);
pageText = ocrPageWithTesseract(document, pageNum, options);
ocrPageCount++;
}
if (!pageText.isBlank()) {
Map<String, Object> chunkMeta = new HashMap<>();
chunkMeta.put("page", pageNum);
chunkMeta.put("total_pages", totalPages);
chunkMeta.put("ocr_applied", ocrPageCount > 0);
chunks.add(new ParseResult.DocumentChunk(pageText, pageNum - 1, chunkMeta));
fullText.append(pageText).append("\n\n");
}
}
document.close();
// 评估解析质量
ParseResult.ParseQuality quality = determineQuality(ocrPageCount, processPages, warnings);
return ParseResult.builder()
.text(fullText.toString())
.chunks(chunks)
.metadata(extractPdfMetadata(pdfBytes))
.warnings(warnings)
.quality(quality)
.build();
} catch (IOException e) {
throw new DocumentParseException("Failed to parse PDF: " + filename, e);
}
}
private boolean isScannedPage(String extractedText) {
// 提取的文字太少,认为是扫描页(只有图片)
return extractedText.trim().length() < SCANNED_PAGE_TEXT_THRESHOLD;
}
private String ocrPageWithTesseract(PDDocument document, int pageNum, ParseOptions options) {
try {
// 把 PDF 页面转换为图片
PDFRenderer renderer = new PDFRenderer(document);
BufferedImage image = renderer.renderImageWithDPI(pageNum - 1, 300, ImageType.RGB);
// 用 Tesseract 识别图片中的文字
return ocrEngine.recognize(image, options.getOcrLanguage());
} catch (Exception e) {
log.warn("OCR failed for page {}: {}", pageNum, e.getMessage());
return "";
}
}
private ParseResult.ParseQuality determineQuality(int ocrPages, int totalPages,
List<ParseWarning> warnings) {
double ocrRatio = (double) ocrPages / totalPages;
if (ocrRatio > 0.5) return ParseResult.ParseQuality.MEDIUM;
if (!warnings.isEmpty()) return ParseResult.ParseQuality.MEDIUM;
return ParseResult.ParseQuality.HIGH;
}
private Map<String, Object> extractPdfMetadata(byte[] pdfBytes) throws IOException {
PDDocument doc = PDDocument.load(pdfBytes);
PDDocumentInformation info = doc.getDocumentInformation();
Map<String, Object> metadata = new HashMap<>();
metadata.put("title", info.getTitle());
metadata.put("author", info.getAuthor());
metadata.put("creator", info.getCreator());
metadata.put("pages", doc.getNumberOfPages());
doc.close();
return metadata;
}
}Tesseract OCR 引擎封装
@Component
@ConditionalOnProperty(name = "ai.document.ocr.enabled", havingValue = "true", matchIfMissing = false)
@Slf4j
public class TesseractOcrEngine {
@Value("${ai.document.ocr.tessdata-path:/usr/share/tessdata}")
private String tessdataPath;
/**
* 对图片做 OCR 识别
*/
public String recognize(BufferedImage image, String language) {
Tesseract tesseract = new Tesseract();
tesseract.setDatapath(tessdataPath);
tesseract.setLanguage(language); // 如 "chi_sim+eng"
// 优化 OCR 质量的配置
tesseract.setPageSegMode(1); // 自动分页
tesseract.setOcrEngineMode(1); // LSTM 模式,准确率更高
// 图像预处理(提升 OCR 准确率)
BufferedImage processedImage = preprocessImage(image);
try {
String result = tesseract.doOCR(processedImage);
return cleanOcrResult(result);
} catch (TesseractException e) {
log.error("OCR recognition failed: {}", e.getMessage());
return "";
}
}
/**
* 图像预处理:灰度化、去噪、增强对比度
* 这一步对 OCR 准确率影响很大
*/
private BufferedImage preprocessImage(BufferedImage original) {
// 转换为灰度
BufferedImage gray = new BufferedImage(original.getWidth(), original.getHeight(),
BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g = gray.createGraphics();
g.drawImage(original, 0, 0, null);
g.dispose();
// 二值化(简单阈值,实际可以用 Otsu 算法)
// 这里用 Java 2D 简单实现,生产环境推荐用 OpenCV
return gray;
}
/**
* 清洗 OCR 结果:去除多余空行、修正常见识别错误
*/
private String cleanOcrResult(String raw) {
if (raw == null) return "";
// 去除多余空行
String cleaned = raw.replaceAll("\n{3,}", "\n\n");
// 去除行首行尾空格
cleaned = Arrays.stream(cleaned.split("\n"))
.map(String::trim)
.collect(Collectors.joining("\n"));
return cleaned.trim();
}
}Word 文档解析
@Component
@Slf4j
public class WordDocumentParser implements DocumentParser {
@Override
public Set<String> supportedExtensions() {
return Set.of("docx", "doc");
}
@Override
public ParseResult parse(InputStream inputStream, String filename, ParseOptions options)
throws DocumentParseException {
try {
String ext = getExtension(filename).toLowerCase();
if ("docx".equals(ext)) {
return parseDocx(inputStream, filename, options);
} else {
return parseDoc(inputStream, filename);
}
} catch (Exception e) {
throw new DocumentParseException("Failed to parse Word document: " + filename, e);
}
}
private ParseResult parseDocx(InputStream inputStream, String filename, ParseOptions options)
throws IOException {
XWPFDocument document = new XWPFDocument(inputStream);
StringBuilder fullText = new StringBuilder();
List<ParseResult.DocumentChunk> chunks = new ArrayList<>();
int chunkIndex = 0;
// 提取段落(按标题分块)
StringBuilder currentSection = new StringBuilder();
String currentTitle = "开头";
for (IBodyElement element : document.getBodyElements()) {
if (element instanceof XWPFParagraph) {
XWPFParagraph para = (XWPFParagraph) element;
String style = para.getStyle();
String text = para.getText().trim();
if (text.isBlank()) continue;
// 检测标题段落
if (style != null && style.startsWith("Heading")) {
// 保存上一个章节
if (currentSection.length() > 0) {
Map<String, Object> meta = new HashMap<>();
meta.put("section_title", currentTitle);
chunks.add(new ParseResult.DocumentChunk(
currentSection.toString(), chunkIndex++, meta));
currentSection.setLength(0);
}
currentTitle = text;
} else {
currentSection.append(text).append("\n");
}
fullText.append(text).append("\n");
} else if (element instanceof XWPFTable && options.isExtractTables()) {
XWPFTable table = (XWPFTable) element;
String tableText = extractTableAsText(table);
currentSection.append(tableText).append("\n");
fullText.append(tableText).append("\n");
}
}
// 最后一个章节
if (currentSection.length() > 0) {
Map<String, Object> meta = new HashMap<>();
meta.put("section_title", currentTitle);
chunks.add(new ParseResult.DocumentChunk(currentSection.toString(), chunkIndex, meta));
}
document.close();
return ParseResult.builder()
.text(fullText.toString())
.chunks(chunks)
.quality(ParseResult.ParseQuality.HIGH)
.build();
}
/**
* 把 Word 表格提取成 Markdown 格式
* 这样表格的行列关系在文本中能保留
*/
private String extractTableAsText(XWPFTable table) {
StringBuilder sb = new StringBuilder("\n");
List<XWPFTableRow> rows = table.getRows();
if (rows.isEmpty()) return "";
// 第一行作为表头
XWPFTableRow headerRow = rows.get(0);
sb.append("| ");
headerRow.getTableCells().forEach(cell ->
sb.append(cell.getText().trim()).append(" | "));
sb.append("\n| ");
headerRow.getTableCells().forEach(cell -> sb.append("--- | "));
sb.append("\n");
// 其余行作为数据行
for (int i = 1; i < rows.size(); i++) {
sb.append("| ");
rows.get(i).getTableCells().forEach(cell ->
sb.append(cell.getText().trim()).append(" | "));
sb.append("\n");
}
return sb.toString();
}
private ParseResult parseDoc(InputStream inputStream, String filename) throws IOException {
// .doc 格式(老格式)用 HWPF
HWPFDocument document = new HWPFDocument(inputStream);
WordExtractor extractor = new WordExtractor(document);
String text = String.join("\n", extractor.getParagraphText());
extractor.close();
document.close();
return ParseResult.builder()
.text(text)
.quality(ParseResult.ParseQuality.HIGH)
.build();
}
}Excel 解析:结构化数据的特殊处理
@Component
@Slf4j
public class ExcelDocumentParser implements DocumentParser {
@Override
public Set<String> supportedExtensions() {
return Set.of("xlsx", "xls", "csv");
}
@Override
public ParseResult parse(InputStream inputStream, String filename, ParseOptions options)
throws DocumentParseException {
try {
if (filename.endsWith(".csv")) {
return parseCsv(inputStream);
}
return parseExcel(inputStream, filename);
} catch (Exception e) {
throw new DocumentParseException("Failed to parse Excel: " + filename, e);
}
}
private ParseResult parseExcel(InputStream inputStream, String filename) throws IOException {
Workbook workbook = WorkbookFactory.create(inputStream);
StringBuilder fullText = new StringBuilder();
List<ParseResult.DocumentChunk> chunks = new ArrayList<>();
for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) {
Sheet sheet = workbook.getSheetAt(sheetIndex);
String sheetName = workbook.getSheetName(sheetIndex);
// 把每个 Sheet 转换成 Markdown 表格
String sheetText = convertSheetToMarkdown(sheet, sheetName);
if (!sheetText.isBlank()) {
Map<String, Object> meta = new HashMap<>();
meta.put("sheet_name", sheetName);
meta.put("sheet_index", sheetIndex);
chunks.add(new ParseResult.DocumentChunk(sheetText, sheetIndex, meta));
fullText.append("## ").append(sheetName).append("\n\n");
fullText.append(sheetText).append("\n\n");
}
}
workbook.close();
return ParseResult.builder()
.text(fullText.toString())
.chunks(chunks)
.quality(ParseResult.ParseQuality.HIGH)
.build();
}
private String convertSheetToMarkdown(Sheet sheet, String sheetName) {
StringBuilder sb = new StringBuilder();
// 找到有数据的范围
int firstRow = sheet.getFirstRowNum();
int lastRow = sheet.getLastRowNum();
if (firstRow > lastRow) return "";
// 第一行作为表头
Row headerRow = sheet.getRow(firstRow);
if (headerRow == null) return "";
int lastCol = headerRow.getLastCellNum();
// 表头
sb.append("| ");
for (int col = 0; col < lastCol; col++) {
Cell cell = headerRow.getCell(col);
sb.append(getCellValueAsString(cell)).append(" | ");
}
sb.append("\n| ");
for (int col = 0; col < lastCol; col++) {
sb.append("--- | ");
}
sb.append("\n");
// 数据行
for (int rowNum = firstRow + 1; rowNum <= lastRow; rowNum++) {
Row row = sheet.getRow(rowNum);
if (row == null) continue;
sb.append("| ");
for (int col = 0; col < lastCol; col++) {
Cell cell = row.getCell(col);
sb.append(getCellValueAsString(cell)).append(" | ");
}
sb.append("\n");
}
return sb.toString();
}
private String getCellValueAsString(Cell cell) {
if (cell == null) return "";
switch (cell.getCellType()) {
case STRING: return cell.getStringCellValue().trim();
case NUMERIC:
if (DateUtil.isCellDateFormatted(cell)) {
return new SimpleDateFormat("yyyy-MM-dd").format(cell.getDateCellValue());
}
// 避免科学计数法
double value = cell.getNumericCellValue();
if (value == Math.floor(value)) {
return String.valueOf((long) value);
}
return String.valueOf(value);
case BOOLEAN: return String.valueOf(cell.getBooleanCellValue());
case FORMULA:
try {
return String.valueOf(cell.getNumericCellValue());
} catch (Exception e) {
return cell.getStringCellValue();
}
default: return "";
}
}
}文档处理流水线
几个重要的实践细节
关于 Tika 的定位
Apache Tika 是个"万能"文档解析器,支持几乎所有格式。但它的问题是:对特定格式的处理不够精细,比如 PDF 的多栏排版、Excel 的表格语义,Tika 都不能很好处理。
我的建议:把 Tika 作为 fallback,对于常见格式(PDF、Word、Excel)用专门的解析器。
PDF 多栏排版
学术论文、报纸这类双栏/三栏排版的 PDF,用 PDFBox 默认的文本提取,会把两栏内容混在一起。
// 设置排序,改善多栏处理
stripper.setSortByPosition(true);这个参数能改善一些情况,但对于复杂的多栏布局,还是需要分析页面的文字块坐标,手动排序。这是个比较深的话题,有需要单独写一篇。
大文件的内存问题
几十 MB 的 PDF 或者 Excel,用 document.load(inputStream) 会把整个文件加载到内存里。对于大文件,要用流式处理:
// Excel 大文件用 SXSSF(流式处理,不把整个文件加载到内存)
// 读取用 SXSSFWorkbook,写入用 SXSSFWorkbookPDFBox 也支持流式处理模式,处理大 PDF 时避免 OOM。
Tesseract 的语言包
Tesseract 需要对应语言的训练数据(tessdata)。中文需要 chi_sim 或 chi_tra(简繁体),下载地址是 GitHub 上的 tesseract-ocr/tessdata 仓库。
生产环境记得把 tessdata 打包进 Docker 镜像,不要依赖宿主机安装。
文档解析的异步化
大文件解析耗时长(几秒到几分钟),不能在 HTTP 请求里同步处理。应该:
- 接收文件 -> 存到 S3/OSS -> 立即返回任务 ID
- 异步触发解析任务
- 前端轮询或 WebSocket 推送处理结果
最后
那个 "TODO: 扫描版 PDF 解析结果是乱码" 的注释,我用了两天时间彻底解决掉了。关键是加了两步:检测是否为扫描页(文字量少于阈值)、扫描页走 Tesseract OCR。
但这只是一个开始。后来陆续碰到了:Word 文档里有大量图片需要 OCR、Excel 里有合并单元格导致解析混乱、某些 PDF 有加密保护解析失败……每一个问题,解决方案都各不相同。
文档解析是个"长尾"问题——常见情况好处理,但各种奇葩格式层出不穷。把 Router + 各种 Parser 的架构搭好,至少能做到:新格式来了,加一个新的 Parser 实现就行,不用改业务代码。
