从文件中提取文本

在 RAG 流程中,拿到文件后的第一件事不是切片,也不是向量化,而是先把文件转换成可以处理的文本。PDF、Word、Excel、PPT、Markdown、HTML 乃至图片,内部结构完全不同,如果这一层没有处理好,后面的切片和检索做得再复杂也没有意义。

微软的 Kernel Memory 已经实现了一套文件解码器,可以处理常见的文档格式。不过 Kernel Memory 最近已经停止更新,官方不再维护,继续依赖它会有风险。另外,即便它还在维护,笔者也没有直接使用它封装好的 RAG 流程,因为完整流程把文件导入、文本提取、切片、向量化和存储串在了一起。这样做开箱即用,但要替换其中某一步,或者在中间加入自己的处理逻辑,就没有那么方便了。

因此 MoAI 没有依赖 Kernel Memory 的完整管线,而是使用自研维护的 Maomi.ToMarkdown 把文件统一转换成 Markdown。它只负责把文件变成文本这一件事:输入文件或文件流,输出一段 Markdown 文本。

Maomi.ToMarkdown 与 Kernel Memory 最大的差别,是输出不是一个被扁平化的普通字符串,而是一段尽量保留语义结构的 Markdown,标题、代码块、列表、表格、行内代码、超链接和图片引用都会被还原出来。这样提取之后,后面的 Markdown 切片器才能利用这些结构,而不是像纯文本那样只能靠换行和空格猜测段落。

NuGet:Maomi.ToMarkdown
源码:https://github.com/whuanle/maomi.tomarkdown


还原效果如下,左是 PDF,右侧是 markdown。

image-20260825091506299


使用 Maomi.ToMarkdown 提取文件

Maomi.ToMarkdown 提供三种使用方式,MoAI 一开始用的是依赖注入方式。

方式一:依赖注入 + TextExtractionService(推荐)

在容器注册处调用 AddTextExtraction(),一次性注册服务与全部默认抽取器:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddTextExtraction();
var provider = services.BuildServiceProvider();

var service = provider.GetRequiredService<TextExtractionService>();

然后在业务层抽取文件:

// 1) 按文件路径抽取(按扩展名识别 MIME 类型)
string markdown = await service.ExtractAsync(@"C:\docs\report.pdf");

// 2) 按文件流抽取(显式传入文件名)
await using var stream = File.OpenRead(@"C:\docs\report.docx");
string markdown2 = await service.ExtractAsync(stream, "report.docx");

例如在 MoAI 的 Wiki 业务层,把文件下载、文本提取和后续流程组装起来,变成这样一段:

var text = await _textExtractionService.ExtractAsync(
    saveFile.LocalFilePath,
    cancellationToken);

方式二:TextExtractorFactory

不依赖容器,根据 MIME 类型或文件名直接创建抽取器:

using Maomi.ToMarkdown.TextExtract;

// 按 MIME 类型创建
ITextExtractor extractor = TextExtractorFactory.Create("application/pdf");

// 或按文件名创建
ITextExtractor extractor2 = TextExtractorFactory.CreateByFileName(@"C:\docs\report.docx");

string markdown = await extractor.ExtractAsync(stream);

方式三:直接调用转换器

希望对某个具体格式做细粒度控制,或通过 images 提取图片时,可以直接调用封装好的转换器:

using Maomi.ToMarkdown;

string md = DocxToMarkdownConverter.ConvertToMarkdown(@"C:\docs\report.docx");
string md2 = PdfToMarkdownConverter.ConvertToMarkdown(@"C:\docs\report.pdf");

Maomi.ToMarkdown 设计

Maomi.ToMarkdown 为不同文件格式提供了统一接口 ITextExtractor。对当前流程来说,每个抽取器需要关注两步。

  • 是否支持当前 MIME 类型
  • 如何把文件流抽取为 Markdown 字符串

public interface ITextExtractor
{
    bool SupportsMimeType(string mimeType);

    Task<string> ExtractAsync(Stream stream, CancellationToken cancellationToken = default);

    // 可选:支持图片抽取时重写此方法
    virtual Task<string> ExtractAsync(
        Stream stream,
        string imagePath,
        IList<string> images,
        CancellationToken cancellationToken = default)
    {
        return ExtractAsync(stream, cancellationToken);
    }
}

有了这个接口,调用方不需要了解 PDF 和 Word 的内部结构有什么区别,只需要先找到支持当前文件类型的抽取器,再调用 ExtractAsync()

与 Kernel Memory 的解码器返回 FileContent 不同,ITextExtractor 直接返回 string (Markdown 格式)。Kernel Memory 需要先解码成包含多个 ContentSection 的对象,再在外部把 Section 拼接成文本;Maomi.ToMarkdown 把结构与输出融合在了一起,各个抽取器自己负责按语境还原层级,调用方拿到手的直接就是一段可用的 Markdown。

