Skip to content

14.18-RAG接口设计

要点

  • RAG 接口覆盖三个核心流程:文档管理(上传/删除/状态查询)、知识库检索、对话集成
  • 文档上传是异步流程——接口返回 202,前端轮询状态
  • 检索接口暴露 topK、阈值、过滤等参数,但控制默认行为避免前端过度配置
  • 对话接口整合检索 + 上下文拼接 + LLM 生成 + 引用提取,关键在上下文窗口预算分配
  • 流式响应先返回检索来源,再流式生成回答——用户体验显著提升
  • 错误处理和降级策略在接口层统一处理:不同组件故障对应不同降级路径

1. 接口设计要解决的三个冲突

前 17 讲逐一拆解了 RAG 的每个组件——索引、检索、查询、评估。现在要把它们拼成一个对外可用的 API。

接口设计的核心难点不是写 CRUD,而是在三组冲突中找到适合你的平衡点:

检索 vs 对话,一个端点还是两个? 合并成一个看起来简洁,但「只检索不生成」是高频需求——前端展示引用来源、调试检索质量、让用户选择上下文,都只需要检索结果。拆成两个端点,各自职责清楚,也便于独立演进。

同步 vs 流式。 检索结果通常在 1-3 秒内返回,同步 JSON 足够。对话接口涉及 LLM 生成,可能需要 5-30 秒,纯同步会让用户面对一个漫长的加载状态。流式响应(SSE)让用户先看到检索到的来源,再逐字看到回答生成——体验差距明显,但实现成本也更高。

异步文档处理:轮询还是推送? 文档上传后要经历解析、切分、向量化,可能需要几十秒到几分钟。WebSocket 推送看似实时,但对这种低频长时任务来说,连接管理成本远超收益。轮询状态接口是更务实的选择

这三个选择没有标准答案,取决于你的用户规模和前端复杂度。本章的实现按「检索和对话分离、对话同时提供同步和流式、文档处理用 202 + 轮询」来组织,你可以根据实际需求调整。

1.1 接口全景

文档管理:
  POST   /api/documents              上传文档(异步处理)
  GET    /api/documents              列出文档(分页)
  GET    /api/documents/:id          文档详情
  DELETE /api/documents/:id          删除文档
  GET    /api/documents/:id/status   处理状态查询

检索:
  POST   /api/rag/search             纯检索(返回 chunk,不生成)

对话:
  POST   /api/rag/chat               同步对话(检索 + 生成)
  POST   /api/rag/chat/stream        流式对话(SSE)

知识库管理:
  POST   /api/knowledge-bases        创建知识库
  GET    /api/knowledge-bases        列出知识库
  DELETE /api/knowledge-bases/:id    删除知识库

1.2 RAG 请求链路

每个 RAG 请求经历的链路比普通 API 长得多——认证、权限、输入校验、Embedding、向量检索、Rerank、上下文拼接、LLM 生成、引用提取。每个环节都可能超时或失败。

接口设计的本质是为这条链路上的每个环节设计延迟策略、失败策略和降级策略。

2. 文档管理接口

文档是 RAG 系统的输入,但上传 ≠ 可用。文档要经过解析、切分、向量化才能被检索到,整个过程可能需要几十秒甚至几分钟。这意味着文档接口必须设计成异步的。

2.1 上传接口:202 + 异步队列

typescript
// src/routes/documents.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

const documentsApp = new Hono()

const uploadSchema = z.object({
  title: z.string().min(1).max(256),
  knowledgeBaseId: z.string().uuid(),
  requestId: z.string().uuid().optional(),  // 幂等键
})

