Architecture¶
项目分层¶
QuantumAtlas 的核心设计不是“把所有东西都塞进一个存储”,而是明确区分不同层级的 source of truth。
QuantumAtlas app repo 对象存储 (RustFS / S3) PostgreSQL 搜索引擎
应用代码与工具 <-> canonical paper assets <-> registry + OpenAlex corpus <-> 派生查询层
建议这样理解:
应用仓库负责代码、CLI、API、测试和脚本。
对象存储保存 PDF、解析 Markdown、元数据 JSON 和图片等论文资产,是 canonical paper asset store(本地
RAW_DIR仅作 dev fallback)。PostgreSQL 是中心化关系库:paper registry(papers / paper_assets / paper_identities,goose 管理)+ OpenAlex works 语料,见 ADR 0006。
搜索引擎(
POST /api/search的多 provider fan-out)与 ingest 任务状态属于派生或运行时层,不是长期主数据源。
为什么要把 registry 和论文资产分开¶
对象存储负责回答“字节在哪”,PostgreSQL 负责回答“有哪些论文、各自处于什么状态、彼此什么关系”。
这带来几个好处:
大文件资产不会污染关系库;registry 行可以独立重建。
集合查询(计数、过滤、租约)走关系型数据库擅长的约束、索引和事务。
应用代码可以按 release tag 固定,语料与资产可以独立高频更新。
搜索只是查询层,不会反向定义数据边界。
Paper 资产¶
qatlasd 服务端默认不通过 HTTP API 对外分发 PDF / Markdown 字节。资产
桶里的对象只为内部处理(如解析、索引)服务,用户/客户端只能查询元数据
(OpenAlex 同步进来的 title / authors / 引用关系等)与论文是否已经具备
某项资产(papers/stats、papers/needs-mineru)。Self-hosted 部署可用
QATLAS_PAPER_ACCESS_ENABLED=true 打开字节分发,合规义务随之转给部署方。
如果需要原始 PDF,请直接到 arXiv 等上游获取(papers.ArxivAbsURL 数据
字段可以拼出 canonical arxiv URL)。
论文元数据索引(PostgreSQL catalog)¶
paperindex是历史锚点名:旧版曾把索引放成 RustFS 上的 Parquet。 现在的决策是 RustFS 只做纯 S3 后端;集合查询、DOI 状态和 MinerU claim 租约进入 PostgreSQL,登录态仍由 PocketBase 单独管理。
问题:对象存储不会回答"集合性"问题¶
对象存储 (S3 / RustFS) 的原生接口只有"按 key 取一个对象 (GetObject)"、 "按 key 列出对象 (ListObjects)"、"按 key 删除"几类。它没有:
跨对象的 query / filter / sort / count / group by
二级索引(按非 key 字段查)
原子租约(例如"抢一篇还没被 MinerU 处理的论文")
对单个论文做"按 id 取 PDF"这种点查是天然合适的(就是 GetObject)。 但真实业务需要集合查询:
需求 |
对象存储原生能力 |
当前实现 |
|---|---|---|
"PDF / Markdown 总数" |
|
PostgreSQL |
"需要 MinerU 的论文(有 PDF 无 MD)" |
双 ListObjects + 内存 diff |
PostgreSQL partial index ( |
"DOI 是否已有本地贡献" |
无二级索引 |
PostgreSQL UNIQUE |
"抢一个 MinerU lease" |
无事务锁 |
PostgreSQL row lock + |
实际方案:PostgreSQL 是非登录态 catalog¶
papers + paper_assets 两张表是 paper catalog 的派生索引(ADR 0009):
papers(一 work 一行):代理主键paper_id;三个外部 id 列各 UNIQUEpaper_arxiv_id/paper_doi/paper_openalex_id(paper_openalex_id带可空 FK →openalex_works);paper_title/paper_publication_date;生成式paper_ref(openalex>arxiv>doi);触发器维护的paper_default_asset_id;精简paper_verification_statuspaper_assets(一份 PDF 一行:arxiv v1/v2/… + 正式版):asset_id、paper_id(FK CASCADE)、source(arxiv|published)、arxiv_version、对象 keypdf_path/mineru_md_path/mineru_json_path、pdf_sha256/pdf_size/image_count/fetched_at;MinerU 租约lease_id/lease_holder/lease_expires_at
使用 PostgreSQL 特性:
INSERT ... ON CONFLICT做 upload write-through 和papers sync重建;partial unique index:
paper_assets (paper_id) WHERE source='published'(正式版每篇一份);partial queue index:只索引
paper_assets ... WHERE mineru_md_path IS NULL的 MinerU 队列候选;row-level lock (
SELECT ... FOR UPDATE) 保证同一资产的 lease 只有一个赢家;触发器在
paper_assets增删时重算papers.paper_default_asset_id(正式版优先,否则最新 arxiv)。
写入路径¶
1. PUT s3://qatlas-pdf/<yymm>/<stem>.pdf ← 字节先落 RustFS
2. INSERT ... ON CONFLICT papers + paper_assets ← PostgreSQL 派生索引同步
3. PostgreSQL 不可用时:HTTP 仍成功 + X-Catalog-Sync: deferred
4. 之后跑 qatlasd papers sync --full --from-rustfs 从 S3 重建缺失状态
RustFS 仍是资产字节的 source of truth;PostgreSQL 是可重建的集合索引与租约层。 这避免了把索引文件、Parquet 或应用级 metadata 放进 RustFS,同时也避免把登录态和 论文业务状态混进 PocketBase。
查询路径¶
GET /api/papers/needs-mineru
→ SELECT ... FROM paper_assets a JOIN papers p ON p.paper_id = a.paper_id
WHERE a.source = 'arxiv'
AND a.pdf_path IS NOT NULL AND a.mineru_md_path IS NULL
AND (a.lease_expires_at IS NULL OR a.lease_expires_at < now())
ORDER BY a.fetched_at DESC
→ JSON 返回
跟直接 ListObjects 相比,这条链路不会触发全桶 LIST;registry 用的是
关系型数据库擅长的约束、索引和事务。
Client / Server 边界¶
QuantumAtlas 既可以作为服务端运行,也可以作为远程客户端使用。
server 模式负责读写对象存储 / PostgreSQL,并提供搜索、论文资产和摄入能力。
client 模式通过 HTTP API 使用这些能力,不要求拿到服务器文件系统权限。
应用仓库内不再保留任何 raw/、data/、pb_data/ 目录——所有
状态目录都有内置默认值,落到 git checkout 之外:
# 所有这些都有内置默认;不写就走默认(无需在 .env 里出现):
# QATLAS_RAW_DIR -> ${XDG_DATA_HOME:-$HOME/.local/share}/qatlasd/raw
# QATLAS_DATA_DIR -> ${XDG_DATA_HOME:-$HOME/.local/share}/qatlasd/data
# QATLAS_PB_DATA_DIR -> ${XDG_DATA_HOME:-$HOME/.local/share}/qatlasd/pb_data
#
# 想搬到挂载盘 / FHS 路径时再显式覆盖:
# QATLAS_RAW_DIR=/srv/quantum-atlas/raw
# QATLAS_PB_DATA_DIR=/var/lib/quantum-atlas/pb_data
旧名
RAW_DIR/DATA_DIR/PB_DATA_DIR的 alias 已在 v0.17.0 移除, 新写法统一QATLAS_*前缀。从仓库内布局迁移到 XDG 默认的 步骤见 migration-storage-layout.md。
把状态目录放在 git checkout 外的好处:fresh clone 永远干净(不需要维护
长串 .gitignore 规则),go ./... 不会撞到 FUSE 挂载,并且符合 XDG /
FHS / 12-factor 的常规约定。
服务端组件清单(internal/ / cmd/)¶
按上面分层对应的 qatlasd 主要 Go 包;都是主仓内的代码,不是独立进程:
组件 |
职责 |
|---|---|
|
PostgreSQL paper registry( |
|
Robust Downloader:多范式 PDF 抓取策略阶梯 + 统一验证管线(v0.26.0;builtin |
|
独立单容器部署的同款梯子服务——放在有出版社 entitlement 的机器(校园出口),qatlasd 经 |
|
搜索 Provider 抽象 + catalog/arxiv/openalex 内置 + remote 微服务 client + backend 静态目录( |
|
每用户第三方搜索 key 的 AES-256-GCM 加密存储( |
|
HTTP 路由层(papers / search(含 multi + backends)/ downloader / admin assets / me / auth …) |
|
惰性摄入管线与 MinerU 转换调度 |
设计上的取舍¶
QuantumAtlas 不把浏览器 OAuth 登录流程内置进应用本体。
QuantumAtlas 不绑定特定反向代理、SSO 或存储产品。
RAW_DIR、DATA_DIR、PB_DATA_DIR、QATLAS_POSTGRES_DSN是显式边界,而不是隐含在仓库结构里的假设。
延伸阅读¶
storage-architecture.md — 当 RAW 资产体量上 TB / 引入对象存储(RustFS / S3 兼容)时,raw / metadata 各层怎么分工、怎么对账、怎么重建。
migration-storage-layout.md — 把 raw / data / pb_data 从仓库内搬到 XDG / 挂载点的实操步骤。
data-flow.md — 一篇论文从 arXiv 到可检索资产的端到端链路。