企业知识库里最容易被低估的一类元数据是文档版本号。它看起来只是个字符串但决定了三件事检索结果该给哪一版、引用时该标哪一版、新版本入库后旧版该怎么处置。版本号识别错了后面全错。这篇文章记录一套在生产环境跑了半年的实现覆盖三块怎么把版本号从各种形态的文档里抽出来、抽不到的时候怎么用启发式补、多个候选冲突时怎么裁决。一、问题的真实形态先说清楚难在哪。如果所有文档都规规矩矩写v1.2.3正则一行就够了。真实情况不是这样。实际采集到的版本标识常见的有这些形态规格书 v2.1.pdf 产品手册_20240315_最终版.docx 报价单第七版.xlsx Rev.C 装配图.dwg 技术白皮书 v3.0(1).pdf # 括号是 Windows 副本标记不是版本 V1.2.0-beta 说明书.md 第二版 修订稿.docx 2026版 产品目录.pdf里面至少藏着四个坑1.分隔符不统一点、下划线、连字符、版、Rev.、第…版混用2.日期伪装成版本20240315不是版本号但它经常出现在版本该在的位置3.副本标记v3.0(1)里的(1)是文件系统加的不是版本一部分4.中文数字第七版、第二版需要先归一化。二、分层抽取先正则再归一化实现上分三层逐层收窄。![](https://i-blog.csdnimg.cn/direct/4b755d22370947f0bd014e324e1a99a5.png)2.1 第一层模式匹配import re # 按优先级排列命中即返回不再往下试 VERSION_PATTERNS [ # 语义化版本v1.2.3 / V1.2 / 1.2.3-beta (r[vV]?(\d(?:\.\d){1,3})(?:[-_](?:alpha|beta|rc|preview)\d*)?, semver, 10), # 修订字母Rev.C / rev D (r(?i)\brev\.?\s*([A-Z])(?:\.|\b), revletter, 8), # 中文数字版第七版 / 第三版 (r第([一二三四五六七八九十百])版, cnnum, 6), # 年份版2026版 / 2026 年版 (r((?:19|20)\d{2})\s*年?版, year, 4), ] def extract_version(filename: str): name filename.rsplit(., 1)[0] # 去扩展名 name re.sub(r\(\d\)$, , name).strip() # 去 Windows 副本标记 for pattern, kind, score in VERSION_PATTERNS: m re.search(pattern, name) if m: return normalize(m.group(1), kind), kind, score return None, None, 0注意re.sub(r\(\d\)$, , name)这一行。副本标记只在文件名末尾出现所以加了$锚定避免误伤v3.0(1)内部这类中间带括号的情况——那种括号大概率是补充说明不是副本标记。2.2 第二层中文数字归一化CN_DIGITS {零:0,一:1,二:2,三:3,四:4, 五:5,六:6,七:7,八:8,九:9} def cn_to_int(s: str) - int: if s 十: return 10 total, tmp 0, 0 for ch in s: if ch 十: tmp (tmp or 1) * 10 elif ch 百: tmp (tmp or 1) * 100 elif ch in CN_DIGITS: tmp tmp * 10 CN_DIGITS[ch] if 十 not in s and 百 not in s else CN_DIGITS[ch] else: continue if ch in 十百: total tmp tmp 0 return total tmp这段处理的是十七和十这类边界。写法上有个取舍十七这种混合结构在版本号场景里极少见所以没有引入完整的中文数字解析器够用即可。真遇到解析失败的交给第三层兜底。![](https://i-blog.csdnimg.cn/direct/0b407cc52ec24fe3bb2a0f6e04697dd1.jpg)2.3 第三层统一成可比较的三元组不同来源的版本号必须归一化到同一个坐标系才能比较。做法是把所有形态都映射成(major, minor, patch)def normalize(raw, kind): if kind semver: parts [int(x) for x in raw.split(.)] parts [0] * (3 - len(parts)) return tuple(parts[:3]) if kind revletter: return (0, ord(raw.upper()) - ord(A) 1, 0) if kind cnnum: return (cn_to_int(raw), 0, 0) if kind year: return (int(raw), 0, 0) return (0, 0, 0)revletter映射成minor位是个工程妥协。修订字母在企业文档里通常跟在数字版本之后如v2.1 Rev.C把它放在 minor 位能保证v2.1 Rev.C v2.1 Rev.B同时不会误判成v2.1 Rev.C v2.2。三、抽不到的时候启发式补位正则的覆盖率实测在 78% 左右。剩下 22% 靠三条启发式。第一条文件 mtime 排序。同一目录下同一主题的文件按修改时间倒序最新的默认版本最高。判据是文件名去掉版本部分后相似度 0.85用difflib.SequenceMatcher否则不算同一主题。第二条内容包含关系。如果 A 文件的正文完整包含 B 文件的正文去空白后B in A成立且 A 更长判定 A 是 B 的后继版本。这一条对产品手册这类只增不改的文档命中率很高。![](https://i-blog.csdnimg.cn/direct/23dbd8c3ab3e4738bfae3688766ce631.jpg)第三条显式声明。文档正文里搜替代作废以本版为准这类标记词命中后按句中提到的文件名建立版本链。SUPERSEDE_MARK re.compile(r(替代|取代|作废|以本(?:版|文件)为准)[^。\n]{0,40})三条启发式都有置信度分别是 0.5 / 0.7 / 0.8。低于 0.6 的结果不写入版本字段只写进version_candidates供人工确认——宁可留空也不要写错。四、冲突消解同一个文档可能同时命中多个模式。比如产品手册 v2.1 第三版.docx里既有 semver 也有中文数字。裁决规则按序执行1.位置优先出现在文件名主干靠后的优先通常版本写在末尾2.类型优先semver revletter cnnum year。年份版优先级最低因为它最可能是时间标记而非版本3.跨源一致如果文件名抽到v2.1正文首部也写了版本2.1则该值置信度提到 0.95两者不一致则整体降级为待人工。def resolve(cands): if not cands: return None # 先按 (类型分, 位置) 排序 cands.sort(keylambda c: (c.score, c.pos), reverseTrue) top cands[0] # 跨源校验 body_v find_version_in_body(top.doc) if body_v and body_v top.value: top.confidence 0.95 elif body_v: top.confidence min(top.confidence, 0.4) return top跨源校验这一步实测能拦下约 6% 的误判成本很低值得做。五、落地时的两个注意点不要试图一次做到全对。这套东西上线时的准确率是 82%跑了半年、积累了两千多条人工修正样本之后到 94%。一开始就把阈值卡死会挡住大量本可以自动处理的文档。版本识别错了要有回滚路径。所有自动写入的版本字段都保留version_source和version_confidence两个伴随字段出错时能快速筛出受影响范围重跑。这个设计在第一次规则调整时救了一次——当时 revletter 的映射写反了靠version_sourcerevletter筛出三百多个文件十分钟重跑完。版本号看着是小字段但它是知识库里少有的结构性信息。结构化做好检索排序、引用溯源、失效清理这三件事都能顺带解决。本文由一支长期做企业知识库工程的技术团队整理欢迎同行交流指正。