ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SNOMED CT落库PostgreSQL:RF2解析、闭包表构建与工程实践

SNOMED CT落库PostgreSQL:RF2解析、闭包表构建与工程实践

简介:面向医疗信息标准化工程师、术语服务开发者及健康数据研究人员,这份资源提供了一套完整的SNOMED CT数据库脚本,用于将RF2分发格式的术语版本创建并填充到MySQL、PostgreSQL、MSSQL或Neo4j等关系或图数据库中,解决从原始发布包到可查询术语库的转换与加载难题。压缩包共115个文件,整体约434KB,其中64个sql脚本覆盖建表、索引与数据装载,py、sh、bat脚本承担自动化处理与批量导入,cfg、ini、cnf等配置模板则便于调整数据库连接与路径参数。目前已有1103人学习浏览。资源按数据库平台分子目录组织,包含MySQL运行与加载脚本、Neo4j图库更新失败检查脚本、bcp命令行格式文件及配置文件示例,读者可据此快速搭建SNOMED CT术语环境,也能参考脚本结构进行二次开发或迁移到其他数据库平台。对于需要将SNOMED CT术语库嵌入业务系统或开展术语映射工作的团队,这些脚本同样提供了可直接复用的基础框架。

1. SNOMED CT 进关系数据库:先把它当“图”,再决定怎么拆成“表”

某医院信息科的A同学接到需求:把SNOMED CT术语库接进HIS做诊断编码推荐。他解压官方发布包后直接当成“字典”导进业务库,结果几十万条术语互相引用,光是“找某个概念的所有子类”这一种查询就跑到超时。问题不在数据量,而在SNOMED CT本质上是一张巨大的语义图:概念与概念通过is-a和几十种属性关系相连,描述与概念之间又是一对多。要在关系数据库中表示SNOMED CT,不是建一张字典表能解决的,必须把这张图拆成少数几张结构清晰的表,再为“树状遍历”补上物化索引。下面这条路径就是从RF2发布文件开始,到建表、灌数、做闭包表、排错,完整落进PostgreSQL的过程。

2. RF2 先读懂再建库:概念、描述、关系三张表怎么拆

2.1 RF2三驾马车的职责边界

SNOMED CT的官方发布格式叫RF2(Release Format 2)。解压后你会看到一堆以tab分隔的文本文件,每个文件第一行是列名,后面每一行是一条记录。RF2的核心术语文件按组件分三类:概念文件、描述文件、关系文件,外加一个同构的Stated Relationship文件,它们的职责完全不同。

概念(Concept)是骨架。每个概念有唯一的SCTID,例如“糖尿病”是一个概念,“2型糖尿病”是另一个概念,二者不能合并。概念文件里每一行只回答“这个概念在某个生效时间点上是有效还是废弃、定义得充分不充分”,不携带任何名称。把概念表当成字典表去展示文本,是第一个常见的建模错误。

描述(Description)是概念的人类可读文字。一个概念对应多条描述,包括Fully Specified Name(全称)、同义词以及不同语言Code下的名称。描述表里最关键的列是concept_id、Term、Language Code和Type Id。数据导入后但凡忘记按概念分组取首选描述,查询出来的就是一个概念拖着几十条描述的“脏”结果。

关系(Relationship)是概念之间的边。每条关系有source_id和destination_id两个端点,type_id表示关系类型,is-a对应的type_id是116680003,这个值建议在建模第一步就固化成常量变量。relationship_group表示该属性关系属于第几组,characteristic_type_id区分定义性关系与推断性关系。Stated Relationship文件与关系文件几乎同构,维护的是逻辑定义用的“声明式”关系,临床查询通常看inferred侧。

2.2 版本化字段怎么参与主键:一张表还是两张表

RF2每个文件里都有三列通用字段:effective_time、active、module_id。effective_time表示这个版本从哪一天开始生效,active表示该版本当前是否有效,module_id表示记录属于核心模块还是某个扩展模块。要正确落库,这三列一个都不能丢,因为它们参与“一个SCTID到底代表哪一版概念”的判定。

