---
title: 数据摄入
description: PAC 的数据摄入(首次全量 / 日级增量)、数据完整性、资源调优与配置变更。
icon: Import
---

`pnpm sync` 是统一入口:首跑(游标为空)等价全量,之后增量。`pnpm cold-import` 保留为"忽略游标强制全量"的旧别名,日常一律用 `pnpm sync`。

> 命令里 `<host>` 指一个 host 标识,对应配置目录 `apps/pac-service/data/<host>/`。

---

## 一、首次全量

**用途**:首次部署 / 重建库 / 配置大版本变更。

**机制**:游标为空即等价全量,**按患者 cohort 分批** —— 每批 load → transform → assemble → 批量写 → 释放内存 → 下一批,内存恒定在 1G 以内,不会因一次性载入而溢出。

```bash
# Docker(prod)— 必带 --env-file
docker compose --env-file apps/pac-service/.env -f docker-compose.prod.yml exec -T \
  -e PAC_COHORT_CONCURRENCY=3 \
  pac-service pnpm sync:prod -- --dir=./data/<host> --cohort-batch=500 --no-recompute

# 裸机 / 本地
PAC_COHORT_CONCURRENCY=3 pnpm sync -- --dir=./data/<host> --cohort-batch=5000 --no-recompute
```

| 参数 | 含义 |
|---|---|
| `--cohort-batch=N` | 每批 N 个患者(默认 5000;数据源较慢时调小到 500 看进度更细) |
| `PAC_COHORT_CONCURRENCY=3` | 3 个 cohort 并发(默认 1 串行);连接池自动随并发放大 |
| `--no-recompute` | 只拉数据,persona / plan 留待单独跑(大数据量分阶段更可控) |

> **量级参考**:十万级患者、`concurrency=3` 约 1.5–2 小时,内存峰值 < 1G。

全量完成后手动触发重算(增量模式下 cron 会自动联动,首跑用了 `--no-recompute` 时需手动补):

```bash
pnpm recompute-persona:prod -- --host=<host>
pnpm recompute-plans:prod -- --host=<host>
```

**验证**:

```sql
-- 规模自检:三者都应 > 0(导入成功)
SELECT 'patients' AS t, count(*) FROM patients
UNION ALL SELECT 'tx', count(*) FROM patient_transactions
UNION ALL SELECT 'facts', count(*) FROM patient_facts;

-- 一致性自检:下面两个都应 = 0(无重复 tx、无多 active fact)
SELECT count(*) AS dup_tx FROM (SELECT source_event_id,host_id,tenant_id FROM patient_transactions
  WHERE source_event_id IS NOT NULL GROUP BY 1,2,3 HAVING count(*)>1) x;
SELECT count(*) AS multi_active FROM (SELECT subject_id,host_id,tenant_id FROM patient_facts
  WHERE status='active' GROUP BY 1,2,3 HAVING count(*)>1) y;
```

---

## 二、日级增量

`SyncIncrementalSchedulerService` 启动时**为每个 `auto_sync: true` 的 host 各注册一个 CronJob**,走与全量同一套 `importDirectory`。cron 时间取自该 host `manifest.incremental_cron`(按 manifest `timezone` 解释),缺省回退全局 `PAC_INCREMENTAL_CRON` —— 各 host 在自己上游刷新窗口后跑,不再挤一个全局时刻:

```yaml
# data/<host>/manifest.yaml
incremental_cron: '15 8 * * *'   # 该 host 数据源当日刷新之后
```

```
到点(每 host 各自 cron)
   ↓
importDirectory({ incremental: true })
   - 并发锁:sync_logs partial UNIQUE (host_id) WHERE status='running' AND direction='pull'
     同 host 已在跑 → 跳过,等下次 cron
   - 读上次 cursor_after → SQL WHERE updated_date > (cursor − 回看窗 48h)
     (回看重叠靠 source_event_id 去重;增量数据量小,通常 1 批)
   - 拉新增/变更 fact,并补全主档(见 §三)
   - 写 cursor_after = 本次起跑时间(非 max(updated_date)),
     保证跑期间数据源的任何新写入下次都能捞回
   ↓
Persona 重算(本次涉及的患者)→ Plan 重算(SQL 召回 + 多因子打分)
```

