qatlas-rag — QuantumAtlas 的 GPU 语义检索微服务

面向论文语义的独立微服务:在一块 GPU 上同时承载 bge-m3(dense+sparse 向量) 与 bge-reranker-v2-m3(重排序)推理,对外提供论文级语义检索与索引构建两个 接口。向量存储复用现有 Qdrant collection,元数据(chunk 登记、索引任务状态) 落在独立的 Postgres 数据库 qatlas_rag。

与文本检索微服务 qatlas-search 并列的另一条检索路径:qatlas-search 走严格 词项匹配加引用量加权,本服务走向量相似度,两条路径共用同一套 hit 格式与 service_token 接入方式,由 qatlasd 统一代理给客户端。

架构

客户端 (qatlas CLI / web)
        │
        ▼
   qatlasd ── 用户 auth、计量、身份锚定都在这里完成
        │  Authorization: Bearer <search.remote.token>
        ▼
 qatlas-rag (本服务, GPU 主机)
        │  ├─ embed engine: bge-m3 + bge-reranker-v2-m3(query/build 双 lane 共享 GPU)
        │  ├─ 检索: Qdrant hybrid(dense+sparse, RRF 融合)→ rerank pool=50 → 按论文归并
        │  └─ 索引: qatlasd 拉 markdown → chunk → embed(build lane) → upsert Qdrant
        ▼
 Qdrant (向量)   Postgres qatlas_rag (元数据)   qatlasd (论文 markdown 资产)

本服务只接受内部网络请求(compose 示例不做任何 ports 映射),用共享的 service_token 校验调用方;用户级鉴权、配额计量、论文身份锚定均由 qatlasd 负责。论文 markdown 资产一律经 qatlasd 的 HTTP API 读取,本服务不直连对象存储。

接口协议

POST /v1/retrieve
  请求:  {"query": str, "max_results": int = 10,
          "chunks_per_paper": int = 3}
  响应:  {"hits": [{title, authors, year, doi, arxiv_id, url,
                    venue, citations, source, score,
                    chunks: [{text, section, chunk_index, score}]}],
          "errors": {stage: msg}}

POST /v1/evidence
  请求:  {"query": str, "arxiv_id": str | "doi": str, "top_k": int = 5}
         # arxiv_id / doi 二选一;arxiv_id 匹配不区分 vN 版本
  响应:  {"chunks": [{text, section, chunk_index, score}],
          "indexed": bool,          # false = 该论文尚未建索引
          "errors": {stage: msg}}

POST /v1/index
  请求:  {"paper_id": str}            # arxiv_id(如 "2401.00001v2")或 DOI
  响应:  {"paper_id", "status", "attempts", "last_error"}
         # status ∈ pending | running | done | failed

POST /embed   {"texts": [...], "return_sparse": bool}   # 内部推理接口,?lane=query|build
POST /rerank  {"query": str, "passages": [...]}         # 内部推理接口,?lane=query|build

GET /healthz → {"status": "ok", "qdrant": bool,
                "gpu_lanes": {"query": bool, "build": bool},
                "postgres": bool}

GET /v1/admin/manifest → 配置清单:分节字段(label/type/description)、
                         当前值(secret 字段打码)、healthz 同款 status
