医疗器械分类目录PDF解析:抽取、清洗与判定接口
2026/9/20 19:13:33 网站建设 项目流程

简介:这份《一类二类三类医疗器械分类目录--政府版.pdf》面向医疗器械研发、注册申报、生产经营及临床采购等从业者,用于快速对照器械的类别归属,判断产品是按一类、二类还是三类管理。资源以政府版分类目录为底稿,按基础外科、显微外科、神经外科、眼科、耳鼻喉科、口腔科、胸腔心血管外科、腹部外科、泌尿肛肠外科、矫形(骨科)外科等手术器械大类逐项展开,每项列出名称、品名举例与对应类别序号,如医用缝合针、基础外科用剪钳镊钩、脑膜刀、角膜剪、拔牙钳、胸骨刀、椎板咬骨钳等均可在表中定位。压缩包内共1个PDF文件,约416KB,篇幅集中、检索方便,适合打印或离线查阅。目前已有544人浏览学习。读者可借此掌握各类器械的典型品名与风险等级划分逻辑,为产品注册分类界定、经营许可范围核对及医院器械选型提供直接依据。

1. 医疗器械分类目录 PDF 的价值不在 PDF,而在它的字段能不能被程序读到

做医疗器械注册申报系统、器械 ERP 或者平台类目审核的人,几乎都会卡在同一个地方:手上有一份《医疗器械分类目录》政府版 PDF,业务方要的却是一句「输入产品名,告诉我它属于一类、二类还是三类」。这份目录的正文里同时塞着分类编码、产品描述、预期用途、品名举例和管理类别,品名举例一格经常并列十几个名称,用顿号、分号混着隔开;表格跨页时表头不重复,管理类别还常常是合并单元格。于是 Ctrl+F 找不到东西,复制出来是断行的。这条链路上真正要写的代码只有三段:把 PDF 版面还原成行记录、把字段清洗成一张可索引的表、再包一层先精确后模糊的判定服务。目标读者是做医疗 SaaS 后端、器械合规风控和类目运营工具的工程师,也包括需要自己造一份查询底表的注册与合规同学。

2. 拆解分类目录版面:编码层级、管理类别列与品名举例的字段逻辑

2.1 一级、二级、三级产品类别的编码树长什么样

目录的主体是按子目录组织的树形结构:一级产品类别用两位数字表示,二级产品类别再加两位,三级产品类别(有些版本叫三级序号)再加两位,拼起来就是06-01-03这种六位三段形态。不同批次的排版差异很大,有的把三段拆成三列独立列出,有的只在首行写完整编码、后续行留空。抽取时最容易踩的坑是:一级产品类别编码只在子目录开篇出现一次,正文页里根本不再重复,如果不做向下填充,抽出来的记录会大面积缺一级归属。

编码段位数含义抽取时的坑
第 1 段2一级产品类别,如 06 医用成像器械仅子目录首页出现,正文页不重复
第 2 段2二级产品类别缺一级则无法还原完整编码
第 3 段2三级产品类别 / 序号同一二级下从 01 递增,跨页易被误判为重置

理解这棵树的意义在于:后面对外提供的所有查询能力,本质上都在编码前缀上做文章。业务问「骨科器械都有哪些三类产品」,落到 SQL 上就是从某个一级编码区间里筛managed_class = 'III'

2.2 政府版 PDF 的三种典型版面与对应的抽取策略

同一份文件在不同年份、不同来源下会呈现完全不同的版面特征,先探测再决定策略,比直接写死一套参数稳妥得多。常见的是三类:带完整边框的真表格、只有横线没有竖线的伪表格、以及扫描件。前两类可以做纯文本层抽取,第三类必须先过 OCR。

判断方法很直接,用 pdfplumber 读一页,看能拿到多少字符对象和线段对象:

import pdfplumber def probe_page(page): """探测单页版面类型,决定后续用哪种抽取策略""" n_chars = len(page.chars) # 有字符对象说明带文本层 h_lines = sum(1 for l in page.lines if abs(l["y0"] - l["y1"]) < 1) # 水平线 v_lines = sum(1 for l in page.lines if abs(l["x0"] - l["x1"]) < 1) # 垂直线 return {"chars": n_chars, "h": h_lines, "v": v_lines}

n_chars接近 0 说明是扫描图,只能走 OCR;hv都很大说明是完整边框表格,用 lines 策略最省事;只有h大而v接近 0,说明是仅横线的排版,必须退到 text 策略,靠列坐标切分。

