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 提供了良好基础,但真正的可靠性来自清晰的服务边界与严格的结果校验。

参考资料

posted @ 2026-09-20 15:52  楼主好菜啊  阅读(4)  评论(0)    收藏  举报