拆解 Surfing-Segment:我是如何折腾出这个分词引擎的

47 阅读12分钟

上次写完第一篇,不少朋友问我具体是怎么实现的。今天我们就来聊聊 Surfing-Segment 背后的那些算法和设计。说实话,这些东西当时真是把我折腾惨了,不过回头看看,倒也学到了不少。

先从 Trie 树说起吧

当时我研究 ik-analyzer 的时候,最让我佩服的就是它的 Trie 树实现。林良益老师在这方面做得真的很精妙。

ik-analyzer 的精妙设计

ik 用了一个很聪明的自适应存储结构:

  • 子节点少(≤3个)时,用数组+二分查找
  • 子节点多了,自动切换成 HashMap

这个设计既节省空间,查询效率又高。我在 Surfing-Segment 里基本保留了这套机制,因为实在没必要重复造轮子。

整个扩容的逻辑挺直接的:

  • 数组没满时,直接插入,然后排序
  • 数组满了(到了3个节点),创建 HashMap,把数组数据迁移过去,然后释放数组空间
  • 之后就一直用 HashMap 了

核心代码大概是这样的:

public class DictSegment {
    private static final int ARRAY_LENGTH_LIMIT = 3;
    
    // 两种存储结构,只会用其中一种
    private Map<Character, DictSegment> childrenMap;
    private DictSegment[] childrenArray;
    
    private int storeSize = 0;
}

查询的时候也很直接

就三步:

  1. 看用的是数组还是 HashMap
  2. 找到了就递归继续找
  3. 找不到就返回
Hit match(char[] charArray, int begin, int length, Hit searchHit) {
    Character keyChar = new Character(charArray[begin]);
    DictSegment ds = null;
    
    // 第一步:选对工具
    if (childrenArray != null) {
        // 数组存储,二分查找
        DictSegment keySegment = new DictSegment(keyChar);
        int position = Arrays.binarySearch(
            childrenArray, 0, storeSize, keySegment);
        if (position >= 0) {
            ds = childrenArray[position];
        }
    } else if (childrenMap != null) {
        // HashMap,直接取
        ds = childrenMap.get(keyChar);
    }
    
    // 第二步:找到了继续往下找
    if (ds != null) {
        if (length > 1) {
            return ds.match(charArray, begin + 1, length - 1, searchHit);
        } else if (length == 1) {
            // 到末尾了,标记状态
            if (ds.nodeState == 1) {
                searchHit.setMatch();
            }
            if (ds.hasNextNode()) {
                searchHit.setPrefix();
            }
            return searchHit;
        }
    }
    
    return searchHit;
}

我们在 ik 基础上的扩展

ik 的 Trie 树设计已经很完善了,我主要做了几个方面的扩展:

1. 多类型词典支持

这是我们最核心的扩展。ik 主要是通用分词,只有主词典、停用词、量词这些。但在电商场景,我们需要区分很多类型:

  • 品牌词典(BRAND):威卡固、3M、SKF...
  • 型号词典(SN):可以定义常见型号,虽然主要靠动态识别
  • 商品词典(GOODS):螺丝胶、轴承、胶水...
  • 属性词典(ATTRIBUTE):高强度、耐高温、防水...
  • 颜色词典(COLOR):红色、蓝色、透明...
  • 性能词典(PERFORMANCE):耐磨、抗压、防腐...
  • 量词词典(QUANTIFIER):50ml、100g、1米...

你想加什么类型都可以,只要在配置文件里定义就行。每个词典文件格式都一样,每行一个词。

在节点上我们加了类型标注:

public class DictSegment {
    // ik 原有的结构
    private Map<Character, DictSegment> childrenMap;
    private DictSegment[] childrenArray;
    private int storeSize = 0;
    
    // 我们扩展的部分:支持多类型标注
    private Set<DictType> dictTypes = Sets.newHashSet();
}

这样同一个词可以同时被标记为多种类型。比如 "VIKAGU" 既是品牌,也可能作为型号的一部分。分词的时候会把所有匹配的类型都带上,后续可以根据类型做更精准的搜索。

词典类型的定义:

public enum DictType {
    BRAND("brand"),           // 品牌
    SN("sn"),                // 型号
    GOODS("goods"),          // 商品
    ATTRIBUTE("attribute"),  // 属性
    COLOR("color"),          // 颜色
    PERFORMANCE("performance"), // 性能
    QUANTIFIER("quantifier"),   // 量词
    STOP("stop"),            // 停用词
    // ... 可以继续扩展
}

词典配置很简单,比如品牌词典 brand.dic:

威卡固
3M
SKF
ABB
西门子
施耐德

属性词典 attribute.dic:

高强度
耐高温
防水
防腐
耐磨
抗压
环保

这些词典加载后,分词的时候就能自动识别并打上对应的标签。最终输出的时候,每个词都会带着它的类型信息,比如:

威卡固 [brand]
螺丝胶 [goods]
高强度 [attribute]
耐高温 [attribute]
VG263B [sn]
50ml [quantifier]

2. 字符对象池优化

Java 里每次 new Character() 都会创建新对象,分词的时候会产生大量垃圾对象。我加了个全局的字符池来缓解这个问题:

private static final Map<Character, Character> charMap = 
    new HashMap<>(16, 0.95f);

Character keyChar = charMap.get(beginChar);
if (keyChar == null) {
    charMap.put(beginChar, beginChar);
    keyChar = beginChar;
}

效果还不错,GC 压力降了不少。

位运算表示状态这个是 ik 里就有的设计。需要表达三种状态:未匹配、完全匹配、前缀匹配。用位运算特别优雅:

public class Hit {
    private static final int UNMATCH = 0x00000000;  // 未匹配
    private static final int MATCH   = 0x00000001;  // 完全匹配
    private static final int PREFIX  = 0x00000010;  // 前缀匹配
    
    private int hitState = UNMATCH;
    
    public boolean isMatch() {
        return (hitState & MATCH) > 0;
    }
    
    public void setMatch() {
        hitState = hitState | MATCH;
    }
}

这样一个词可以同时是"完全匹配"又"有前缀延伸"。比如"中国"匹配了,但"中国人"也在词典里。

型号识别是个硬骨头

这块我当时真是被折腾惨了。工业品的型号千奇百怪:

  • VG263B (字母+数字+字母)
  • AB-CD-EF (带连接符)
  • M20*60 (带星号)
  • φ12mm (带特殊符号)

怎么让程序自动识别这些东西?

字符类型识别

首先得把字符分分类。我定义了几个基本类型:

public class CharacterUtil {
    public static final int CHAR_ARABIC  = 0x00000001;  // 数字
    public static final int CHAR_ENGLISH = 0x00000002;  // 字母
    public static final int CHAR_CONNECT = 0x00000009;  // 连接符
    
    // 这些是型号里常见的特殊字符
    private static Set<Character> SN_CHARS = 
        Sets.newHashSet('φ', '¢', '℃', '~', 'Φ');
    
    private static Set<Character> CONNECT_CHARS = 
        Sets.newHashSet('-', '*');
}

还有个规范化的步骤,主要是处理全角半角、大小写:

public static char regularize(char input) {
    if (input == 12288) {
        input = (char) 32;  // 全角空格转半角
    } else if (input > 65280 && input < 65375) {
        input = (char) (input - 65248);  // 全角转半角
    } else if (input >= 'A' && input <= 'Z') {
        input += 32;  // 大写转小写
    }
    return input;
}

这样 VG263B 就能统一成 vg263b 了。

边界检测是关键

型号识别的核心思路是:通过字符类型转换来检测边界

我画个图你就明白了:

123.png

当字符类型发生变化(字母→数字 或 数字→字母),我们就认为这是一个边界。

代码实现起来是这样的:

public class SnSegmenter implements ISegmenter {
    private int index = -1;  // 型号起始位置
    private List<Integer> starts = Lists.newArrayList();  // 边界起始点
    private List<Integer> ends = Lists.newArrayList();    // 边界结束点
    private int lastCharType = CharacterUtil.OTHERS;
    