版面特征判断依据推荐策略
完整边框h、v 均较大vertical/horizontal_strategy = "lines"
仅横线h 大、v 接近 0text策略 + 列 x 坐标切分
纯图像chars 接近 0先 OCR,再按坐标重构列
混合排版部分页有文本层按页分别探测,不要全局统一参数

提示:同一份 PDF 里子目录封面页和正文页的版面往往不同,封面页通常是通栏文字。按页探测比按文档统一设置准确。

2.3 pdfplumber 抽取表格的最小可用参数组合

参数不是越多越好,下面这套是针对中文政府文档调过的常用组合,重点是三个容差:

TABLE_SETTINGS = { "vertical_strategy": "lines", # 用垂直线定位列边界 "horizontal_strategy": "lines", # 用水平线定位行边界 "snap_tolerance": 3, # 3pt 内的线段吸附成一条,抗排版错位 "join_tolerance": 3, # 把断开的线段接起来 "intersection_tolerance": 5, # 交叉点判定容差,处理线头没对齐 "edge_min_length": 10, # 过滤页眉页脚里的短横线 } def extract_all_tables(pdf_path): rows = [] with pdfplumber.open(pdf_path) as pdf: for pno, page in enumerate(pdf.pages, start=1): for table in page.extract_tables(TABLE_SETTINGS): for r in table: # 单元格里的换行去掉,页码单独存一列便于回查原文 rows.append([pno] + [(c or "").replace("\n", "") for c in r]) return rows

snap_tolerance是最关键的一个。政府版 PDF 多数由排版软件导出,表格线经常有零点几磅的错位,取值太小会把一行拆成两行,太大又会把相邻两行粘在一起,3 是个比较稳的起点。edge_min_length用来过滤装饰性短线,否则页眉页码位置会凭空多出一批假表格。抽取结果一定要保留来源页码pno,后面做人工复核和版本比对时,没有页码几乎没法定位问题。

3. 把目录落成结构化数据:清洗规则、管理类别归一化与建库

3.1 跨页表格合并与一级类别向下填充

抽取出来的原始行是「不完整」的:合并单元格只有首行有值,跨页之后表头消失,一级编码也不会重复。所以第二步必须是补全。填充要按子目录分组进行,否则上一节的编码会串到下一节。

import re CODE_RE = re.compile(r"^(\d{2})-(\d{2})-(\d{2})$") def fill_down(records, key): """合并单元格只在首行出现值,后续空行需要继承上一行的值""" last = None for rec in records: v = (rec.get(key) or "").strip() if v: last = v else: rec[key] = last or "" return records def parse_code(raw): """拆六位三段编码,返回 (一级, 二级, 三级),格式不对返回 None""" m = CODE_RE.match((raw or "").strip()) return m.groups() if m else None

fill_down的设计要点是「有值就更新游标,没值就继承」,这是处理合并单元格最通用的写法。parse_code用严格正则而不是粗暴 split,是因为正文里会混进06-01这种二级编码引用,宽松匹配会把它们误当成有效行。真正的过滤动作放在调用侧:parse_code返回 None 的行直接丢弃,同时把丢弃行数打到日志里,后面校验章节要用。

3.2 管理类别归一化:一类、第一类、I、Ⅰ 的收敛

管理类别这一列是整份数据里最脏的字段,同一份 PDF 里就可能出现「第一类」和「Ⅰ」两套写法,扫描件 OCR 之后还会把罗马数字I认成小写l或者数字1。归一化必须收敛到一个枚举值上,否则后面按类别筛选会漏数据。

原文写法归一化结果说明
第一类 / 一类I口语化写法常出现在注释里
Ⅰ / I / l / 1I全角罗马数字与 OCR 误识别
第二类 / 二类II注意与 III 区分长度
第三类 / 三类III最长,容易与 II 混淆
CN2ROMAN = {"一": "I", "二": "II", "三": "III"} OCR_FIX = {"l": "I", "1": "I", "|": "I"} # OCR 常见误识别映射 def norm_managed_class(raw: str) -> str: s = (raw or "").strip() if not s: return "" s = s.translate(str.maketrans("ⅠⅡⅢ()", "III()")) # 全角转半角 if "类" in s: ch = s.replace("第", "").replace("类", "").strip() return CN2ROMAN.get(ch, "") s = "".join(OCR_FIX.get(c, c) for c in s.upper() if c.strip()) return s if re.fullmatch(r"I{1,3}", s) else "" # 只接受 1~3 个 I

返回空字符串而不是抛异常,是为了让清洗流程能跑完并汇总出「未识别行清单」。这份清单必须人工过一遍,因为漏掉的往往是排版异常的那几行,恰好也是最容易出合规问题的行。返回值的字符集限定在I{1,3},能挡住Il这类噪声。

3.3 品名举例拆行与 SQLite 建表

品名举例是最有查询价值的一列,因为业务方通常只知道产品叫什么,不知道它属于哪个二级类别。一格塞十几个品名,直接存成一列字符串,检索只能全表扫。做法是拆成独立行。

import re SPLIT_RE = re.compile(r"[、;;,,\s]+") def split_examples(cell: str): """拆分品名举例:先剔除括号补充说明,再按多种分隔符切分""" cell = re.sub(r"[((][^))]*[))]", "", cell or "") return [x for x in SPLIT_RE.split(cell) if len(x) >= 2]

剔除括号内容的目的是避免把「超声诊断仪(不含探头)」这类带排除说明的短语切碎。长度小于 2 的结果直接丢弃,防止「等」「及」这类连接词单独成行。分隔符集合里同时放顿号、全角分号、半角分号、逗号和空白,是因为不同批次排版用的符号并不统一。

CREATE TABLE device_category ( full_code TEXT PRIMARY KEY, -- 06-01-03 top_code TEXT NOT NULL, -- 一级编码,两位 mid_code TEXT NOT NULL, -- 二级编码,两位 leaf_code TEXT NOT NULL, -- 三级编码,两位 leaf_name TEXT, managed_class TEXT NOT NULL, -- I / II / III description TEXT, -- 产品描述 intended_use TEXT, -- 预期用途 src_page INTEGER -- 来源页码,便于回查原文 ); CREATE INDEX idx_top_code ON device_category(top_code); CREATE INDEX idx_class ON device_category(managed_class); CREATE TABLE device_example ( full_code TEXT NOT NULL, example_name TEXT NOT NULL, FOREIGN KEY(full_code) REFERENCES device_category(full_code) ); CREATE INDEX idx_example_name ON device_example(example_name);

full_code做定长补零是整套设计的关键前提,只有这样,字符串比较才能等价于区间比较,索引才用得上。src_page看着多余,实际是排查数据问题时最常被用到的列。

4. 面向业务的分类查询:编码前缀检索、品名模糊匹配与判定接口

4.1 编码前缀检索为什么用区间比用 LIKE 更可控

编码定长之后,一级类别过滤可以写成区间条件,执行计划能稳定走到 B-Tree 索引上:

-- 查 06 医用成像器械下的全部条目,按编码排序 SELECT full_code, leaf_name, managed_class FROM device_category WHERE full_code >= '06-00-00' AND full_code <= '06-99-99' ORDER BY full_code; -- 查某个二级类别下的全部三类产品 SELECT full_code, leaf_name FROM device_category WHERE full_code LIKE '06-01-%' AND managed_class = 'III';

区间写法不依赖数据库对 LIKE 前缀的优化程度,跨 SQLite、MySQL、PostgreSQL 行为一致。而一旦模式串以%开头,索引立刻失效。

查询意图SQL 写法是否走索引
按一级类别full_code BETWEEN '06-00-00' AND '06-99-99'
按二级前缀full_code LIKE '06-01-%'走(前缀)
按中间段full_code LIKE '%06%'不走
品名精确example_name = ?
品名模糊example_name LIKE '%X%'不走

注意:不要把top_codemid_code当成可以省掉的冗余列。它们存在的意义就是让区间条件不必反复做字符串截取,截取会让索引失效。

4.2 品名举例的字级倒排与排序

整个目录的品名举例条目数在万级,这个量级下 LIKE 其实也够快,但对中文短词来说,模糊匹配的召回质量比速度更值得优化。SQLite 的 FTS5 用 unicode61 分词器时对中文按字切分,相当于字级别的倒排索引,短词匹配的召回比LIKE '%词%'更稳。

CREATE VIRTUAL TABLE device_fts USING fts5( example_name, full_code UNINDEXED, -- 只做返回用,不进索引 managed_class UNINDEXED, tokenize = 'unicode61' ); -- 把拆分结果灌进倒排表 INSERT INTO device_fts(example_name, full_code, managed_class) SELECT example_name, full_code, managed_class FROM device_example; -- 检索:按相关度排序,rank 由 FTS5 自动计算 SELECT full_code, example_name, managed_class FROM device_fts WHERE device_fts MATCH '超声' ORDER BY rank LIMIT 20;

UNINDEXED标记的列不会被写入倒排结构,纯粹作为返回字段携带,能省下不少索引体积。如果后续想按词而不是按字切分,就在写入前先做一遍分词,把结果用空格连起来再灌进 FTS 表。

4.3 一个先精确后模糊的判定接口

业务方要的不是搜索框,是一个「给名字返回类别」的接口。两段式匹配能把准确率和响应时间同时控住:

from fastapi import FastAPI, Query import sqlite3 app = FastAPI() DB = "device.db" @app.get("/classify") def classify(name: str = Query(..., min_length=2), limit: int = 10): conn = sqlite3.connect(DB) conn.row_factory = sqlite3.Row # 第一段:品名举例精确命中,命中即可直接返回类别 exact = conn.execute( "SELECT full_code, example_name, managed_class FROM device_example " "WHERE example_name = ?", (name,) ).fetchall() if exact: return {"hit": "exact", "items": [dict(r) for r in exact]} # 第二段:退化为包含匹配,短名字优先,limit 兜底防止大结果集拖慢接口 fuzzy = conn.execute( "SELECT full_code, example_name, managed_class FROM device_example " "WHERE example_name LIKE ? ORDER BY length(example_name) LIMIT ?", (f"%{name}%", limit) ).fetchall() return {"hit": "fuzzy", "items": [dict(r) for r in fuzzy]}

ORDER BY length(example_name)让短名称排在前面,直觉上短名字更接近通用品名,长名字多是带规格后缀的具体型号。min_length=2挡住单字查询,单字在字级倒排里命中会泛滥成灾。limit是必要的兜底,任何模糊匹配接口都要有返回上限。

参数建议值原因
min_length2单字命中过泛,人工无法确认
limit10返回给人工挑选的合理上限
相似度阈值0.6 起调低于该值不如直接让人工从候选里选
FTS 与 LIKE万级数据 LIKE 足够数据量小时别过度设计

返回体里给出hit字段(exact / fuzzy)很重要,前端可以据此决定是直接展示类别,还是提示用户从候选列表里确认。把「系统判定」和「人工确认」在接口层就区分开,能省掉后面大量的责任界定扯皮。

5. 校验与版本比对:怎么证明解析没漏行

抽取类项目最大的风险是静默漏行——流程跑通了,数据少了几百条,没人发现。三道校验能拦住绝大多数问题。第一道是编码连续性:同一二级类别下,三级序号应当从 01 连续递增,出现断号基本就是漏抽了。

-- 找出同一二级类别下序号断档的条目 WITH seq AS ( SELECT full_code, mid_code, CAST(SUBSTR(full_code, 7, 2) AS INTEGER) AS leaf_seq, LAG(CAST(SUBSTR(full_code, 7, 2) AS INTEGER)) OVER (PARTITION BY mid_code ORDER BY full_code) AS prev_seq FROM device_category ) SELECT * FROM seq WHERE leaf_seq - prev_seq > 1;

第二道是行数守恒。抽取出的三级条目数要和 PDF 尾页的最大序号、或者每个子目录的条目数对得上,对不上就回到src_page定位。第三道是抽样断言,挑几条众所周知的三类产品写进测试用例,用 pytest 固定住行为,防止后续改动参数时把清洗逻辑改坏。

版本比对是这份数据真正的长期价值所在。目录会动态调整,新旧两版按full_code做全外连接,一次分出新增、删除和类别变更三类差异:

-- PostgreSQL 或 SQLite 3.39+ 支持 FULL OUTER JOIN SELECT COALESCE(n.full_code, o.full_code) AS full_code, o.managed_class AS old_class, n.managed_class AS new_class, CASE WHEN o.full_code IS NULL THEN '新增' WHEN n.full_code IS NULL THEN '删除' WHEN o.managed_class <> n.managed_class THEN '类别变更' ELSE '未变' END AS diff_type FROM new_cat n FULL OUTER JOIN old_cat o USING (full_code) WHERE o.managed_class IS DISTINCT FROM n.managed_class OR o.full_code IS NULL OR n.full_code IS NULL;

三类差异里只有「类别变更」必须逐条回看原文页,因为它会直接改变产品的注册路径,一类走备案、二类和三类分别对应不同的注册层级,接口返回的这个字段一旦错了,下游的合规判断全错。把 diff 结果导成 CSV 交给合规同事逐条签字确认,比在系统里弹一个变更通知有用得多。有意思的是,实际跑下来最常见的 diff 类型不是新增,而是品名举例里的措辞微调——这类改动不影响判定,但在文本比对时会全部冒出来,所以比对一定要落在full_codemanaged_class两个键上,别拿整行做哈希。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询