主键设计取决于你打算存多少历史。只存当前快照,概念表主键直接用id即可;但如果每天用增量包刷新,旧行可能被新行替代,同一个SCTID会在不同effective_time下出现多行。我一般会把基表主键设计成(id, effective_time)组合键,再用一个视图专门取每个id的最新版本。这样一条路径同时支撑“当前查询”和“历史版本回溯”,索引会大一点,但对诊断编码这类需要追踪当时语义的数据而言非常值得。

描述表和关系表同理:描述表用(description_id, effective_time),关系表用(relationship_id, effective_time)。关系表还有一个天然的重复来源:同一个source、destination、type、group完全相同的两条记录,只要characteristic_type_id不同就会同时存在。所以关系表的主键只能落在物理行的id上,不能“去重”后把(source, destination, type)当主键,否则一进增量就炸。

2.3 最小DDL:PostgreSQL建表语句与约束细节

下面这套DDL是我在PostgreSQL里最常用的最小结构。为了节省篇幅,只放了概念、描述、关系三张核心表。

CREATE TABLE snomed_concept ( id BIGINT NOT NULL, effective_time CHAR(8) NOT NULL, -- YYYYMMDD active BOOLEAN NOT NULL, module_id BIGINT NOT NULL, definition_status_id BIGINT NOT NULL, -- 充分定义 / 必要定义 PRIMARY KEY (id, effective_time) ); CREATE TABLE snomed_description ( id BIGINT NOT NULL, effective_time CHAR(8) NOT NULL, active BOOLEAN NOT NULL, module_id BIGINT NOT NULL, concept_id BIGINT NOT NULL, -- 指向概念 language_code TEXT NOT NULL, -- 如 en、en-us、zh type_id BIGINT NOT NULL, -- FSN / 同义词 term TEXT NOT NULL, case_significance_id BIGINT NOT NULL, PRIMARY KEY (id, effective_time) ); CREATE TABLE snomed_relationship ( id BIGINT NOT NULL, effective_time CHAR(8) NOT NULL, active BOOLEAN NOT NULL, module_id BIGINT NOT NULL, source_id BIGINT NOT NULL, destination_id BIGINT NOT NULL, relationship_group INT NOT NULL DEFAULT 0, type_id BIGINT NOT NULL, characteristic_type_id BIGINT NOT NULL, modifier_id BIGINT NOT NULL, PRIMARY KEY (id, effective_time) );

这里我没有加物理外键,只保留普通索引。原因是在导入Full历史包时,某条关系引用的概念可能只存在于更早的版本里,物理外键会让导入直接失败。对外键有洁癖的团队,可以在“当前快照视图”建好之后单独做校验,而不是建表时就锁死。列类型上,SCTID最大是18位数字,BIGINT足够;effective_time用CHAR(8)而非DATE,便于和发布包文件名里的日期直接比对。

3. 打开官方包灌进PostgreSQL:全量装载与增量刷新

3.1 三种发布文件:Snapshot、Delta与Full的用途差异

RF2发布包打开后会有以版本日期命名的目录,术语文件按模块和发布类型拆成多种形式。最需要分清三种发布粒度:Snapshot快照、Delta增量、Full全历史。

Snapshot是截至某日期的全量现行状态,里面同时包含active=1和active=0的记录,适合第一次灌库。Delta是从上一个发布日期到当前日期的变化记录,active=1表示新增或更新,active=0表示停用,适合把已跑起来的库推进到新版本。Full是自发布以来所有历史的集合,同一id会出现多行不同effective_time,只有做历史回溯时才用它。

很多团队第一次导入就把Full误当成快照,结果概念表主键被重复id撑爆。我一般直接用最新一期的Snapshot做初始库,主键天然唯一,等业务稳定后再考虑进入Delta流程。如果是国际版核心发布包,目录里通常还带Refset和Cross Map文件,比如到ICD的映射表,这些不是三张核心表,但往往是业务真正用起来的入口。