> 端到端约 T+1(数据源本身比临床事件晚一天)。

手动触发(调试 / 紧急补跑):

```bash
pnpm sync -- --dir=./data/<host>                 # 增量 + 联动重算
pnpm sync -- --dir=./data/<host> --no-recompute  # 只拉数据
pnpm sync -- --dir=./data/<host> --dry-run       # 不写库,预览游标注入
pnpm sync -- --dir=./data/<host> --full          # 忽略游标强制全量(灾后)
```

### 2.1 增量 cohort 与「无 cursor 表」

增量分两步,理解成本要分开看:

1. **选 cohort**:`manifest.incremental.per_query` 声明的表,各按自己 `cursor_column`
   取 `WHERE cursor > 水位` 的患者,**并集**成本轮 cohort(UNION —— 任一表有变更就纳入,
   避免"预约变了但人没来"被漏)。
2. **拉资源**:对 cohort 患者,**所有**源表按 `(patient_id, brand) IN (cohort)` 拉行。
   没进 `per_query` 的表(无 cursor)也在此步被拉 —— **按患者收窄,不全表扫**。

**关键推论**:无 cursor 表**不能独立把患者拉进增量**。若某患者只有无 cursor 表变了
(且 cursor 表都没动)→ 不进 cohort → 该变更**积压到该患者下次因 cursor 表再进 cohort 才补上**。
所以无 cursor 表适合**弱信号 / 展示类**(容忍延迟),不适合驱动召回的核心信号。

以 **jvs-dw(瑞尔)** 为例(2026-07 DW 实测行数):

| 源表 | 行数 | cursor | CH 排序键 | 说明 |
|---|---:|---|---|---|
| fact_client_out | 5.7M | `last_visit_time` | patient_id | 患者主档 |
| fact_emr_treatment_out | 5.1M | `updated_date` | patient_id… | 治疗/病历(召回核心) |
| fact_appointment_out | 9.1M | `updated_date` | id | 预约 |
| fact_settlement_out | 9.1M | `updated_date` | id | 结算 |
| fact_settlement_mode_out | 4.4M | `updated_date` | — | 支付通道 |
| **fact_returnvisit_out** | **13.4M** | **无** | 无 | 回访任务(展示) |
| **fact_consult_out** | **10.8M** | **无** | org… | 咨询(意向,弱信号) |
| fact_emr_image_analysis_out | 553K | 无 | emr_id | 影像 AI |
| fact_customer_referee_out | 499K | 无 | 无 | 转介 |

<Callout type="info">
**提频(如 2h 一轮)的成本**——由**空轮短路**决定,不是 cohort 大小:

- **cohort 选择每轮都跑**(含空轮):cursor 列(`updated_date` 等)通常**不是 CH 排序键
  → 无索引 → 每轮全列扫**。但只扫单列,9M 行亚秒级,几张合计几秒/轮,绝对量可忽略。
- **资源拉取只在 cohort 非空时跑**:上游多为日更 → 日内多数轮 cohort 为空 →
  在选 cohort 后即**短路**(跳过资源拉取 + persona/plan 重算),不触碰上面两张最大的无 cursor 表。
- 所以提频主要增加的是**廉价的空轮探查**,重活(资源全表扫 + 重算)仍是刷新后 1–2 轮,不被放大。

真要压这点空轮扫描,治本是给 cursor 表的 `updated_date` 加 CH 数据跳过索引;收益很小,通常不值当。
</Callout>

---

## 三、数据完整性

**幂等(数据库双 UNIQUE)** —— 任何并发 / 重跑结果一致:

- `patient_transactions`:partial UNIQUE `(host_id, tenant_id, source_event_id) WHERE source_event_id NOT NULL`
- `patient_facts`:UNIQUE `(host_id, tenant_id, subject_id, version)` + active 行 partial UNIQUE

`source_event_id` 内含 `updatedAt`(缺失时确定性回退 `createdAt`),因此源数据行级 in-place 更新会自然产生新 tx 与新 fact 版本(旧版 supersede);同行同 `updatedAt` 则幂等跳过。fact 层的变更判定是**内容驱动**的(比对 content hash + 状态 + 时间锚,不看 `updatedAt`),所以即使事务被幂等跳过,只要行被重新处理,内容真变了 fact 仍会升版本——变更不会因时间戳丢失而漏掉。

<Callout type="warn">
**`updatedAt` 维护缺失的两个后果**(如某些源表主档 `updated_gmt_at` 大量为 null)。当前设计有意保留 `updatedAt` 在幂等键中,**不改**;接入时按下面两点评估:

1. **pull/增量取数够不到**：增量按 `WHERE updated_date > cursor` 拉数,**内容变了但 `updatedAt` 没随之更新的行根本不会被选出来**——这道过滤在幂等之前,fact 层内容比对再准也无行可比。file 全量装载 / push(宿主自选推送)不受此限(不按 cursor 过滤)。治本靠源表维护 `updatedAt`;主档另有定期全量反拉兜底(见上「主档补全」)。