documentsApp.post('/', zValidator('form', uploadSchema), async (c) => {
  const user = c.get('user')
  const { title, knowledgeBaseId, requestId } = c.req.valid('form')
  const file = c.req.valid('form').file

  if (!file) {
    return c.json({ error: 'No file provided' }, 400)
  }

  // 幂等检查:同一 requestId 直接返回已有结果
  if (requestId) {
    const existing = await documentService.getByRequestId(requestId)
    if (existing) {
      return c.json({
        id: existing.id,
        status: existing.status,
        message: '文档已上传(幂等返回)',
      }, 202)
    }
  }

  // 1. 验证知识库权限
  const kb = await knowledgeBaseService.getById(knowledgeBaseId)
  if (!kb || kb.tenantId !== user.tenantId) {
    return c.json({ error: 'Knowledge base not found' }, 404)
  }
  if (!hasPermission(user, kb, 'write')) {
    return c.json({ error: 'Permission denied' }, 403)
  }

  // 2. 保存文件到对象存储
  const fileKey = `docs/${user.tenantId}/${Date.now()}-${file.name}`
  await objectStorage.put(fileKey, await file.arrayBuffer())

  // 3. 创建文档记录(状态:pending)
  const document = await documentService.create({
    title,
    knowledgeBaseId,
    fileKey,
    fileName: file.name,
    fileSize: file.size,
    mimeType: file.type,
    status: 'pending',
    requestId,
    userId: user.id,
    tenantId: user.tenantId,
  })

  // 4. 推入处理队列(异步)
  await queue.publish('document.process', {
    documentId: document.id,
    fileKey,
    mimeType: file.type,
  })

  return c.json({
    id: document.id,
    status: document.status,
    message: '文档已上传,正在处理',
  }, 202)
})

这里有个容易忽略的点:幂等性。网络抖动、用户重复点击都可能导致重复上传。给前端一个 requestId 字段,服务端用它做去重——相同的 requestId 直接返回已有记录,而不是创建新文档。

2.2 状态查询接口

前端轮询文档处理进度。状态查询是只读的,适合高频轮询。

typescript
documentsApp.get('/:id/status', async (c) => {
  const user = c.get('user')
  const { id } = c.req.param()

  const document = await documentService.getById(id)
  if (!document || document.tenantId !== user.tenantId) {
    return c.json({ error: 'Not found' }, 404)
  }

  return c.json({
    id: document.id,
    status: document.status,       // pending | processing | completed | failed
    progress: document.progress,   // 0-100
    error: document.error,         // 失败时的错误信息
    chunks: document.chunkCount,   // 处理完成后的 chunk 数量
    createdAt: document.createdAt,
    updatedAt: document.updatedAt,
  })
})

文档处理的完整状态机:

pending → processing → completed
                    ↘ failed → processing (重试)

重试策略:处理失败后不自动重试,由前端触发。原因很清楚——有些失败是内容问题(不支持的格式、文件损坏),自动重试只会反复失败。建议前端限制最多重试 3 次,间隔递增(5s → 15s → 45s)。

2.3 删除接口

删除文档需要清理三个地方:向量数据、原始文件、文档记录。

typescript
documentsApp.delete('/:id', async (c) => {
  const user = c.get('user')
  const { id } = c.req.param()

  const document = await documentService.getById(id)
  if (!document || document.tenantId !== user.tenantId) {
    return c.json({ error: 'Not found' }, 404)
  }
  if (!hasPermission(user, document, 'delete')) {
    return c.json({ error: 'Permission denied' }, 403)
  }

  // 三步清理,每步独立容错
  const errors: string[] = []

  try {
    await vectorStore.deleteDocument(document.knowledgeBaseId, id)
  } catch (e) {
    errors.push('Vector data cleanup failed')
  }

  try {
    await objectStorage.delete(document.fileKey)
  } catch (e) {
    errors.push('File cleanup failed')
  }

  await documentService.updateStatus(id, 'deleted')

  return c.json({
    success: errors.length === 0,
    warnings: errors,
  })
})

三步清理不放在一个事务里——向量数据库和对象存储通常不支持跨系统事务。每步独立容错,部分失败时返回警告,由运维或定时任务兜底清理残留数据。

3. 检索接口

检索接口只返回相关 chunk——不生成回答。这个接口用于前端展示引用来源、调试检索质量,或让用户在对话前选择上下文范围。

设计检索接口的关键决策是:暴露多少参数? 参数太少,前端无法针对不同场景调整;参数太多,接口变得复杂且难以维护。折中方案是暴露核心参数(topK、阈值、过滤),其余用合理默认值。

3.1 检索参数设计

typescript
const ragApp = new Hono()

const searchSchema = z.object({
  query: z.string().min(1).max(2000),
  knowledgeBaseIds: z.array(z.string()).optional(),
  topK: z.number().int().min(1).max(50).optional().default(5),
  threshold: z.number().min(0).max(1).optional().default(0.7),
  enableRerank: z.boolean().optional().default(false),
  filters: z.object({
    documentIds: z.array(z.string()).optional(),
    dateRange: z.object({
      start: z.string().datetime().optional(),
      end: z.string().datetime().optional(),
    }).optional(),
  }).optional(),
})