PUT /v1/admin/config   → {"updates": {key: value}},写回实际加载的 YAML
                         配置文件的 rag: 段(qdrant.* 等写回嵌套子段),
                         需重启生效
  • hit 格式与 qatlas-search 完全对齐,source 固定为 "rag";payload 里没有的 字段(url / venue / citations)返回 null。

  • max_results 夹取到 1..100;chunk 级结果按论文身份(DOI > arXiv > 标题)归并, 每篇论文保留最高分,并附 chunks_per_paper 条(默认 3,夹取 0..10)证据 chunk——命中论文的原文段落、所在章节与 rerank 分数,供相关性核验引文使用。

  • /v1/evidence 面向"按篇验证":给定论文身份与查询,把该论文的全部 chunk 用 reranker 对查询打分,返回 top_k 证据段落。论文尚未建索引时 indexed=false、 chunks 为空(调用方可先 POST /v1/index 再重试);rerank 故障记入 errors。

  • chunk payload 自本版本起写入 chunk_index / section / arxiv_base(版本 去后缀的 arXiv id,供 evidence 过滤);旧索引点缺这些字段时以 -1 / "" 兜底,arxiv_base 缺失的旧点无法被 evidence 命中,重建索引即恢复。

  • 检索失败不拖垮响应:embed / Qdrant 故障记入 errors(hits 为空);rerank 故障 降级为融合分数原序返回。

  • /v1/index 幂等可重试:同一论文 pending/running 时重复提交只返回当前任务状态, 不产生重复任务;done/failed 的任务再次提交会重新排队。

  • 配置了 rag.service_token 时,除 /healthz 外全部接口校验 Authorization: Bearer <token>(与 qatlasd 侧 search.remote.token 同值), 不符返回 401;/healthz 始终开放,只返回粗粒度就绪位,不泄露拓扑细节。

  • 未配置 service_token 时(仅限开发环境):读接口(/v1/retrieve、/embed、 /rerank、manifest)保持开放,写接口(/v1/index、PUT /v1/admin/config) 返回 403。

  • /embed 与 /rerank 是进程内推理引擎对外暴露的内部接口,供调试与同机其他 消费者复用;/v1/retrieve 与 /v1/index 走的是同一套双 lane 队列(query lane 优先,build lane 供索引跑批)。

配置

独立的 YAML(不依赖 qatlas 主包),按顺序取第一个存在的文件:

  1. ~/.qatlas/rag.yaml(单机 / 本地开发)

  2. /etc/qatlas-rag/config.yaml(容器挂载点)

与 qatlasd 同机部署时:qatlasd 对 config.yaml 里的未知 key 严格报错, 两者不能共用一个文件。把本服务的配置写到独立的 ~/.qatlas/rag.yaml, 挂载为容器的 /etc/qatlas-rag/config.yaml(见 deploy/docker-compose.example.yaml)。

只读顶层的 rag: 段;缺文件 / 缺段不报错,用内置默认值。默认模型仍为 BAAI/bge-m3 / BAAI/bge-reranker-v2-m3,不会因安装新版本自动切换为 Qwen3; 使用 Qwen3 需显式配置匹配的 Embedding / Reranker 模型,并准备对应 collection 与索引。以下示例显式设为 CPU;GPU 部署需另行选择设备并验证,不是照抄后即启用 GPU:

rag:
  # --- 服务层 ---
  service_token: <与 qatlasd 的 search.remote.token 同值>
  service_port: 8801

  # --- 推理模型(GPU;无 GPU 主机设 embed_device: cpu)---
  embed_model: BAAI/bge-m3
  reranker_model: BAAI/bge-reranker-v2-m3
  embed_device: cpu              # 省略 = 自动检测(有 GPU 用 GPU);cpu 关闭 fp16
  # 模型族按名称自动识别:Qwen/Qwen3-Embedding-* + Qwen/Qwen3-Reranker-* 走
  # transformers 适配器(dense-only,无 sparse 混合检索,检索层自动降级为
  # 纯 dense;需要新建对应维度的 collection,例如 Qwen3-0.6B 为 1024 维)。

  # --- Qdrant(向量,沿用现有 collection)---
  qdrant:
    url: http://qdrant:6334       # 也接受 host:port 或 https://...
    api_key:
    collection: papers

  # --- Postgres(chunk 登记 + 索引任务状态,独立库 qatlas_rag)---
  postgres:
    dsn: postgresql://qatlas:secret@postgres:5432/qatlas_rag

  # --- qatlasd(论文 markdown 资产来源)---
  qatlasd:
    url: http://qatlasd:8090
    token: <qatlasd-side token>

  # --- 检索 ---
  rerank_pool: 50                 # 重排候选池大小(与 Go 侧既定值一致)
  request_timeout: 30.0

  # --- 索引 ---
  chunk_target_chars: 1800        # chunk 目标长度(字符)
  index_max_attempts: 5           # 失败重试上限(指数退避)
  markdown_poll_interval: 5.0     # qatlasd 转换 LRO 轮询间隔(秒)
  markdown_max_wait: 900.0        # 单次尝试内等待 markdown 就绪的上限(秒)