第二个带 imagePathimages 参数的重载是可选实现的。默认实现会直接调用第一个重载,即不抽取图片。需要把文档里的图片落盘、并改写成本地相对路径的抽取器(PDF、Word、Excel、PPT、HTML)会复写它。


MoAI 使用 TextExtractorFactory 作为默认抽取器的注册表。它同时承担两个职责:一是给依赖注入提供默认抽取器类型列表,二是提供一个不依赖容器的手工创建入口。

源码位置:https://github.com/whuanle/maomi.tomarkdown/blob/main/src/Maomi.ToMarkdown/TextExtract/TextExtractorFactory.cs


默认抽取器在静态数组 Defaults 中集中定义,注册顺序与查找优先级保持一致:

private static readonly (Type Type, Func<ILoggerFactory, ITextExtractor> Creator)[] Defaults =
{
    (typeof(PlainTextExtractor),    f => new PlainTextExtractor(f)),
    (typeof(MarkdownExtractor),     f => new MarkdownExtractor(f)),
    (typeof(HtmlExtractor),         f => new HtmlExtractor(f)),
    (typeof(PdfExtractor),          f => new PdfExtractor(f)),
    (typeof(MsWordExtractor),       f => new MsWordExtractor(f)),
    (typeof(MsExcelExtractor),      f => new MsExcelExtractor(new MsExcelExtractorConfig(), f)),
    (typeof(MsPowerPointExtractor), f => new MsPowerPointExtractor(new MsPowerPointExtractorConfig(), f)),
};

DefaultExtractorTypes 对外暴露类型集合,供依赖注入注册时一次性遍历:

public static IReadOnlyList<Type> DefaultExtractorTypes => DefaultTypeList;

public static ITextExtractor Create(string mimeType, ILoggerFactory? loggerFactory = null)
{
    if (string.IsNullOrWhiteSpace(mimeType))
    {
        throw new ArgumentException("MIME type is required.", nameof(mimeType));
    }

    var factory = loggerFactory ?? NullLoggerFactory.Instance;
    foreach (var (_, creator) in Defaults)
    {
        var extractor = creator(factory);
        if (extractor.SupportsMimeType(mimeType))
        {
            return extractor;
        }
    }

    throw new NotSupportedException($"No text extractor found for MIME type: {mimeType}");
}

public static ITextExtractor CreateByFileName(string fileName, ILoggerFactory? loggerFactory = null)
{
    if (string.IsNullOrWhiteSpace(fileName))
    {
        throw new ArgumentException("File name is required.", nameof(fileName));
    }

    if (new MimeTypesDetection().TryGetFileType(fileName, out var mimeType) && mimeType is not null)
    {
        return Create(mimeType, loggerFactory);
    }

    throw new NotSupportedException($"File type not supported: {fileName}");
}

目前注册的抽取器如下,每种格式对应一份底层解析库:

抽取器文件类型底层库
PlainTextExtractor纯文本 / JSON直接读取
MarkdownExtractorMarkdown直接读取
HtmlExtractorHTMLReverseMarkdown
PdfExtractorPDFPdfPig
MsWordExtractorWord (.docx)OpenXml
MsExcelExtractorExcel (.xlsx)ClosedXML
MsPowerPointExtractorPowerPoint (.pptx)OpenXml

注意到里面没有图片抽取器。图片本身没有可检索的文字,需要 OCR,而 OCR 依赖具体引擎,Maomi.ToMarkdown 不内置,留给业务层去接。这和 Kernel Memory 通过注入 IOcrEngine 的思路一致:把选择权交给调用方。


TextExtractorFactory 把默认抽取器的类型集中到一处,AddTextExtraction() 就基于它完成 DI 注册:

public static IServiceCollection AddTextExtraction(this IServiceCollection services)
{
    services.TryAddSingleton<MsExcelExtractorConfig>();
    services.TryAddSingleton<MsPowerPointExtractorConfig>();

    foreach (var extractorType in TextExtractorFactory.DefaultExtractorTypes)
    {
        services.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(ITextExtractor), extractorType));
    }

    services.TryAddSingleton<TextExtractionService>();

    return services;
}

文件处理

PDF:字体大小与版面分析

PDF 没有标题、段落、列表这类结构化标签,只有涂到页面上的"字"以及它们的位置和字体。PdfToMarkdownConverter 的做法是先从 PdfPig 拿到每个单词的坐标、字号和字体名,然后:

  • 用字号大小的众数估算正文 body 尺寸;
  • 按基线(Baseline)把单词聚成一行,再按行距把行聚成块;
  • 整行字号显著大于正文的行判为标题,并按字号比例决定 # 级别;
  • 等宽字体(如 monospace、courier、menlo)判为代码块/行内代码,并排除 CJK 回退字体;
  • 列对齐的行聚类成表格,首列若是等宽标识符或加粗表头则识别为表行。