2. **事务与 fact 可能不一致 → reparse 会打回**:若内容变了但 `updatedAt` 没变,事务因 `source_event_id` 未变被幂等跳过(`rawPayload` 停在旧内容),但 fact 层仍按新内容升版本 —— 二者内容不再一致。此后 [reparse](#52-reparse--字典改了补存量无需全量重摄) 从 `transaction.rawPayload`(旧值)离线重放,会把 fact 打回旧内容。**规避**:对 `updatedAt` 不可靠的源表,不要依赖 reparse 修其存量,改走 DW 重摄 / 重推。
</Callout>

**主档补全(两条保障,确保 patient_id 不丢)**:

- 增量只拉到 fact、主档游标未动时,用三段键建空 stub,真实主档随后 upsert 补 PII;
- 每次跑完按 `(patient_id, brand)` 反向强拉一次主档,补齐当批关联。

**数据源无关**:cohort 的取数维度(患者列表来源、患者键、租户键、游标列)全部由 `manifest.sql_source.cohort` 声明,代码不硬编码表名列名 —— 接新 host 只改 yaml。

---

## 四、资源调优

| 旋钮 | env / 参数 | 作用 | 建议 |
|---|---|---|---|
| cohort 大小 | `PAC_COHORT_BATCH_SIZE` / `--cohort-batch=N` | 每批患者数(控内存) | 5000;数据源较慢看进度可调 500 |
| cohort 并发 | `PAC_COHORT_CONCURRENCY` | 并发 cohort 数(提吞吐) | 默认 1 稳;3-4 并行(连接池自动放大) |
| 写批大小 | `PAC_WRITE_BATCH_SIZE` | 每批写入行数 | 1000;0 = 逐行(回退开关) |
| query 并行 | (自动) | 多个数据源 query 并发 | load 阶段约 4x,无需配置 |

并发安全:cohort 是互不相交的患者集,所有写操作按患者隔离,叠加数据库双 UNIQUE 兜底,任何并发下都一致(并发模式下禁用 checkpoint resume,靠幂等从头重跑)。

---

## 五、配置变更

配置 yaml 位于 `apps/pac-service/data/<host>/`。

### 5.1 变更影响速查

| 改动 | 影响 | 处理 |
|---|---|---|
| `enum_mapping` / `keyword_mapping` 加改术语 | 新数据生效,老数据需重解析 | `pnpm reparse --subject-type=<canonical>`(§5.2,不用全量重摄) |
| `field_mapping` 改字段名 | 同上 | 同上 |
| `transforms` 改 split / derive | 影响装配管道 | 同上(reparse 会重跑 transforms) |
| `scenario` SQL 改召回口径 | 立即生效(下次重算) | 等 cron 或手动 `recompute-plans`(facts 不变,无需 reparse) |
| `canonical-codes` 字典 | 立即生效 | 重启 pac-service |

### 5.2 Reparse —— 字典改了补存量(无需全量重摄)

改了 assembler yaml(`enum_mapping` / `keyword_mapping` / `field_mapping` / `transforms`)后,要把**已入库的老 fact 按新口径重新衍生**。无需 truncate + 全量重摄(那会重拉数据源且删掉执行历史与分配)。`reparse` 用已存的 `transaction.rawPayload`(原始源行)**离线**重跑装配 + 解析:

- **非破坏**:fact 走版本流(内容变则升版本 supersede,不变则跳过),不 truncate、不碰 transaction 账本,客服执行历史与分配全部保留;
- **不连数据源**:rawPayload 在本地,比全量重摄快一个量级;
- **可圈定**:按 host / subject-type / 患者集 / dry-run / 是否重算;
- **覆盖范围**:field/enum/keyword_mapping、transforms 算子、parser 改动;**不覆盖**改了 `sql_source` 的 SELECT 本身(需从数据源多拉列/表,那才需真重摄)。非 transform 产出的资源(如 SQL 视图类)会自动跳过。
- **前提:rawPayload 是最新的**。reparse 只能和源行的最后一次落库一样新。若某源表 `updatedAt` 维护缺失,曾出现「内容变了但事务被幂等跳过」(见 §三 Callout),其 `rawPayload` 已滞后 —— 对这类表 reparse 会把 fact 打回旧内容,应改走重摄 / 重推而非 reparse。

```bash
# dry-run:报 scope(患者数 / txn 数),不写库
pnpm reparse:prod -- --host=<host> --subject-type=diagnosis --dry-run

# 实跑某资源(按患者分批,内存恒定;自动定向重算受影响患者的 persona/plan)
pnpm reparse:prod -- --host=<host> --subject-type=diagnosis

# 限定患者集(秒级,改 keyword 只影响一小批时用)
pnpm reparse:prod -- --host=<host> --subject-type=diagnosis --patient=<uuid>,<uuid>

# 只重衍 fact、不重算(超大批量时,稍后单独 recompute-plans)
pnpm reparse:prod -- --host=<host> --subject-type=diagnosis --no-recompute

# Docker(prod)容器内 detached 跑 + 看日志
docker compose --env-file apps/pac-service/.env -f docker-compose.prod.yml \
  exec -d pac-service sh -c "pnpm reparse:prod -- --host=<host> --subject-type=diagnosis > /tmp/reparse.log 2>&1"
docker compose -f docker-compose.prod.yml exec -T pac-service tail -f /tmp/reparse.log
```

| 参数 | 含义 |
|---|---|
| `--subject-type=<canonical>` | 限定资源(如 diagnosis / treatment_actual);空 = 所有 transform 产出的资源 |
| `--patient=<uuid,...>` | 限定患者集(秒级);空 = 全 host(分批) |
| `--dry-run` / `--no-recompute` | 只报 scope / 只重衍 fact 不重算 |
| env `PAC_REPARSE_BATCH` | 全量时每批患者数(默认 3000,控内存) |

> **典型场景**:源数据中文诊断名变体繁多、多数无标准 ICD 码,全靠 enum / keyword 白名单翻译;漏映射会导致 `code=null`、进不了对应病种召回。修法:在 `diagnosis.yaml` 补 keyword → `pnpm reparse:prod -- --subject-type=diagnosis`,即可把该病种此前漏召的存量患者补回召回池。十万级患者全量 reparse 内存恒定在 1G 以内,单患者 / 小批为秒级。