未配置 qdrant.url 时 /v1/retrieve 返回 503;未配置 postgres.dsn 或 qatlasd.url 时 /v1/index 返回 503。Qdrant / Postgres / qatlasd 暂时不可用 不会阻止进程启动,/healthz 的就绪位会反映出来。

索引管线

POST /v1/index 触发的构建流程在进程内异步队列里单 worker 执行:

  1. 经 qatlasd 的 GET /api/papers/{id}/markdown(Bearer)拉取 markdown; qatlasd 的转换是长任务,未就绪时返回 202 + Retry-After,本服务按该间隔 轮询,超过 markdown_max_wait 记为一次可重试失败;502 等错误按指数退避 重试,404/400/503(转换器禁用)直接判失败。

  2. 按 markdown 标题结构切 chunk,超长节按段落向 chunk_target_chars 归并, payload 带 arxiv_id / title / chunk_text / yymm / authors, 与现有 Qdrant collection 的 payload 格式兼容。

  3. 全部 chunk 走 build lane 嵌入(不阻塞 query lane 的在线检索)。

  4. 以确定性 point id(paper_id + chunk 序号的 UUID5)upsert 进 Qdrant, 重复索引是覆盖写而不是产生重复点。

  5. chunk 登记与任务状态(pending/running/done/failed、attempts、last_error) 写入 Postgres;建表 DDL 在服务启动时自动执行,不依赖迁移框架。

本地开发

使用 Python 3.12(项目最低 3.11)和 uv 0.11.30;依赖、开发工具统一由 pyproject.toml / uv.lock 管理。首次同步可联网下载 CPU 依赖,测试执行本身离线:

uv sync --locked --extra dev      # 普通 uv sync --extra dev 也不装 GPU extra
uv run --offline --no-sync ruff check .
uv run --offline --no-sync python -m pytest -q
uv lock --check --offline

dev 包含 Commitizen、Ruff、pytest、packaging,不包含 torch / transformers / FlagEmbedding。独立 build dependency group 锁定 Hatchling;不属于默认 dev 安装。 需要轻量校验 wheel/sdist 时显式同步 uv sync --locked --extra dev --group build, 再运行 uv build --offline --no-build-isolation --python .venv/bin/python(uv build 不会仅凭 UV_PROJECT_ENVIRONMENT 自动选择构建解释器)。这些包只做检查,不发布 PyPI。 不要在默认测试中使用 --all-extras。新增依赖后运行 uv lock, 审查并提交锁文件;Ruff 只启用 E4/E7/E9/F 错误检查,不要求全仓格式化。

真实网络和 GPU 用例分别标记 network / gpu,默认两者均排除。测试中的轻量 Python socket guard 拦截外部 DNS/连接,允许 loopback、TestClient、MockTransport; 它不是原生库/子进程的安全沙箱。默认 CPU 测试使用临时 Hugging Face 目录并强制 离线,不读取个人模型 cache。仅在明确授权的测试环境,才选择 -m network --allow-network 或 -m gpu;GPU 用例需先独立准备依赖和获准使用的模型 cache, 不因单元测试自动下载模型。需要模型联网下载时另行明确启用 --allow-network。

GPU 依赖安装、服务运行与模型下载均是需明确授权的独立操作,不属于默认 CPU 测试。下面命令仅供获准的 GPU 开发环境使用;安装会下载数 GB CUDA/torch 依赖, 运行时还可能加载或下载配置的模型。镜像构建/发布通过、配置展示或 /healthz 返回响应,都不能单独证明设备可用、模型已成功推理或检索质量达标:

uv sync --locked --extra gpu
uv run --no-sync uvicorn qatlas_rag.server:app --host 0.0.0.0 --port 8801

CLI

qatlas-rag 是 server 模式的查询客户端,POST 到本服务的 /v1/retrieve; --server / --token 缺省时取配置里的 service_port / service_token:

qatlas-rag "surface code threshold" --max-results 5
qatlas-rag "VQE ansatz" --json