3.2 全量导入最小命令:\copy的核心参数与导入顺序

导入顺序必须按引用方向从无到有:先概念,再描述,最后关系。别看官方文件体积大,用PostgreSQL的\copy走文本流非常快,几十万行的核心表十几秒就能完成。下面是我常用的命令。

# 用 psql 的 \copy 把快照文件导入三张基表 # \copy 在客户端执行,文件路径是本地路径;COPY 在服务端执行,路径要落在数据库服务器上 psql "$CONN_STR" <<'SQL' TRUNCATE snomed_concept, snomed_description, snomed_relationship; \copy snomed_concept FROM 'sct2_Concept_Snapshot_INT_20250131.txt' WITH (FORMAT csv, DELIMITER E'\t', NULL ''); \copy snomed_description FROM 'sct2_Description_Snapshot_INT_20250131.txt' WITH (FORMAT csv, DELIMITER E'\t', NULL ''); \copy snomed_relationship FROM 'sct2_Relationship_Snapshot_INT_20250131.txt' WITH (FORMAT csv, DELIMITER E'\t', NULL ''); SQL

三个文件我都加了FORMAT csv和DELIMITER E'\t'。RF2文件本身是制表符分隔文本,直接按csv读完全没问题。列名在第一行,\copy会把它当成数据,所以要先处理掉表头;空字段在部分历史包里会出现,NULL ''可以让它正确落成NULL而不是空字符串。

提示:\copy与COPY的性能差异不大,区别只在读取文件的位置。客户端拿不到数据库服务器文件系统权限时,用\copy是最省事的选择。

TRUNCATE和\copy在同一个会话里顺序执行,表上没有外键是前提,这也是前面不做物理外键的好处:灌数据时可以随时清空重建。

3.3 Delta增量两张策略,选哪种提前定

增量刷新最朴素且最不容易出错的策略是“以Delta为准,把库里的旧版本直接替换”。具体做法是:先把Delta文件导到一张临时表,再对三张基表分别执行“删除同id所有旧行、再插入delta行”。

-- 以概念表为例,description、relationship 同理 BEGIN; DELETE FROM snomed_concept WHERE id IN (SELECT DISTINCT id FROM concept_delta); INSERT INTO snomed_concept SELECT * FROM concept_delta; COMMIT;

这个方案牺牲了历史版本,换来的是主键永远是当前最新状态,查询和关系表join时只需过滤active=1,不需要每次取最新。如果业务要求保留历史,就把DELETE换成“按id取最大effective_time将旧行置为active=0”,再把新行insert进去,相当于用时间戳标记废弃版本。

两个策略都要求Delta文件必须与基线快照日期衔接,否则最终结果会和官方浏览器对不上。跑完合并后,我习惯做一次数量核对:取概念表的active=1行数,和该版本发布说明里的有效概念数对比,差一条都要查导入链路。

4. 让 is-a 层级查询跑得快:闭包表生成与索引设计

4.1 递归查询为什么在这里“玄学”地慢

很多工程师第一次接触SNOMED CT时,会觉得“父子关系不就是递归查询吗”。但在概念规模几十万、层级深度十几层到三十几层的模型里,每条查询都要把从当前节点出发的整棵子树重新计算一遍。PostgreSQL的递归CTE在中间结果集变大后会明显变慢,再加上诊断推荐这类业务往往要在几百个候选概念上反复做“找子类”,递归次数就乘上了候选数,慢得没有规律。这就是我常说的“玄学”:同样的SQL在这个模块快,换个模块慢,很难提前预测。

与其让查询时现算,不如在数据入表时就把“谁是祖先、谁是后代、隔了几层”算好存好。这就是关系数据库表示SNOMED CT时最实用的闭包表(closure table)。

4.2 一条SQL生成闭包表:从is-a边到全部祖先-后代对

闭包表的核心是:只保留真正的父子边,然后反复连接,把任意两个节点之间“可推导出的祖先-后代关系”全部写进一张表。生成前先确认关系表里只取active=1、type_id=116680003、relationship_group=0的行,这三层过滤缺一不可。

