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 主包),按顺序取第一个存在的文件:
~/.qatlas/rag.yaml(单机 / 本地开发)/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 执行:
经 qatlasd 的
GET /api/papers/{id}/markdown(Bearer)拉取 markdown; qatlasd 的转换是长任务,未就绪时返回 202 + Retry-After,本服务按该间隔 轮询,超过markdown_max_wait记为一次可重试失败;502 等错误按指数退避 重试,404/400/503(转换器禁用)直接判失败。按 markdown 标题结构切 chunk,超长节按段落向
chunk_target_chars归并, payload 带arxiv_id/title/chunk_text/yymm/authors, 与现有 Qdrant collection 的 payload 格式兼容。全部 chunk 走 build lane 嵌入(不阻塞 query lane 的在线检索)。
以确定性 point id(paper_id + chunk 序号的 UUID5)upsert 进 Qdrant, 重复索引是覆盖写而不是产生重复点。
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-imagesummary 的原始 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)。