技术资料里最常见的一个困境是:同一项参数在不同页面写成不同样子。"厚度 3mm""厚度 3 毫米""板厚≤3.0mm""加工精度 0.05—0.1 毫米",人一眼就知道在说同一件事,程序却比不出来。想做参数一致性校验、范围校验或者结构化入库,第一步绕不开归一化。
下面这套实现处理四类现实问题:中文与英文单位混写、全角数字与千分位、比较符号(不大于、不小于、约、区间)、以及一段文本里连写多个数量。目标不是做一个通用解析器,而是把抽取结果落成能直接比较的字段:量纲、基准值、下限、上限、符号、原文。
单位表与基准量纲
先把单位映射到同一量纲下的基准单位。这里刻意把量纲显式列出,因为跨量纲比较是这类代码最容易出的错——把"3 升"与"3 千克"当成同一项参数,比对结果看起来正常,实际全是错的。
DIMENSIONS = { "长度": {"mm": 1.0, "毫米": 1.0, "cm": 10.0, "厘米": 10.0, "m": 1000.0, "米": 1000.0, "km": 1000000.0, "公里": 1000000.0}, "质量": {"g": 1.0, "克": 1.0, "kg": 1000.0, "千克": 1000.0, "公斤": 1000.0, "t": 1000000.0, "吨": 1000000.0}, "温度": {"℃": 1.0, "°C": 1.0, "摄氏度": 1.0}, "体积": {"ml": 1.0, "毫升": 1.0, "l": 1000.0, "L": 1000.0, "升": 1000.0}, "比例": {"%": 1.0, "percent": 1.0}, } UNIT_INDEX = {} for dim, table in DIMENSIONS.items(): for unit in table: UNIT_INDEX.setdefault(unit.lower(), (dim, table[unit]))映射表用小写键索引,查询时把候选单位也转小写,可以省掉 "L/l"、"Mm/MM" 这一类大小写分裂。温度不参与倍率换算,基准系数一律写 1.0,避免后续误把量纲当可乘单位。
数字与符号的解析
中文技术文本里的数字形态比想象中多:全角、半角混用,千分位带逗号,负号有短横与连字符两种。解析函数把这些折叠成浮点数,同时单独返回比较符号。
import re FULL_TO_HALF = str.maketrans("0123456789.-+", "0123456789.-+") NUM_PATTERN = r"[-+]?\d[\d,]*(?:\.\d+)?" OP_ALIASES = {"≤": "<=", "≦": "<=", "<": "<=", "≥": ">=", ">": ">=", "不大于": "<=", "不小于": ">=", "约": "~", "≈": "~", "以内": "<=", "以上": ">="} def parse_number(raw): text = raw.translate(FULL_TO_HALF).replace(",", "") return float(text)OP_ALIASES这张表需要按行业语料维护。"以内"和"以下"在很多语境里等价,但"以下"在温度语境里含义更模糊,建议先只登记本企业语料里出现过的写法,遇到未知写法保留符号原文,不要靠猜。
抽取:一条正则覆盖四种形态
抽取的目标形态有四种:单值、带符号的单值、闭区间、以及一个短语里的多值。用一条带可选组的正则即可覆盖,剩下的交给后处理。
TOKEN = r"(?:%s)" % NUM_PATTERN RANGE_SEP = r"(?:[-—~~]|至|到)" PATTERN = re.compile( r"(?P<op>[≤≦≥~≈<>]{0,2}|不大于|不小于|约|以内|以上)?" r"\s*(?P<n1>%s)\s*(?P<u1>[a-zA-Z]{1,3}|[\u4e00-\u9fa5]{1,3}|%%)?" r"(?:\s*%s\s*(?P<n2>%s)\s*(?P<u2>[a-zA-Z]{1,3}|[\u4e00-\u9fa5]{1,3}|%%)?)?" % (TOKEN, RANGE_SEP, TOKEN) )单位组写成"拉丁字母(1 到 3 位)、汉字(1 到 3 位)或百分号",几个限定都是必要的。上限 3 是防止正则把紧随其后的量词或连接字吞进单位里,得到"毫米厚"这种查不到映射的键;百分号写成%%不是笔误,因为这条正则是用 %-格式化拼出来的,模式里的字面量%必须双写,单写会在编译期抛ValueError: unsupported format character;下限 1 更隐蔽——如果把单位组写成可以匹配零个字符,正则会在数字后先接受一个空单位,拉丁单位(mm、kg)整段被漏掉,而中文单位因为能匹配汉字看起来一切正常。查不到映射时按未知单位返回,不要静默返回默认量纲。
后处理:把匹配结果变成可比字段
拿到匹配片段之后,分三步收尾:判定量纲、换算到基准单位、按符号决定值落在下限还是上限。区间与单值走同一套出口,比对逻辑就不需要分支。
def to_field(match): op = (match.group("op") or "").strip() op = OP_ALIASES.get(op, op) v1 = parse_number(match.group("n1")) unit = (match.group("u1") or match.group("u2") or "").strip() info = UNIT_INDEX.get(unit.lower()) if not info: return {"raw": match.group(0), "dimension": None, "value": v1, "unit": unit, "op": op, "note": "unit-unknown"} dim, factor = info low, high = v1 * factor, v1 * factor if match.group("n2"): high = parse_number(match.group("n2")) * factor return {"raw": match.group(0), "dimension": dim, "low": low, "high": high, "op": op, "unit": unit}百分比和温度这两类要单独打标记。百分比的基准值直接用原数即可,但"3"和"3%"在文本里外观接近,抽取时如果没有取到%,建议返回unit-unknown而不是默认按单值处理。
三条实测出来的经验
· 多值连写要单独处理。"长 3 米、宽 1.2 米"用一条正则整体匹配会把"长"当成单位的一部分。做法是按分隔符(顿号、逗号、分号)先切短语,再对每个短语单独抽值。
· 抽取结果保留原文。字段里带raw,出现争议时能立刻定位是哪一句文本被解析错了;只存数值的话,排错成本会成倍上升。
· 单位表按版本落盘。这张表一旦改动,历史抽取结果就不可比。把它连同版本号一起写成配置,比写在代码里更容易复核。
按这套实现,在一批约 400 页的工业品参数文本上跑,抽取召回接近全量,误报集中在未知单位那一类,逐条补进映射表即可收敛。
(本文由一支长期做工业文本结构化与数据质量检测的技术团队整理,欢迎同行交流指正。)