17.10-文档向量化任务

要点

  • 文档向量化是 RAG 系统的核心任务——把上传的文档变成可检索的向量
  • 流程:解析文档 → 切 chunk → 生成 embedding → 写入向量库
  • 每个步骤都可以异步、分阶段、可重试
  • 长文档需要分批处理——避免单次 API 调用超时
  • 向量化任务需要追踪进度——前端展示「已处理 X / Y 段」

1. 文档向量化的完整流程

用户上传一个文档(PDF、Word、Markdown),系统需要把它变成可检索的向量。完整流程:

上传文档
  ↓
[1] 解析文档 → 提取纯文本
  ↓
[2] 文本清洗 → 去除噪音
  ↓
[3] 切 chunk → 按语义切分段落
  ↓
[4] 生成 embedding → 每个 chunk 转向量
  ↓
[5] 写入向量库 → 存入向量数据库
  ↓
可以检索

每个步骤对应前面章节讲过的能力:

步骤对应文章
解析文档16.04 PDF、16.05 Word、16.06 Markdown、16.07 HTML
文本清洗17.06 任务状态设计
切 chunk14.04 文本分块
生成 embedding14.06 向量生成
写入向量库14.09 pgvector、14.10 Milvus

这篇讲的是把这些步骤串成一个异步任务,在 Queue 消费者里执行。

2. 任务定义

interface VectorizeTask {
  taskId: string
  userId: string
  knowledgeBaseId: string
  fileKey: string        // R2 存储路径
  fileName: string
  mimeType: string
}
 
type VectorizeStage =
  | 'parse'
  | 'clean'
  | 'chunk'
  | 'embed'
  | 'index'
  | 'completed'
  | 'failed'

3. 消费者实现

// src/consumers/vectorize-consumer.ts
import type { MessageBatch } from '@cloudflare/workers-types'
 
export async function vectorizeConsumer(
  batch: MessageBatch<VectorizeTask>,
  env: Env
): Promise<void> {
  for (const msg of batch.messages) {
    const task = msg.body
 
    try {
      // 更新状态为 processing
      await updateTaskStatus(env, task.taskId, 'processing', 'parse')
 
      // 从 R2 读文件
      const object = await env.BUCKET.get(task.fileKey)
      if (!object) throw new Error('File not found in R2')
 
      const buffer = await object.arrayBuffer()
 
      // 阶段一:解析
      await updateProgress(env, task.taskId, 0, 'parse')
      const rawText = await parseDocument(buffer, task.mimeType)
 
      // 阶段二:清洗
      await updateProgress(env, task.taskId, 20, 'clean')
      const cleanedText = cleanText(rawText)
 
      // 阶段三:切 chunk
      await updateProgress(env, task.taskId, 30, 'chunk')
      const chunks = chunkText(cleanedText, { chunkSize: 512, overlap: 64 })
 
      // 阶段四:生成 embedding
      await updateProgress(env, task.taskId, 40, 'embed')
      const vectors = await generateEmbeddings(chunks, env)
 
      // 阶段五:写入向量库
      await updateProgress(env, task.taskId, 80, 'index')
      await indexVectors(task.taskId, chunks, vectors, env)
 
      // 完成
      await updateTaskStatus(env, task.taskId, 'completed', null)
      await updateProgress(env, task.taskId, 100, 'completed')
 
      msg.ack()
    } catch (err) {
      const errMsg = err instanceof Error ? err.message : String(err)
      console.error(`Vectorize task ${task.taskId} failed:`, errMsg)
 
      await updateTaskStatus(env, task.taskId, 'failed', null, errMsg)
 
      const retryCount = msg.attempts - 1
      if (retryCount < 3 && isRetryable(err)) {
        msg.retry({ delaySeconds: 30 * (retryCount + 1) })
      } else {
        msg.ack()
      }
    }
  }
}

4. 文档解析

根据 MIME 类型分发到不同的解析器:

async function parseDocument(buffer: ArrayBuffer, mimeType: string): Promise<string> {
  switch (mimeType) {
    case 'application/pdf':
      return parsePdf(buffer)
    case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
      return parseDocx(buffer)
    case 'text/markdown':
      return new TextDecoder().decode(buffer)
    case 'text/html':
      return parseHtml(buffer)
    case 'image/png':
    case 'image/jpeg':
      return await performOCR(buffer)
    case 'audio/mpeg':
    case 'audio/wav':
      return await transcribeAudio(buffer)
    default:
      // 纯文本
      return new TextDecoder().decode(buffer)
  }
}

