RAG 后端接口设计:为什么 ingest 要异步,query 要同步?
做 RAG 后端时,很容易把接口设计想得太简单:
一个接口负责导入文档,一个接口负责提问回答。
看起来没问题,但真正落到工程里,ingest 和 query 其实是两类完全不同的 API。
ingest 面对的是长任务。它要扫描文件、解析 Markdown、切 chunk、生成 embedding、写入索引或数据库。这个过程可能很慢,也可能部分失败。
query 面对的是一次即时回答。用户发出问题后,系统要完成检索、构建上下文、生成答案,并返回引用证据。
所以这篇文章想讲清楚一个核心判断:
ingest API 管后台任务生命周期,query API 管本次回答的可信结果。
如果把这两个接口都当成普通同步接口来写,后面很容易遇到超时、重复导入、错误码混乱、无证据乱答等问题。
1. 常见误区:让 ingest 接口一直等到导入完成
很多人第一版会把导入接口设计成这样:
http
POST /api/ingest
然后希望它做完整件事:
- 接收文档路径或上传文件;
- 扫描文件;
- 解析 Markdown;
- 切分 chunk;
- 生成 embedding;
- 写入数据库或向量索引;
- 返回导入成功。
这个设计在 demo 里可能能跑,但一旦文档多一点,就会暴露问题。
第一,HTTP 请求会超时。客户端、网关、后端服务、反向代理都可能有超时限制。
第二,客户端无法知道任务进度。它不知道系统卡在扫描、解析、embedding,还是写库。
第三,失败很难表达。如果 100 个文件里 95 个成功、5 个失败,单纯返回一个 HTTP 200 或 500 都不够准确。
第四,重试会变危险。如果客户端没收到响应,它不知道后台任务是否已经创建成功。再次请求可能会制造一个重复导入任务。
所以,ingest 不适合设计成"请求一直挂着,直到全部导入完成"。
它更适合设计成异步 job。
2. ingest API:先创建 job,再查询状态
一个更稳的设计是:
http
POST /api/ingest/jobs
这个接口只负责一件事:
接收导入请求,创建后台任务,返回 jobId。
比如请求:
json
{
"projectId": "agent-runtime",
"sourceType": "markdown_directory",
"sourcePath": "docs/"
}
响应:
http
202 Accepted
json
{
"jobId": "ingest_20260523_001",
"status": "accepted"
}
这里用 202 Accepted 比 200 OK 更准确。
它表达的是:请求已经被接受,但后台处理还没有完成。
然后再提供一个状态查询接口:
http
GET /api/ingest/jobs/{jobId}
返回:
json
{
"jobId": "ingest_20260523_001",
"status": "running",
"stage": "embedding",
"totalFiles": 120,
"processedFiles": 76,
"failedFiles": 2
}
这样,导入任务就从一次 HTTP 请求,变成了一个可追踪的生命周期。
3. job 状态比 HTTP 状态更适合表达后台进度
导入任务可以有一组明确的状态:
| 状态 | 含义 |
|---|---|
accepted |
请求已接收,任务已创建 |
scanning |
正在扫描文件 |
parsing |
正在解析文档 |
embedding |
正在生成向量 |
storing |
正在写入索引或数据库 |
completed |
全部完成 |
partial_failed |
部分文件失败 |
failed |
任务整体失败 |
cancelled |
任务被取消 |
这样做的好处是:HTTP 状态码只表达"这次接口调用是否正常",job 状态表达"后台任务执行到哪里了"。
这两个层次不要混在一起。
比如:
| 场景 | 推荐表达 |
|---|---|
请求缺少 sourcePath |
POST /api/ingest/jobs 返回 400 Bad Request |
| 用户没有项目权限 | POST /api/ingest/jobs 返回 403 Forbidden |
| 任务创建成功 | POST /api/ingest/jobs 返回 202 Accepted |
| 后台 embedding 服务挂了 | job 状态变成 failed,错误记录在 job 里 |
| 部分 Markdown 文件解析失败 | job 状态变成 partial_failed |
也就是说,后台执行阶段失败,不应该倒过来让最初的 POST /api/ingest/jobs 返回 500。
因为最初的接口调用已经完成了它的职责:创建任务。
4. 超时不等于任务失败
ingest 设计里最容易出错的是重试。
假设客户端调用:
http
POST /api/ingest/jobs
但是因为网络抖动或网关超时,客户端没有拿到响应。
这时客户端不能简单认为:
请求失败了,所以我再创建一个新任务。
因为后台可能已经成功创建了 job,只是响应没有传回客户端。
如果客户端直接重试,就可能创建两个导入任务:
ingest_001ingest_002
它们处理同一批文档,浪费资源,还可能造成重复 chunk、重复索引或版本冲突。
所以 ingest API 需要幂等设计。
5. 用 Idempotency-Key 避免重复导入
客户端创建导入任务时,可以带一个幂等键:
http
POST /api/ingest/jobs
Idempotency-Key: agent-runtime-docs-20260523-v1
服务端逻辑可以是:
- 如果这个 key 第一次出现,创建新的 job;
- 如果这个 key 已经出现过,返回之前创建的 job;
- 如果同一个 key 对应的请求参数不同,返回冲突错误。
比如第二次请求时,服务端可以返回:
json
{
"jobId": "ingest_20260523_001",
"status": "running"
}
而不是再创建一个新的 job。
这里的关键是:
超时不等于后台任务失败,重试不应该制造重复任务。
对 RAG 导入来说,幂等不是锦上添花,而是基础能力。
6. 后台失败应该记录在 job 里
假设导入任务执行到 embedding 阶段时,embedding 服务暂时不可用。
这时不应该让最初的 POST /api/ingest/jobs 返回 500,因为那个请求早就结束了。
更合理的是让状态查询接口返回:
json
{
"jobId": "ingest_20260523_001",
"status": "failed",
"failedStage": "embedding",
"errorCode": "EMBEDDING_SERVICE_UNAVAILABLE",
"message": "Embedding service is temporarily unavailable.",
"retryable": true
}
这里的 retryable 很重要。
它告诉客户端或调度系统:这个失败是否适合重试。
比如:
| 失败原因 | 是否适合重试 |
|---|---|
| embedding 服务暂时不可用 | 是 |
| 数据库临时连接失败 | 是 |
| 用户没有权限 | 否 |
| 文件格式不支持 | 否 |
| 请求参数缺失 | 否 |
这样,系统才能区分"临时故障"和"请求本身有问题"。
7. query API 为什么通常适合同步返回
和 ingest 不同,query 通常是用户正在等待的一次回答。
一个典型接口可以是:
http
POST /api/query
请求:
json
{
"projectId": "agent-runtime",
"question": "ingest API 为什么要异步?",
"topK": 5,
"filters": {
"sourceType": "docs"
}
}
响应:
json
{
"status": "success",
"answer": "ingest API 适合异步设计,因为导入包含扫描、解析、embedding 和写库等长任务,需要用 jobId 管理进度、失败和重试。",
"citations": [
{
"sourcePath": "docs/rag-api-design.md",
"heading": "ingest API",
"chunkId": "chunk_001"
}
]
}
这个接口通常要在一次请求里完成:
- 根据问题生成 query embedding;
- 使用 metadata filter 限定范围;
- 检索 top-k chunks;
- 构建上下文;
- 调用模型生成回答;
- 返回 answer 和 citations。
它是同步的,不是因为它没有内部步骤,而是因为用户需要的是"这次问题的结果"。
8. query 的核心不是"尽量回答",而是"可信回答"
RAG 的 query API 有一个底线:
没有证据时,不要装作知道。
如果没有检索到足够相关的 chunk,接口不应该编一个看起来合理的答案。
更合理的返回是:
json
{
"status": "insufficient_evidence",
"answer": null,
"citations": [],
"reason": "No retrieved chunks met the relevance threshold."
}
这里要注意:insufficient_evidence 不应该直接返回 HTTP 500。
因为查询过程本身是正常完成的,只是业务结果是"没有足够证据"。
HTTP 500 表示服务内部错误,比如程序异常、依赖服务异常、数据库异常。
而没有检索到证据,属于正常业务结果。
所以更合理的是:
http
200 OK
配合:
json
{
"status": "insufficient_evidence",
"answer": null,
"citations": []
}
这样客户端可以提示用户:
- 换一个问题;
- 放宽过滤条件;
- 先导入更多资料;
- 检查文档是否已经完成索引。
9. query 的几类典型结果
query API 不应该只有成功和失败两种结果。
更实用的设计是至少区分三类:
| 状态 | 含义 |
|---|---|
success |
找到足够证据,并生成完整答案 |
partial_answer |
有部分证据,只能回答一部分 |
insufficient_evidence |
没有足够证据,不生成最终答案 |
比如部分回答可以是:
json
{
"status": "partial_answer",
"answer": "目前只能确认 ingest API 适合用 jobId 管理后台任务,但没有找到关于重试策略的完整说明。",
"citations": [
{
"sourcePath": "docs/rag-api-design.md",
"heading": "ingest API",
"chunkId": "chunk_003"
}
]
}
这里最重要的是:
answer 不能比 citations 支持的内容走得更远。
这也是 RAG 和普通聊天接口的区别之一。
RAG 的回答不只是"语言上通顺",还要能被证据支撑。
10. query 超时怎么处理
query 也可能超时,但它的处理要看 API 合同。
如果接口承诺必须返回最终答案,那么 LLM 生成阶段超时,可以返回:
http
504 Gateway Timeout
因为系统没有完成本次 query 的最终目标。
但如果产品允许降级,也可以返回 evidence-only 结果:
json
{
"status": "only_evidence",
"answer": null,
"citations": [
{
"sourcePath": "docs/rag-api-design.md",
"heading": "query API",
"chunkId": "chunk_008"
}
],
"reason": "Generation timed out after retrieval succeeded."
}
这两种设计没有绝对对错,关键是接口合同要清楚。
如果调用方期待的是 answer,那么超时就是失败。
如果调用方可以接受"先给证据,稍后再生成",那么降级结果就是一种可用设计。
11. 三层状态不要混在一起
RAG 后端接口设计里,最重要的是分清三层状态:
| 层次 | 负责表达什么 | 例子 |
|---|---|---|
| HTTP 状态码 | 请求/服务层是否正常 | 200、202、400、403、500、503、504 |
| job 状态 | 后台任务生命周期 | accepted、running、completed、failed |
| 业务结果 | 本次回答是否有证据、是否完整 | success、partial_answer、insufficient_evidence |
几个典型判断:
| 场景 | 推荐设计 |
|---|---|
| 创建导入任务成功 | 202 Accepted + jobId |
| 导入任务后台失败 | GET job 返回 failed |
| 客户端重复提交导入 | 用 Idempotency-Key 返回同一个 job |
| query 没有证据 | 200 OK + insufficient_evidence |
| query 服务内部异常 | 500 Internal Server Error |
| query 依赖服务暂时不可用 | 503 Service Unavailable |
| query 生成阶段超时且不能降级 | 504 Gateway Timeout |
如果这三层混在一起,接口会越来越难维护。
比如把"没有证据"返回成 500,调用方就会误以为服务挂了。
把"后台任务失败"塞回创建接口的 HTTP 状态码里,又会让异步任务的生命周期变得不可追踪。
12. 一个可复用的设计 checklist
设计 RAG 后端 API 时,可以用下面这组问题检查:
ingest API
- 这个接口是否只负责创建导入任务,而不是同步完成全部导入?
- 是否返回
jobId? - 是否有
GET /api/ingest/jobs/{jobId}查询状态? - job 状态是否能表达
accepted、running、completed、failed、partial_failed? - 是否记录
failedStage、errorCode、retryable? - 是否支持
Idempotency-Key,避免重复导入? - 是否能区分请求参数错误和后台执行失败?
query API
- 返回里是否包含
answer和citations? - 没有证据时,是否返回
insufficient_evidence,而不是编答案? insufficient_evidence是否被当作业务结果,而不是 HTTP 500?- 是否区分
success、partial_answer、insufficient_evidence? - 如果 LLM 超时,接口合同是返回 504,还是允许
only_evidence降级? - answer 是否只表达 citations 能支持的内容?
总结
ingest 和 query 看起来都是 RAG 系统的接口,但它们解决的问题完全不同。
ingest API 面对的是后台长任务,所以要异步化,用 jobId 管理状态、进度、失败和重试。
query API 面对的是用户的一次问题,所以通常同步返回 answer + citations,并在没有证据时明确返回 insufficient_evidence。
更重要的是,不要把 HTTP 状态码、job 状态和业务结果混在一起。
HTTP 状态码表达请求和服务是否正常。
job 状态表达后台任务执行到哪里。
业务结果表达本次回答是否可信、是否有足够证据。
这三个边界清楚了,RAG 后端接口才不会在超时、重试、失败和无证据场景里乱掉。