enableRerank 默认 false 是有意的。Rerank 增加 200-500ms 延迟,对调试场景来说太慢;对话场景下再默认开启。

3.2 检索实现

typescript
ragApp.post('/search', zValidator('json', searchSchema), async (c) => {
  const user = c.get('user')
  const params = c.req.valid('json')

  // 1. 查询 embedding
  const queryVector = await embeddingService.embed(params.query)

  // 2. 构建过滤条件
  const filter = buildSearchFilter(user, params)

  // 3. 向量检索
  // 启用 rerank 时多召回,让 reranker 有足够候选
  let results = await vectorStore.search(
    params.knowledgeBaseIds ?? user.accessibleKnowledgeBases,
    queryVector,
    {
      topK: params.enableRerank ? params.topK * 5 : params.topK,
      filter,
      threshold: params.threshold,
    }
  )

  // 4. Rerank
  if (params.enableRerank) {
    results = await rerankService.rerank(params.query, results, {
      topK: params.topK,
    })
  }

  // 5. 格式化返回
  return c.json({
    results: results.map((r) => ({
      id: r.id,
      content: r.content,
      score: r.score,
      metadata: {
        documentId: r.metadata.documentId,
        documentTitle: r.metadata.documentTitle,
        sectionTitle: r.metadata.sectionTitle,
        chunkIndex: r.metadata.chunkIndex,
      },
    })),
    total: results.length,
  })
})

3.3 过滤条件构建

过滤条件的第一优先级永远是租户隔离——SaaS 场景下不能跨租户检索。

typescript
function buildSearchFilter(
  user: User,
  params: z.infer<typeof searchSchema>
): Filter {
  const conditions: FilterCondition[] = [
    // 租户隔离——不可覆盖
    { key: 'tenant_id', match: { value: user.tenantId } },
  ]

  // 知识库范围
  if (params.knowledgeBaseIds?.length) {
    conditions.push({
      key: 'knowledge_base_id',
      match: { any: params.knowledgeBaseIds },
    })
  } else {
    conditions.push({
      key: 'knowledge_base_id',
      match: { any: user.accessibleKnowledgeBases },
    })
  }

  // 文档过滤
  if (params.filters?.documentIds?.length) {
    conditions.push({
      key: 'document_id',
      match: { any: params.filters.documentIds },
    })
  }

  // 时间范围
  if (params.filters?.dateRange?.start) {
    conditions.push({
      key: 'created_at',
      range: { gte: params.filters.dateRange.start },
    })
  }
  if (params.filters?.dateRange?.end) {
    conditions.push({
      key: 'created_at',
      range: { lte: params.filters.dateRange.end },
    })
  }

  return { must: conditions }
}

这个 buildSearchFilter 函数在检索接口和对话接口中都会用到。提取成公共函数避免重复实现,同时确保过滤逻辑一致。

4. 对话接口

对话接口把检索和生成整合在一起。它比检索接口复杂得多,因为涉及一个检索接口不需要考虑的问题:上下文窗口预算分配

4.1 上下文窗口预算

这是 RAG 对话最容易踩的坑。模型的上下文窗口是有限的——GPT-4o 是 128K token,但大多数场景用不到这么多。问题不在于窗口够不够大,而在于如何把有限的空间分配给系统 Prompt、检索到的上下文、对话历史和用户输入。

一个常见的错误:把 topK 设为 10,每个 chunk 平均 500 token,加上系统 Prompt 和历史,轻松超出上下文限制。

typescript
// 上下文预算分配策略
function calculateContextBudget(maxTokens: number) {
  // 系统 Prompt + 用户输入:固定预留
  const systemAndUserTokens = 1500

  // 检索上下文:占比 55%(可调)
  const contextTokens = Math.floor(maxTokens * 0.55)

  // 历史对话:剩余空间
  const historyTokens = maxTokens - systemAndUserTokens - contextTokens

  return { systemAndUserTokens, contextTokens, historyTokens }
}

经验值:检索上下文占 55%、历史占 25%、系统 Prompt 和用户输入占 20%。这个比例适用于大多数 FAQ 和知识查询场景。如果你的场景涉及长文档分析,可以提高上下文占比到 70%,相应压缩历史。

4.2 对话实现