每个解析器的实现参考第 16 章的对应文章。

5. 文本清洗

function cleanText(text: string): string {
  return text
    // 移除连续空行
    .replace(/\n{3,}/g, '\n\n')
    // 移除行首行尾空白
    .split('\n')
    .map((line) => line.trim())
    .join('\n')
    // 修复 PDF 断行
    .replace(/(\w)-\n(\w)/g, '$1$2')
    // 移除页码
    .replace(/---+\s*\d+\s*---+/g, '')
    .replace(/\[\d+\]/g, '')
    // 移除控制字符
    .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
}

6. 切 chunk

interface Chunk {
  id: string
  index: number
  content: string
  metadata: {
    heading?: string
    page?: number
    startOffset: number
    endOffset: number
  }
}
 
function chunkText(
  text: string,
  opts: { chunkSize: number; overlap: number }
): Chunk[] {
  const paragraphs = text.split(/\n\n+/)
  const chunks: Chunk[] = []
  let current = ''
  let offset = 0
  let index = 0
 
  for (const para of paragraphs) {
    if (current.length + para.length > opts.chunkSize && current.length > 0) {
      // 当前 chunk 已满
      chunks.push({
        id: crypto.randomUUID(),
        index: index++,
        content: current.trim(),
        metadata: { startOffset: offset - current.length, endOffset: offset },
      })
 
      // overlap
      current = current.slice(-opts.overlap) + '\n\n' + para
    } else {
      current += (current ? '\n\n' : '') + para
    }
 
    offset += para.length + 2
  }
 
  if (current.trim()) {
    chunks.push({
      id: crypto.randomUUID(),
      index,
      content: current.trim(),
      metadata: { startOffset: offset - current.length, endOffset: offset },
    })
  }
 
  return chunks
}

7. 生成 embedding

批量生成 embedding,避免单次 API 调用超时:

async function generateEmbeddings(
  chunks: Chunk[],
  env: Env
): Promise<number[][]> {
  const BATCH_SIZE = 10  // 每次处理 10 个 chunk
  const vectors: number[][] = []
 
  for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
    const batch = chunks.slice(i, i + BATCH_SIZE)
    const texts = batch.map((c) => c.content)
 
    // Workers AI
    const result = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
      text: texts,
    })
 
    vectors.push(...result.data.map((d: any) => d.embedding ?? d))
 
    // 更新进度
    const progress = 40 + Math.round(((i + BATCH_SIZE) / chunks.length) * 40)
    // await updateProgress(env, taskId, progress, 'embed')
  }
 
  return vectors
}

批量调用的好处:减少 API 调用次数、降低延迟。坏处:单次失败整批重试。可以折中——每批 5-10 个,失败时只重试当前批。

8. 写入向量库

async function indexVectors(
  taskId: string,
  chunks: Chunk[],
  vectors: number[][],
  env: Env
): Promise<void> {
  const points = chunks.map((chunk, i) => ({
    id: chunk.id,
    vector: vectors[i],
    payload: {
      taskId,
      chunkId: chunk.id,
      content: chunk.content,
      heading: chunk.metadata.heading,
      page: chunk.metadata.page,
    },
  }))
 
  // Cloudflare Vectorize
  const BATCH_SIZE = 100
  for (let i = 0; i < points.length; i += BATCH_SIZE) {
    const batch = points.slice(i, i + BATCH_SIZE)
 
    await env.VECTORIZE.upsert(batch.map((p) => ({
      id: p.id,
      values: p.vector,
      metadata: p.payload,
    })))
  }
}

Vectorize 单次最多 100 条,分批写入。

9. 长文档处理

大文档(100+ 页 PDF、几万字)需要特殊处理:

分段解析

PDF 逐页解析,每解析 N 页更新一次进度:

async function parseLargePdf(buffer: ArrayBuffer, taskId: string, env: Env): Promise<string> {
  const totalPages = estimatePageCount(buffer)
  let text = ''
 
  for (let page = 0; page < totalPages; page++) {
    text += await parsePage(buffer, page)
 
    // 每 10 页更新进度
    if (page % 10 === 0) {
      const progress = Math.round((page / totalPages) * 20)  // 解析占 20%
      await updateProgress(env, taskId, progress, 'parse')
    }
  }
 
  return text
}