CREATE TABLE snomed_closure ( ancestor_id BIGINT NOT NULL, descendant_id BIGINT NOT NULL, distance INT NOT NULL, PRIMARY KEY (ancestor_id, descendant_id) ); -- 从父子边推导所有传递关系,限制最大深度防止极端异常 WITH RECURSIVE isa AS ( SELECT source_id, destination_id FROM snomed_relationship WHERE active = TRUE AND type_id = 116680003 ), closure AS ( SELECT source_id AS ancestor_id, destination_id AS descendant_id, 1 AS distance FROM isa UNION ALL SELECT c.ancestor_id, i.destination_id, c.distance + 1 FROM closure c JOIN isa i ON c.descendant_id = i.source_id WHERE c.distance < 39 ) INSERT INTO snomed_closure (ancestor_id, descendant_id, distance) SELECT DISTINCT ancestor_id, descendant_id, MIN(distance) FROM closure GROUP BY ancestor_id, descendant_id;

递归部分每迭代一次往下走一层,distance从1开始,限制到39层是因为SNOMED CT的实际层级深度不会超过这个量级,同时能挡住脏数据造成的无限循环。最外层用GROUP BY MIN(distance),是因为同一对祖先和后代可能存在多条路径,闭包表只需要最短距离。

生成一次之后,日常的“找所有子类”从递归变成一条主键查询。之后每次发布新版本,两条路:全量重建,或只针对增量涉及的局部节点重建。全量重建成本并不高,千万行的闭包表在普通服务器上也就是几分钟,所以我建议直接重建。

4.3 必建索引清单与EXPLAIN验证方法

闭包表建好不代表万事大吉,索引要按查询方向配齐。SNOMED CT业务里“向上找祖先”和“向下找子类”都是高频,所以两个方向都要有索引。单靠PRIMARY KEY (ancestor_id, descendant_id)只能支持“给一个后代找祖先”,反向“给一个祖先找所有后代”会全表扫描。

CREATE INDEX idx_closure_descendant ON snomed_closure (descendant_id, ancestor_id); CREATE INDEX idx_closure_distance ON snomed_closure (descendant_id, distance);

索引配置思路可以按场景整理成表:

查询场景推荐索引说明
向下找子类(ancestor_id, descendant_id)主键已覆盖,无需另建
向上找祖先(descendant_id, ancestor_id)反向遍历的关键索引
按层级深度过滤descendant_id + distance统计叶子数量时有用

验证方法很直接:随便拿一个已知概念,比如“糖尿病”,跑一句带EXPLAIN的查询,看有没有命中索引而不是Seq Scan。如果命中索引但速度仍不理想,再看是不是返回的节点太多,这时加LIMIT和分页比继续调索引更有效。

5. 关系库落地SNOMED CT的5个高频坑:现象、原因与处理

5.1 同一个概念查出来好几个“标准名称”

现象:做完描述表查询后,一个concept_id下冒出一大串term,完全分不清展示给用户用哪个。

原因:描述表里同时存在FSN和多个同义词,语言Code可能还分en、en-us,所有active=1的记录都被当成“标准名称”返回了。

解决:建首选描述视图。查询时按concept_id分组,在描述类型里按优先级取一条,语言Code上按当前系统语言排序。不要试图在ETL阶段把其他term删掉,保留全量描述是为了后续检索和模糊匹配。

5.2 Delta增量导入后数据越导越多

现象:按Delta合并时只做“插入”处理,结果概念表出现大量重复ID,新老版本同时有效。

原因:Delta里的active=1既表示“新概念”也表示“旧概念的更新版本”。只插入不删除,同一id就出现两条当前有效记录。

解决:合并时先按id把旧记录清理掉,再插入delta行。处理关系表时不要只看id,要连同characteristic_type_id一起清理,因为同一个关系在inferred和stated两侧各有一条记录,混在一起就是两倍。

5.3 关系方向搞反,把父类查成子类

现象:查“低血糖的病因”时,返回了一大堆“血糖”的祖先而不是子类。