例如下面这段逻辑根据字号比例给标题分级,同时避免把个别夹在正文里的大字误判成标题:

var minBodySize = bodyFontWords.Min(w => w.Size);
if (minBodySize < bodySize * 1.35)
{
    continue;
}

line.Heading = true;
double ratio = line.Size / bodySize;
line.Level = ratio >= 1.9 ? 1 : ratio >= 1.6 ? 2 : 3;

这种用尺寸和位置猜结构的方式注定不是 100% 准确,但它无需付费的商业库,也不依赖 OCR,速度快,操作简单。


Word:直接用样式还原

.docx 是 Open XML,本身就带结构信息,比 PDF 轻松得多。DocxToMarkdownConverter 依据段落样式还原标题和代码块:

// 标题
if (styleId == "Title") { level = 1; return true; }
if (styleId.StartsWith("Heading", ...)) { ... }

// 代码块:段落样式以 SourceCode 开头
and styleId.StartsWith("SourceCode", StringComparison.OrdinalIgnoreCase);

它还处理了编号(w:numPr)区分有序/无序列表、行内样式 VerbatimChar 或等宽字体识别行内代码、w:tbl 还原为表格(含合并单元格 GridSpan/VerticalMerge)、w:hyperlink 还原为 [text](url),并把嵌入图片(w:drawing 里的 A.Blip)落盘。

需要注意 .doc 老式二进制格式不受 OpenXml 支持,需要先转存为 .docx


Excel:每个工作表一张表格

MsExcelExtractor 遍历每个工作表,默认在每个工作表开头写入编号,再把 RangeUsed() 渲染成一个 Markdown 表格:

blocks.Add(_config.WorksheetNumberTemplate.Replace("{number}", $"{worksheetNumber}", ...).Trim());
var range = worksheet.RangeUsed();
...
var table = RenderTable(rows, firstColumn, columnCount).Trim();

首行作为表头,后面的行作为数据行,单元格值按类型格式化(日期、时间、布尔值等各有对应的配置项)。浮在每个工作表上方的图片则统一追加到该表之后,避免与表格混排。表格里的换行会转成 <br/>,避免破坏 Markdown 表格结构。

PowerPoint:形状树与占位符

.pptx 里一页一页的幻灯片由形状树组成。MsPowerPointExtractor 遍历 ShapeTree 里的元素,标题占位符单独渲染为 ## 标题,正文段落按 buChar / buAutoNum 识别为项目符号或编号列表,并按缩进级别加空格。图片则通过 Blip 的关系抽取落盘。

默认还会跳过隐藏页:

bool isVisible = slidePart.Slide?.Show ?? true;
if (_config.SkipHiddenSlides && !isVisible)
{
    continue;
}

HTML:语义化转换

HtmlExtractor 使用 ReverseMarkdown,直接把网页语义化为 Markdown。转换前先做预处理:移除脚本、样式、注释,去掉导航和广告区域,再解开 spanfont 这类无意义的包裹标签。图片被单独处理,data URI、http(s) 和本地路径都会落到磁盘,并把 src 改写为本地相对路径。

注意它的配置选了 Default Flavor 并开启 GithubFlavored(让 pre 变成围栏代码块、启用任务列表),而没有用 CommonMark/GitHub Flavor:后两者会把以块级标签开头的输入原样当成 HTML 透传,而不是转成 Markdown。

Markdown / 纯文本 / JSON

这三种没有结构可还原,直接读取后 Trim 即可。


图片的抽取

图片是一类特殊内容:它不参与向量化,但应该在 Markdown 里保留引用,这样人类 reviewer 或后续多模态模型还能看到原图。所有需要抽图的抽取器都复写了带 imagePathimages 的重载,把图片落盘到 imagePath(默认 images 目录),并把完整路径追加进 images

例如 PDF 的图片抽取:

public static string SaveImage(IPdfImage image, int pageNumber, int imageIndex, string imageOutputDirectory)
{
    Directory.CreateDirectory(imageOutputDirectory);
    var filename = $"page_{pageNumber}_image_{imageIndex}";
    ...
}


Convert 时,每个页面把正文和图片引用拼接起来:

if (images is not null)
{
    parts.AddRange(ExtractPageImages(page, imageOutputDirectory ?? "images", images, pageIndex));
}

调用方可以这样拿到图片路径列表:

var images = new List<string>();
string markdown = PdfToMarkdownConverter.ConvertToMarkdown(
    pdfPath, "images", images);