    @Override
    public void analyzer(Context context) {
        boolean isSnChar = CharacterUtil.isSNCharacter(
            context.getCurrentChar());
        int currentCharType = CharacterUtil.identifyCharType(
            context.getCurrentChar());
        
        if (isSnChar) {
            if (index == -1) {
                // 第一个型号字符
                index = context.getCursor();
                addStartPos(context.getCursor());
            }
            
            // 检测字符类型转换
            if (currentCharType == CHAR_ENGLISH && 
                lastCharType == CHAR_ARABIC) {
                // 数字后面跟字母,新段开始
                addStartPos(context.getCursor());
                addEndPos(context.getCursor() - 1);
            } else if (currentCharType == CHAR_ARABIC && 
                       lastCharType == CHAR_ENGLISH) {
                // 字母后面跟数字,新段开始
                addStartPos(context.getCursor());
                addEndPos(context.getCursor() - 1);
            }
        }
        
        lastCharType = currentCharType;
    }
}

切分组合生成

检测到边界后,怎么生成所有可能的切分呢?答案是:笛卡尔积

VG263B 来说:

starts = [0, 2, 5]VG263B 的起始位置
ends   = [1, 4, 5]VG263B 的结束位置

笛卡尔积生成所有组合:
[0,1]VG      
[0,4]VG263   
[0,5]VG263B  ← 完整型号
[2,4]263     
[2,5]263B    
[5,5]B       

这样无论用户搜 VG263 还是 VG263B,都能匹配上。

private void doAddSnSegment(Context context) {
    List<Lexeme> toAddLexems = Lists.newArrayList();
    
    // 生成所有组合
    for (int i = 0; i < starts.size(); i++) {
        for (int j = 0; j < ends.size(); j++) {
            int begin = starts.get(i);
            int finish = ends.get(j);
            
            if (finish >= begin && finish - begin >= 2) {
                Lexeme lexeme = new Lexeme(begin, finish, 
                    DictType.SN, context.getInput());
                toAddLexems.add(lexeme);
            }
        }
    }
    
    // 加到结果里
    for (Lexeme lexeme : toAddLexems) {
        context.addLexeme(lexeme);
    }
}

连接符的坑

如果型号里有连接符,比如 AB-CD-EF,我们不能把 BC 这种跨段的组合也生成出来。

所以需要加个约束:只允许同一段内的切分,或者连续段的组合。

2.jpg 实现的时候是这样判断的:

private boolean isSameSegment(int begin, int end) {
    if (begin == this.index && end == lastPos) {
        return true;  // 完整型号肯定OK
    }
    
    int allowStart = 0;
    for (int i = 0; i < connectPos.size(); i++) {
        int allowEnd = connectPos.get(i);
        if (begin >= allowStart && end <= allowEnd) {
            return true;  // 在同一段内
        }
        allowStart = allowEnd + 1;
    }
    return false;
}

同义词处理

品牌同义词是另一个头疼的问题。同一个品牌,用户的叫法五花八门:

  • 中文:微卡固威卡固
  • 英文:VIKAGU
  • 拼音:weikagu

存储结构

我们用一个 Map 存储,key 是任意一个同义词,value 是所有同义词用逗号拼接,第一个是标准词:

private Map<String, String> synonyms = Maps.newHashMap();

// 示例数据:
// "威卡固" → "威卡固,微卡固,vikagu,weikagu"
// "微卡固" → "威卡固,微卡固,vikagu,weikagu"
// "vikagu" → "威卡固,微卡固,vikagu,weikagu"

加载词典的时候稍微处理一下:

public void fillSynoym(String standard, Set<String> synos) {
    StringBuilder sb = new StringBuilder(standard);
    for (String syno : synos) {
        sb.append(",").append(syno);
    }
    String synoyStr = sb.toString();
    
    // 所有同义词都指向同一个字符串
    synonyms.put(normalizeSn(standard), synoyStr);
    for (String syno : synos) {
        synonyms.put(normalizeSn(syno), synoyStr);
    }
}

归一化的时候会把连接符都去掉:

private static String normalizeSn(String sn) {
    sn = sn.toLowerCase().trim();
    sn = sn.replaceAll("-", " ");
    sn = sn.replaceAll("\\*", " ");
    return sn;
}

查询接口

提供了两个常用方法:

