简介:面向医疗信息化与术语标准化开发者,这份资源提供一套在MySQL、PostgreSQL、MSSQL及Neo4j等数据库中表示SNOMED CT词表的脚本方案。使用者可依据RF2标准分发格式完成术语库的创建与数据填充,适合需要在本地仿真环境中管理临床医学术语的工程人员,也可作为医学知识图谱、临床数据标准化项目的底层组件。资源共一百一十五个文件,包体约四百三十四KB,以SQL脚本为核心,辅以Python处理脚本、Shell/批处理加载工具及多种数据库配置文件,各数据库子目录分别提供建表语句、装载命令与连接配置,便于按目标平台快速选择对应方案。已有一千一百人学习下载,内容还包括图数据库更新检查Cypher脚本与系统配置模板,能够帮助读者快速搭建SNOMED CT关系模型、完成数据装载并排查常见导入问题,尤其适合希望理解术语层级、属性关系及描述逻辑的工程人员。
1. 一个临床术语体系,为什么要搬进关系数据库
某个临床知识库项目第一次接入 SNOMED CT 时,有人以为只要在库里放一张对照表、把编码和词条映射好就够了。等产品提出「把糖尿病往下的所有细分概念查出来」这个需求,才发现发布文件里的概念和概念之间是靠关系行串起来的,根本不能当普通词表直接查。SNOMED CT 本质是一张由数十万概念节点和数百万关系边组成的大网,把它解构成关系数据库里的概念表、描述表、关系表,再用索引和预计算的传递闭包把「沿 IS-A 关系展开」这类查询压到可控的毫秒级,这才是工程落地该做的事。这篇文章不聊临床语义本身,只讲数据接入:从发布包格式讲起,到建表、导入、闭包计算,再到最折磨人的版本更新问题,给出一条在 PostgreSQL 里可复制的路径。适合正在搭临床字典服务、电子病历术语表或临床知识库的工程师。
2. 先看透发布包:快照、增量与三张核心表
很多首次处理的人会下意识把压缩包里所有 txt 文件一股脑导进库,结果表建了十几个,用到的只有三张。SNOMED CT 的发布包文件虽多,真正支撑日常查询的核心数据是概念、描述、关系三张表,其余大多是支撑性文件。先把这一点想清楚,后面的工作能省一半力气。
2.1 发布包里的文件类型
发布包通常按内容前缀区分文件,常见的有sct2_Concept_*、sct2_Description_*、sct2_Relationship_*。其中*位置还会区分Snapshot、Delta、Full三种数据形态。此外还有文本定义、参考集、语言参考集等文件,它们服务于更长文本的定义或特定地域的语言偏好,可以等核心流程跑通后再补。
我建议第一轮只处理三张核心表。参考集里的语言映射会影响你最终展示哪个同义词,但它不改变概念之间的父子结构;如果产品只做编码解析和子类查询,先把参考集放在一边,风险很小。
2.2 一种发布状态与三种文件形态
发布包里最常见的文件形态是:
Snapshot:截止到某个发布时间点的完整快照,包含当时所有概念、描述和关系的有效记录,也包含已经停用的历史行。初始建库选这个。Delta:只包含相邻两个发布周期之间新增、修改、停用的行。初次建库不要用 Delta,它是给已有快照做增量更新用的。Full:从首个发布到今天的所有变更累加,相当于把每天变更攒成一条超长流水线。需要做完整历史回溯时用它。
实际项目里最容易犯的错是把 Delta 当成全量数据导进去。Delta 里没有几万条历史概念,只有几百条变更记录,导完表里空荡荡,再排查会浪费大量时间。
2.3 三张核心表的行结构
概念文件每一行对应一个概念,字段固定:
id effectiveTime active moduleId definitionStatusId含义分别是概念标识、生效日期、是否有效、所属模块、定义状态。概念 id 是一串数字,在关系数据库里用 BIGINT 存储更合适,用 INT 可能在特殊扩展位上溢出。
描述文件字段多两列:
id effectiveTime active moduleId conceptId languageCode typeId term caseSignificanceId其中conceptId把描述挂到某个概念上,term才是人可读的名称。很多人踩坑在languageCode上,它通常只标en这类基础语言代码,不代表方言版本。真正的语言偏好要借助语言参考集,不能只看这个字段就断定是哪国语言。
关系文件是 SNOMED CT 的灵魂:
id effectiveTime active moduleId sourceId destinationId relationshipGroup typeId characteristicTypeId modifierIdsourceId和destinationId都是概念标识,typeId指明关系类型。最常见的typeId = 116680003表示 IS-A,也就是子类关系。比如「病毒性肺炎」的 sourceId 指向「肺炎」的 destinationId,关系类型就是 IS-A。
2.4 为什么 effectiveTime 不能当发布版本时间用
effectiveTime写的是该记录真正生效的日期,不是文件发布的日期。一个概念在上一版就已经生效,这一版快照里它的 effectiveTime 仍然是旧日期。如果按「发布日期筛选」,你会漏掉大量从未变化的概念。处理时要把「发布批次」和「记录生效时间」分成两个字段,前者自己加,后者用文件里的原值,混在一起会让后续查询逻辑混乱。
3. 建表与导入:把 RF2 快照装入 PostgreSQL 的第一条完整路径
表结构设计得是否贴近发布格式,直接影响导入成本。这里我给一套在项目里反复用过的结构,核心思想是「保留原始行语义,加一列外部版本标记」。这样既不会丢失历史,也不会在下次发布时被迫重建表。
3.1 三张核心表 DDL,以及为什么要加版本标签列
概念表:
CREATE TABLE IF NOT EXISTS snomed_concept ( concept_id BIGINT NOT NULL, effective_time CHAR(8) NOT NULL, active SMALLINT NOT NULL, module_id BIGINT NOT NULL, definition_status_id BIGINT NOT NULL, release_version VARCHAR(32) NOT NULL, PRIMARY KEY (concept_id, effective_time, release_version) );关键点是release_version列。它用来标识这批数据来自哪个发布包,比如导入 2024 年某次发布就把该发布的时间戳写进来。有了这列,同一概念在不同发布批次中的不同表现才能共存,也才能做历史回溯。没有它,第二次导入新快照会直接撞主键,逼你 truncate,把历史数据丢掉。
描述表:
CREATE TABLE IF NOT EXISTS snomed_description ( description_id BIGINT NOT NULL, effective_time CHAR(8) NOT NULL, active SMALLINT NOT NULL, module_id BIGINT NOT NULL, concept_id BIGINT NOT NULL, language_code VARCHAR(8) NOT NULL, type_id BIGINT NOT NULL, term TEXT NOT NULL, case_significance_id BIGINT NOT NULL, release_version VARCHAR(32) NOT NULL, PRIMARY KEY (description_id, effective_time, release_version) );关系表:
CREATE TABLE IF NOT EXISTS snomed_relationship ( relationship_id BIGINT NOT NULL, effective_time CHAR(8) NOT NULL, active SMALLINT NOT NULL, module_id BIGINT NOT NULL, source_id BIGINT NOT NULL, destination_id BIGINT NOT NULL, relationship_group INTEGER NOT NULL DEFAULT 0, type_id BIGINT NOT NULL, characteristic_type_id BIGINT NOT NULL, modifier_id BIGINT NOT NULL, release_version VARCHAR(32) NOT NULL, PRIMARY KEY (relationship_id, effective_time, release_version) );active 字段我用 SMALLINT 而不是 BOOLEAN,是为了让这套建表语句在 PostgreSQL 和 MySQL 之间都能跑,导入时也不需要在布尔值和数字之间做转换。关系表的relationship_group默认 0,表示不分组;同一关系组内的多条关系共同描述一个复合属性,比如「某个部位的某个形态」。
3.2 第一批数据怎么倒:用 COPY 而不是一行行 INSERT
INSERT 逐行插入在几十万条数据面前太慢,我用 PG 的\copy客户端命令直接灌文件。假设发布包里解压出的概念快照文件名是sct2_Concept_Snapshot_...txt:
psql -h localhost -U postgres -d clinical_terms \ -c "\\copy snomed_concept(concept_id, effective_time, active, module_id, definition_status_id, release_version) FROM 'sct2_Concept_Snapshot_...txt' WITH (FORMAT csv, DELIMITER E'\t', NULL '')"命令里DELIMITER E'\t'表示制表符分隔,因为 RF2 文件不是逗号分隔,用普通 CSV 参数解析会把描述里的逗号误拆。NULL ''表示空字符串按空值处理。后面那句release_version列如果不想逐条写,可以先不导这列,导入成功后用一条 UPDATE 统一补:
UPDATE snomed_concept SET release_version = '2024-07-31'; -- 替换成实际发布批次注意\copy是 psql 客户端命令,读取的是执行 psql 这台机器上的文件路径,适合本地或跳板机操作。若用服务端COPY,文件必须在数据库服务器上,且有对应文件系统权限,两者注意区分。
导入描述表和关系表时,命令结构完全一样,文件路径分别换成对应 snapshot txt 即可。务必检查文件是否带表头:多数 RF2 发布文件直接以数据行开头,没有列名行,但个别定制包会在第一行写字段名。先执行head -1 文件名看第一行,如果第一个字符是数字,放心导入;如果看到id字样,先剔除首行,否则主键会撞上一条脏数据。
3.3 必建索引,和一个只查当前版本的习惯
导入之后先建索引,再跑业务查询。一个比较稳的索引集合:
CREATE INDEX idx_concept_active ON snomed_concept (active, concept_id) WHERE active = 1; CREATE INDEX idx_description_concept ON snomed_description (concept_id); CREATE INDEX idx_description_term_trgm ON snomed_description USING gin (term gin_trgm_ops); CREATE INDEX idx_relationship_source ON snomed_relationship (source_id, type_id, active); CREATE INDEX idx_relationship_dest ON snomed_relationship (destination_id, type_id, active);其中idx_description_term_trgm依赖pg_trgm扩展,别忘执行CREATE EXTENSION pg_trgm;。普通 B-tree 索引对term LIKE '%肺炎%'这种前后模糊查询没有帮助,trigram 索引才能覆盖。如果数据库是 MySQL,这条语法不适用,可以用全文索引替代,但查询语法要相应调整。
建议再建一个只过滤当前版本的视图,所有业务查询用视图而不是裸表:
CREATE OR REPLACE VIEW concept_current AS SELECT * FROM snomed_concept WHERE release_version = '2024-07-31' -- 换成最新批次 AND active = 1;这个习惯能避免后续增量更新时旧版本数据混进来,也把「当前有效」的语义统一收敛到一个地方。
4. 让数据真正能用:IS-A 继承与传递闭包的计算与验证
表导进去之后,如果只会按 id 查单条,说明只是搬了个家。SNOMED CT 被临床系统反复消费的能力集中在「沿着子类关系把一棵树展开」。这一步要不要做、怎么做,直接决定接口延迟差十倍还是百倍。
4.1 先搞清楚 IS-A 在关系表里怎么表达
关系表里type_id = 116680003是 IS-A 关系的固定标识。例如「病毒性肺炎」作为 source_id,指向「肺炎」这个 destination_id,就表达了「病毒性肺炎是肺炎的一种」的语义。因为这是标准行为,所以不需要自己发明 type 编码。特征类型characteristic_type_id也很重要,它区分某一版本的关系是自带定义还是推理产生的。稳定环境中,我会优先使用发布包中标记为 inferred 的那批关系,因为它经过发布方一致性校验,父子层级更完整,用它做闭包不会漏掉推理边。
如果查询时把active条件漏掉,旧版本停用关系也会混进来,子类计数虚高,这是另一个隐蔽问题。
4.2 递归查询能跑,但更适合探索性验证
PostgreSQL 的递归 CTE 可以直接沿 IS-A 关系往下找后代,举例如下:
WITH RECURSIVE descendants AS ( SELECT r.source_id AS descendant_id FROM snomed_relationship r WHERE r.destination_id = 73211009 -- 示例起始概念,按实际替换 AND r.type_id = 116680003 AND r.active = 1 UNION SELECT r.source_id FROM snomed_relationship r JOIN descendants d ON r.destination_id = d.descendant_id WHERE r.type_id = 116680003 AND r.active = 1 ) SELECT DISTINCT descendant_id FROM descendants;逻辑是:第一步先取所有父概念为起始点的直接子节点,然后递归地取这些子节点的子节点,直到没有新行。type_id = 116680003限定只走 IS-A 边,避免把「查找部位」「病因」等其他属性关系也带进子类集合。
这套递归能跑,但在几百万关系行上每查询一次就要重复扫描多层,在请求量大的在线场景不划算。我一般只在初次验证数据对不对时用递归方式,生产逻辑改用预计算闭包表。
4.3 预计算闭包表:结构设计与迭代填充
闭包表存的是「某一概念祖先到后代」的全部配对。设计如下:
CREATE TABLE snomed_closure ( ancestor_id BIGINT NOT NULL, descendant_id BIGINT NOT NULL, depth INT NOT NULL, PRIMARY KEY (ancestor_id, descendant_id) ); CREATE INDEX idx_closure_descendant ON snomed_closure (descendant_id);depth表示相隔层数,直接父子关系为 1,需要跨两层才算出的为 2,依次递增。有了这张表,查询糖尿病的所有后代变成一次主键搜索:
SELECT descendant_id FROM snomed_closure WHERE ancestor_id = 73211009;反过来找「某个节点所有祖先」用descendant_id查询即可,走idx_closure_descendant。填充方式用迭代扩展,先插直接边,再不断把已收录的后代继续往下延展,直到一次都插不进去:
-- 1. 清空闭包表,避免旧版本残留 TRUNCATE snomed_closure; -- 2. 先写直接父子边,depth = 1 INSERT INTO snomed_closure (ancestor_id, descendant_id, depth) SELECT r.source_id, r.destination_id, 1 FROM snomed_relationship r WHERE r.type_id = 116680003 AND r.active = 1; -- 3. 用 PL/pgSQL 反复迭代,直到没有新配对产生 DO $$ DECLARE added BIGINT; BEGIN LOOP WITH new_rows AS ( SELECT DISTINCT c.ancestor_id, r.source_id, c.depth + 1 FROM snomed_closure c JOIN snomed_relationship r ON r.destination_id = c.descendant_id AND r.type_id = 116680003 AND r.active = 1 WHERE NOT EXISTS ( SELECT 1 FROM snomed_closure x WHERE x.ancestor_id = c.ancestor_id AND x.descendant_id = r.source_id ) ) INSERT INTO snomed_closure (ancestor_id, descendant_id, depth) SELECT * FROM new_rows; GET DIAGNOSTICS added = ROW_COUNT; EXIT WHEN added = 0; END LOOP; END; $$;这里的核心逻辑是每次把「已知后代的子节点」变成新后代,并通过NOT EXISTS去重。因为 SNOMED CT 的 IS-A 结构是有向无环图,不会出现死循环,如果数据被污染出了环,这个循环会陷入一直有新增行的状态。实践中我会在循环里加一个iterations计数,超过最大层数后直接报错退出,当作一种数据质量告警。
闭包表是空间换时间的典型取舍。行数会比关系表大不少,但换来的是查询接口从几秒变成几十毫秒。对在线临床字典服务来说,这个代价值得。
4.4 验证闭包没有算错的三个快速检查
第一,总数合理性。执行SELECT COUNT(*) FROM snomed_closure;,在几十万概念的量级下,闭包行数通常应该在数百万到千万级。如果只有几十万行,大概率只导入了直接父子关系,迭代阶段没有生效。
第二,抽查一个已知父类。比如你明确知道某疾病的直接子类有哪些,查snomed_closure WHERE ancestor_id = 该概念 AND depth = 1,返回集合应该能对应上人工展开出的直接下级。
第三,反向一致性。任意取一个后代节点,反向查它会得到一条或多个祖先路径,路径末端应该是顶级层级概念。如果反向链条断掉,多半是闭包表只算了单向,或者关系表里destination_id的根节点没有排进。
5. 版本更新与历史追溯:典型症状排查与切换流程
版本更新是这个方向翻车率最高的环节。很多人把首次导入弄通之后,第二次发布一来就开始出错。这里把这些年在现场看到的典型问题整理成现象到解决,每一条都是实际踩过的。
5.1 现象:增量文件进库后,大量概念消失
增量发布包发布后,我把sct2_Concept_Delta_*直接导进现网库,结果之前能查到的「哮喘」等常见概念全部查询不到。原因是 Delta 文件只包含相邻版本的变化行,不是全量数据。把 Delta 当成快照导入核心表,等于是用几百条变更记录替换掉了几十万条完整概念。
这种问题没有优雅的后悔药,只能重新从快照导入。也因此我把一条铁律记了下来:初次建库只认 Snapshot,任何后续更新的前提都是「你手上有一个正确的完整基线」。Delta 只适合给已经存在的同版本快照做持续推进,不适合空库起步。
5.2 现象:历史版本查不回来,旧行被新行覆盖
某次升级后产品想回溯上个季度某个概念当时使用的术语描述,结果数据库里只有最新一条,旧的一去不回。查下来是建表时用concept_id做唯一主键,导入新快照直接走ON CONFLICT DO UPDATE,旧行被覆盖。
要保留历史,必须让「同一概念的不同生效记录」并存。我在第 3 章给出的 DDL 里用concept_id + effective_time + release_version做联合主键,就是为了防止这种情况。如果你已经建错了表,补救办法是重新用 Full 发布文件从空库重建,同时把版本标记列补上。既然要接 SNOMED CT,历史可追溯就是刚需,不能省。
5.3 现象:同一概念出现多条 active 记录,前端不知取哪条
正常当前视图里,一个概念只应该有一条 active=1 的记录。某次排查发现库里同一个 concept_id 对应多条 active=1,查下去是因为第一次导入了快照,第二次又把增量文件重复叠加,停用记录没有正确生效。
处理方式是先清掉问题数据,再重建当前视图。SQL 层面判断“真正当前”可以这样写:
SELECT DISTINCT ON (concept_id) concept_id, effective_time, module_id FROM snomed_concept WHERE active = 1 AND release_version = '2024-07-31' -- 换成最新发布批次 ORDER BY concept_id, effective_time DESC;DISTINCT ON取同一概念下生效时间最新的一条。这种写法可以作为视图内部逻辑,也可以在做一致性检查时直接跑。如果每个版本更新都坚持「先导入新批次、再切换视图、不覆盖旧批次」的顺序,这类问题基本不会出现。
5.4 一套相对稳妥的版本切换工作流
在实际操作里,我偏向用「新版本先并行导入、验证后再切换」的方式。步骤是:
- 拿到新的 Snapshot 发布包,先解压检查文件形态和时间戳。
- 建立一个独立 staging 表,把三张核心表按相同结构导入,
release_version填新批次标识。 - 跑一致性检查:对比新旧批次 active 概念总数、描述总数、关系总数,变化不应该异常跳动。
- 重点抽查几个业务高频概念,确认父子关系和停用状态符合发布说明。
- 确认无误后,重建闭包表,再把业务视图切到新批次标识上。如果发现异常,视图回退到旧批次即可,旧数据没有被破坏。
这个流程把发布切换的风险集中在视图切换这一步,回滚也只需要改视图里的release_version,代价很小。
6. 进阶:近义词搜索、历史查询和三句习惯
如果三张核心表和闭包表都稳定了,日常查询已经够用。下面几个进阶动作能让你在真实业务里少挨几次骂。
6.1 让描述表真正具备搜索能力
建立 trigram 索引后,描述表的模糊搜索可以走索引路径:
CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_description_term_trgm ON snomed_description USING gin (term gin_trgm_ops);之后WHERE term LIKE '%糖尿病%'这类前后模糊查询就不再是全表扫描。中文场景下这种方式虽然不算语义搜索,但对诊断名、症状名的片段匹配已经足够。若后续要做拼音或分词,建议单独加一个规范化文本列,用触发器统一维护,不要每次查询时现算。
6.2 历史查询别绕过版本标签
保留release_version之后,想要某个历史时间点的概念视图,可以按生效时间过滤:
SELECT concept_id, term FROM snomed_description WHERE effective_time <= '20230101' -- 例子,按需替换 AND language_code = 'en' ORDER BY effective_time DESC;这里要理解effective_time与发布批次的关系。发布批次标记表示「这批数据来自哪个包」,记录生效时间表示「这条语义什么时候开始生效」。想要追溯的是业务时间点,应该用后者;想要对比的是发布版本,应该用前者。两个字段各管各的,不要互相替代。
6.3 三个值得坚持的检查习惯
第一,每次开始导入前,先head -1看首行,别假设所有发布包格式一致。第二,导入完成后立刻跑一遍第 3 章的数量校验 SQL,把结果记录到版本更新日志里,下次发布再对比,异常数字能提前露出苗头。第三,发布包原始文件保留归档,压缩包不删。数据库可以随时重建,原始发布包反而只有一次拿到完整版本的机会,删了就真删了。
后来我处理任何术语发布包,都会先花两分钟确认「它是 Snapshot 还是 Delta」再决定后续操作,这个小检查帮我避免了很多次白忙。希望这些经验能帮你把这个方向一次做稳。
本文还有配套的精品资源,点击获取