typescript
const chatSchema = z.object({
  message: z.string().min(1).max(5000),
  knowledgeBaseIds: z.array(z.string()).optional(),
  topK: z.number().int().min(1).max(20).optional().default(5),
  enableRerank: z.boolean().optional().default(true),  // 对话默认开启 rerank
  history: z.array(z.object({
    role: z.enum(['user', 'assistant']),
    content: z.string(),
  })).optional().default([]),
})

ragApp.post('/chat', zValidator('json', chatSchema), async (c) => {
  const user = c.get('user')
  const params = c.req.valid('json')

  // 1. 计算上下文预算
  const budget = calculateContextBudget(8000)  // 以 8K token 为例

  // 2. 检索上下文
  const queryVector = await embeddingService.embed(params.message)
  const filter = buildSearchFilter(user, params)

  let chunks = await vectorStore.search(
    params.knowledgeBaseIds ?? user.accessibleKnowledgeBases,
    queryVector,
    { topK: params.enableRerank ? params.topK * 5 : params.topK, filter }
  )

  if (params.enableRerank) {
    chunks = await rerankService.rerank(params.message, chunks, {
      topK: params.topK,
    })
  }

  // 3. 按预算截断上下文
  const context = contextBuilder.build(chunks, {
    maxTokens: budget.contextTokens,
  })

  // 4. 按预算截断历史
  const truncatedHistory = truncateHistory(
    params.history,
    budget.historyTokens
  )

  // 5. 组装消息
  const messages: Message[] = [
    { role: 'system', content: buildSystemPrompt(context) },
    ...truncatedHistory,
    { role: 'user', content: params.message },
  ]

  // 6. 调用 LLM
  const response = await llm.chat({ messages, temperature: 0.3 })

  // 7. 提取引用
  const { cleanAnswer, citations } = extractCitations(response.content)
  const references = buildReferences(citations, chunks)

  return c.json({
    answer: cleanAnswer,
    citations,
    references,
    sources: chunks.map((c) => ({
      id: c.id,
      documentTitle: c.metadata.documentTitle,
      sectionTitle: c.metadata.sectionTitle,
    })),
  })
})

注意对话接口的 enableRerank 默认是 true——和检索接口相反。对话场景对回答质量要求更高,用户愿意多等 200-500ms 换来更好的排序。

4.3 历史截断

对话历史不能无限保留。简单的截断策略:从最近的对话开始往前保留,直到超出 token 预算。

typescript
function truncateHistory(
  history: { role: string; content: string }[],
  maxTokens: number
): Message[] {
  const result: Message[] = []
  let usedTokens = 0

  // 从最近的消息开始,往前保留
  for (let i = history.length - 1; i >= 0; i--) {
    const msg = history[i]
    const tokens = estimateTokens(msg.content)

    if (usedTokens + tokens > maxTokens) break

    result.unshift({
      role: msg.role as 'user' | 'assistant',
      content: msg.content,
    })
    usedTokens += tokens
  }

  return result
}

这个策略有一个隐含特性:它优先保留最近的对话。对于多轮追问场景足够用。如果你的场景需要更长的上下文记忆,可以考虑摘要压缩——把早期对话总结成一段摘要塞进系统 Prompt。

4.4 系统 Prompt 设计

系统 Prompt 的质量直接影响回答质量。核心原则:明确约束模型行为,而不是泛泛地说「请好好回答」

typescript
function buildSystemPrompt(context: string): string {
  return `你是一个知识库助手。请根据以下参考资料回答用户的问题。

## 回答要求

1. 只基于参考资料回答,不要编造信息
2. 在每个事实陈述后面用 [编号] 标注来源
3. 如果参考资料中没有相关信息,明确说明「根据已有资料无法回答」
4. 保持回答简洁、准确

## 参考资料

${context}`
}

第三条是关键:当知识库没有答案时,要求模型明确说出来而不是编造。这能有效减少幻觉,但代价是回答率会降低——用户可能得到「无法回答」。这是质量 vs 覆盖率的权衡,根据你的业务场景调整。

5. 流式对话接口

流式响应让用户体验显著提升——不再等待 5-30 秒的空白加载,而是先看到检索到的来源,再逐字看到回答生成。

实现流式响应的关键是选择合适的协议。SSE(Server-Sent Events)是最务实的选择:基于 HTTP、单向推送、浏览器原生支持。WebSocket 支持双向通信,但对 RAG 对话来说,用户发送消息是低频操作,不需要双向通道。

5.1 SSE 协议设计

流式响应使用 SSE 格式,包含三种事件类型:

data: {"type":"sources","sources":[...]}    ← 先发送检索来源
data: {"type":"token","content":"根据"}      ← 逐 token 发送回答
data: {"type":"token","content":"知识库"}
data: {"type":"citations","citations":[...]} ← 最后发送引用
data: [DONE]                                 ← 结束标记

先发送 sources 的价值:用户在前 1-3 秒就能看到检索到了哪些文档,而不是盯着一个空白页面等回答生成完。

5.2 流式实现

typescript
ragApp.post('/chat/stream', zValidator('json', chatSchema), async (c) => {
  const user = c.get('user')
  const params = c.req.valid('json')

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder()

      const send = (data: unknown) => {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
        )
      }

      try {
        // 1. 检索
        const queryVector = await embeddingService.embed(params.message)
        const filter = buildSearchFilter(user, params)
        let chunks = await vectorStore.search(
          params.knowledgeBaseIds ?? user.accessibleKnowledgeBases,
          queryVector,
          { topK: params.topK * 3, filter }
        )

        if (params.enableRerank) {
          chunks = await rerankService.rerank(params.message, chunks, {
            topK: params.topK,
          })
        }

        // 2. 先发送来源信息
        send({
          type: 'sources',
          sources: chunks.map((c) => ({
            id: c.id,
            documentTitle: c.metadata.documentTitle,
            sectionTitle: c.metadata.sectionTitle,
            score: c.score,
          })),
        })

        // 3. 构建上下文和消息
        const context = contextBuilder.build(chunks)
        const messages: Message[] = [
          { role: 'system', content: buildSystemPrompt(context) },
          ...params.history.map((h) => ({
            role: h.role as 'user' | 'assistant',
            content: h.content,
          })),
          { role: 'user', content: params.message },
        ]

        // 4. 流式调用 LLM
        const llmStream = await llm.stream({ messages, temperature: 0.3 })

        let fullAnswer = ''
        for await (const chunk of llmStream) {
          const text = chunk.content ?? ''
          fullAnswer += text
          send({ type: 'token', content: text })
        }

        // 5. 发送引用信息
        const { citations } = extractCitations(fullAnswer)
        send({ type: 'citations', citations })

        // 6. 结束
        controller.enqueue(encoder.encode('data: [DONE]\n\n'))
        controller.close()
      } catch (err) {
        send({ type: 'error', error: String(err) })
        controller.close()
      }
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
})

5.3 前端消费流式响应

SSE 的标准消费方式是通过 fetch + ReadableStream。注意 POST 请求不能用浏览器原生的 EventSource(只支持 GET),需要用 fetch 手动解析。

typescript
const response = await fetch('/api/rag/chat/stream', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message: '退款多久到账?' }),
})

const reader = response.body!.getReader()
const decoder = new TextDecoder()

while (true) {
  const { done, value } = await reader.read()
  if (done) break

  const text = decoder.decode(value)
  const lines = text.split('\n').filter((l) => l.startsWith('data: '))

  for (const line of lines) {
    const data = line.replace('data: ', '')
    if (data === '[DONE]') continue

    const event = JSON.parse(data)

    switch (event.type) {
      case 'sources':
        renderSources(event.sources)
        break
      case 'token':
        appendAnswer(event.content)
        break
      case 'citations':
        renderCitations(event.citations)
        break
      case 'error':
        showError(event.error)
        break
    }
  }
}

一个实际开发中需要注意的点:客户端可能在回答生成过程中关闭页面或取消请求。服务端应该在 send() 中检测连接状态,如果客户端已断开就提前终止 LLM 调用,避免浪费计算资源。

6. 错误处理和降级策略

RAG 接口比普通 API 多了一层复杂度:链路涉及四个外部服务——Embedding、向量数据库、Rerank、LLM,每个都可能超时或故障。降级策略不是笼统的「返回 500」,而是根据故障组件选择不同的降级路径。

6.1 降级路径设计

Embedding 故障 → 无法向量化查询 → 完全无法检索 → 返回 503
向量数据库故障 → 无法检索 → 返回友好提示(200),不生成回答
Rerank 故障 → 跳过 rerank,用原始向量检索结果继续
LLM 故障 → 检索成功但无法生成 → 返回检索结果,answer 为 null

为什么向量数据库故障返回 200 而不是 500? 因为前端可以展示友好提示(「知识库暂时不可用」),而不是触发全局错误处理。对用户体验来说,一个带解释的空结果比一个红色错误弹窗好得多。

