企业AI 平台从 0 到 1(二):上传一个 PDF,到它能被检索

本篇目标:只讲一条主线——一个文件从上传,到能被 AI 检索到,中间发生了什么。每一步解决什么问题、关键技术怎么实现、踩过哪些坑。


上一篇我们用总览图讲清了平台由哪些模块构成。这篇讲知识采集与文档处理这条最核心的链路,但只讲一条主线:

文件上传 → 解析 → 切分 → 向量化与索引 → 可被检索

先给一个朴素的问题:一个工程师往平台里拖进去一个 50 页的 PDF,过了十几秒,同事问 AI"这个系统的备份策略是什么",AI 给出带引用的答案——中间到底发生了什么?

一句话全景:

上传文件

解析
各种格式 → 标准 Markdown

切分
大文本 → 语义分块

向量化 + 落库
向量库 + 关键词索引

状态收口
completed / failed

可被检索命中

顺带说一句架构决策:平台其实还有网页、问答、数据库等其它来源,但它们都只是先把内容变成"标准文档",然后走和文件上传同一条流水线。所以把文件这条线讲透,其它来源就都懂了。


一、上传:第一道闸门

上传接口不能干等处理完,它只做四件事:

  1. 校验:文件大小(默认 50MB)、类型、数量(单次最多 20 个)、文件名清理——挡住不该进来的;
  2. 重名保护:同一知识库内文件名唯一,重复上传直接报错;允许覆盖时先完整清理旧文档(连同它的分块、向量、索引)再入库,不留中间态;
  3. 原文持久化:原始文件在入库瞬间就上传对象存储(key 形如 kb/{知识库ID}/docs/{文档ID}/{文件名})——即使后面解析失败,原文还在,可以重试、可以审计;
  4. 记内容哈希:sha256(原始字节) 写入文档记录,作为后续"内容没变就不用重跑"的判断依据。

做完这些,创建一个 pending 状态的文档记录,返回"已入库、处理中",真正的重活交给后台任务(后面第五节讲)。


二、解析:把一切格式变成一种东西

解析的目标非常专一:把任意文件变成一种带结构的标准 Markdown(标题、表格、图片引用都有约定)。这样下游切分只需要面对一种格式,不用关心来源是 Word 还是 PDF。

解析器按文件后缀路由:

输入走法
.txt / .html直接转 Markdown
.png/.jpg 等图片OCR 抠文字 + 视觉模型理解
.docx/.pdf/.xlsx/.pptx/.md逐格式提取 → 标准 Markdown
.vsdx(Visio)专用解析(见 2.3)

2.1 图片去哪儿了:占位符 → 对象存储 → 重写链接

Word、PPT、PDF 里的插图不能丢,也不能留本地路径。做法三步:

  1. 解析时先用占位 URL(__kb_image__/xxx)占位;
  2. 提取出的图片上传对象存储;
  3. 把 Markdown 里的占位链接批量重写为对象存储的访问 URL。

正文与图片彻底解耦:正文是纯文本,图片是远程资源,各取所需。图片不参与切分长度计算(下一节解释为什么)。

2.2 OCR 与视觉模型:扫描件怎么办

纯文本解析搞不定扫描件。这里分两层:

  • OCR 把图片里的文字抠出来。注意一个工程细节:OCR 冷启动可能要下载模型,是长时间阻塞操作,必须丢到线程池执行,否则会卡死整个上传接口;
  • 视觉模型(VLM)负责"看懂"图片内容,输出结构化描述。调用时把 OCR 文字作为上下文一并喂给模型——先有字,再理解图。

2.3 一个特殊格式:Visio

.vsdx 其实是个 zip 包。我们的方案分两层:

  • 保底层:直接解包,从页面 XML 里提取每个流程节点的文本和连接关系——流程图变成"节点 + 边"的结构化文本,没有图形环境也能被全文检索;
  • 增强层:有渲染工具时把页面渲染成图片,交给视觉模型分析,输出"流程主题、关键节点、判断条件"的结构化描述。

