从文件中提取文本
在 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。

使用 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。
第二个带 imagePath 和 images 参数的重载是可选实现的。默认实现会直接调用第一个重载,即不抽取图片。需要把文档里的图片落盘、并改写成本地相对路径的抽取器(PDF、Word、Excel、PPT、HTML)会复写它。
MoAI 使用 TextExtractorFactory 作为默认抽取器的注册表。它同时承担两个职责:一是给依赖注入提供默认抽取器类型列表,二是提供一个不依赖容器的手工创建入口。
默认抽取器在静态数组 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 | 直接读取 |
MarkdownExtractor | Markdown | 直接读取 |
HtmlExtractor | HTML | ReverseMarkdown |
PdfExtractor | PdfPig | |
MsWordExtractor | Word (.docx) | OpenXml |
MsExcelExtractor | Excel (.xlsx) | ClosedXML |
MsPowerPointExtractor | PowerPoint (.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。转换前先做预处理:移除脚本、样式、注释,去掉导航和广告区域,再解开 span、font 这类无意义的包裹标签。图片被单独处理,data URI、http(s) 和本地路径都会落到磁盘,并把 src 改写为本地相对路径。
注意它的配置选了 Default Flavor 并开启 GithubFlavored(让 pre 变成围栏代码块、启用任务列表),而没有用 CommonMark/GitHub Flavor:后两者会把以块级标签开头的输入原样当成 HTML 透传,而不是转成 Markdown。
Markdown / 纯文本 / JSON
这三种没有结构可还原,直接读取后 Trim 即可。
图片的抽取
图片是一类特殊内容:它不参与向量化,但应该在 Markdown 里保留引用,这样人类 reviewer 或后续多模态模型还能看到原图。所有需要抽图的抽取器都复写了带 imagePath 和 images 的重载,把图片落盘到 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);