原因:SNOMED CT关系表的语义是source_id指向宿主概念,destination_id指向目标概念。is-a的读法是“source is a destination”,source是子类、destination是父类,方向反掉整棵树都会倒过来。

解决:闭包表和业务视图里统一约定“ancestor_id为上级,descendant_id为下级”,生成闭包时确认亲子方向,不要靠临时记忆。写查询前先拿“糖尿病”和“2型糖尿病”这对已知关系验证一次方向。

5.4 闭包表行数膨胀到上千万,查询反而更慢

现象:闭包表生成后行数巨大,跑一次“所有子类”还是慢,甚至比递归更慢。

原因:闭包表把“所有可能路径”都物化了,没有做结果集限制。某些顶级概念直接拥有几万后代,一次返回几十万行,排序和统计被拖垮。

解决:看查询目标。只显示下一级子类时用父子边界表,不用闭包表;要统计叶子节点时在查询里加层级过滤或分页。闭包表适合“判断两个节点是否连通”和“统计子类数量”,不是所有层级查询都能靠它无脑提速。

5.5 把Stated Relationship当Inferred关系用,层级和官方对不上

现象:闭包表生成后,某些概念的层级跟官方浏览器看到的不一致,多出不少重复祖先。

原因:关系文件里既有stated又有inferred两套关系。Stated关系是逻辑定义侧的“声明式父类”,包含了一些可推断但不直接显示的边;官方浏览器的类目层级大多按inferred关系展示。

解决:生成闭包表时明确筛选inferred侧的特征类型id,只保留这一类的边。如果两种关系都要入库,就必须在关系表上加视图,把“查询用关系”和“定义用关系”分开。

6. 把表封装成接口:首选词视图、官方核对与迁移图库的时机

6.1 一个常用视图:取一个概念的首选描述

描述表保留全量后,“取首选词”就成了高频操作。我习惯把它做成物化视图,让业务查询只通过一条join取到展示名。

CREATE MATERIALIZED VIEW v_concept_preferred_description AS SELECT DISTINCT ON (d.concept_id) d.concept_id, d.id AS description_id, d.term AS preferred_term, d.language_code, d.type_id FROM snomed_description d WHERE d.active = TRUE ORDER BY d.concept_id, CASE WHEN d.language_code = 'en' THEN 0 ELSE 1 END, CASE WHEN d.type_id = 900000000000013009 THEN 0 ELSE 1 END;

DISTINCT ON的作用是按concept_id分组后,只保留排序第一的行。ORDER BY里语言Code优先,FSN类型其次,这样在英文环境优先给FSN做权威名,避免把同义词当正名。这个视图依赖一个事实:FSN的type_id是900000000000013009,如果你用的扩展包改动过类型定义,要先把映射表查出来,不要硬编码这个值。

6.2 两类核对方法:层级数和版本维度

数据灌完后我至少做两次核对。第一次拿一个中间层概念,查它的直接子类个数并与官方浏览器展示的子类树比对,确认is-a方向没反、过滤条件没漏。第二次核对版本维度:执行select distinct effective_time from snomed_concept,结果应该只有一个日期,这个日期要和发布包目录名一致。只要有第二个日期混进来,说明混了Delta或上一个快照的残留,查询结果一定对不上。

6.3 什么时候该换图数据库

如果业务涉及“找两个概念之间的最短路径”“语义相似度计算”这类需要反复跨多层关系的计算,关系库里的闭包表维护成本会变高,这时候迁移到图数据库是自然的演进方向。图数据库天然保存节点和边,大部分场景不再需要闭包表。我自己的习惯是:核心业务先用关系库把术语字典跑通,把周边系统集成做扎实,等真有图计算需求了再额外导一份到图数据库,而不是一开始就把它当主存储。这两年我经手过好几个被“图数据库热潮”带偏的项目,最后都靠关系库里的闭包表撑着上线。先明确你要解决的是“集成问题”还是“图计算问题”,再决定要不要动迁移。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进