“先结构提取保底,再视觉分析增强”——这是处理一切图形格式的通用套路。


三、切分:把大文本切成"语义单元"

解析完,我们拿到一坨可能几万字的标准 Markdown。不能整篇丢给向量模型——模型有输入长度上限,而且检索粒度太大(命中一篇 50 页文档没有意义)。切分的目标:每块是一个语义相对完整、检索粒度合适的小单元。

3.1 三种策略

平台内置三种切分策略(ChunkStrategy 是扩展点,新增策略只需实现一个接口):

策略切分依据典型场景
fixed(默认)固定大小滑动窗口 + 分隔符回退通用文档
delimiter按 Markdown 标题切段章节结构明显的文档
section按标记切段,保护表格/代码块完整含大表格的结构化文档

默认参数 chunk_size=800, chunk_overlap=100(约 12.5% 重叠),每个知识库可覆盖。

3.2 为什么"滑动窗口"没那么简单

表面看,固定大小切分就是把文本每 800 字切一刀。但直接干会踩三个坑:

坑一:切在句子中间。 解决办法是"回退找分隔符"——窗口先扩大到目标大小,然后从后往前找最近的分隔符(优先级 \n\n → \n → . → ; → 空格),分隔符位置在窗口一半之后才切。这样每块尽量断在语义边界。

用伪代码看核心逻辑:

end = start + chunk_size
while 窗口有效长度 < chunk_size 且 没到硬顶:
    end += 50                              # 逐步扩窗
在 [start, end] 里从后往前找分隔符:
    若分隔符位置 > chunk_size/2 且不在图片语法内:
        切在分隔符处
否则:
    切在 end(硬切,保底)

坑二:把 Markdown 图片语法拦腰切断。 切分逻辑维护了图片位置扫描表,切分点如果在图片语法内部,就把边界拉长到整个图片语法之外,保证 ![alt](url) 不被拆成两半。

坑三:图片字符把"长度"撑爆。 一张 base64 图片的 markdown 可能有几万字符,如果算进 chunk 长度,一个窗口就被撑爆。所以计算长度用 effective_length——图片 markdown 不计入长度。同时防另一个极端:窗口可能被撑到几万字符,所以有硬顶 hard_limit = max(chunk_size * 3, 2400),超过就强制切。

这三个坑对应三个词:语义边界、结构完整性、长度安全——任何切分器都要同时满足。

3.3 一个真实教训:section 模式的"保护"过了头

以前用户显式指定 fixed 模式时,代码会"好心"自动升级成 section 模式。结果呢?section 把超大表格整段保护,单块冲到 60KB,向量模型截断。

教训:"自动升级策略"这种隐式行为害人。现在只有用户没指定模式时才自动选择,显式指定的模式绝不悄悄改变——配置的语义必须可预测。


四、增强:AI 参与,但不篡改正文

切分完,还有一个"AI 增强"环节。这里有一个关键设计决策:

模型输出的内容只进入元数据(标签、摘要、分类),正文切分永远使用解析器输出的原文。

为什么?因为让模型重写正文会"美化"事实——表格丢列、数字失真。检索要的是忠实原文,不是"AI 润色版"。所以:

  1. 视觉模型给文档里的图片生成结构化描述(装饰性图片先过滤掉,不浪费算力);
  2. 大模型从原文提取元数据(标题、分类、标签、文档类型)存入 doc_metadata;
  3. 增强失败静默降级——只记告警不阻断主流程,"AI 增强"是锦上添花,不是主链路依赖。

另外每个分块还会被包一层"上下文头":

文档摘要: <整篇摘要>
章节路径: 3.2 系统架构 > 容灾方案
内容: <正文块>

