視頻平台搜索框用戶輸入常含錯別字、同音字、漏字(如「非誠勿擾」輸入為「非誠無擾」)。為提升命中率與用戶體驗,需實現**毫秒級、高召回、可擴展**的中文糾錯服務。本文分享基於 **2-Gram 語言模型 + 領域詞典** 的融合方案,附核心 Java 實現與優化路徑。
| 方案 | 原理 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|---|
| 純詞典分詞匹配 | 最大匹配/最少分詞 + 編輯距離 | 實現簡單、解釋性強 | OOV (未登錄詞) 處理弱、組合詞覆蓋低 | 垂直領域實體識別 (醫療、法律) |
| N-Gram 統計語言模型 | 馬爾可夫假設:P(w1..wn) = ∏ P(wi|wi-n+1..wi-1) | 無需詞典、捕捉共現規律、處理 OOV | 需大規模語料、長距離依賴弱、冷啟動難 | 通用搜索、輸入法、拼寫檢查 |
| 深度學習 (BERT/Seq2Seq) | 預訓練模型微調糾錯任務 | 語義理解強、上下文建模佳 | 推理延遲高、模型大、需 GPU、標註數據貴 | 高精度要求、複雜語義糾錯 |
| 融合方案 (本文) | 領域詞典過濾候選 + 2-Gram 概率打分 | 工程落地快、延遲低、解釋性強、領域適配性好 | 長距離依賴受限、需維護詞典 | 垂直搜索 (影視、電商、音樂) |
com.ictclas
ictclas
2011
org.apache.commons
commons-lang3
3.12.0
public class IctclasSegmenter implements AutoCloseable {
private final ICTCLAS2011 ictclas;
public IctclasSegmenter(String dictPath) {
this.ictclas = new ICTCLAS2011();
if (!ictclas.ICTCLAS_Init(dictPath.getBytes(StandardCharsets.GB2312), 0)) {
throw new IllegalStateException("ICTCLAS 初始化失敗");
}
ictclas.ICTCLAS_SetPOSmap(2); // 北大二級標注集
}
public List segment(String text) {
byte[] result = ictclas.ICTCLAS_ParagraphProcess(text.getBytes(StandardCharsets.GB2312), 0, 1);
return Arrays.stream(new String(result, StandardCharsets.GB2312).split(" "))
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
@Override
public void close() { ictclas.ICTCLAS_Exit(); }
}
public class NGramModelBuilder {
private final Map unigram = new HashMap<>();
private final Map bigram = new HashMap<>();
private long totalUnigram = 0, totalBigram = 0;
public void train(List tokens) {
for (int i = 0; i < tokens.size(); i++) {
String w1 = tokens.get(i);
unigram.merge(w1, 1L, Long::sum);
totalUnigram++;
if (i + 1 < tokens.size()) {
String w2 = tokens.get(i + 1);
String key = w1 + "\t" + w2;
bigram.merge(key, 1L, Long::sum);
totalBigram++;
}
}
}
// 條件概率 P(w2|w1) = Count(w1,w2) / Count(w1)
public double condProb(String w1, String w2) {
long c1 = unigram.getOrDefault(w1, 0L);
if (c1 == 0) return 0.0;
long c12 = bigram.getOrDefault(w1 + "\t" + w2, 0L);
return (double) c12 / c1;
}
// 序列對數概率 (避免下溢)
public double logProb(List tokens) {
double sum = 0;
for (int i = 0; i < tokens.size() - 1; i++) {
double p = condProb(tokens.get(i), tokens.get(i + 1));
sum += (p > 0) ? Math.log(p) : -20; // 平滑懲罰
}
return sum;
}
// 序列化持久化 (Kryo/Protobuf/JSON)
public void save(Path path) throws IOException { ... }
public static NGramModelBuilder load(Path path) throws IOException { ... }
}
public class EntityDictionary {
private final DoubleArrayTrie trie = new DoubleArrayTrie<>();
public void build(Collection entities) {
entities.forEach(e -> trie.put(e, e)); // value 存實體標準名
}
// 返回查詢中所有匹配的實體 (最長優先)
public List matchLongest(String query) {
List matched = new ArrayList<>();
for (int i = 0; i < query.length(); i++) {
List> entries = trie.commonPrefixSearch(query.substring(i));
if (!entries.isEmpty()) {
// 取最長匹配
entries.sort((a, b) -> b.getKey().length() - a.getKey().length());
matched.add(entries.get(0).getValue());
}
}
return matched.stream().distinct().collect(Collectors.toList());
}
}
@Service
public class SpellCheckService {
private final NGramModelBuilder langModel;
private final EntityDictionary dict;
private final IctclasSegmenter segmenter;
private static final double LAMBDA = 0.7; // 語言模型權重
public List correct(String query, int topK) {
// 1. 字符級分詞 (細粒度)
List chars = query.chars().mapToObj(c -> String.valueOf((char)c)).collect(toList());
// 2. 詞典匹配候選實體
List candidates = dict.matchLongest(query);
if (candidates.isEmpty()) return Collections.emptyList();
// 3. 打分排序
List scored = candidates.stream()
.map(cand -> {
List candChars = cand.chars().mapToObj(ch -> String.valueOf((char)ch)).collect(toList());
double lmScore = langModel.logProb(candChars); // 語言模型分
double editSim = 1.0 - (double) StringUtils.getLevenshteinDistance(query, cand) / Math.max(query.length(), cand.length());
double finalScore = LAMBDA * normalize(lmScore) + (1 - LAMBDA) * editSim;
return new CorrectionCandidate(cand, finalScore, lmScore, editSim);
})
.sorted(Comparator.comparingDouble(CorrectionCandidate::score).reversed())
.limit(topK)
.collect(toList());
return scored;
}
private double normalize(double logProb) {
// 簡單 Sigmoid 歸一化,實際可用 MinMax/Percentile
return 1.0 / (1.0 + Math.exp(-logProb / 10));
}
@Data @AllArgsConstructor
public static class CorrectionCandidate {
private String entity;
private double score;
private double lmScore;
private double editSim;
}
}
| 優化點 | 方案 | 效果 |
|---|---|---|
| 分詞效能 | ICTCLAS JNI 呼叫開銷大 → 改用純 Java 分詞 (HanLP/Jieba) 或字符級 N-Gram 免分詞 | 延遲從 5ms 降至 <1ms |
| 詞典匹配 | Trie 樹 → DoubleArrayTrie (Aho-Corasick 多模匹配) → 單次掃描全匹配 | O(n) 匹配,無回溯 |
| 語言模型存儲 | HashMap → 機器學習特徵雜湊 / 量化概率表 (int16) / RocksDB 持久化 | 內存從 2GB 降至 200MB |
| 冷啟動 | 模型預熱載入、異步重載、版本化熱更新 | 啟動 <5s,零停機發布 |
| 召回擴展 | 同音字映射拼音索引、編輯距離 1/2 近鄰生成、用戶行為挖掘 (點擊/轉化) | 覆蓋率 +35% |
源碼已開源:GitHub Repo,歡迎 Star、Issue、PR 共建。