FastAPI与Elasticsearch知识问答后端
用 FastAPI 与 Elasticsearch 搭建可追溯的知识问答后端
摘要:本文不追求一个“几十行即可运行”的演示,而是梳理生产型知识问答后端的接口边界、检索结果结构、引用校验和日志设计。
标签:FastAPI Elasticsearch RAG Python 后端架构
一、先把回答过程拆成服务步骤
知识问答接口常被写成一个大函数:接收问题、检索、拼提示词、调用模型并返回。演示阶段很快,后期却难以测试和定位问题。
更清晰的拆分是:
API 参数校验
→ 查询理解
→ 权限与元数据过滤
→ 混合检索
→ 重排与上下文构建
→ 模型生成
→ 引用与安全校验
→ 结果返回与审计
每一步都返回结构化对象,并带同一个 request_id,便于追踪一次请求。
二、接口模型要包含可追溯信息
请求模型除了问题,还可以包含会话 ID、知识范围和客户端已知版本,但权限角色不应完全相信前端传值,应由认证信息在服务端确定。
from pydantic import BaseModel, Field
class AskRequest(BaseModel):
question: str = Field(min_length=2, max_length=2000)
conversation_id: str | None = None
knowledge_scope: list[str] = []
class Citation(BaseModel):
chunk_id: str
title: str
page: int | None = None
quote: str
class AskResponse(BaseModel):
request_id: str
answer: str
citations: list[Citation]
status: str
引用不是一段随意生成的文字,而应关联真实 chunk_id。标题、页码和原文片段由服务端根据 ID 回填,避免模型编造来源。
三、检索索引如何组织
Elasticsearch 文档可以同时保存文本字段、向量字段和元数据:
{
"chunk_id": "doc-2026-001#p12-c03",
"content": "知识片段正文",
"title": "文档标题",
"heading_path": ["第三章", "适用范围"],
"version": "2026.1",
"effective_date": "2026-01-01",
"security_tags": ["internal"],
"embedding": [0.01, -0.02]
}
检索时,先根据用户权限和文档有效期构造过滤条件,再并行执行 BM25 与向量召回。两路结果可用 RRF 融合,再交给重排模型。
四、项目目录如何拆分
一个便于测试的目录结构可以是:
app/
├── api/ # 路由、请求与响应模型
├── auth/ # 身份认证与权限计算
├── retrieval/ # BM25、向量检索、融合与重排
├── generation/ # 提示词、模型客户端、输出校验
├── knowledge/ # 文档版本与元数据服务
├── audit/ # 审计记录
├── observability/ # 日志、指标与链路追踪
└── settings.py # 配置入口
API 层不应直接拼 Elasticsearch DSL,也不应直接处理模型返回字符串。它只负责参数校验、调用应用服务并转换响应。这样可以在不启动 Web 服务的情况下测试检索与生成逻辑。
配置要区分代码默认值、环境配置和密钥。模型地址、索引别名、超时时间等可以来自环境变量,密钥则交给专用密钥管理服务,不能写入仓库或普通配置文件。
五、上下文构建需要预算
不能把所有候选直接塞进提示词。应根据模型上下文预算,按重排分数逐条加入,同时考虑:
- 合并同一文档的相邻片段;
- 删除高度重复内容;
- 保留标题路径、版本和来源 ID;
- 为用户问题和输出预留足够 token;
- 冲突文档同时保留并明确标注版本。
一个可测试的上下文构建器,比在字符串模板里临时拼接更容易维护。
上下文预算可以显式计算:总窗口减去系统提示、用户问题、对话历史和预期输出所需 token。文档片段加入前先估算 token,超过预算就停止,而不是依赖模型接口报错。
对话式问答还要谨慎处理历史消息。可以将历史对话压缩为“已确认事实”和“尚未解决的问题”,不要无限拼接全部消息。用户在上一轮有权访问某份文档,也不代表下一轮权限一定相同,因此每次请求都要重新计算授权范围。
六、异步不等于无限并发
FastAPI 支持异步调用,但外部模型、重排服务和 Elasticsearch 都有容量上限。生产环境需要连接池、并发信号量、超时和熔断。
import asyncio
model_limit = asyncio.Semaphore(20)
async def call_model(client, payload):
async with model_limit:
return await asyncio.wait_for(
client.generate(payload),
timeout=30,
)
重试只适用于短暂网络错误或明确的限流响应,并应使用指数退避。对参数错误或内容校验失败盲目重试没有意义。
除了并发限制,还要设置请求级截止时间。例如总预算为 45 秒,检索最多使用 5 秒、重排 8 秒、生成 30 秒,其余时间留给校验和网络开销。若前置步骤已经消耗过多时间,应提前降级,而不是等到网关统一超时。
对于耗时较长的文档导入、批量向量化和索引重建,应使用任务队列,不要放在 HTTP 请求中同步完成。任务记录状态、进度、输入版本和错误信息,并支持安全重试。
七、统一错误响应
内部异常不应原样暴露给客户端。可以定义统一结构:
{
"request_id": "req-xxx",
"error": {
"code": "RETRIEVAL_TIMEOUT",
"message": "知识检索暂时超时,请稍后重试",
"retryable": true
}
}
错误码应稳定,方便前端决定是否提示重试、请求补充信息或转人工。服务端日志通过 request_id 关联详细异常,不必把堆栈和内部地址返回给用户。
区分“没有找到答案”和“系统失败”也非常重要。前者是正常业务结果,后者是技术错误;如果混为同一个 500 响应,监控和用户体验都会受到影响。
八、用依赖注入固化认证与服务边界
FastAPI 的依赖系统适合注入当前用户、权限上下文和应用服务:
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException
app = FastAPI()
async def current_user(authorization: Annotated[str, Header()]):
claims = await verify_bearer_token(authorization)
if not claims.active:
raise HTTPException(status_code=401, detail="inactive user")
return claims
async def rag_service():
# 实际项目可从 app.state 或容器中获取长生命周期客户端
return RAGService(retriever=retriever, generator=generator)
UserDep = Annotated[UserClaims, Depends(current_user)]
ServiceDep = Annotated[RAGService, Depends(rag_service)]
@app.post("/v1/knowledge/ask", response_model=AskResponse)
async def ask(req: AskRequest, user: UserDep, service: ServiceDep):
access = AccessContext(
user_id=user.sub,
tenant_id=user.tenant_id,
security_tags=frozenset(user.security_tags),
)
return await service.ask(req, access)
不要让请求体传入 tenant_id 或管理员标记后直接生效。权限上下文必须来自经过验证的身份凭据,检索服务只接受服务端构造的 AccessContext。
长生命周期的 Elasticsearch 和模型客户端应在应用启动时创建、关闭时释放,避免每个请求重新建立连接。依赖函数只负责取用客户端,不负责重复初始化。
九、服务层如何组织降级逻辑
class RAGService:
def __init__(self, retriever, generator):
self.retriever = retriever
self.generator = generator
async def ask(self, req: AskRequest, access: AccessContext) -> AskResponse:
request_id = new_request_id()
hits = await self.retriever.search(
query=req.question,
access=access,
top_k=20,
)
if not hits or hits[0].quality < MIN_EVIDENCE_QUALITY:
return AskResponse(
request_id=request_id,
status="insufficient_evidence",
answer="当前知识库中没有找到足够可靠的依据。",
citations=[],
)
context = build_context(hits, token_budget=6000)
generated = await self.generator.generate(req.question, context)
citations = validate_and_hydrate_citations(generated, hits)
return AskResponse(
request_id=request_id,
status="ok",
answer=generated.answer,
citations=citations,
)
这里的 quality 不应直接等同于某个模型分数,而应综合有效期、适用范围、重排结果和问题覆盖度。生成失败时可以返回已授权的检索片段,形成“检索结果模式”的降级响应。
十、Elasticsearch 查询构造器
查询构造器只接收权限上下文,不接受任意 DSL:
def build_filters(access: AccessContext, as_of: str) -> list[dict]:
return [
{"term": {"tenant_id": access.tenant_id}},
{"terms": {"security_tags": sorted(access.security_tags)}},
{"range": {"effective_from": {"lte": as_of}}},
{
"bool": {
"should": [
{"range": {"effective_to": {"gte": as_of}}},
{"bool": {"must_not": {"exists": {"field": "effective_to"}}}},
],
"minimum_should_match": 1,
}
},
]
把过滤器集中封装并编写测试,可以避免不同接口各自拼接权限条件造成遗漏。搜索日志只记录过滤条件摘要和命中文档 ID,不记录用户令牌。
十一、引用校验是最后一道关键防线
模型生成后,服务端检查:引用 ID 是否来自本次候选、引用原文是否匹配、答案中的关键结论是否至少有关联证据。无法通过时,可以降级为只返回检索结果,或明确提示“现有资料不足”。
不要让模型直接决定权限。即使模型要求读取某个文档,真正的数据访问仍必须经过服务端授权过滤。
十二、缓存要包含权限和版本
可以缓存 Embedding、检索候选和最终回答,但三者的失效条件不同。检索缓存至少包含问题规范化结果、知识版本、权限范围和检索配置版本;回答缓存还应包含模型与提示词版本。
不要只用问题文本作为缓存键。两个用户提出同样问题,可能因为所属组织和权限不同而应该得到不同结果。文档更新后也必须使旧缓存失效,否则系统会长期返回过期内容。
敏感回答是否允许缓存,需要单独评估。即使允许,也应设置短有效期、加密存储和严格访问范围。
十三、日志与可观测性
一次请求至少产生以下指标:总耗时、检索耗时、重排耗时、模型耗时、候选数量、最终引用数量、输入输出 token、错误类型和降级路径。
日志内容要遵循最小化原则。问题和上下文可能包含敏感信息,可以记录哈希、脱敏摘要或受控存储中的引用,而不是无条件写入普通日志。
链路追踪可以为检索、重排和模型调用分别创建 span,记录状态与耗时,但不要把完整敏感正文放入 tracing 标签。高基数字段也会显著增加监控成本,应谨慎选择。
十四、测试策略
单元测试覆盖查询解析、权限过滤、RRF 融合、上下文预算和引用校验;集成测试使用临时索引验证 Elasticsearch 查询;契约测试确保模型客户端和返回 Schema 一致;端到端测试则从 HTTP 请求走完整链路。
还应加入故障测试:Elasticsearch 超时、重排服务不可用、模型返回非法 JSON、引用 ID 不存在、用户权限在请求中途变化。系统应按设计降级,而不是只在所有依赖正常时表现良好。
上线前用固定评测集比较答案质量,同时进行并发与限流测试。性能测试不能只看平均耗时,要关注 P95、P99 以及依赖服务异常时的表现。
生产型知识问答后端的重点,不在于能否调用模型,而在于每一步是否可测试、可限流、可追溯和可降级。FastAPI 与 Elasticsearch 提供了良好基础,但真正的可靠性来自清晰的服务边界与严格的结果校验。

浙公网安备 33010602011771号