为什么要把摘要和章节路径拼进每个分块? 因为检索时如果只命中正文块,模型不知道"这段话来自哪篇文档、哪个章节";把上下文写进分块,等于让每个分块自带"身份信息",召回和生成质量都明显变好。这是低成本高收益的技巧。


五、落库与索引:先删旧、再写新

处理完的分块要写入三处:数据库分块表、向量库(相似度检索)、Elasticsearch(关键词检索)。顺序和幂等设计是关键:

批量向量化(事务外,长操作)
  → 向量库:先删该文档旧向量 → 写入新向量
  → ES:删旧 → 批量索引(失败只告警,不影响主流程)
  → 数据库事务内:删旧分块 → 插新分块 → 状态置为 completed

三个要点:

  • 先删后写保证幂等。重切分、重试,任何重复执行都不会留下孤儿分块或重复向量;
  • ES 失败不影响主流程。向量库是主路径,ES 是增强路径,ES 挂了文档照样完成——降级而不是失败;
  • 长操作不占事务。向量化、解析、外部模型调用全部在数据库事务之外,事务里只有内存操作,避免长事务占用连接。

到这里,一个文件的处理闭环完成:状态置为 completed,分块进入向量库和 ES,它已经可以被检索命中了。


六、后台处理:并发、取消与幂等

上传接口立刻返回,处理在后台进行。三个设计点:

1)限流。 用一个信号量限制并发(默认同时最多处理 2 个文档),防止几个大文件同时触发向量化把服务打爆。注意这是进程内信号量——当前单机部署够用,文档里明确写了边界:“多进程部署时不能假设任务天然全局唯一”,将来扩容需要引入外部队列。

2)取消。 用户点"取消",后台任务怎么感知?机制是"取消请求集合 + 检查点":处理函数在每个耗时阶段之间调用检查点,发现收到取消请求,先清理已写入的向量/ES 索引,再中止——绝不留下半截索引。检查点还兜住另一种情况:处理到一半文档被删除,同样清理索引并中止,不产生孤儿数据。

3)失败收口。 整个处理函数是"大 try + 收口"结构:解析失败、向量化失败、保存失败,任何异常都被捕获,把状态置为 failed、写入错误信息。processing 状态要么变成 completed,要么变成 failed,没有第三种结局——绝不会留下"永远处理中"的僵尸文档。

顺带提一下进度:每个阶段处理函数会回调写入内存进度表(解析 10% → 切分 40% → 向量化 65% → 完成 100%),前端轮询显示。进度用内存态而不是数据库——它是高频写入的低价值数据,丢了也不影响处理本身。


七、踩坑清单(都是真金白银)

坑后果解法
OCR 冷启动在协程里跑上传接口假死丢线程池执行
显式 fixed 被"自动升级"成 section单块 60KB,向量截断显式模式不再隐式升级
切分切在图片语法中间分块残缺、图片引用断裂图片边界扫描 + 边界拉长
图片字符计入分块长度单块被 base64 撑爆有效长度不计图片 + 硬顶
模型重写正文表格丢列、数字失真模型只产元数据,正文用原文
任务失败不落库永久"处理中"僵尸文档全链路 try + 收口到 failed
重切分不先删旧索引孤儿分块、重复向量向量/ES/数据库全部先删后写
长操作占事务数据库连接被拖死事务外执行耗时调用

八、小结与下期预告

这一篇只讲了文件这条主线,核心要点:

  • 解析的目标是"变成一种东西"——标准 Markdown,图片走"占位符 → 对象存储 → 重写链接";
  • 切分的三个底线:语义边界、结构完整性、长度安全,默认 800/100;
  • 模型只进元数据,不进正文——保真是检索的命根子;
  • 落库先删后写,ES 降级不失败;
  • 后台任务:限流 + 检查点取消 + 失败收口,不留僵尸文档。

下一篇进入检索侧:一个分块落库之后,用户的问题进来,是怎么从几十万块里把最相关的几块捞出来的——召回、重排、证据合成,以及我们怎么做检索质量评估。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