本包还声明了 qatlas.plugins entry point,安装后 qatlas CLI 会挂载 qatlas rag 子命令(qatlas 包为惰性 import,未安装不影响本服务)。

部署

镜像由本仓库 .github/workflows/release.yml 发布到 GHCR;仅发布 ghcr.io/iai-ustc-quantum/qatlas-rag:v<版本>,不更新 latest、裸版本标签, 也不发布 PyPI。镜像固定 linux/amd64 CUDA runtime,非 root 运行;构建用 uv 0.11.30 先用锁内 build group / Hatchling 和显式 Python 构建当前项目 wheel, 再精确同步生产 + gpu 依赖(移除构建工具),最后离线 --no-deps 安装该 wheel; torch 继续独占 cu128 explicit index。部署与发布分开授权,部署机只 pull 不 build。 先核验目标版本已发布及其镜像 digest,再导出 QATLAS_RAG_VERSION 为对应的 v 前缀 tag;不要把待发布的 RC 当作已可部署镜像。以下命令只在获准的部署环境运行:

: "${QATLAS_RAG_VERSION:?先导出已核验的已发布版本 tag(含 v 前缀)}"
cp deploy/docker-compose.example.yaml deploy/docker-compose.yaml
# 显式启用 gpu profile;未启用时该示例服务不会随普通 up 启动。
docker compose -f deploy/docker-compose.yaml --profile gpu pull
docker compose -f deploy/docker-compose.yaml --profile gpu up -d

固定版本并记录 digest 只固定镜像选择,不保证数据/索引/配置向后兼容,也不保证 可回滚。部署前独立核验备份、迁移与恢复方案,并在获准环境验证服务、GPU 和模型; 不能以发布成功代替部署验收。

版本与发布

pyproject.toml 的 [project].version 是唯一版本来源,运行时读取已安装包的 importlib.metadata。Commitizen 的 uv provider 同步 uv.lock,version_files 同步 VERSION,维护 CHANGELOG 并创建 annotated v$version tag。 不要单独编辑 VERSION 或在 CI 临时生成版本,也不要改写历史 tag/产物。 日常提交采用 Conventional Commits。

在干净、已同步的默认分支 main 上预览,取得单独的发版授权后才执行后两步:

git switch main
git fetch --tags origin
git pull --ff-only
uv sync --locked --extra dev
uv run --extra dev cz bump --dry-run
# 以下会改版本/锁文件/日志、创建 commit 和 tag;只在明确获准发版后运行:
uv run --extra dev cz bump
# 检查本次 tag、版本、日志和 git diff 后,再获准推送:
git push origin main --follow-tags

pre_bump_hooks 执行 Ruff / 离线 pytest,失败时可能已改版本文件、但尚未创建 commit/tag;先检查并恢复本次半途改动,不盲目重跑。不要运行会覆盖手写历史的裸 cz changelog。日志段保持唯一完整标题 ## vX.Y.Z (YYYY-MM-DD) 和非空正文; 版本采用 canonical PEP 440(支持 a/b/rc/dev/post,不允许 epoch / local)。

CI 对 main push、PR、v* tag 和手动 dispatch 均运行 test;建议将 test 设为 main 分支保护必需检查。仅 tag 引用可进入发布(手动选择 main 也只测试)。 发布先由可离线测试的 scripts/validate_release.py 核对 tag、项目 metadata、 VERSION、lock 和精确 CHANGELOG 段,再由 scripts/ghcr_preflight.py 使用 GITHUB_TOKEN 认证获取 scoped bearer、查询 GHCR manifest。仅标准 404 + MANIFEST_UNKNOWN/NAME_UNKNOWN 明确不存在时才 build/push;401/403、 5xx、网络错误、不明 404 或重定向都停止,绝不当作不存在。GHCR package 需允许 本仓库 GITHUB_TOKEN 写入;权限问题由维护者修复,不走匿名查询绕过。

已发布镜像的只读验证