// 获取某个词的所有同义词
public Set<String> getSynoymSet(String standard) {
    if (synonyms.containsKey(standard)) {
        return Arrays.stream(synonyms.get(standard).split(","))
            .collect(Collectors.toSet());
    } else {
        return Sets.newHashSet(standard);
    }
}

// 获取标准词(用于归一化)
public String getSynoValue(String text) {
    if (!synonyms.containsKey(text)) {
        return text;
    }
    return synonyms.get(text).split(",")[0];
}

这样 getSynoValue("微卡固")getSynoValue("vikagu") 都会返回 "威卡固"

消歧的时候要小心

有个坑我踩了很久才发现。假如词典里有个品牌叫 GB,但用户输入 948GBM,这里的 GB 明显不是品牌,而是型号的一部分。

怎么判断呢?我们用临界区字符检测:

graph LR
    A[9] --> B[4] --> C[8] --> D[G] --> E[B] --> F[M]
    
    D -.检查前一个字符.-> C
    E -.检查后一个字符.-> F
    
    style C fill:#ff9
    style F fill:#ff9

如果品牌词的前后都是数字或字母,那它很可能不是真的品牌,而是型号的一部分:

private boolean calSubEngNumber(Lexeme lexeme, Context context) {
    char[] chars = context.getInput().toCharArray();
    
    // 前向检测
    int start = lexeme.getBegin();
    if (start > 0) {
        char beforeChar = chars[start - 1];
        char startChar = chars[start];
        if (CharacterUtil.isDigitalEnglish(beforeChar) && 
            CharacterUtil.isDigitalEnglish(startChar)) {
            // 前面是数字/字母,后面也是,大概率是型号
            removeDictType(lexeme, DictType.BRAND);
            return true;
        }
    }
    
    // 后向检测
    int end = lexeme.getEnd();
    if (end < chars.length - 1) {
        char afterChar = chars[end + 1];
        char endChar = chars[end];
        if (CharacterUtil.isDigitalEnglish(endChar) && 
            CharacterUtil.isDigitalEnglish(afterChar)) {
            removeDictType(lexeme, DictType.BRAND);
            return true;
        }
    }
    
    return false;
}

这个规则后来证明还挺好用的,误识别率降了不少。

多段切分策略

最后说说多段切分。这个功能主要是为了提升搜索的召回率。

需求是这样的

用户搜索行为千奇百怪:

  • AB-CD 要能找到 AB-CD-EF
  • CD 也要能找到 AB-CD-EF
  • AB CD 还是要能找到

怎么办?把型号切成多个粒度。

graph TD
    A[AB-CD-EF<br/>完整型号] --> B[单段切分]
    A --> C[多段组合]
    A --> D[保留完整]
    
    B --> E[AB]
    B --> F[CD]
    B --> G[EF]
    
    C --> H[AB-CD]
    C --> I[CD-EF]
    
    D --> J[AB-CD-EF]
    
    style A fill:#bbf
    style E fill:#bfb
    style F fill:#bfb
    style G fill:#bfb
    style H fill:#fbf
    style I fill:#fbf
    style J fill:#ffb

策略选择

有连接符和没连接符的型号,处理方式不太一样:

无连接符的型号(比如 VG263B):保留所有可能的切分,最大化召回。

有连接符的型号(比如 AB-CD-EF):只保留段内切分和连续段组合,避免生成无意义的切分。

private void doAddSnSegment(Context context) {
    List<Lexeme> toAddLexems = Lists.newArrayList();
    
    // 先生成所有可能的切分
    for (int i = 0; i < starts.size(); i++) {
        for (int j = 0; j < ends.size(); j++) {
            int begin = starts.get(i);
            int finish = ends.get(j);
            
            if (finish >= begin && finish - begin >= 2) {
                Lexeme lexeme = new Lexeme(begin, finish, 
                    DictType.SN, context.getInput());
                toAddLexems.add(lexeme);
            }
        }
    }
    
    // 根据有无连接符选择策略
    if (connectPos.size() == 0) {
        // 没有连接符,全部保留
        for (Lexeme lexeme : toAddLexems) {
            context.addLexeme(lexeme);
        }
    } else {
        // 有连接符,只保留合法的切分
        for (Lexeme lexeme : toAddLexems) {
            if (isSameSegment(lexeme.getBegin(), lexeme.getEnd())) {
                context.addLexeme(lexeme);
            }
        }
    }
}

