16.13-知识库文件流水线
要点
- 前面 12 篇把文件处理的各个能力拆开讲了——上传、类型校验、解析、存储、异步队列,这篇把它们串成一条完整的流水线
- 流水线按责任链模式组织,每个步骤只做一件事,可以独立测试、独立替换
- 文件从上传到最终进入向量数据库,中间经历 6 个阶段,每个阶段都有独立的状态和错误处理
- 增量更新策略避免重复处理——文件只改了一页,不需要把整份文档重新跑一遍
- 这篇同时也是整个第 16 章的收束,最后会回顾这个系列讲了哪些能力、它们怎样拼在一起
1. 先看清整条流水线
知识库场景的文件处理和简单的「上传-下载」不一样。用户上传一份 PDF、一个 Word 文档、一张图片,目标不是存起来让别人下载,而是把里面的文字提取出来,切成合适大小的文本块,向量化后存入向量数据库,供后续的 RAG 检索使用。
把这条链路拆开,一共 6 个阶段:
上传 → 类型检测 → 内容解析 → 文本清洗 → 分块 → 向量化 → 存入向量数据库
每个阶段对应前面某篇文章讲过的能力:
| 阶段 | 对应文章 |
|---|---|
| 上传 | 16.01 文件上传接口 |
| 类型检测 | 16.02 文件类型校验 |
| 大小限制 | 16.03 文件大小限制 |
| PDF / Word / Markdown / HTML 解析 | 16.04 ~ 16.07 |
| 图片 OCR | 16.08 图片 OCR |
| 音频转文本 | 16.09 音频转文本 |
| 文件存储 | 16.10 ~ 16.11 |
| 异步队列 | 16.12 异步文件处理队列 |
这篇的任务是把上面这些独立模块组装到一起,形成一条可以从上传一直跑到向量入库的完整流水线。
2. 责任链模式组织处理步骤
如果用一个巨大的 if-else 把所有处理逻辑堆在一起,代码会很快膨胀到不可维护。更合理的做法是用责任链模式——每个处理步骤是一个独立的类,接收上一步的输出,产出自己的输出,然后交给下一步。
先定义一个贯穿整条流水线的 FileContext,每个步骤往里写东西、从里面读东西:
// src/pipeline/types.ts
export type FileContext = {
fileId: string; knowledgeBaseId: string; originalName: string
mimeType: string; size: number; storageKey: string
rawText: string; cleanedText: string
chunks: Chunk[]; vectors?: Vector[]
status: 'pending' | 'processing' | 'completed' | 'failed' | 'retrying'
currentStage: Stage
error?: { stage: Stage; message: string; retryCount: number }
}
export type Stage =
'upload' | 'detect' | 'parse' | 'clean' | 'chunk' | 'vectorize' | 'store' | 'done'
// 每个步骤实现同一个接口——接收上下文,返回处理后的上下文
export interface PipelineStep {
readonly name: Stage
process(ctx: FileContext): Promise<FileContext>
}每个步骤可以独立写测试;新增或替换步骤不影响其他代码——加一种新的解析器只需要加一个类。
3. 各阶段实现
类型检测
这一步在 16.02 已经写过。包装成流水线步骤后,核心就是一个 MIME 类型白名单检查:
class DetectTypeStep implements PipelineStep {
readonly name = 'detect' as const
private allowedTypes = new Set(['application/pdf', /* ... */])
async process(ctx: FileContext): Promise<FileContext> {
if (!this.allowedTypes.has(ctx.mimeType)) {
throw new PipelineError(this.name, `不支持的文件类型: ${ctx.mimeType}`)
}
return ctx
}
}内容解析
根据 MIME 类型分发到不同的解析器——前面 16.04 ~ 16.09 实现了各种解析器,这里做的是把它们统一管理起来:
class ParseContentStep implements PipelineStep {
readonly name = 'parse' as const
constructor(private bucket: R2Bucket) {}
async process(ctx: FileContext): Promise<FileContext> {
const buffer = await (await this.bucket.get(ctx.storageKey))!.arrayBuffer()
let result: { text: string; pages?: { pageNumber: number; text: string }[] }
switch (ctx.mimeType) {
case 'application/pdf': result = await parsePDF(buffer); break
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
result = await parseWord(buffer); break
case 'text/markdown': result = { text: new TextDecoder().decode(buffer) }; break
case 'text/html': result = await parseHTML(buffer); break
case 'image/png':
case 'image/jpeg': result = await performOCR(buffer, ctx.mimeType); break
case 'audio/mpeg':
case 'audio/wav': result = await transcribeAudio(buffer, ctx.mimeType); break
default: throw new PipelineError(this.name, `无对应的解析器: ${ctx.mimeType}`)
}
return { ...ctx, rawText: result.text, pages: result.pages }
}
}这个 switch 把「哪种 MIME 类型交给哪个解析器」集中到了一个地方。后续加新格式只需要加一个 case。
文本清洗
从各种格式中提取出来的原始文本需要做一轮清洗——统一换行符、合并连续空行、去除控制字符、去掉过短的行(可能是页眉页脚)。清洗规则可以根据实际数据持续调整,实际项目中可能还需要处理 PDF 断行拼接、HTML 残留标签等问题。
分块
分块策略直接影响检索质量——块太大,检索精度下降;块太小,上下文丢失:
class ChunkTextStep implements PipelineStep {
readonly name = 'chunk' as const
constructor(private chunkSize = 512, private chunkOverlap = 64) {}
async process(ctx: FileContext): Promise<FileContext> {
const paragraphs = ctx.cleanedText.split(/\n\n+/)
const chunks: Chunk[] = []
let current = '', idx = 0
for (const para of paragraphs) {
if (this.estimateTokens(para) > this.chunkSize) {
// 单段超长,按句子拆分
if (current) { chunks.push(makeChunk(current, idx++)); current = '' }
for (const sub of this.splitLongParagraph(para)) chunks.push(makeChunk(sub, idx++))
} else if (this.estimateTokens(current + para) <= this.chunkSize) {
current += '\n\n' + para
} else {
chunks.push(makeChunk(current, idx++))
// overlap——取上一个块末尾几句,和新段落拼在一起
current = this.getLastSentences(current, this.chunkOverlap) + '\n\n' + para
}
}
if (current.trim()) chunks.push(makeChunk(current, idx++))
return { ...ctx, chunks }
}
private estimateTokens(text: string): number {
const cjk = (text.match(/[一-鿿]/g) || []).length
return Math.ceil(cjk / 1.5 + (text.length - cjk) / 4)
}
}分块策略分三层:先按段落切分保持语义完整;段落之间往一个块里攒,攒到接近 chunkSize 就输出;单段超长时退化为按句子切分。chunkOverlap 控制相邻块之间的重叠 token 数。
向量化与入库
class VectorizeStep implements PipelineStep {
readonly name = 'vectorize' as const
constructor(private ai: Ai) {}
async process(ctx: FileContext): Promise<FileContext> {
const vectors: Vector[] = []
for (let i = 0; i < ctx.chunks.length; i += 10) {
const batch = ctx.chunks.slice(i, i + 10)
const res = await this.ai.run('@cf/baai/bge-m3', { text: batch.map(c => c.text) })
batch.forEach((c, j) => vectors.push({ chunkIndex: c.index, embedding: res.data[j] }))
}
return { ...ctx, vectors }
}
}向量生成后写入 Cloudflare Vectorize,同时把 chunk 文本存入 D1 供检索时返回上下文。Vectorize 单次最多 100 条,分批写入。metadata 里存 chunkText 的前 1000 个字符,检索时直接拿到摘要,不需要回 D1 查全文。
4. 流水线编排
有了各个步骤,接下来需要一个编排器把它们按顺序跑起来:
// src/pipeline/runner.ts
export class PipelineRunner {
private steps: PipelineStep[] = []
addStep(step: PipelineStep): this { this.steps.push(step); return this }
async run(initialContext: FileContext): Promise<FileContext> {
let ctx: FileContext = { ...initialContext, status: 'processing' }
for (const step of this.steps) {
ctx = { ...ctx, currentStage: step.name }
await this.updateStatus(ctx)
try {
ctx = await step.process(ctx)
} catch (error) {
if (error instanceof PipelineError) {
const retryCount = ctx.error?.retryCount ?? 0
// 可重试的错误:网络超时、速率限制
if (this.isRetryable(error) && retryCount < 3) {
ctx = { ...ctx, status: 'retrying', error: {
stage: step.name, message: error.message, retryCount: retryCount + 1
}}
await this.updateStatus(ctx)
await this.sleep(1000 * Math.pow(2, retryCount)) // 指数退避
ctx = await step.process(ctx)
continue
}
// 不可重试或重试耗尽
ctx = { ...ctx, status: 'failed', error: {
stage: step.name, message: error.message, retryCount
}}
await this.updateStatus(ctx)
return ctx
}
throw error
}
}
ctx = { ...ctx, status: 'completed', currentStage: 'done' }
await this.updateStatus(ctx)
return ctx
}
private isRetryable(error: PipelineError): boolean {
return /timeout|rate limit|503/i.test(error.message)
}
private async updateStatus(ctx: FileContext): Promise<void> {
// 把当前阶段和状态写入 D1
}
private sleep(ms: number): Promise<void> {
return new Promise(r => setTimeout(r, ms))
}
}编排器的核心逻辑:按顺序执行每个步骤,步骤失败时区分可重试和不可重试的错误。可重试的等待后重来,不可重试的直接标记失败。
组装和入队
在 API 路由里把各个步骤组装起来,上传文件后立即返回 202,把处理工作交给队列:
importApp.post('/api/kb/:kbId/import', async (c) => {
const kbId = c.req.param('kbId')
const body = await c.req.parseBody()
const file = body['file'] as File
if (!file) return c.json({ error: '缺少文件' }, 400)
if (file.size > 50 * 1024 * 1024) return c.json({ error: '文件过大' }, 413)
const fileId = crypto.randomUUID()
const storageKey = `kb/${kbId}/${fileId}/${file.name}`
await c.env.BUCKET.put(storageKey, file.stream(), {
httpMetadata: { contentType: file.type },
})
// 记录文件信息
await c.env.DB.prepare(`
INSERT INTO kb_files (id, kb_id, name, mime_type, size, storage_key, status, stage)
VALUES (?, ?, ?, ?, ?, ?, 'pending', 'upload')
`).bind(fileId, kbId, file.name, file.type, file.size, storageKey).run()
// 发送到队列异步处理
await c.env.PROCESSING_QUEUE.send(JSON.stringify({
fileId, knowledgeBaseId: kbId, storageKey,
mimeType: file.type, originalName: file.name, size: file.size,
}))
return c.json({ fileId, status: 'pending', message: '文件已接收,后台处理中' }, 202)
})队列消费者
队列消费者拿到消息后,创建流水线并执行:
// src/queues/file-processing.ts
export default {
async fetch(batch: MessageBatch, env: AppEnv): Promise<void> {
const runner = new PipelineRunner()
runner
.addStep(new DetectTypeStep())
.addStep(new ParseContentStep(env.BUCKET))
.addStep(new CleanTextStep())
.addStep(new ChunkTextStep(512, 64))
.addStep(new VectorizeStep(env.AI))
.addStep(new StoreVectorsStep(env.DB, env.VECTOR_DB))
for (const message of batch.messages) {
const payload = JSON.parse(message.body as string)
const ctx: FileContext = { ...payload, rawText: '', cleanedText: '', chunks: [], status: 'pending', currentStage: 'detect' }
try {
const result = await runner.run(ctx)
message.ack()
} catch (error) {
message.retry({ delaySeconds: 30 })
}
}
},
}5. 处理状态跟踪
每个文件在处理过程中,状态会不断更新。前端需要知道「当前处理到哪一步了」。
数据库表结构
CREATE TABLE kb_files (
id TEXT PRIMARY KEY,
kb_id TEXT NOT NULL,
name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
storage_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
stage TEXT NOT NULL DEFAULT 'upload',
chunk_count INTEGER,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);查询进度
根据 stage 可以估算一个大致的进度百分比:
const stageProgress: Record<Stage, number> = {
upload: 10, detect: 15, parse: 30, clean: 50,
chunk: 65, vectorize: 80, store: 95, done: 100,
}
importApp.get('/api/kb/:kbId/files/:fileId/status', async (c) => {
const file = await c.env.DB.prepare(
'SELECT * FROM kb_files WHERE id = ?'
).bind(c.req.param('fileId')).first()
if (!file) return c.json({ error: '文件不存在' }, 404)
return c.json({
fileId: file.id,
status: file.status,
stage: file.stage,
progress: file.status === 'failed' ? -1 : stageProgress[file.stage],
chunkCount: file.chunk_count,
error: file.error_message,
})
})progress 不是精确的进度(每个阶段耗时差异很大),但比一个不确定的转圈图标更有用。如果要做得更精确,可以在 ParseContentStep 里按页更新进度,在 VectorizeStep 里按块更新进度——代价是数据库写入次数增加。
SSE 实时推送
如果想做到实时更新,可以用 Server-Sent Events 推送状态变化:
importApp.get('/api/kb/files/:fileId/progress', async (c) => {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
let lastStage = ''
for (let i = 0; i < 150; i++) { // 最多监听 5 分钟
const file = await c.env.DB.prepare(
'SELECT status, stage, chunk_count, error_message FROM kb_files WHERE id = ?'
).bind(c.req.param('fileId')).first()
if (!file) break
if (file.stage !== lastStage) {
controller.enqueue(encoder.encode(
`data: ${JSON.stringify({ status: file.status, stage: file.stage })}\n\n`
))
lastStage = file.stage
}
if (file.status === 'completed' || file.status === 'failed') break
await new Promise(r => setTimeout(r, 2000))
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
})
})只在阶段变化时推送,减少无意义的数据。终态时自动结束流。
6. 增量更新
知识库里的文件不是一次性导入就不动了。用户可能更新了一份文档的某几页,把整份文档从头跑一遍流水线,既浪费算力,也浪费时间。
增量更新的核心思路是对比差异,只重新处理变化的块。
基于内容哈希的差异检测
export async function detectChanges(
db: D1Database, fileId: string,
newChunks: { index: number; text: string }[]
) {
const oldChunks = await db.prepare(
'SELECT chunk_index, content_hash FROM kb_file_chunks WHERE file_id = ?'
).bind(fileId).all()
const oldMap = new Map(oldChunks.results.map(c => [c.chunk_index, c.content_hash]))
const newMap = new Map(newChunks.map(c => [c.index, hashText(c.text)]))
const added: typeof newChunks = []
const modified: typeof newChunks = []
const removed: number[] = []
const unchanged: { oldIndex: number; newIndex: number }[] = []
for (const [index, hash] of newMap) {
const oldHash = oldMap.get(index)
if (!oldHash) added.push(newChunks.find(c => c.index === index)!)
else if (oldHash !== hash) modified.push(newChunks.find(c => c.index === index)!)
else unchanged.push({ oldIndex: index, newIndex: index })
}
for (const [index] of oldMap) {
if (!newMap.has(index)) removed.push(index)
}
return { added, modified, removed, unchanged }
}增量向量更新
检测到差异后,只需要对新增和修改的块重新做向量化:
// 保留未变化的块对应的向量
for (const { oldIndex, newIndex } of changes.unchanged) {
const existing = existingVectors.find(v => v.chunkIndex === oldIndex)
if (existing) vectors.push({ chunkIndex: newIndex, embedding: existing.embedding })
}
// 只对新增和修改的块重新生成向量
for (const chunk of [...changes.added, ...changes.modified]) {
const res = await this.ai.run('@cf/baai/bge-m3', { text: chunk.text })
vectors.push({ chunkIndex: chunk.index, embedding: res.data[0] })
}
// 删除被移除的块对应的向量
for (const oldIndex of changes.removed) {
await this.deleteVector(ctx.fileId, oldIndex)
}这样做的好处在文件规模大时尤为明显。一份 200 页的文档只改了 3 页,重新向量化的工作量从 200 块降到 3 块。
7. 错误处理策略
流水线里任何一步都可能出错。不同类型的错误需要不同的处理方式:
| 错误类型 | 示例 | 处理策略 |
|---|---|---|
| 文件不合法 | 不支持的类型、空文件 | 直接标记失败,不重试 |
| 解析失败 | PDF 加密、Word 损坏 | 标记失败,提示用户检查文件 |
| 外部 API 超时 | AI Embedding 超时 | 指数退避重试,最多 3 次 |
| 速率限制 | Vectorize 写入限流 | 等待后重试 |
| 存储满 | R2 配额用完 | 标记失败,通知管理员 |
在 PipelineRunner 里,通过 isRetryable 方法来区分可重试和不可重试的错误。不可重试的错误直接标记失败,不会浪费 Queue 的重试配额。
对于解析失败的文件,可以考虑一种降级策略:如果 PDF 解析只失败了某几页,可以把成功的页保留,只标记失败的页。这样至少部分内容可以进入知识库,用户也可以针对性地补传缺失的页面。
8. 性能考量
流水线跑起来后,几个地方需要关注性能。
解析阶段的耗时差异 — 一份 10 页的 PDF 可能只要 2 秒,500 页的 PDF 可能需要 30 秒以上。Queue 的 maxBatchSize 和 maxConcurrency 要根据耗时来调整:
// wrangler.jsonc
[[queues.consumers]]
queue = "file-processing"
maxBatchSize = 3 // 大文件场景调小
maxConcurrency = 1 // 串行处理,避免内存峰值
maxRetries = 3
deadLetterQueue = "file-processing-dlq"向量化的批量控制 — Embedding 接口有速率限制。50 个文件 × 100 块 = 5000 次向量调用。前面代码里已经用了每 10 段一批的方式,如果仍然触发限流,可以在批次之间加延迟:
if (i + 10 < ctx.chunks.length) {
await new Promise(r => setTimeout(r, 200))
}大文件的内存管理 — Workers 的内存限制是 128MB。解析完成后,原始的 ArrayBuffer 可以被 GC 回收,只需要保留提取出的文本。避免把 buffer 和解析结果同时保存在上下文里。
9. 本系列总结回顾
这是第 16 章的最后一篇。回头看一下文件处理这个章节都覆盖了哪些东西。
基础能力层
- 16.01 ~ 16.03:文件上传接口、类型校验、大小限制。解决的是「文件怎么安全地接收进来」
- 16.10 ~ 16.11:文件存储服务和对象存储接入。解决「文件存在哪里」
内容解析层
- 16.04 ~ 16.07:PDF、Word、Markdown、HTML 四种文本格式的解析
- 16.08:图片 OCR——从图片中提取文字
- 16.09:音频转文本——把语音内容变成可检索的文字
异步与工程化层
- 16.12:异步文件处理队列——用 Cloudflare Queue 把耗时操作从请求周期中剥离
- 16.13(本文):把上面所有能力串成一条知识库文件流水线
这些模块单独看都不复杂。每个解析器就是一个函数,存储就是 R2 的 put/get,队列就是 send/consume。需要花心思的是把它们组织到一起,让它们在面对不同文件格式、不同大小、不同失败场景时都能稳定运行。
从整个 Hono 后端系列来看,文件处理这一章用到的能力在前面都已经铺垫过:
- 路由和中间件(04 ~ 05 章)——文件上传接口的入口
- 类型安全(06 章)——请求参数的校验
- 数据库(09 章)——D1 存储文件状态和处理记录
- AI 能力(11 ~ 15 章)——Embedding 向量化、OCR、音频转文本
- 队列和工作流(17 章)——异步处理的基础设施
一篇一篇分开讲,是为了每个主题能聚焦。到了实际项目里,这些能力是同时发挥作用的。这篇流水线就是把它们重新拼到一起的那张图。
总结
这篇把前面 12 篇文件处理的内容组装成了一条完整的知识库文件流水线。几个核心设计:
- 责任链模式 — 每个处理步骤独立成类,接收上一步输出,传给下一步。新增格式或替换实现只改一个步骤,不影响整条链路
- 分阶段状态跟踪 — 文件在数据库里有明确的状态和当前阶段,前端可以查询进度或通过 SSE 接收实时推送
- 增量更新 — 通过内容哈希对比,只重新处理变化的块,避免全量重跑
- 错误分级处理 — 区分可重试和不可重试的错误,可重试的用指数退避,不可重试的直接标记失败
- 异步队列驱动 — 上传接口秒级返回,耗时的解析和向量化在后台完成
这个系列从文件上传开始,到完整流水线收尾,中间覆盖了类型校验、多种格式解析、对象存储、异步队列。单独看每一篇解决的是一个具体问题,放在一起是一套可以在知识库场景下直接复用的文件处理方案。