镜像发布后,可从默认分支 main 手动运行 verify-release.yml,只输入已有的 版本 tag;例如获准后执行 gh workflow run verify-release.yml --ref main -f tag=vX.Y.Z。 该流程只有 contents: read / packages: read,用本仓 GITHUB_TOKEN 拉取本仓 linux/amd64 镜像,不需要扩大本地个人 token 权限,也不构建、重发或覆盖产物。 先从 Docker RepoDigests 固定准确镜像 digest,再读取原始 manifest 核验平台, 最后按该 digest 在无网络、只读、无挂载/端口、无 GPU 的受限容器中检查包版本。

结果与 digest 记录在 Actions summary:输入版本须等于运行时和发行包 metadata; GPU 依赖只查询已安装版本 metadata,不导入 torch/transformers、不加载模型或 下载权重。这是 CPU metadata smoke,不是 GPU 推理、检索质量或生产部署验收。 鉴权/拉取/平台/版本检查失败会直接停止,不改用匿名读取或重新构建。

发布失败恢复(不覆盖已发布版本)

  • 镜像成功后才创建 GitHub Release;两者是独立 job,不是原子事务。

  • 镜像成功而 create-release 失败:记录 docker-image summary 的原始 digest, 用 Actions 的 Re-run failed jobs / 仅重试 create-release,不要重跑成功的 镜像 job。Release 正文来自精确日志段并附原 digest。

  • 整个 workflow 重跑时,preflight 若发现同名镜像 tag 已存在,会输出 digest 并 失败停止,不构建、不覆盖。核对 digest 对应原 tag/commit 后,仅补 GitHub Release;若原 run 已不可重试,由维护者通过 GitHub UI/CLI 使用原日志和 digest 补 Release,本流程不自动恢复或重新构建。无法证明原产物身份则停下核查。

  • 若 push 中途失败,也先查注册表:可能已经产生镜像,不能假设自动撤回。需要 改源码/产物时发新的、另行授权的版本;不移动旧 Git tag、不覆盖旧镜像。

  • 同一 tag 的整个 workflow 串行且不自动取消;preflight 不是 GHCR 原子 compare-and-set,外部手工发布者也必须遵守不覆盖约定。GitHub provenance attestation 保留 id-token: write;私有仓库 plan 不支持时该步骤可失败, 不撤回已推送镜像,BuildKit provenance/SBOM 仍随镜像生成。

示例 compose 面向 GPU 主机:以 gpu profile 按需启动,接入外部 shared-infra 网络且不做 ports 映射(流量一律经 qatlasd 进入),通过 deploy.resources.reservations.devices 预留一张 GPU(要求宿主机安装 nvidia-container-toolkit),并以只读方式挂载 ~/.qatlas/rag.yaml,另挂一个 Hugging Face 缓存卷保存模型权重。还需事先准备共享网络、独立配置与后端服务, 确认 GPU 驱动/容器工具链和 cache 权限;声明 GPU reservation 不代表独占 GPU, 挂载 cache 也不代表所选模型已经存在或可用。

明确获准后,本地开发可用 docker build -t qatlas-rag:local . 自建镜像(会下载 GPU 构建依赖),但这不是发布或部署验收路径;线上使用已核验的 GHCR 产物。

测试

uv sync --locked --extra dev
uv run --offline --no-sync ruff check .
uv run --offline --no-sync python -m pytest -q

离线覆盖:配置加载(含嵌套段拍平与 admin 写回)、论文身份归一化(arXiv 去 版本号 / DOI 归一 / yymm 推年份)、检索层(RRF 融合请求形状、rerank 重排与 故障降级、chunk 按论文归并、孤儿 chunk 跳过、上限截断,行为规格与主仓 qdrant_test.go 对齐)、embed 引擎双 lane 优先级与队列饱和、chunker 的 标题切分与长尾归并、索引管线(httpx mock qatlasd:LRO 轮询 / 重试 / 幂等 入队)、qatlasd 客户端状态码分流、FastAPI 服务(TestClient + 注入假组件: hit 形状 / 401 与 403 / healthz / admin manifest 打码与配置写回)。带 gpu 标记的真模型用例默认跳过。另覆盖版本/Commitizen/CI/Docker 配置契约、PEP 440 和日志校验、认证 GHCR preflight 的存在/缺失/权限/服务故障分流、默认网络 guard, 以及 Qwen3 pooling 的左右 padding(CPU fake,不安装 torch)。