你是不是也遇到过这种场景:身边有 PDF 论文、Word 报告、Excel 表格……各种格式的文档堆积如山,你想把它们全部塞进自己的知识库,却发现每换一种格式就得写一套新的解析代码。文档格式五花八门,每个都有一套自己的脾气——你明明只是想把内容存起来,却被格式转换搞得一个头两个大。
系列第四篇。本篇讲如何把各种格式的文件自动处理进知识库。
🚪 知识库的入口问题
搜索引擎做好了,但文档怎么进来?
你的原始材料格式五花八门:Markdown 文章、PDF 论文、Word 文档、PowerPoint、甚至 Excel 表格。知识库需要一条流水线,把这些东西统一转成可索引的文本。
整条流水线分四步:
raw/ 文件变更→ Step 1: convert(非 Markdown 文件转换为文本)→ Step 2: distill(LLM 提炼为 wiki source-note)→ Step 3: index(写入 SQLite FTS 索引)→ Step 4: 清理孤立记录
每一步都是增量的——跳过没有变化的文件,只处理新增或修改的部分。

📄 Step 1:格式转换
Markdown 文件直接进入下一步,不需要转换。需要处理的是二进制格式:PDF、docx、pptx、xlsx 等。
Go 生态里没有成熟的纯 Go 方案能高质量处理所有这些格式。最省事的做法是调外部命令:markitdown(微软开源的文档转 Markdown 工具)或 pandoc。
转换结果写入 raw/converted/ 目录,文件名对应原始文件名加 .md 后缀。增量判断:检查 raw/converted/ 里是否已有对应文件,有就跳过。
有一个特殊情况值得单独处理:docx 和 pptx 里嵌入的 Excel 表格。这类文件在常规转换时会被忽略,表格数据完全丢失。
处理思路:docx 和 pptx 本质上是 ZIP 压缩包,里面的嵌入 Excel 文件存在 xl/ 子目录下。用 Go 标准库的 archive/zip 解包,找到 .xlsx 文件,分别转换,把表格内容作为 Markdown 表格追加到主文档末尾。
还有一个需要特别处理的场景:技术规范文档。这类文档的核心内容就是表格——字段定义、API 列表、数据字典。如果按普通文章的方式蒸馏,LLM 会把表格内容概括成几句话,关键信息大量丢失。
解决方案是表格密度检测:统计文档里 Markdown 表格行占总行数的比例,超过 30% 判定为高密度表格文档,触发轻量化蒸馏模式——保留原始表格结构,只补充简短说明,不做深度提炼。
🧪 Step 2:LLM 蒸馏
convert 完成后,扫描 raw/ 下的 .md 文件,找出在 wiki/source-notes/ 里没有对应文件的那些,送去蒸馏。
蒸馏就是调 LLM API,输入原始文章,输出符合 OKF 规范的 source-note 页面。
关键是 prompt 设计。一个好的蒸馏 prompt 需要告诉 LLM:
输出格式:YAML frontmatter + 固定章节结构(Summary / Key Facts / Terms / ...) metadata 提取规则:timestamp 从文章发布日期提取,不是今天的日期;tags 必须是领域术语,不能是"AI"、"技术"这种泛词 ALIAS RULE:key_claims 里每条主张必须内联所有同义词和中英文等价词 related_to 约束:只能填已存在的 wiki/ 路径,不确定就留空
蒸馏是一个网络调用,可能失败(LLM 返回 429、网络超时、输出格式不符合预期)。
初期实现可以串行处理,但当批量导入超过几十篇文章时,串行会阻塞几十分钟,这段时间内 watcher 无法处理新文件。
解决方案是蒸馏队列:在 SQLite 里加一张 distill_queue 表,存储待处理任务的路径和状态(pending / processing / done / failed)。watcher 触发时只是把新文件路径写入队列,真正的蒸馏由后台 worker pool 并发处理。
队列的好处:
watcher 不被阻塞,新文件随时能进来 LLM 429 时,任务自动进入重试队列,指数退避(1s、2s、4s...),最多重试 5 次 进程重启后,pending 和 failed 状态的任务自动恢复,不丢失
默认 3 个并发 worker,通过 config.yaml 可以调整。
🗂️ Step 3:FTS 索引
convert 和 distill 完成后,扫描 raw/、wiki/、schema/ 三个目录的所有 .md 文件,用 content_hash 做增量判断,写入 documents 表,触发器自动更新 FTS 索引,同时解析 frontmatter 里的关系字段写入 links 表。
索引完成后还要做孤立清理:把 documents 表里记录的、但文件系统上已经不存在的文档删掉。
👀 文件监控:fsnotify + debounce
上面四步流水线需要在文件变化时自动触发。
Go 生态里 fsnotify 是标准选择,跨平台(macOS / Linux / Windows),API 简洁。监听 raw/ 目录,收到 create 或 write 事件就触发 reindex。
但有一个问题:当你批量复制 100 个文件进 raw/,会产生 100 个文件系统事件,如果每个事件都触发一次完整流水线,会产生 100 次 LLM API 调用排队,索引也在反复重建。
解决方案是 debounce(防抖):收到事件后不立即处理,启动一个 3 秒的计时器。3 秒内如果又来了新事件,重置计时器。计时器到期才真正触发 reindex。
这样你批量复制 100 个文件,最终只触发一次 reindex,一次性处理所有新文件。
实现细节:用 time.AfterFunc 创建计时器,新事件到来时用 timer.Reset(3 * time.Second) 重置。需要用 sync.Mutex 保护计时器变量,避免并发竞争。

🚀 冷启动同步
程序启动时,需要执行一次完整 reindex,把当前 raw/ 里的所有文件同步进索引。这次 reindex 应该在后台 goroutine 里运行,不阻塞主程序启动——你应该能立刻打开 Web UI,即使索引还没建完。
main goroutine:启动 HTTP server,显示托盘图标background goroutine:执行完整 reindex
冷启动 reindex 完成前,搜索可能返回不完整的结果,但这是可以接受的——你能用,只是知识库还没完全建好。
⚠️ 错误处理原则
流水线的任何一步失败,不应该崩溃整个程序,也不应该静默跳过。
实践原则:
convert 失败:记录警告,跳过这个文件,继续处理下一个 distill 失败:写入 failed状态到 distill_queue,等待重试,不影响 index 步骤index 单个文件失败:记录警告,跳过,继续索引其他文件 整体流水线失败:不应该阻止下一次 watcher 触发
一个实际的坑:LLM 返回的蒸馏结果有时会被代码块包裹(yaml ... ),需要在写入文件前清理掉这个包装,否则 frontmatter 解析会失败。
📝 思路小结
四步流水线:convert → distill → index → 清理,全部增量处理 调外部工具处理二进制文件,Go 不需要自己实现文档解析 特殊处理:嵌入 Excel 提取、表格密度检测触发轻量蒸馏模式 蒸馏队列:SQLite 持久化,后台 worker pool,失败自动重试 fsnotify + 3 秒 debounce:批量操作只触发一次 冷启动后台 reindex,不阻塞主程序
📺 下篇预告
下一篇我们将带你进入 embedding 的世界——从文本到向量,聊聊如何让机器理解文档语义,以及如何用向量检索实现模糊搜索和语义匹配。文档进来了,接下来该让它们真正被"理解"了。
夜雨聆风