三种切分模式

我们还提供了三种模式给用户选:

MAX 模式(最大化切分):保留所有可能的切分,召回率最高。建索引的时候用这个。

MOST 模式(最优切分):智能选择最佳切分组合。查询分析的时候用这个。

DISTINCT 模式(去重模式):同一个词只保留一次。做标签提取的时候用这个。

if (segmentMode == SegmentMode.DISTINCT) {
    Iterator<Lexeme> iterator = context.getResults().iterator();
    Set<String> addTexts = Sets.newHashSet();
    
    while (iterator.hasNext()) {
        Lexeme lexeme = iterator.next();
        String text = lexeme.getText();
        if (addTexts.contains(text)) {
            iterator.remove();  // 重复了就删掉
        }
        addTexts.add(text);
    }
}

整体流程

我画个图把整个流程串起来:

graph TD
    A[输入文本] --> B[字符规范化]
    B --> C[词典匹配<br/>Trie树查询]
    C --> D[型号动态识别<br/>边界检测]
    D --> E[笛卡尔积生成切分]
    E --> F[连接符约束过滤]
    F --> G[同义词标准化]
    G --> H[临界区检测消歧]
    H --> I[多粒度切分]
    I --> J[语义标注]
    J --> K[输出结果]
    
    style A fill:#e1f5ff
    style C fill:#fff9e1
    style D fill:#ffe1f5
    style G fill:#e1ffe1
    style K fill:#f5e1ff

来个实际例子感受一下:

输入:微卡固/VIKAGU 螺丝胶VG263B

Step 1: 字符规范化

  • VIKAGUvikagu

Step 2: 词典匹配

  • 微卡固 → [品牌]
  • vikagu → [品牌]
  • 螺丝胶 → [商品]

Step 3: 型号识别

  • VG263B → 检测到边界 VG | 263 | B

Step 4: 生成切分

  • VG, VG263, VG263B, 263, 263B, B

Step 5: 同义词标准化

  • 微卡固 → 标准词:威卡固
  • vikagu → 标准词:威卡固

Step 6: 语义标注

  • 微卡固 [brand] → 威卡固
  • vikagu [brand,sn] → 威卡固
  • 螺丝胶 [goods]
  • VG263B [sn]

性能这块

说实话,加了这么多功能后,我一开始很担心性能问题。不过实测下来还好,比 ik-analyzer 慢了大概 15%,但在可接受范围内。

几个优化点:

  • 字符对象池:减少 90% 的 GC 压力
  • 双重检查锁:线程安全又不影响性能
  • 位运算状态:判断状态特别快
  • 自适应存储:空间利用率提升 60%

在生产环境跑了几个月,还算稳定。500万 SKU 的商品库,日均 200 万次搜索,扛得住。

一些还没研究透的地方

机器学习辅助识别这块我一直想尝试,但还没找到合适的方法。现在的规则引擎已经能应付大部分场景了,但对于一些特别奇葩的型号还是会识别不准。不知道能不能训练个小模型来辅助决策。

跨语言分词也是个有意思的方向。目前主要支持中文和英文,如果要支持日语、韩语,可能需要重新设计一些模块。

大规模词典的加载优化还有改进空间。现在启动的时候要加载完整的词典,如果词典特别大(几百 MB),启动时间会比较长。能不能做成懒加载或者分片加载?这个我还在琢磨。

写在最后

这篇写得有点长,主要是想把核心的几个算法讲清楚。回头看看这些代码,想起当时调试的时候真是各种崩溃,现在倒觉得挺有意思的。

如果你也在做类似的东西,遇到了问题,欢迎来交流。说不定我踩过的坑能帮你省点时间。

下次准备写写怎么集成到 Elasticsearch 里,还有一些实际应用的经验。

对了,代码都在 Gitee 上开源了,感兴趣的可以去看看: gitee.com/sh_wangwanb…


2025-11-17