跳到正文
博客园· 努力的小雨·· 3 小时前AI 评分22

Milvus 知识库 PDF 导入失败怎么排查?用观测云多行日志采集定位异常

Milvus知识库PDF导入失败怎么办?10分钟看懂异常日志排查 - 努力的小雨

AI 导读

基于 Spring AI Alibaba 的 RAG 项目在 PDF 导入 Milvus 失败时,用观测云 DataKit 多行日志采集对比了三种配置:逐行采集把 71 行日志拆成 71 条记录,只按日期匹配会误把正文日期当新日志头、整批变成 19 条,完整首行规则(时间+级别+线程)则还原为 18 条、与 18 次日志输出一一对应。

正文

上一篇,我用调用链拆开了一次 RAG 问答的耗时,看清时间花在 Embedding、Milvus 检索还是模型生成。这次把视线往前移一步:回答要用的资料,得先成功进入知识库。PDF 如果卡在导入阶段,单看问答接口,还解释不了它为什么没有被检索到。

我的项目基于 Spring AI Alibaba,PDF 要先解析、切分、转成向量,再存入 Milvus。本篇要查的,就是这段文档导入业务的失败原因。

日志里明明有报错,点开却只看到开头。后面几条全是 at,真正的文件解析原因又在另一条里。到底文件坏了,还是自己的代码有问题,凭这半截内容很难判断。最后又容易绕回本地:把文件拿回来,再跑一次,对着控制台找完整异常。

这次我在现有 PDF 解析环节放入一份故意损坏的文件,得到真实异常,再比较三种采集方式。我用观测云查看结果,用到的是多行日志采集:先让应用留下完整日志,再配置 DataKit,最后看页面里的排查方式有什么变化。

图片

第一步:准备日志

项目沿用 Spring Boot Web Starter 默认的 Logback。应用把完整异常写进文件,DataKit 读取后送到观测云,没有另外添加 Java 观测 SDK。采集器还没接入的话,先按官方安装说明连接工作空间。

部署关系如下:

图片

在业务代码里记录错误时,把异常对象一起传进去:

log.error("knowledge document import failed", exception);

这句放在原有的异常处理位置:业务说明交代正在做什么,异常对象保留调用位置和底层原因。如果只记录 exception.getMessage(),或者只打印“导入失败”,文件里就缺少后半段,采集配置也补不出来。

接下来让日志落盘。对于使用 Spring Boot 默认 Logback 配置的应用,可以在 application.yml 中配置文件位置、编码和格式:

logging:
  file:
    name: D:/app/logs/application.log
  charset:
    file: UTF-8
  pattern:
    file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{40} - %msg%n%ex{full}"

这里把本次实验使用的格式整理成了默认 Logback 应用的配置写法:每条日志以时间、级别、线程开头,%ex{full} 保留完整异常。如果应用已经用自己的 Logback XML 管理文件输出,就在现有文件输出配置里采用这个格式,不必再建另一套。

重启应用,触发一次业务操作,打开 application.log,确认错误头后跟着调用栈和 Caused by。

我的这次样本来自项目已有的 PDF 解析器。它先按段落读取,失败后再按页读取;必须继续看后面的异常,才能知道最后为什么没有解析成功。

图片

故意损坏的 PDF 在这一步抛出了实际的文件结构错误。我另外加入业务包装异常、Suppressed 附加异常、一行日期正文,并连续输出两次独立错误,用来检查复杂异常和相邻日志会不会被切错。

整份日志共有 71 行,对应 18 次日志输出:14 条 INFO、4 条 ERROR。后面比较的都是同样的内容;自己应用的记录数会不同,关键要看一次错误的开头和原因是否仍在一起。

第二步:配置采集

应用这边有文件以后,再让 DataKit 读取它。这里先给出最终采用的完整配置,文件名可以用 rag-logging.conf:

