# 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 ▼ 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 `(与 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: ```yaml 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: # --- 检索 --- 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 依赖,测试执行本身离线: ```bash 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` 返回响应,都不能单独证明设备可用、模型已成功推理或检索质量达标: ```bash 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`: ```bash 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 当作已可部署镜像。以下命令只在获准的部署环境运行: ```bash : "${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` 上预览,取得单独的发版授权后才执行后两步: ```bash 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 产物。 ## 测试 ```bash 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)。