基于 HanLP + 编辑距离的医疗术语智能纠错实战
1. 背景与痛点
在医疗文书、电子病历、药品说明等场景中,专业术语的准确性至关重要。一个错别字可能导致完全不同的诊断或药品。例如:
- “心机梗塞” → 应为“心肌梗塞”
- “糖料病” → 应为“糖尿病”
- “阿莫西林胶襄” → 应为“阿莫西林胶囊”
传统的纠错方案要么依赖巨大的语言模型,部署成本高;要么直接使用 HanLP 的自定义词典,但对于错词、变形词的识别能力有限。
本文介绍一种轻量级、纯内存、可嵌入的术语纠错实现:
利用 HanLP 分词结果圈定中文语块 + 基于编辑距离的滑窗模糊匹配,在不依赖大规模模型的前提下,精准纠正专业术语。
2. 整体设计思路
该纠错器核心流程分为三步:
HanLP 分词 + 词性标注
使用HanLP.segment()对输入文本进行分词,并获取每个词语的词性。合并中文连续语块
根据分词结果和词性(名词、专名等),将相邻的中文字符序列合并成一个待检测的候选区间。这解决了错词被 HanLP 切碎的问题。滑窗模糊扫描 + 编辑距离匹配
在每个候选区间内,使用长度滑窗提取子串,与预加载的标准术语词典进行编辑距离计算。距离 ≤ 1 且与原文不同的,触发替换。
最终,对多个修正进行位置排序,安全重建输出文本。
3. 代码结构解析
3.1 记录类定义
recordCorrectionDetail(Stringoriginal,Stringcorrected,intstart,intend,inteditDistance){}recordCorrectionResult(StringcorrectedText,List<CorrectionDetail>details){}CorrectionDetail:记录每次纠错的原文、修正词、起止位置和编辑距离。CorrectionResult:封装修正后的完整文本与所有纠错明细。
3.2 核心类HanLPCorrector
词典存储与加载
privatefinalMap<Integer,List<String>>dictByLen=newConcurrentHashMap<>();privateintminLen=Integer.MAX_VALUE,maxLen=0;publicvoidload(Set<String>correctTerms){dictByLen.clear();minLen=Integer.MAX_VALUE;maxLen=0;for(Stringterm:correctTerms){dictByLen.computeIfAbsent(term.length(),k->newArrayList<>()).add(term);if(term.length()<minLen)minLen=term.length();if(term.length()>maxLen)maxLen=term.length();}}- 使用长度索引(
Map<Integer, List<String>>)存放标准词,便于后续滑窗时快速获取候选词列表,避免遍历全部词典。
主流程correct(String original)
List<Term>terms=HanLP.segment(original);// 合并中文语块// ...// 在语块上执行滑窗纠错// ...// 排序并重建文本关键点:合并中文语块
for(Termterm:terms){Stringword=term.word;booleanisChinese=word.chars().allMatch(c->c>='\u4e00'&&c<='\u9fa5');Stringnature=term.nature!=null?term.nature.toString():"x";booleanisNounLike=nature.startsWith("n")||nature.equals("x");if(isChinese&&(isNounLike||word.length()>=minLen)){if(currentStart==-1)currentStart=charPos;currentEnd=charPos+word.length();}else{// 非中文/非名词性词语时,结束当前语块}charPos+=word.length();}- 仅当词语为纯中文且**词性以 n 开头(名词/专名)或为 x(字符串)**时,才纳入连续语块。
- 使用
charPos精准记录字符位置,避免字节偏移错误。
模糊扫描fuzzyScan(...)
for(inti=start;i<=end-minLen;i++){if(covered[i])continue;// 已修正的位置跳过for(intlen=Math.min(maxLen,end-i);len>=minLen;len--){Stringsub=text.substring(i,i+len);List<String>candidates=dictByLen.get(len);if(candidates==null)continue;StringbestMatch=sub;intminDist=Integer.MAX_VALUE;for(Stringcand:candidates){intdist=levenshtein(sub,cand);if(dist<minDist){minDist=dist;bestMatch=cand;}}if(minDist<=MAX_EDIT_DIST&&!sub.equals(bestMatch)){changes.add(newCorrectionDetail(sub,bestMatch,i,i+len,minDist));for(intk=i;k<i+len;k++)covered[k]=true;break;}}}- 滑窗策略:窗口长度从
maxLen递减到minLen,优先匹配长词,符合术语优先原则。 - 编辑距离阈值:
MAX_EDIT_DIST = 1,即最多允许一个字符的增删改。 - 位置覆盖数组:避免同一位置被多次修正造成重叠。
编辑距离算法
privateintlevenshtein(Strings1,Strings2){// 标准动态规划实现}使用经典的 Wagner–Fischer 算法,时间复杂度 O(mn),对于短术语(通常 < 20 字符)完全可接受。
4. 测试演示
在main方法中,我们加载了医疗术语词典,并输入了一段包含多个错别字的文本:
HanLPCorrectorcorrector=newHanLPCorrector();corrector.load(Set.of("心肌梗塞","糖尿病","阿莫西林胶囊","冠状动脉粥样硬化","心电图"));Stringinput="患者确诊为心机梗塞,伴有轻度糖料病,建议复查心电图。医生开了阿莫西林胶襄。检查单号:20240512-001。";CorrectionResultresult=corrector.correct(input);输出结果
📝 原始文本: 患者确诊为心机梗塞,伴有轻度糖料病,建议复查心电图。医生开了阿莫西林胶襄。检查单号:20240512-001。 ✅ 修正文本: 患者确诊为心肌梗塞,伴有轻度糖尿病,建议复查心电图。医生开了阿莫西林胶囊。检查单号:20240512-001。 🔍 纠错明细: ❌ 原文: "心机梗塞" | ✅ 替换: "心肌梗塞" | 📍 位置: [6, 10) | 📏 距离: 1 ❌ 原文: "糖料病" | ✅ 替换: "糖尿病" | 📍 位置: [16, 19) | 📏 距离: 1 ❌ 原文: "胶襄" | ✅ 替换: "胶囊" | 📍 位置: [37, 39) | 📏 距离: 1可以看到:
- “心机梗塞” 被正确纠正为 “心肌梗塞”(错一字,编辑距离 1)
- “糖料病” → “糖尿病”
- “阿莫西林胶襄” → “阿莫西林胶囊”
而“心电图”本身正确,未被改动;数字和符号部分被 HanLP 语块合并逻辑自然跳过,保留原样。
5. 方案优势与适用场景
优点
- 轻量级:无需 GPU,无需加载大型语言模型,内存占用仅词典大小。
- 精准可控:编辑距离阈值可调,词典可动态热加载。
- 与 HanLP 无缝集成:充分利用分词与词性标注能力,避免了对整句无差别纠错。
- 易扩展:只需替换词典集合,即可适配法律、金融、机械等领域的术语纠错。
适用场景
- 电子病历、医疗报告的后处理清洗
- 客服聊天记录中的产品名称纠正
- 垂直领域搜索框的输入提示与纠错
- 任何需要术语级精确纠正的文本预处理环节
6. 局限性及改进方向
- 词典覆盖率依赖:只能纠正词典中已存在的词,无法处理未登录术语。
- 编辑距离 1 的限制:对于多字错误(如“冠状动脉粥样硬化” 错为 “冠状动卖粥样硬化” 两个错字),当前阈值无法纠正。可通过增加
MAX_EDIT_DIST或引入拼音相似度来改善。 - 分词准确性依赖:若 HanLP 将错误术语切分为非名词(例如动词),则可能不会被纳入候选区间。可通过自定义 HanLP 词性映射或强制将所有中文片段纳入处理。
7. 完整代码获取
需引入 HanLP 依赖
Maven 依赖:
<dependency><groupId>com.hankcs</groupId><artifactId>hanlp</artifactId><version>portable-1.8.4</version></dependency>package com.fenci;importcom.hankcs.hanlp.HanLP;importcom.hankcs.hanlp.seg.common.Term;importjava.util.*;importjava.util.concurrent.ConcurrentHashMap;public class TermCorrectorFinal{record CorrectionDetail(String original, String corrected, int start, int end, int editDistance){}record CorrectionResult(String correctedText, List<CorrectionDetail>details){}static class HanLPCorrector{// 纯内存标准词索引(替代不可靠的 CustomDictionary) private final Map<Integer, List<String>>dictByLen=new ConcurrentHashMap<>();private int minLen=Integer.MAX_VALUE, maxLen=0;private static final int MAX_EDIT_DIST=1;public void load(Set<String>correctTerms){dictByLen.clear();minLen=Integer.MAX_VALUE;maxLen=0;for(String term:correctTerms){dictByLen.computeIfAbsent(term.length(), k ->new ArrayList<>()).add(term);if(term.length()<minLen)minLen=term.length();if(term.length()>maxLen)maxLen=term.length();}}public CorrectionResult correct(String original){if(original==null||original.isEmpty())returnnew CorrectionResult(original, List.of());// 🔍1. 真正调用 HanLP:获取带词性标注的分词流 List<Term>terms=HanLP.segment(original);// 🔍2. 合并连续中文语块(解决 HanLP 将错词切碎的问题) List<int[]>ranges=new ArrayList<>();int currentStart=-1, currentEnd=0, charPos=0;for(Term term:terms){String word=term.word;boolean isChinese=word.chars().allMatch(c ->c>='\u4e00'&&c<='\u9fa5');// HanLP 词性:n(名词)/nz(专名)/x(字符串)视为潜在术语载体 String nature=term.nature!=null ? term.nature.toString():"x";boolean isNounLike=nature.startsWith("n")||nature.equals("x");if(isChinese&&(isNounLike||word.length()>=minLen)){if(currentStart==-1)currentStart=charPos;currentEnd=charPos + word.length();}else{if(currentStart!=-1){ranges.add(new int[]{currentStart, currentEnd});currentStart=-1;}}charPos+=word.length();// 精准追踪字符位置}if(currentStart!=-1)ranges.add(new int[]{currentStart, currentEnd});// 🔍3. 在 HanLP 圈定的中文语块上执行滑窗纠错 List<CorrectionDetail>changes=new ArrayList<>();boolean[]covered=new boolean[original.length()];for(int[]range:ranges){fuzzyScan(original, range[0], range[1], covered, changes);}// 🔍4. 安全重建文本 changes.sort(Comparator.comparingInt(d ->d.start()));StringBuilder sb=new StringBuilder(original.length());int lastEnd=0;for(CorrectionDetail d:changes){sb.append(original, lastEnd, d.start());sb.append(d.corrected());lastEnd=d.end();}sb.append(original, lastEnd, original.length());returnnew CorrectionResult(sb.toString(), changes);}private void fuzzyScan(String text, int start, int end, boolean[]covered, List<CorrectionDetail>changes){for(int i=start;i<=end - minLen;i++){if(covered[i])continue;for(int len=Math.min(maxLen, end - i);len>=minLen;len--){String sub=text.substring(i, i + len);List<String>candidates=dictByLen.get(len);if(candidates==null)continue;String bestMatch=sub;int minDist=Integer.MAX_VALUE;for(String cand:candidates){int dist=levenshtein(sub, cand);if(dist<minDist){minDist=dist;bestMatch=cand;}}if(minDist<=MAX_EDIT_DIST&&!sub.equals(bestMatch)){changes.add(new CorrectionDetail(sub, bestMatch, i, i + len, minDist));for(int k=i;k<i + len;k++)covered[k]=true;break;}}}}private int levenshtein(String s1, String s2){int m=s1.length(), n=s2.length();int[][]dp=new int[m +1][n +1];for(int i=0;i<=m;i++)dp[i][0]=i;for(int j=0;j<=n;j++)dp[0][j]=j;for(int i=1;i<=m;i++){for(int j=1;j<=n;j++){int cost=s1.charAt(i -1)==s2.charAt(j -1)?0:1;dp[i][j]=Math.min(Math.min(dp[i-1][j]+1, dp[i][j-1]+1), dp[i-1][j-1]+cost);}}returndp[m][n];}}// 🧪 测试入口 public static void main(String[]args){HanLPCorrector corrector=new HanLPCorrector();corrector.load(Set.of("心肌梗塞","糖尿病","阿莫西林胶囊","冠状动脉粥样硬化","心电图"));String input="患者确诊为心机梗塞,伴有轻度糖料病,建议复查心电图。医生开了阿莫西林胶襄。检查单号:20240512-001。";CorrectionResult result=corrector.correct(input);System.out.println("📝 原始文本: "+ input);System.out.println("✅ 修正文本: "+ result.correctedText());System.out.println("\n🔍 纠错明细:");if(result.details().isEmpty()){System.out.println(" (无术语错误)");}else{for(var d:result.details()){System.out.printf(" ❌ 原文:\"%s\"| ✅ 替换:\"%s\"| 📍 位置: [%d, %d) | 📏 距离: %d%n", d.original(), d.corrected(), d.start(), d.end(), d.editDistance());}}}}注意:HanLP 的不同版本 API 略有差异,本文基于
portable-1.8.4测试通过。
8. 总结
通过HanLP 分词 + 编辑距离滑窗匹配的组合拳,我们实现了一个简洁、高效的术语纠错工具。它既克服了传统自定义词典“只能识别、不能容错”的短板,又避免了重型语言模型的部署开销。
如果你的项目中有类似的术语纠错需求,不妨基于此代码进行定制——只需准备一份高质量的标准术语表,即可快速上线。