OpenAlex works 内联引用(弃 work_referenced 边表)+ 审计¶
细化 ADR 0006 中 OpenAlex works 存储的物理模式;其中"完整元数据保存在本地,只过滤、不修改"的决策不变。
ADR 0006 将引用边作为独立的 work_referenced(work_id, referenced_id) 表发布,并草拟了一个存储的
被引概念。梳理 corpus(grill Q8–Q16)后发现两者都可以避免:原始 record 已经包含
referenced_works,而 PostgreSQL 的 jsonb + GIN 可以基于一个生成列服务两个引用方向,并且零维护。
我们还补上 ADR 0006 中隐含未写的刷新水位线和审计界面。
决策¶
内联引用,不设边表(Q8)。
openalex_referenced_work_ids jsonb GENERATED ALWAYS AS (strip_openalex_prefix(record->'referenced_works')) STORED—— 裸W…ids。生成列不能子查询/聚合,所以前缀剥离封装进一个IMMUTABLEplpgsqlstrip_openalex_prefix()函数(Q12)。在其上构建 GIN 索引。删除
work_referenced。 正向("W 引用了什么")就是数组本身;反向("谁引用了 W")是WHERE openalex_referenced_work_ids ? 'W…'—— 这是 OpenAlex 官方cites:filter 的本地等价物, 永远新鲜,不需要回填,也不需要 dangling-edge 记账。删除存储的
cited_by_work_ids(Q8):它与上面的反向查询相同,存储它只会引入漂移。
刷新水位线,而不是逐行记账(Q11)。
openalex_sync_state—— 一张单行表(id boolean PRIMARY KEY DEFAULT true CHECK (id)、last_updated_date date、last_synced_at timestamptz、snapshot_version text)驱动增量拉取。 我们不向openalex_works添加逐行arxiv_id/updated_date/publication_year回填列。
受约束取值使用 text + CHECK,绝不使用 PG 原生 enum(Q10) —— 增量取值变更是一行
CHECK 编辑,而不是 ALTER TYPE。
按成本/历史拆分完整性检查(Q9/Q14/Q15/Q16)。
结构检查是 VIEWS —— 本地、计算得出、零存储:(1)
paper_assetsmd/json 异常;(2)papers.paper_openalex_idNULL 缺口(应该映射到 corpus work 但没有映射的 paper)。不涉及 HTTP, 因此 view 可以完整表达这些检查。API 对比审计是一张持久、append-only 的单表
openalex_audit(需要历史 / 趋势 / unresolved-over-time):id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, run_id bigint, openalex_id text, field text, verdict text CHECK (verdict IN ('ok','field_mismatch','cited_by_regressed','stale_drift')), local_value jsonb, remote_value jsonb, checked_at timestamptz DEFAULT now(), indexed on(run_id)和(openalex_id, field)。记录每一行抽样结果,包括ok。sampled_count/ coverage / "unresolved" 全部派生,从不存储或修改 —— unresolved = 每个(openalex_id, field)的最新一次 run 中verdict ≠ ok的记录(Q16)。审计规则(Q9): 稳定字段(
referenced_works/doi/year/type)精确比较;cited_by_count容忍单调增长(只有 remote < snapshot,或漂移超过阈值时才报告——被引只会增长)。 对比需要一个会抓取 OpenAlex API 的外部脚本;SQL view 不能发起 HTTP 调用。
为什么内联 jsonb + GIN 优于边表¶
record 已经是源。
referenced_works存在于每条 OpenAlex record 中;生成列以确定性方式重新派生它—— 它永远不会与 record 漂移,ingest 一个 work 也不需要单独的边抽取过程或完整重跑。一个索引,两个方向。 GIN 倒排索引同时回答正向(读取数组)和反向(
? 'W…')查询;边表要做到同样能力, 需要一个 PK 加上第二个索引,还要维护 insert/delete 并容忍 dangling-edge。不会过期的被引。 反向查询在读取时从同一批 records 计算,因此始终与 corpus 当前持有的内容一致—— 存储列表只是一个 cache,每次 ingest 都必须失效。
备选方案¶
保留
work_referenced(ADR0006发布时的形态)。 否决(Q8):GIN 反向查询已经能零维护服务两个方向; 边表增加写入、第二个索引和 dangling-edge 处理,却没有唯一能支撑的查询。逐行存储
cited_by_work_ids。 否决:这是可派生 cache,每次 ingest 都会漂移。约束列使用 PostgreSQL 原生
enum。 否决(Q10):text + CHECK只需一行编辑即可演进;ALTER TYPE … ADD VALUE对事务不友好,也难以回滚。可变审计摘要行(running counters / "unresolved" 标志)。 否决(Q16): append-only 抽样行 + 派生聚合提供历史、趋势和可复现的 "unresolved",且永远不会谎报过去。
每个 work 上逐行
updated_date。 否决(Q11):单行openalex_sync_state水位线驱动增量刷新, 无需约 10⁸ 个冗余 timestamp。
影响¶
ADR
0006机制被细化。 其中的work_referenced边表由内联生成列 + GIN 取代;"引用解析始终在本地, 永不退回 public API"这一保证不变(现在由数组 + 反向查询服务)。破坏性迁移(Phase B)。
internal/openalexcorpus/{schema,ingest,query,record,store}.go和cmd/qatlasd/openalex_cmd.go删除work_referenced,添加生成列 + GIN +strip_openalex_prefix()函数、openalex_sync_state和openalex_audit;被引查询变为?反向查询。测试随之迁移。新增审计脚本 + 视图。 运维人员运行的采样器抓取 OpenAlex 并写入
openalex_audit行; 结构性视图随模式发布。只读 SQL 协议(ADR0007)从其可读子集中移除work_referenced。