[[inputs.logging]]
  logfiles = ["D:/app/logs/application.log"]
  source = "rag-document"
  service = "rag-app"
  character_encoding = "utf-8"
  from_beginning = true
  enable_multiline = true
  multiline_match = '''^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} (TRACE|DEBUG|INFO|WARN|ERROR)\s+\'''

logfiles 填第一步实际生成的日志路径;source 是查询时用的来源名,service 用来标识应用。它们可以按自己的业务命名,但配置和页面筛选要对应。character_encoding 与文件编码一致。

DataKit 读取的是它所在机器能访问的文件。本机运行应用,就读本机路径;应用部署在服务器上,也要让采集器能读取服务器上的日志。把远程路径填进本机配置,并不会自动把远程文件传过来。

多行规则只识别新事件的首行:匹配就新建记录,其余行追加到上一条。这里沿用前面的时间、级别和线程格式,具体机制见[官方多行日志说明。

图片

^ 从行首匹配,末尾的 \s+\[ 识别级别后的空白和线程方括号。at、Caused by 等异常行继续追加,不写进首行匹配条件。

规则要覆盖 INFO、WARN 等全部级别。只认 ERROR,会把错误后的普通日志也接进来。如果原有格式使用 T 分隔日期和时间,或用逗号分隔毫秒,匹配规则也要相应调整。

为了看清差别,我还保留了两组对照。第一组关闭多行采集:

enable_multiline = false

第二组打开多行,但规则只检查日期:

enable_multiline = true
multiline_match = '''^\d{4}-\d{2}-\d{2}'''

第三组采用完整首行规则。上面两组分别替换完整配置末尾的多行参数,关闭多行时去掉 multiline_match,不要在同一配置块重复添加同名项。三组使用内容相同的新文件、不同来源名,其余设置保持一致;实际接入只需保留适合自己格式的一组。

正式启用前,先用 DataKit 检查配置。下面命令假定配置文件就保存在当前目录:

& 'C:\Program Files\datakit\datakit.exe' debug `
  --input-conf .\rag-logging.conf

终端会持续显示采集结果,检查完按 Ctrl+C 结束。看的是同一条记录能不能读到根因,以及下一条错误有没有自己的边界;仅仅出现内容,还不能说明它们分组正确。

确认分组正确后,将配置放进 DataKit 安装目录的 conf.d,重启采集器。Windows 默认安装目录通常是 C:\Program Files\datakit,可以在管理员 PowerShell 中执行:

Restart-Service -Name DataKit

配置中的 from_beginning = true 用于从头读取新文件。已经采集过的文件还有读取位置缓存,不一定会因改成 true 而重新读。本次对照采用新的文件区分三组,没有删除缓存,也没有让同一份文件的旧进度干扰结果。

第三步:查看效果

进入观测云的日志查看器,在左侧“数据来源”里筛选配置中的 source,把时间范围设到覆盖日志采集的时刻。三组来源分开查,再展开同一个错误,比只看总数更容易看清差别。

前面的 rag-document 是接入示例名;本次实测的三个来源分别是 clxzs_multiline_line_20261028、clxzs_multiline_broad_20261028、clxzs_multiline_strict_20261028,对应逐行、日期和完整首行规则。截图显示的是这些实测名,自己接入时按自己的配置查。

效果一:逐行采集

关闭多行以后,71 行原文形成了 71 条记录。普通的 PDF 错误本来有 13 行,现在也就分成了 13 条;嵌套错误有 18 行,结果同样分成 18 条。

图片

这组样例在错误头中带了 case=PDF-SINGLE 编号。搜索这个编号,可以找到报错开头;但后面的异常原因没有重复编号,不能一起出现在这条记录里。下面摘出原文中的关键位置,中间省略了重复调用栈:

2026-10-28 15:45:16.019 ERROR [main] ... case=PDF-SINGLE ...
java.lang.RuntimeException: java.io.IOException: ...
    at ...PdfFileParser.parsePagePdf(PdfFileParser.java:51)
Caused by: java.io.IOException: Missing root object specification in trailer.
    at ...PDFParser.initialParse(PDFParser.java:112)

文件里这几行连着,逐行采集后却分别成为记录。业务说明告诉我在处理导入,自己的代码位置告诉我走到了 PDF 解析,最后的 IOException 说明文件内部结构不完整。它们本来共同解释一次失败,拆开以后就得来回搜索、重新确认归属。

尤其连续发生两次错误时,两段调用栈可能很像。仅凭一行 at,很难判断是哪次操作;把条件放宽虽然能翻出更多内容,仍然需要人工拼接前后关系。

本次核对过,逐行结果能按顺序拼回原文件,文字并没有丢失。问题在于一次错误被拆成了多条。这里是我主动关闭多行做的对照,DataKit 本身也有自动多行识别,不能把这组结果当作默认表现。

效果二:只认日期

规则只认日期后,普通异常的效果已经明显不同:带日期的错误头开始一条记录,后面不带日期的调用栈继续追加。那条 13 行的普通 PDF 错误,完整留在了一条记录里。

但整批结果是 19 条,嵌套错误仍然被拆开了:

图片

多出的这一条,来自测试消息中的日期正文:

2026-10-28 this date is document content, not a new log event

它只是正文,没有时分秒、级别和线程,却满足“行首是日期”。采集器到这里开始了新记录,于是原来的 18 行异常被拆成 2 行和 16 行。

展开错误头所在的记录,只剩业务说明和包装异常两行,到“资料摘录如下”就结束了:

图片

再打开另一条独立记录,开头已经是日期正文,后面才跟着附加异常和 PDF 根因:

图片

第一条有业务错误头,第二条才有日期正文、附加异常和底层原因。查错误编号,找到的仍然是一段不完整的信息。只看记录数从 71 降到了 19,很容易觉得已经配好了;把这条嵌套错误展开,才能发现还断在正文中间。

图片

这条反例也能解释为什么业务层的包装容易让人绕路。第一条只剩“知识导入被拒绝”,具体的 PDF 错误在第二条。要是排查时停在第一条,注意力就可能一直放在业务检查上,还没有走到真正的文件结构问题。

因此,这一组解决了普通调用栈逐行分散的问题,却没有避开知识文档里的日期。在 RAG 应用里,错误消息可能带一段资料摘录,只认日期就有把正文误认为日志头的风险。

效果三:完整首行

补上时间、级别和线程特征以后,那句测试正文不再满足首行格式,继续留在当前异常中。整批结果是 18 条,正好对应原来的 18 次日志输出。

图片

展开编号为 PDF-NESTED 的嵌套错误,可以从业务说明一直读到日期正文、Suppressed、Caused by 和 PDF 根因。下面是本次实际记录的详情:

图片

这次在观测云里按来源找到日志,再展开同一次异常,就能顺着业务说明、解析方法,读到底层报错 Missing root object specification in trailer.。排查对象随之缩小到测试 PDF 的内部结构,不用再跨记录拼接调用栈。

附加异常也保留着,但阅读时还要分清分支。Suppressed 是我加入的测试条件;沿着 Caused by 往下读,才能找到这里的 PDF 解析原因。采完整以后,才有这些信息可供判断。

我还检查了连续写出的 ADJACENT-A、ADJACENT-B。两次错误时间很近、级别相同,但各自都有完整日志头,所以仍然保持两条记录,没有被合并成一大段。

三组云端正文我都取出,与本地采集结果逐条比较过,内容一致。

页面上还有一处容易误会。首行正则里虽然写了日期和 ERROR,它们在这里只用来判断“一条日志从哪里开始”。采集器据此合并异常栈,把完整内容放进 message;匹配到了 ERROR,并不会自动把平台的 status 改成 error。

这次没有为这份 Logback 日志配置字段解析规则(Pipeline),所以 status 使用默认值 info,time 使用采集时的系统时间。这是官方日志采集说明中约定的默认处理方式。合并异常栈和提取字段,是两项不同的处理。

我把同一条记录的两处局部放大:上面是平台用来筛选和显示时间的字段,下面是日志正文。红圈里能直接看到两组值的差别:

图片

这里的时间差也有具体原因:实验先在 15:45 生成日志文件,到 16:48 才由采集器读取。由于没有把正文时间提取成 time,平台便按采集时的系统时间显示这批旧日志。

因此,我先按 source 找到这批数据,查询时间覆盖 16:48,再展开正文检查。此时直接筛选 status=error,会把这些正文带着 ERROR 的记录排除掉;本文的“四条错误”也是按正文核对的。如果需要按原日志级别和业务发生时间筛选,还要用 Pipeline 提取级别、转换时间,再写入对应字段。

先把一次异常看完整

这次从知识库导入失败出发,比较了三种采集方式:逐行会把异常拆散,只认日期会被正文里的日期误导,完整首行规则则把业务说明、调用栈和根因保留在同一条记录里,也没有把相邻两次错误串起来。

观测云的日志查看器把三组结果放在同一个查询入口。按来源和采集时间定位后,就能展开同一次异常,顺着调用位置读到底层原因。多行合并解决了“能不能读完整”,按级别、时间和业务字段筛选还需要继续处理。

下一篇继续看JSON 字段模式:让文档编号、处理阶段这些信息成为可筛选字段,少靠关键词翻日志。仍然围绕这段文档导入业务,看配置前后的区别。

图片

先把一次失败的前因后果留在一起,排查才有了起点。

来源:博客园 · cnblogs.com