6.2 超时控制

RAG 链路上的每个外部调用都需要超时。不设超时意味着一个慢服务可能拖垮整个请求。

typescript
// 通用超时包装:超时后返回降级值而非抛异常
async function withTimeout<T>(
  fn: () => Promise<T>,
  timeoutMs: number,
  fallback: T
): Promise<T> {
  return Promise.race([
    fn(),
    new Promise<T>((resolve) =>
      setTimeout(() => resolve(fallback), timeoutMs)
    ),
  ])
}

// 使用示例
const chunks = await withTimeout(
  () => vectorStore.search(queryVector, { topK: 20 }),
  3000,   // 3 秒超时
  []      // 超时返回空数组
)

各组件的推荐超时值(经验值,根据你的基础设施调整):

组件推荐超时超时后行为
Embedding2s返回 503
向量检索3s返回空结果
Rerank2s跳过,用原始结果
LLM 首 token10s返回超时错误
LLM 完整响应60s中断,返回已生成部分

6.3 统一错误中间件

typescript
// src/middleware/rag-error-handler.ts
import { createMiddleware } from 'hono/factory'

export const ragErrorHandler = createMiddleware(async (c, next) => {
  try {
    await next()
  } catch (err) {
    const error = err as Error

    // 向量数据库不可用 → 返回友好提示
    if (isVectorDBError(error)) {
      return c.json({
        answer: '抱歉,知识库暂时不可用,请稍后重试。',
        sources: [],
        error: 'Knowledge base temporarily unavailable',
      }, 200)
    }

    // LLM 不可用 → 只返回检索结果
    if (isLLMError(error)) {
      return c.json({
        answer: null,
        sources: c.get('ragResults') ?? [],
        error: 'Answer generation temporarily unavailable',
      }, 200)
    }

    // Embedding 服务不可用 → 503
    if (isEmbeddingError(error)) {
      return c.json({ error: 'Embedding service unavailable' }, 503)
    }

    // 其他错误 → 500
    return c.json({ error: 'Internal server error' }, 500)
  }
})

这个中间件覆盖三种最常见的故障场景。生产环境还需要补充:对错误分类统计(哪个组件故障率最高)、告警通知(连续失败 N 次触发告警)、以及限流保护(某个用户的异常请求不应影响其他用户)。

7. 接口聚合与生产化

7.1 路由挂载

把所有 RAG 相关的接口注册到主应用。中间件的顺序很重要——认证最先,权限其次,错误处理最后(兜底所有上游错误)。

typescript
// src/app.ts
import { Hono } from 'hono'
import { ragPermissionMiddleware } from './middleware/rag-permission'
import { ragErrorHandler } from './middleware/rag-error-handler'
import { documentsApp } from './routes/documents'
import { ragApp } from './routes/rag'

const app = new Hono()

// 全局中间件(按顺序执行)
app.use('/api/*', authMiddleware)
app.use('/api/rag/*', ragPermissionMiddleware)
app.use('/api/rag/*', ragErrorHandler)

// 路由挂载
app.route('/api/documents', documentsApp)
app.route('/api/rag', ragApp)

export default app

7.2 示例可用 vs 生产可用

本章的代码是示例可用——核心逻辑完整,但生产部署还需要补充以下能力:

生产化需求说明优先级
限流上传和搜索接口需要限流,防止滥用
结构化日志每个 RAG 链路的耗时、token 消耗、失败原因
缓存高频相同查询缓存检索结果(注意缓存失效策略)
熔断器外部服务连续失败时快速失败,不反复等待超时
请求取消客户端断开后及时中止 LLM 调用
审计日志谁在什么时候搜了什么、得到了什么结果

限流的实现取决于你的部署环境。Cloudflare Workers 用 KV 或 Durable Objects 做滑动窗口;Node.js 环境用 rate-limiter-flexible。关键原则是区分接口——上传接口按次数限,搜索接口按并发限,对话接口按 token 消耗限。


到此,RAG 的接口蓝图已经完整:五类端点、同步与流式并存、异步文档处理、上下文预算分配、四级降级策略。

这些设计决策不是唯一的,但每一个都对应了具体的冲突和权衡。你可以根据自己的业务场景调整参数默认值、降级策略和上下文预算比例。

下一章用 Cloudflare Vectorize + Workers AI 把这套接口落地成一个最小可运行的 RAG 系统——从设计到实现,走完最后一步。

基于 MIT 协议开源