简介:面向医学信息学开发者、数据库工程师和术语运维人员的 SNOMED CT 数据库装载脚本包,解决将 RF2 分发版中的 SNOMED CT 术语表导入各类数据库的难题。压缩包内共 115 个文件,大小仅 434KB,核心为 64 个 SQL 脚本,配合 Python/Perl 辅助脚本、Markdown 说明、配置文件、模板以及 Windows 批处理和 Linux Shell 命令,可覆盖 MySQL、PostgreSQL、MSSQL、Neo4j 的建表、数据装载、运行管理与版本检查。目前已有 1100 人学习下载。资源按数据库类型分子目录组织,结构清晰,包含建表与数据填充脚本、环境配置示例、图数据库更新失败检测的 Cypher 脚本、运行时参数模板等附加工具;同时预留了面向其他数据库脚本的扩展入口,便于二次开发与贡献。整体适合需要快速搭建 SNOMED CT 术语服务、做术语库迁移或开展医学信息标准化研究的初中级技术人员,是一份可直接参考的跨数据库装载方案。
1. 在关系数据库里装下 SNOMED CT:比解压 RF2 复杂在哪
拿到 SNOMED CT 的 RF2 发布包时,很容易产生一种“这就完事了?”的错觉:解压、导入、建个索引,好像就能给业务系统用。真正把 Concept(概念)、Description(描述)、Relationship(关系)这三张核心表落进关系型数据库之后,你才会发现这个临床术语集跟普通主数据完全不是一回事——它有版本快照、有层次继承,关系还分推理前和推理后,很多概念之间带有条件约束。没有一张靠谱的关系模型兜底,后面做术语检索、做子类型推理、做临床数据标准化,全都会卡壳。这篇笔记要拆的,就是把 SNOMED CT 表示成关系数据库表结构这件事:从 RF2 文件到建表、导入、查询、踩坑,整个过程是可以靠一套 SQL 脚本跑完的。适合正在做临床数据标准化、电子病历集成或独立术语服务的开发者和数据工程师。
2. RF2 数据模型:先搞清楚发布包里装的什么,再谈建表
2.1 RF2 发布包解开后到底有哪些文件
SNOMED CT 的官方发布包遵循 RF2(Release Format 2)规范,压缩包解压后,第一层目录通常按聚焦(Focus)划分,最常见的三个目录是Snapshot、Delta和Full。如果你只是要搭一个能用的术语库,Snapshot目录下的文件是唯一需要关心的。
Snapshot/Terminology里放着一大堆以sct2_开头的 TSV 文件,命名规则可以拆成几段:
sct2_Concept_Snapshot_GB1000000_20190731.txt:概念快照sct2_Description_Snapshot-en-GB1000000_20190731.txt:英文描述快照sct2_Relationship_Snapshot_GB1000000_20190731.txt:关系快照sct2_Identifier_Snapshot_GB1000000_20190731.txt:标识符快照(用于映射其他编码体系)
文件名中间那段en-GB表示语言区域,20190731表示快照的生效时间。为什么快照文件这么重要?因为Delta只放增量行,Full放全部历史行,你如果从增量开始拼版本,要处理一堆 effectiveTime 和 active 标志的叠加逻辑,很容易拼错;而Snapshot就是“此刻的完整状态”,直接倒进数据库就能用。
概念、描述、关系这三张表是 SNOMED CT 的骨架,其余文件要么是补充说明,要么是映射表。实际建库时,先把这三张表立起来,后面需要文本定义、属性值再做外键扩展。
2.2 概念表、描述表、关系表:字段不多,但每个字段都别省
把概念表、描述表、关系表拆开之前,先明确一个底层逻辑:SNOMED CT 里的“概念”是一个抽象编号,本身没有文字含义;你能读懂的“糖尿病”“高血压”,存在描述表里;“糖尿病是一种内分泌疾病”这种语义关系,存在关系表里。三者通过 ID 互相引用。
概念表(Concept)的字段:
| 字段 | 类型 | 含义 |
|---|---|---|
| id | BIGINT | 概念唯一标识 |
| effectiveTime | DATE | 该行生效日期 |
| active | BOOLEAN | 当前是否有效 |
| moduleId | BIGINT | 所属模块 |
| definitionStatusId | BIGINT | 概念定义状态(完全定义/原始定义) |
描述表(Description)的字段:
| 字段 | 类型 | 含义 |
|---|---|---|
| id | BIGINT | 描述唯一标识 |
| effectiveTime | DATE | 生效日期 |
| active | BOOLEAN | 是否有效 |
| moduleId | BIGINT | 所属模块 |
| conceptId | BIGINT | 指向概念表 id |
| languageCode | VARCHAR(2) | 语言代码,如 en |
| typeId | BIGINT | 描述类型(FSN / 同义词) |
| term | TEXT | 实际描述文本 |
| caseSignificanceId | BIGINT | 大小写敏感标志 |
关系表(Relationship)的字段:
| 字段 | 类型 | 含义 |
|---|---|---|
| id | BIGINT | 关系唯一标识 |
| effectiveTime | DATE | 生效日期 |
| active | BOOLEAN | 是否有效 |
| moduleId | BIGINT | 所属模块 |
| sourceId | BIGINT | 源概念 |
| destinationId | BIGINT | 目标概念 |
| relationshipGroup | INTEGER | 关系组编号 |
| typeId | BIGINT | 关系类型,116680003 表示 is-a |
| characteristicTypeId | BIGINT | 推理前/推理后标志 |
| modifierId | BIGINT | 修饰符 |
这里最容易被新手忽略的是characteristicTypeId。同一个“is-a”关系,可能以 stated(推理前,值 900000000000010007)和 inferred(推理后,值 900000000000011006)两种形态存在。做临床查询时,如果你不区分,后代概念会翻倍;如果只取 stated,又会漏掉推理出来的关系。到底该选哪个,取决于你的查询目的——一般术语服务用 inferred,因为它是整理过的、可直接消费的完整关系集。
2.3 建表 DDL:为什么 id 一定用 BIGINT 不用 INT
SNOMED CT 的概念 ID 是 10 到 18 位的纯数字,比如“73211009”代表糖尿病。这个量级已经远超 32 位 INT 的上限,所以建表时 id 字段必须用 BIGINT。我用 PostgreSQL 做示例,因为它的 COPY 命令处理 TSV 最顺手:
CREATE TABLE snomed_concept ( concept_id BIGINT PRIMARY KEY, effective_time DATE, active BOOLEAN, module_id BIGINT, definition_status_id BIGINT ); CREATE TABLE snomed_description ( description_id BIGINT PRIMARY KEY, effective_time DATE, active BOOLEAN, module_id BIGINT, concept_id BIGINT NOT NULL REFERENCES snomed_concept(concept_id), language_code CHAR(2), type_id BIGINT, term TEXT, case_significance_id BIGINT ); CREATE TABLE snomed_relationship ( relationship_id BIGINT PRIMARY KEY, effective_time DATE, active BOOLEAN, module_id BIGINT, source_id BIGINT NOT NULL REFERENCES snomed_concept(concept_id), destination_id BIGINT NOT NULL REFERENCES snomed_concept(concept_id), relationship_group INTEGER, type_id BIGINT, characteristic_type_id BIGINT, modifier_id BIGINT ); CREATE INDEX idx_desc_concept ON snomed_description(concept_id); CREATE INDEX idx_rel_source ON snomed_relationship(source_id); CREATE INDEX idx_rel_dest ON snomed_relationship(destination_id); CREATE INDEX idx_rel_type ON snomed_relationship(type_id);外键约束的说明:SNOMED CT 官方快照里,理论上不存在指向不存在概念的关系行,所以外键可以放心建。但如果你导入了增量合并导致的脏数据,外键约束会直接报错帮你拦住问题,这是好事。
索引方面,idx_rel_source和idx_rel_type是查询 is-a 子类型时的关键路径索引。idx_desc_concept则是“按概念 ID 查显示名”的必用索引。如果数据量到了千万行级别,还可以考虑建覆盖索引(concept_id, type_id, active),不过这是后话,先跑通主流程再说。
3. 把 RF2 快照灌进数据库:从 TSV 到 SQL 的完整导入流程
3.1 导入前先做文件体检:表头、行数、编码
RF2 文件是 TSV 格式,第一行是字段名,从第二行开始是数据。导入之前,我习惯先做三件事:确认文件编码是 UTF-8、确认没有 BOM 头、统计行数跟发布说明对得上。
# 检查编码和 BOM file Snapshot/Terminology/sct2_Concept_Snapshot_GB1000000_20190731.txt # 正常输出:UTF-8 Unicode text # 统计行数 wc -l Snapshot/Terminology/sct2_Concept_Snapshot_GB1000000_20190731.txt # 去掉行首不可见字符,如果第一行出现 \ufeff 就需要处理 head -1 Snapshot/Terminology/sct2_Concept_Snapshot_GB1000000_20190731.txt | od -c | head -1od -c看到第一列是357 273 277,那就是典型的 UTF-8 BOM。这时候直接 COPY 会把 BOM 当作字段名的一部分,导致后续 CRUD 的列名对不上。处理方式就是用sed -i 's/^\xEF\xBB\xBF//'把文件头洗一遍,再做导入。
另一个坑藏在描述表里:术语文本本身可能包含制表符或换行。RF2 规范里说字段值不允许有制表符,但描述文本偶尔有空格和引号,遇到奇怪的文本别慌,先看是不是引号包裹问题。导入前做一次grep -P '\t'检查非首行数据,确认没有额外跳格,能省掉后面排查错位的时间。
3.2 用 COPY 批量导入:绕开 INSERT 逐行插入的性能灾难
逐行 INSERT 百万行数据,在 PostgreSQL 里可能要跑几十分钟,而 COPY 命令可以在十几秒内完成同样的事。关键在于 COPY 直接从文件读取,不经过 SQL 语法解析。
# 将 TSV 快照导入 PostgreSQL psql -d snomed_ct -v ON_ERROR_STOP=1 <<'EOF' -- 先把表清空,保证可重复执行 TRUNCATE snomed_concept, snomed_description, snomed_relationship; COPY snomed_concept(concept_id, effective_time, active, module_id, definition_status_id) FROM '/path/to/Snapshot/Terminology/sct2_Concept_Snapshot_GB1000000_20190731.txt' WITH (FORMAT csv, DELIMITER E'\t', HEADER true, NULL ''); COPY snomed_description(description_id, effective_time, active, module_id, concept_id, language_code, type_id, term, case_significance_id) FROM '/path/to/Snapshot/Terminology/sct2_Description_Snapshot-en-GB1000000_20190731.txt' WITH (FORMAT csv, DELIMITER E'\t', HEADER true, NULL ''); COPY snomed_relationship(relationship_id, effective_time, active, module_id, source_id, destination_id, relationship_group, type_id, characteristic_type_id, modifier_id) FROM '/path/to/Snapshot/Terminology/sct2_Relationship_Snapshot_GB1000000_20190731.txt' WITH (FORMAT csv, DELIMITER E'\t', HEADER true, NULL ''); EOF几个参数的含义值得说清楚:
FORMAT csv:这里不是真的 CSV,而是借用了 CSV 的解析规则,可以对 TSV 做标准分隔符处理。DELIMITER E'\t':指定制表符作为分隔符,E 前缀表示转义字符。HEADER true:跳过文件第一行的字段名。NULL '':把空字符串当成 NULL,因为 RF2 文件里部分字段可能为空。
COPY 报错时最常见的两类情况:一是字段类型不匹配,比如active列在 TSV 里是 1/0,而 PostgreSQL 的 BOOLEAN 类型需要 true/false;二是字段数跟表列数对不上。第一条我一般直接改用 SMALLINT 存 active,省去转换的折腾;第二条就要对照官方文件的字段顺序调整 COPY 列清单。
3.3 active 标志与 effective_time:导入后必须做的两步清洗
RF2 快照文件虽然代表当前状态,但里面不是所有行都是 active=1 的。因为快照记录的是“发布时刻的全量数据”,包括历史遗留的非活跃行。直接查概念名称时如果不加 active 过滤,同一概念可能返回两条描述,一条失效、一条有效。
清洗做法是在导入完成后执行一次视图封装,让业务查询永远只面对有效数据:
CREATE OR REPLACE VIEW v_concept AS SELECT concept_id, effective_time, module_id, definition_status_id FROM snomed_concept WHERE active = true; CREATE OR REPLACE VIEW v_description AS SELECT description_id, concept_id, language_code, type_id, term FROM snomed_description WHERE active = true; CREATE OR REPLACE VIEW v_relationship AS SELECT relationship_id, source_id, destination_id, relationship_group, type_id, characteristic_type_id FROM snomed_relationship WHERE active = true;推荐直接用视图而不是改表数据,原因有两点:一是保留原始快照的历史痕迹,将来要做版本对比时有据可查;二是视图是逻辑过滤,不复制数据,不占额外空间。那effective_time要不要转成 DATE 类型?RF2 文件里写的是20190731这种八位数字字符串,COPY 直接灌进 DATE 字段会失败。我的习惯是建表时先用 VARCHAR(8) 接收,导入完成后再用TO_DATE(effective_time, 'YYYYMMDD')更新一次。如果嫌多一步更新麻烦,也可以在 COPY 后用临时表中转,但单次导入量达到几十万行时,UPDATE全表的代价不算高,推荐直接更新原表。
4. 查询 SNOMED CT 的 SQL 实战:从概念定位到子类型推理
4.1 用一条 SQL 把概念 ID 转换成人类可读的名称
这是术语服务最基础的查询,本质上是概念表与描述表的关联。SNOMED CT 的每个概念通常有多条描述,其中类型 ID 为900000000000013009的表示 Fully Specified Name(FSN),类型 ID 为900000000000013011的表示同义词(Synonym)。
SELECT c.concept_id, f.term AS fsn, d.term AS synonym FROM v_concept c JOIN v_description f ON f.concept_id = c.concept_id AND f.type_id = 900000000000013009 LEFT JOIN v_description d ON d.concept_id = c.concept_id AND d.type_id = 900000000000013011 AND d.language_code = 'en' WHERE c.concept_id = 73211009;逻辑说明:FSN 是每个概念必须有的,所以用 JOIN;同义词可能没有,所以用 LEFT JOIN。查询结果会返回一行:“糖尿病”的 FSN 是“Diabetes mellitus”,同义词是“Diabetes”。类型 ID 和语言代码都是过滤条件,写错任意一个,结果都可能是空的。
这个查询的实用场景包括:HL7 FHIR 报文中携带的概念代码需要翻译成显示名展示给医生看;或反向的,临床系统里输入一个诊断名词,要转成 SNOMED CT 概念 ID 才能上传给上层平台。
4.2 递归 CTE 遍历 is-a 关系:子类型查询的通用写法
is-a 关系在数据库里的表现为:snomed_relationship表里存在一条记录,source_id是子概念,destination_id是父概念,type_id等于 116680003。要注意方向——跟直觉相反,关系表里的方向是“子指向父”。
查询某个概念的所有后代,需要反复沿 is-a 关系向下走,这正是递归 CTE 的用武之地:
WITH RECURSIVE descendants AS ( -- 锚点:先找到直接子概念 SELECT r.destination_id AS child_id FROM v_relationship r WHERE r.source_id = 73211009 AND r.type_id = 116680003 AND r.characteristic_type_id = 900000000000011006 UNION -- 递归:子概念的下一层子概念 SELECT r.destination_id FROM v_relationship r JOIN descendants d ON r.source_id = d.child_id WHERE r.type_id = 116680003 AND r.characteristic_type_id = 900000000000011006 ) SELECT child_id FROM descendants;参数说明:73211009是糖尿病概念 ID;116680003是 is-a 关系类型的固定 ID;900000000000011006是 inferred 关系标志。如果只查 stated 关系,把最后那个值换成900000000000010007,查询结果会显著减少——因为推理前的层次比推理后稀疏很多。
递归 CTE 的边界条件是靠“不再匹配到新行”来结束的,所以如果你查询的概念恰好是某个深层叶子节点,递归会很快返回空结果,不会死循环。但有个性能隐患:如果关系表里存在环(理论上 SNOMED CT 不允许,但增量合并时可能产生脏数据),递归会无限循环。标准解法是在 CTE 里加一个路径数组,判断是否重复访问:
WITH RECURSIVE descendants AS ( SELECT r.destination_id AS child_id, ARRAY[r.source_id, r.destination_id] AS path FROM v_relationship r WHERE r.source_id = 73211009 AND r.type_id = 116680003 AND r.characteristic_type_id = 900000000000011006 UNION ALL SELECT r.destination_id, d.path || r.destination_id FROM v_relationship r JOIN descendants d ON r.source_id = d.child_id WHERE r.type_id = 116680003 AND r.characteristic_type_id = 900000000000011006 AND NOT r.destination_id = ANY(d.path) ) SELECT child_id FROM descendants;加path数组的成本是每个递归层级都要做一次数组包含判断,数据量大的时候会拖慢查询,但比起死循环导致数据库连接挂死,这个开销完全可以接受。生产环境我通常默认带上。
4.3 性能优化:递归 CTE 不是万能的,物化路径才是
递归 CTE 的返工点在于:每次查询都要从头递归,无法提前缓存中间结果。在术语服务场景,子类型关系是相对稳定的,频繁重复计算同一棵子树的代价很高。
我见过的生产级做法是:维护一张“祖先后代关系表”,把每个概念的所有祖先都展开成扁平行:
CREATE TABLE concept_ancestor ( ancestor_id BIGINT NOT NULL, descendant_id BIGINT NOT NULL, depth INTEGER, PRIMARY KEY (ancestor_id, descendant_id) ); CREATE INDEX idx_ancestor_descendant ON concept_ancestor(descendant_id);填充这张表的逻辑可以借用上文的递归 CTE,即使一次性全量计算需要跑几分钟,也值得——因为之后每次查询子类型就是一次 B 树索引定位加一次范围扫描,毫秒级返回。
物化路径表带来的查询简化效果很直观:
SELECT descendant_id FROM concept_ancestor WHERE ancestor_id = 73211009;这条 SQL 的响应时间跟概念层级深度无关,只跟后代数量有关。临床术语服务中“根据 ICD 代码反查 SNOMED 概念”“按部位筛选所有相关手术操作”这类高频查询,都应该映射到物化路径表,而不是直接打递归 CTE。
需要提醒的是,物化路径表是全量计算,SNOMED CT 更新版本后必须重建。所以要么做成定时任务,要么做成重建脚本,在导入新快照后自动触发一次。后面讲导入流程时,我还会再提这个衔接点。
5. 避坑指南:RF2 导入与查询的五个经典雷区
5.1 概念 ID 溢出 INT 范围,查询报错
现象:把concept_id定义为 INTEGER,导入时报错integer out of range,或者导入成功但数据被截断。
原因:SNOMED CT 的概念 ID 是无符号长整型,最大可达 18 位十进制数,INT 类型最大只有 21 亿,远不够装。
解决:建表时概念 ID、描述 ID、关系 ID 全部用 BIGINT。外键引用列也要跟着用 BIGINT,不然 JOIN 时类型不匹配,PostgreSQL 会报operator does not exist: bigint = integer。
5.2 忘了过滤 active=1,查询结果多出一倍概念
现象:按概念 ID 查描述,返回了两行甚至多行,其中一行 term 是空的或带历史标记。
原因:RF2 快照包含了历史失效行,同一个概念 ID 可能对应多条描述,只是 effectiveTime 和 active 不同。
解决:所有业务查询强制走v_concept、v_description、v_relationship这三个视图。视图里已经默认过滤 active=true。不要直接查底层表,除非你明确在做历史版本回溯。
5.3 is-a 关系里 mixed 了 stated 和 inferred,子类型数量翻倍
现象:同一个概念查后代,结果集里出现大量重复概念,或层级关系看着奇怪。
原因:关系表同时存了 stated 和 inferred 两套 is-a 关系。characteristic_type_id字段没有过滤时,两套数据混在一起。
解决:递归 CTE 里强制加条件characteristic_type_id = 900000000000011006(inferred)。如果只想要推理前关系,就换成900000000000010007(stated)。业务上推荐 inferred,因为它是经过分类器整理过的,更接近最终临床语义。
5.4 描述文本里出现制表符或引号,COPY 导入错位
现象:导入描述表成功后,发现某些term字段被截断,或者相邻字段串位。
原因:极少数描述的 term 文本里包含引号或不可见控制字符,COPY 的 CSV 解析规则遇到引号时会做转义处理;如果引号不成对,整行会被错误解析。
解决:导入前用grep -n $'\t'扫描描述文件偶发异常。处理方式的常见做法是:对term字段用REPLACE(term, chr(9), ' ')做一次清洗,再从临时表倒进正式表。把清洗步骤放在 COPY 之后,而不是去改原始 RF2 文件,保证源头文件保持原样。
5.5 effectiveTime 是八位字符串,DATE 类型导入直接报错
现象:COPY 导入概念表时,报错invalid input syntax for type date: "20190731"。
原因:RF2 的日期格式是 YYYYMMDD,不带分隔符,PostgreSQL 的 DATE 类型标准格式是 YYYY-MM-DD。
解决:建表时effective_time先用VARCHAR(8)接收,导入完成后执行UPDATE snomed_concept SET effective_time = TO_DATE(effective_time, 'YYYYMMDD')。如果介意大表 UPDATE 的性能,可以建一个带日期转换的视图,但视图的方式会让索引失效,所以生产环境我建议直接更新表字段。
6. 进阶用法:把 SNOMED CT 变成一个可对外提供的术语服务
当概念、描述、关系这三张表稳定运行后,你会发现绝大多数业务系统要的不是原始表,而是一个输入“糖尿病”返回“73211009”,或者输入“73211009”返回所有后代概念的服务。与其每次让业务方写递归 CTE,不如预先把常用查询固化成数据库函数,用 SQLPL(过程化 SQL)把整套逻辑封装成黑匣子。
下面这个函数实现“按显示名模糊搜索概念 ID 和 FSN”,是术语服务最常用的入口:
CREATE OR REPLACE FUNCTION search_concepts(keyword TEXT, lang_code CHAR(2)) RETURNS TABLE(concept_id BIGINT, term TEXT, type_id BIGINT) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT d.concept_id, d.term, d.type_id FROM v_description d WHERE d.language_code = lang_code AND d.term ILIKE '%' || keyword || '%' AND d.type_id IN (900000000000013009, 900000000000013011) ORDER BY CASE WHEN d.type_id = 900000000000013009 THEN 0 ELSE 1 END, d.term LIMIT 20; END; $$;这个函数的逻辑说明:先按语言代码过滤,再用ILIKE做不区分大小写的文本匹配,结果里优先返回 FSN,再返回同义词,最后限制 20 行防止输入太短时返回爆炸数据。参数lang_code传en或zh(如果导入了中文扩展包),keyword直接传用户输入的诊断名。业务方调用时就是一个SELECT * FROM search_concepts('diabet', 'en'),完全不需要理解底层表结构。
再往前走一步,把物化路径表的重建也封装成函数。我在实际项目中是这么处理的:每次导入新快照后,先TRUNCATE concept_ancestor,然后调用一个重建函数,函数内部跑一次深度优先遍历,把每对祖先和后代关系写入物化表。这个过程跑完约需几分钟,但做完之后,术语服务的子类型查询响应时间从秒级降到毫秒级。
CREATE OR REPLACE FUNCTION refresh_ancestor_table() RETURNS void LANGUAGE plpgsql AS $$ BEGIN TRUNCATE concept_ancestor; INSERT INTO concept_ancestor(ancestor_id, descendant_id, depth) WITH RECURSIVE all_pairs AS ( SELECT r.source_id AS ancestor_id, r.destination_id AS descendant_id, 1 AS depth FROM v_relationship r WHERE r.type_id = 116680003 AND r.characteristic_type_id = 900000000000011006 UNION ALL SELECT p.ancestor_id, r.destination_id, p.depth + 1 FROM all_pairs p JOIN v_relationship r ON r.source_id = p.descendant_id WHERE r.type_id = 116680003 AND r.characteristic_type_id = 900000000000011006 ) SELECT DISTINCT ancestor_id, descendant_id, MIN(depth) AS depth FROM all_pairs GROUP BY ancestor_id, descendant_id; END; $$;这个封装的价值在于:把容易出错的递归逻辑、类型过滤条件全部收敛到一个函数里,业务侧只看到一个结果表。从那以后我每次接手新的 SNOMED CT 项目,都强制走一遍“建表——COPY——清洗——物化——封装函数”这个流程,尤其是物化路径表,每次换新版本发布包都要记得重建。不要相信一次性建好就能用三年,临床术语集是活数据。希望这篇拆解能帮你把 SNOMED CT 的关系数据库表示一次性做对,少走我当初踩过的那些弯路。
本文还有配套的精品资源,点击获取