简介:面向医学信息工程、临床数据仓库建设及术语标准化方向的技术人员,这份资源提供将SNOMED CT术语库以RF2分发格式导入MySQL、PostgreSQL、MSSQL及Neo4j的一整套数据库脚本方案,解决手工解析RF2文件建表效率低、更新易出错的问题,帮助使用者快速完成术语表创建、配置与批量装载。压缩包共115个文件、约434KB,主体为64个SQL建表与装载脚本,另含11个Python处理工具、5个模板文件、3个Shell及3个批处理脚本、多种数据库配置文件,覆盖从连接参数配置、数据清洗导入到图数据库更新的完整操作链路。目录按不同数据库类型分类,附带MySQL导入纠错校验脚本、Neo4j更新失败检查的Cypher查询等细节,方便逐库对照使用。已有1103人学习下载,适合具备SQL基础、希望自建SNOMED CT本地库或基于该术语库做二次开发的工程人员,也便于在此基础上扩展提交其他数据库的支持脚本。
1. SNOMED CT到底难在哪:RF2文件不是数据表,是给你加工的半成品
某医院信息科拿到最新一期的 SNOMED CT 发布包,解压后是一堆 txt 文本文件,点开一看,每行都是一长串 tab 分隔的字段,id 后面跟着 effective_time、active、module_id……数据量从几十万到几百万行不等。要把它变成临床系统能直接 join、能走索引、能递归查询的关系库,是一整套从格式解析到模型设计的工程问题。
这就是“在关系数据库中表示 SNOMED CT”要做的事:把标准发布包拆成概念、描述、关系三张核心表,配上导入、合并、查询和验证工具链。适合做医学信息化、术语库集成、数据标准化改造的工程师,也适合正在评估这个方向要不要投入的团队。这套方案本身不难,难在两处:一是 RF2 文件格式的细节,二是关系模型里每个字段的取舍。这两点搞清楚,剩下就是流水线工作。
2. 从RF2到关系模型:概念、描述、关系三张核心表怎么设计
2.1 快照、增量、全量:RF2 三种文件到底怎么用
接触 SNOMED CT 的第一个门槛不是表结构,而是发布包里的文件类型。每个组件的文件通常分三类:全量(Full)、快照(Snapshot)、增量(Delta)。文件名里能看到组件名、类型、模块和版本时间戳,例如概念组件、描述组件、关系组件都会分别带 Full / Snapshot / Delta 三套。
| 文件类型 | 内容特点 | 建库时的角色 |
|---|---|---|
| 全量 Full | 从历史上线以来的全部记录,包含大量历史版本 | 归档用,很少直接导入 |
| 快照 Snapshot | 当前版本这一时刻的完整有效状态 | 建库基线,首选导入对象 |
| 增量 Delta | 相邻两个版本之间新增或变更的记录 | 升级维护用,与快照合并 |
按我的习惯,首次建库只导入快照文件,把主表建好,再把增量文件丢进一个临时表做合并。原因很简单:快照天然代表“当前有效状态”,而增量文件需要配合上一版本的 SCTID 状态做判断,直接导入增量会导致主表数据缺胳膊少腿。
另外要注意 RF2 的所有文件都带表头,第一行是字段名。数据行里的 id 不是普通数据库自增 id,而是 SNOMED CT 的 SCTID,它全局唯一而且长期不回收。同一个 SCTID 在多个版本里可能对应多行记录,区别在 effective_time 和 active 字段,所以关系模型里主表怎么做主键是个需要先想清楚的问题。
2.2 概念、描述、关系三张表:字段和主键怎么定
RF2 里有二十多种组件文件,但建库落地时通常只需要三张核心表:concept、description、relationship。把通配符文件、参考集、映射文件先放到一边,它们要么是辅助业务表,要么依赖三张主表才能解释。
concept 表字段不多,最关键的是 id、effective_time、active、module_id、definition_status_id。description 表是描述表,多语言术语都在这,字段里 term 是真实文本,language_code 是语言标记,type_id 区分“规范名”和“同义词”等描述类型。relationship 表最复杂,source_id 和 destination_id 存概念间的指向关系,type_id 表明关系类型,characteristic_type_id 区分推断关系还是声明关系,relationship_group 表示属性分组。
主键我建议直接用 SCTID 自然主键,不额外加自增主键。对外引用、与临床系统映射时,SCTID 是跨系统唯一标识,如果库内换成自增 id,每次导入映射都要改一次,改完还容易错。自然主键的代价是并发插入效率差一点,但概念几十万行、关系几百万行,导入是一次性操作,这点开销完全可以接受。
字段类型上,effective_time 用 CHAR(8),保留 YYYYMMDD 原样。它跟 DATE 类型能直接比较大小,但用 CHAR 可以避免导入时反复格式转换,也跟原文件一一对应,排查问题方便。module_id、type_id 这类代码全部用 BIGINT 无符号整数存储,不建物理外键,因为 SNOMED CT 来源文件自带完整性校验,物理外键在这里只增加导入负担。
2.3 关系类型字段里的玄机:is-a、属性关系与推断/声明关系
relationship 表里最容易犯的错,是把所有关系当同一种数据用。type_id = 116680003 的关系是 is-a,也就是父子层级关系,这是整个本体的骨架。遍历某个概念的全部后代、展开某个临床子集,都靠它。其他 type_id 是属性关系,比如“发现部位”“致病病原体”“关联药物”,它们同样存在 relationship 表里,靠 type_id 区分语义。
建库时建议只导推断关系文件,声明关系按需再导。推断关系已经经过推理器计算,可以直接用于查询;声明关系是编辑阶段的人工作业结果,需要推理器二次加工,直接导入会得到大量冗余甚至冲突的路径。如果只想搭查询库,导入推断关系就够了。
characteristic_type_id 也很关键。它标识这条关系是推断出来的还是声明出来的,如果查询时不按它过滤,可能把声明关系和推断关系同时算进去,导致同一层级递归出多倍结果。建表时给 source_id、destination_id 各建一个索引,这是支撑后续 is-a 遍历和属性检索的基础。
3. 把几百万行TSV倒进关系库:建表、导入与增量合并
3.1 建表 DDL:主键用 SCTID 而不是自增 id
以 MySQL 为例,一张可用的关系库结构是这样。注意这里没有建任何物理外键,全部用索引来保证查询性能。
CREATE TABLE concept ( id BIGINT UNSIGNED NOT NULL, effective_time CHAR(8) NOT NULL DEFAULT '', active TINYINT NOT NULL DEFAULT 1, module_id BIGINT UNSIGNED NOT NULL, definition_status_id BIGINT UNSIGNED NOT NULL, PRIMARY KEY (id), KEY idx_concept_active (active), KEY idx_concept_module (module_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE description ( id BIGINT UNSIGNED NOT NULL, effective_time CHAR(8) NOT NULL DEFAULT '', active TINYINT NOT NULL DEFAULT 1, module_id BIGINT UNSIGNED NOT NULL, concept_id BIGINT UNSIGNED NOT NULL, language_code CHAR(3) NOT NULL, type_id BIGINT UNSIGNED NOT NULL, term VARCHAR(1024) NOT NULL, case_significance_id BIGINT UNSIGNED NOT NULL, PRIMARY KEY (id), KEY idx_description_concept (concept_id), KEY idx_description_type (type_id), KEY idx_description_term (term(64)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE relationship ( id BIGINT UNSIGNED NOT NULL, effective_time CHAR(8) NOT NULL DEFAULT '', active TINYINT NOT NULL DEFAULT 1, module_id BIGINT UNSIGNED NOT NULL, source_id BIGINT UNSIGNED NOT NULL, destination_id BIGINT UNSIGNED NOT NULL, relationship_group TINYINT NOT NULL DEFAULT 0, type_id BIGINT UNSIGNED NOT NULL, characteristic_type_id BIGINT UNSIGNED NOT NULL, modifier_id BIGINT UNSIGNED NOT NULL, PRIMARY KEY (id), KEY idx_relationship_source (source_id), KEY idx_relationship_destination (destination_id), KEY idx_relationship_type (type_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;id 用 BIGINT UNSIGNED,原因是 SCTID 是十进制数值型标识,范围在 BIGINT 内;effective_time 用 CHAR(8) 而不是 DATE,导入时不需要任何转换。term 字段长度给 1024,兼容一些特别长的全称;索引只取前 64 个字符,避免超长索引影响写入速度。
relationship 表的 relationship_group 声明成 TINYINT 而不是 BIGINT,因为实际业务里同一个概念属性组的数量很小,不需要大范围。另一个细节是排序规则用了 utf8mb4,term 列不要单独用 utf8,临床术语里偶尔会出现包含特殊字符的描述,utf8mb4 能减少导入时的字符集报错。
3.2 LOAD DATA 导入:命令、参数与导入前检查
RF2 文件本质是 TSV,MySQL 的 LOAD DATA 就是为这种格式设计的。直接一条语句导入概念表:
LOAD DATA LOCAL INFILE 'sct2_Concept_Snapshot_INT_20250131.txt' INTO TABLE concept FIELDS TERMINATED BY '\t' ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (id, effective_time, @active, module_id, definition_status_id) SET active = CAST(@active AS UNSIGNED);这段命令里有几个关键参数。FIELDS TERMINATED BY '\t' 说明列分隔符是 tab;ENCLOSED BY '"' 告诉 MySQL 字段外层可能包着双引号,这在描述文件里比概念文件更常见;IGNORE 1 LINES 跳过表头,因为 RF2 每个文件第一行都是字段名。
@active 是会话变量,先把 active 列读成字符串再在 SET 里转成数字。这么做是给数据一次显式转换的机会,避免纯文本导入时出现意外的隐式类型转换。导入完成后,先跑一次行数核对,看表里行数和文件总行数是否一致;不一致时优先检查是不是 ENCLOSED 参数写错,或者文件里有换行符被误判成行终止符。
LOAD DATA 默认有一个限制,文件必须放在 mysqld 可读的目录下,最稳的做法是把发布包文件先统一放到一个目录,再通过配置文件或服务端参数指定这个目录,而不是每次找路径。
3.3 增量合并:把 delta 安全合进快照主表
版本升级时,把新的 delta 文件合进快照主表是核心操作。常见做法是先引入一个 delta 临时表,结构跟主表完全一致,导入增量文件后做一次合并。合并的关键在于,同一 SCTID 可能出现“新行 + 旧行失效”的组合。
-- 先把失效记录标记掉 UPDATE relationship r JOIN relationship_delta d ON r.id = d.id SET r.active = 0, r.effective_time = d.effective_time WHERE d.active = 0; -- 再插入新增或复活的记录 INSERT INTO relationship (id, effective_time, active, module_id, source_id, destination_id, relationship_group, type_id, characteristic_type_id, modifier_id) SELECT id, effective_time, active, module_id, source_id, destination_id, relationship_group, type_id, characteristic_type_id, modifier_id FROM relationship_delta d WHERE d.active = 1 AND NOT EXISTS ( SELECT 1 FROM relationship r WHERE r.id = d.id AND r.active = 1 );注释里已经把逻辑拆成两步:第一步让增量里标记为 active=0 的记录把主表同 id 的现行记录置为失效;第二步把增量里新增的行补进主表。NOT EXISTS 防止重复插入已经在主表生效的同 id 记录,这个保护在脚本重跑时尤其重要,整个流程因此可以幂等。
这套合并策略的核心思想是保留全部历史,只变更 active 状态。主表里的记录不删,只是把旧版本标记失效,这样回溯某概念在某时间点的状态也做得到。执行合并时建议把两步包在事务里,要么全部成功,要么全部回滚。
4. 让临床查询跑得动:FSN检索、is-a递归与属性关系
4.1 精确查 FSN:type_id 和 language_code 必须一起过滤
描述表里一个概念有多条描述,英文环境下至少有一条 Fully Specified Name,也就是全称,还有多条同义词。全称的 type_id 是 900000000000003001,同义词的 type_id 是 900000000000013009。查询规范名时,这两个条件漏掉任何一个都会把错误数据带出来。
SELECT c.id, d.term FROM concept c JOIN description d ON d.concept_id = c.id WHERE d.type_id = 900000000000003001 AND d.language_code = 'en' AND d.active = 1 AND d.term LIKE 'Diabetes mellitus%' ORDER BY d.term LIMIT 10;这里 type_id 和 language_code 是硬条件,active=1 保证不把已废弃描述混进来。term 用 LIKE 前缀匹配,能走 description 表 term 前缀索引;如果写成 LIKE '%Diabetes%',前导通配符会让索引失效,几百万行的表全表扫描直接卡死。这条查询是术语检索最底层的样板,后续做的搜索、自动完成、输入提示都在它基础上扩展。
4.2 递归查 is-a:找全后代和全祖先的 CTE 写法
SNOMED CT 的层级复用关系表,MySQL 8.0 以后可以用 WITH RECURSIVE 写递归查询。下面的语句找某个概念的全部后代,比如找到糖尿病(SCTID 73211009)下的所有子概念。
WITH RECURSIVE descendants AS ( SELECT 73211009 AS root_id, 73211009 AS node_id, 0 AS depth UNION ALL SELECT d.root_id, r.destination_id, d.depth + 1 FROM relationship r JOIN descendants d ON r.source_id = d.node_id WHERE r.type_id = 116680003 AND r.active = 1 AND d.depth < 15 ) SELECT DISTINCT node_id AS descendant_id FROM descendants;递归的锚点部分是概念自身,递归部分每次从 relationship 表里找 source_id 等于当前 node_id 的 is-a 关系,把 destination_id 作为下一层 node_id。type_id = 116680003 是 is-a 关系的固定标识,必须写死;active=1 过滤掉失效关系,避免把历史废弃的父子链拉进来。
depth < 15 是防环保护,因为 is-a 理论上是无环有向图,但实际发布数据里偶尔有加工产生的循环引用,没有深度上限,递归会一直跑下去直到报错。distinct 去重很关键,同一后代可能通过多条路径到达,不去重结果集会膨胀好几倍。查祖先只需要把递归里的方向反过来,锚点不变,JOIN 条件改成 destination_id 等于当前节点。
4.3 属性关系查询:一表多用的语义检索
is-a 之外的属性关系同样存在 relationship 表里,比如 finding site(发现部位)的 type_id 是 363698007。查一个疾病的常见发现部位,表结构完全一样,只是 type_id 换成属性类型。
SELECT t.term AS finding_site FROM relationship r JOIN description t ON t.concept_id = r.destination_id WHERE r.source_id = 73211009 AND r.type_id = 363698007 AND r.active = 1 AND t.type_id = 900000000000003001 AND t.language_code = 'en' AND t.active = 1;destination_id 指向部位概念,再通过 description 表把它翻译成人可读的规范名。这条查询里 source_id、type_id、active 过滤掉噪音数据,destination_id 的 join 走主键,t.type_id 保证取到的是全称而不是同义词。临床语义检索里,发现部位、关联药物、致病原因这一类问题全都可以用这个模式解决。
4.4 性能观察:先用 EXPLAIN 判断索引是否命中
关系表数据量上来以后,递归查询和 join 查询有没有走索引,差别是秒级和分钟级。任何一条 SQL 上线前先跑 EXPLAIN,看 key 列有没有命中索引,看 type 列是不是 ref 或 eq_ref,是全表扫描 all 就说明条件写偏了。
EXPLAIN SELECT t.term AS finding_site FROM relationship r JOIN description t ON t.concept_id = r.destination_id WHERE r.source_id = 73211009 AND r.type_id = 363698007 AND r.active = 1;关注点有两个:relationship 表的 source_id 是否走了 idx_relationship_source,description 表的 concept_id 是否走了主键或唯一索引。只要这两条命中,几百万行关系表上的属性查询能压到几十毫秒。递归查询的 EXPLAIN 会显示递归临时表,那部分主要看递归内部对 relationship 的访问是否依赖 source_id 索引,索引缺失时递归每深一层都全表扫一遍,基本跑不完。
5. 建库最容易翻车的五个点:从导入报错到查询死循环
5.1 delta 当快照导:概念越查越少
现象:拿到发布包以后图省事,直接导入了 Delta 文件,结果库里概念数比文档标注的少一大截,很多常用概念查不到。
原因:Delta 只包含本轮版本相对于上一轮的新增和变更,不是当前版本的全部状态。比如上一轮上游删了某个概念,下一轮的 Delta 可能完全没有它的记录,直接用 Delta 建库就等于把主库里所有没变化的存量数据全丢了。
解决:首次建库只用 Snapshot,Delta 只作为后续升级的增量数据,按 3.3 节的合并流程合入主表。每次导入前先数一数文件行数,Snapshot 文件行数比 Delta 高一个数量级,肉眼就能区分开。
5.2 term 里的引号和换行:LOAD DATA 数据错位
现象:导入描述表时 MySQL 偶尔报 row doesn't contain data for all columns,或者查出来 term 字段末尾多了一堆引号。
原因:描述文件的 term 列里存在包含双引号甚至换行的文本,LOAD DATA 的 ENCLOSED BY '"' 只能识别字段外层包裹的引号,碰到内容里自带的引号就会拆错字段边界,后续列全部错位。
解决:最稳的办法是不直接用 LOAD DATA 解析描述文件,改用 Python 的 csv 模块先做一次清洗。csv.reader 对 TSV 格式的引号转义处理比 MySQL 严格,解析完行直接交给数据库驱动批量写入:
import csv with open("sct2_Description_Snapshot_INT_20250131.txt", newline="", encoding="utf-8") as f: reader = csv.reader(f, delimiter="\t", quotechar='"') header = next(reader) for row in reader: # row 是正确切分后的字段列表,这里直接拼参数交给批量插入 if len(row) != len(header): print("skip bad row:", row[:8]) continue # 正常写入 description 表封装好后,描述表字段永远不会因为引号错位。如果项目强制只用 LOAD DATA,另一个办法是先不 ENCLOSED BY '"',把原始行整行导入 staging 表,再用 SQL 或脚本二次拆分,但那样多一层中间表,排障成本更高。
5.3 递归死在环路里:is-a 关系没过滤干净
现象:跑 is-a 后代查询,数据库报 recursive query aborted after 1001 iterations,或者直接内存涨满。
原因:递归 CTE 里没有加 type_id = 116680003 过滤,把属性关系也当成父子关系往下走了;另一个常见原因是没加 active=1,把历史废弃的关系链也拉进递归路径,两条路径交叉成环。
解决:递归查询里把 type_id 和 active 两个条件写死,同时在 CTE 里加 depth 上限。即使数据本身有问题,深度上限也能让查询在可控时间内终止。发现环以后定位它,用那条 destination_id 和 source_id 互相可达的路径反查发布包,看是不是特性处理的脏数据。
5.4 描述三种 type_id 混在一起:FSN、同义词、文本定义
现象:按关键词搜 concept,一个概念查出来五条六条描述,系统不知道哪个是展示给用户的标准名。
原因:description 表里 type_id 区分全称、同义词、文本定义;语言码还区分中英文。查询时只过滤了 active 没过滤 type_id,所有类型混在一起返回,应用层没法直接消费。
解决:查询规范名固定加 d.type_id = 900000000000003001,查同义词用 900000000000013009。另外,全称末尾带着括号里的语义类型后缀,比如“Diabetes mellitus (disorder)”,做界面展示时对术语排序要注意。
5.5 几百万行导入太慢:单条 INSERT 是最大元凶
现象:关系表几百万行,用 ORM 一条一条 INSERT,跑了半小时还有一半。
原因:单条 INSERT 每一条都触发一次事务提交和磁盘 fsync,加上 InnoDB 的 redo log 刷盘策略,几百万条就变成几百万次磁盘同步,慢是必然的。
解决:能走 LOAD DATA 就不要走单条 INSERT;必须走应用层写入时,用批量 INSERT,每批 500 到 1000 行包一个事务。导入前如果有逻辑外键,事务和唯一键校验可以先关掉,导完再开启重建索引。这样几百万行的导入时间能从半小时压到几分钟。
6. 建完库先别急着上线:三层数据验证与增量同步
6.1 数量级校验:三个数字判断库有没有导偏
建完库第一件事不是写接口,而是数数。概念表、描述表、关系表的行数各跑一条 count,跟发布文档里的声明值对比数量级。概念表在几十万量级、描述表通常是概念数的一到两倍、关系表比描述表更多,如果关系表数量级不对,那问题通常出在导入文件上。
SELECT COUNT(*) FROM concept; SELECT COUNT(*) FROM description; SELECT COUNT(*) FROM relationship;6.2 完整性校验:孤儿概念、死链关系与重复描述
数量级对不代表数据没损坏。概念必须至少有一条 active 的规范名,没有就说明描述表丢数据;关系的 source_id 和 destination_id 必须能在概念表里找到,找不到就是死链;同一个概念的同一种语言下,不应该有两条 active=1 的全称。这三类异常写一条 SQL 就能框出来:
-- 找出没有规范名的孤儿概念 SELECT c.id FROM concept c LEFT JOIN description d ON d.concept_id = c.id AND d.type_id = 900000000000003001 AND d.language_code = 'en' AND d.active = 1 WHERE c.active = 1 AND d.id IS NULL LIMIT 20;这条查询把 concept 作为主表,左侧 join 规范名描述;如果概念存在但描述关联不上,d.id 就是 NULL,说明该概念缺少最基本的存在性要求。
6.3 增量同步的自动化:一个可重跑的合并脚本
把 3.3 节的手工合并写成脚本,后续每次发布新版本只要传文件路径进去,别的不用管。脚本的核心是幂等,跑了十遍跟跑一遍结果一致,让人敢在凌晨自动执行。
#!/usr/bin/env bash # 增量合并脚本,参数为新版本 delta 文件路径 delta_file="$1" mysql -u reader -p"${DB_PASS}" <<SQL START TRANSACTION; -- 处理失效记录 UPDATE relationship r JOIN relationship_delta d ON r.id = d.id SET r.active = 0, r.effective_time = d.effective_time WHERE d.active = 0; -- 插入新增记录 INSERT INTO relationship (id, effective_time, active, module_id, source_id, destination_id, relationship_group, type_id, characteristic_type_id, modifier_id) SELECT id, effective_time, active, module_id, source_id, destination_id, relationship_group, type_id, characteristic_type_id, modifier_id FROM relationship_delta d WHERE d.active = 1 AND NOT EXISTS ( SELECT 1 FROM relationship r WHERE r.id = d.id AND r.active = 1 ); COMMIT; SQL我把这套验证流程固化成了自己的习惯:每次导完库,先数行数,再跑孤儿概念、死链关系,最后做深度抽样——随机抽几十个概念,手工去网站或发布包核对它们的父子路径和属性关系。全绿才允许系统切流量。这个习惯救过我一次,某次发布包解压不完整,描述文件少了几万行,行数对不上被 first count 直接拦住,避免了一次线上翻车。希望帮到你。
本文还有配套的精品资源,点击获取