分批 embedding

大文档切出几百个 chunk,分批生成 embedding:

async function generateEmbeddingsWithProgress(
  chunks: Chunk[],
  taskId: string,
  env: Env
): Promise<number[][]> {
  const BATCH_SIZE = 10
  const vectors: number[][] = []
 
  for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
    const batch = chunks.slice(i, i + BATCH_SIZE)
    const result = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
      text: batch.map((c) => c.content),
    })
 
    vectors.push(...result.data.map((d: any) => d.embedding ?? d))
 
    // 更新进度
    const progress = 40 + Math.round(((i + BATCH_SIZE) / chunks.length) * 40)
    await updateProgress(env, taskId, progress, 'embed')
  }
 
  return vectors
}

10. 任务状态追踪

CREATE TABLE vectorize_tasks (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  knowledge_base_id TEXT NOT NULL,
  file_key TEXT NOT NULL,
  file_name TEXT NOT NULL,
  mime_type TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  stage TEXT,
  progress INTEGER NOT NULL DEFAULT 0,
  chunk_count INTEGER,
  error_message TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);
async function updateTaskStatus(
  env: Env,
  taskId: string,
  status: string,
  stage: string | null,
  errorMessage?: string
): Promise<void> {
  const updates = ['status = ?', 'updated_at = ?']
  const values: unknown[] = [status, new Date().toISOString()]
 
  if (stage !== null) {
    updates.push('stage = ?')
    values.push(stage)
  }
  if (errorMessage) {
    updates.push('error_message = ?')
    values.push(errorMessage)
  }
 
  values.push(taskId)
 
  await env.DB.prepare(`
    UPDATE tasks SET ${updates.join(', ')} WHERE id = ?
  `).bind(...values).run()
}

11. 完整流程串联

// 1. HTTP 接口接收上传
app.post('/api/knowledge-base/:kbId/documents', async (c) => {
  const kbId = c.req.param('kbId')
  const formData = await c.req.formData()
  const file = formData.get('file') as File
 
  const taskId = crypto.randomUUID()
  const fileKey = `documents/${taskId}/${file.name}`
 
  // 存 R2
  await c.env.BUCKET.put(fileKey, file.stream(), {
    httpMetadata: { contentType: file.type },
  })
 
  // 写 D1
  await c.env.DB.prepare(`
    INSERT INTO vectorize_tasks (id, user_id, knowledge_base_id, file_key, file_name, mime_type, status, created_at, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)
  `).bind(taskId, c.get('user').id, kbId, fileKey, file.name, file.type, new Date().toISOString(), new Date().toISOString()).run()
 
  // 入队
  await c.env.VECTORIZE_QUEUE.send({
    taskId,
    userId: c.get('user').id,
    knowledgeBaseId: kbId,
    fileKey,
    fileName: file.name,
    mimeType: file.type,
  })
 
  return c.json({ taskId, status: 'pending' }, 202)
})
 
// 2. Queue 消费者处理
export default {
  async queue(batch: MessageBatch<VectorizeTask>, env: Env) {
    await vectorizeConsumer(batch, env)
  },
}
 
// 3. 前端查询进度
app.get('/api/tasks/:taskId/progress', async (c) => {
  const task = await c.env.DB.prepare(`
    SELECT status, stage, progress, chunk_count, error_message
    FROM vectorize_tasks WHERE id = ?
  `).bind(c.req.param('taskId')).first()
 
  return c.json(task)
})

总结

回顾这一节的要点:

  • 文档向量化是 RAG 系统的核心任务——解析 → 清洗 → 切 chunk → embedding → 写入向量库
  • 每个步骤在 Queue 消费者里异步执行,分阶段可重试
  • 根据 MIME 类型分发到不同解析器——PDF、Word、Markdown、HTML、OCR、音频
  • 长文档需要分段处理——逐页解析、分批 embedding,避免超时
  • 进度追踪:KV 存实时进度,D1 存最终状态,前端轮询或 SSE
  • 批量 embedding 减少 API 调用,但失败时整批重试
  • 写入向量库分批进行——Vectorize 单次最多 100 条

下一篇讲批量 AI 生成任务——批量调 LLM 生成内容。