上次写完第一篇,不少朋友问我具体是怎么实现的。今天我们就来聊聊 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;
}
查询的时候也很直接
就三步:
- 看用的是数组还是 HashMap
- 找到了就递归继续找
- 找不到就返回
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 了。
边界检测是关键
型号识别的核心思路是:通过字符类型转换来检测边界。
我画个图你就明白了:
当字符类型发生变化(字母→数字 或 数字→字母),我们就认为这是一个边界。
代码实现起来是这样的:
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] → VG、263、B 的起始位置
ends = [1, 4, 5] → VG、263、B 的结束位置
笛卡尔积生成所有组合:
[0,1] → VG
[0,4] → VG263
[0,5] → VG263B ← 完整型号
[2,4] → 263
[2,5] → 263B
[5,5] → B
这样无论用户搜 VG、263 还是 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 这种跨段的组合也生成出来。
所以需要加个约束:只允许同一段内的切分,或者连续段的组合。
实现的时候是这样判断的:
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: 字符规范化
VIKAGU→vikagu
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