1. Введение
При использовании ES для китайского поиска эффект сегментации слов напрямую влияет на результаты поиска. Для тех, у кого нет возможности разработать свою собственную сегментацию слов или общие сценарии использования, устройство сегментации слов ik будет использоваться в качестве подключаемого модуля сегментации слов. Основное использование ik tokenizer может относиться к:Использование токенизатора ik в Elasticsearch. Основная логика токенизатора ik состоит из трех частей:
1) Словарь: Качество словаря напрямую влияет на качество результата сегментации слов.Эта статья познакомит вас со структурой построения и хранения словаря.
2) Сопоставление слов: после того, как у вас есть словарь, вы можете сопоставить входную строку со словарем пословно.
3) Устранение неоднозначности: существует множество способов сегментации посредством сопоставления со словарем. Устранение неоднозначности заключается в поиске наиболее разумного способа.
2 Предварительная подготовка
Прежде чем изучать принцип IK, вам нужно клонировать исходный код IK к местным,GitHub.com/many out/E последний…. После клонирования в локальную версию проверьте версию 6.8.1, а затем напишите метод для вызова токенизатора ik, чтобы можно было отлаживать.
public class TestIK {
public static void main(String[] args) throws IOException {
testIkSegment();
}
public static void testIkSegment() throws IOException {
String t = "得饶人处且饶人";
Settings settings = Settings.builder()
.put("use_smart", false)
.put("enable_lowercase", false)
.put("enable_remote_dict", false)
.build();
Configuration configuration=new Configuration(null,settings).setUseSmart(false);
IKSegmenter segmenter = new IKSegmenter(new StringReader(t), configuration);
Lexeme next;
while ((next = segmenter.next())!=null){
System.out.println(next.getLexemeText());
}
}
}
При запуске возникает следующая ошибка:Причина ошибки в том, что путь к словарю не указан, так как конструкция объекта Environment относительно сложна, поэтому вместо него напрямую используется метод пути к файлу, а код в файле org.wltea.analyzer. Конструктор dic.Dictionary изменен для ускорения работы кода.
// this.conf_dir = cfg.getEnvironment().configFile().resolve(AnalysisIkPlugin.PLUGIN_NAME);
this.conf_dir = Paths.get("/Users/zjb/code/mine/commit/ik/elasticsearch-analysis-ik/config");
3. Словарь
Из вышеуказанных препаратов можно увидеть конфигурацию словаря на самом деле очень важна, IK предоставляет нам три общих словаря:
1) main.dic: основной словарь, некоторые общеупотребительные слова
2) quantifier.dic: часто используемые квантификаторы
3) stopword.dic: стоп-слово
Пользователи этих словарей могут расширяться самостоятельно, достаточно настроить файл IKAnalyzer.cfg.xml.
Как загружается словарь?Широко используемое дерево словарей – это дерево шин с простой структурой, также называемое префиксным деревом.Структура показана на рисунке ниже.Запустить из корневого узла и повесить слово на дерево. Начало слова является дочерним узлом корневого узла, каждое слово является дочерним узлом предыдущего слова, а красный узел представляет собой конец слова. В середине рисунка выше Цяньмэнь — это слово, а дверь — это конец, Цяньмэнь — Гигантский Тигр — это тоже слово, а тигр — это конец.
Поскольку в main.dic много слов, и пользователи могут настраивать расширение, это приведет к тому, что дерево будет очень большим.Если оно будет храниться в карте, это будет занимать место впустую, поэтому ik применяет метод компромисса. Ссылка на структуру кода DictSegment.java:
class DictSegment implements Comparable<DictSegment>{
//公用字典表,存储汉字
private static final Map<Character , Character> charMap = new ConcurrentHashMap<Character , Character>(16 , 0.95f);
//数组大小上限
private static final int ARRAY_LENGTH_LIMIT = 3;
//Map存储结构
private Map<Character , DictSegment> childrenMap;
//数组方式存储结构
private DictSegment[] childrenArray;
//当前节点上存储的字符
private Character nodeChar;
//当前节点存储的Segment数目
//storeSize <=ARRAY_LENGTH_LIMIT ,使用数组存储, storeSize >ARRAY_LENGTH_LIMIT ,则使用Map存储
private int storeSize = 0;
//当前DictSegment状态 ,默认 0 , 1表示从根节点到当前节点的路径表示一个词
private int nodeState = 0;
}
комментарии к коду ik относительно полны, и все они на китайском языке, который легче понять. Основная идея состоит в том, чтобы настроить структуру хранилища в соответствии с количеством дочерних узлов.Если количество дочерних узлов меньше или равно 3, используйте хранилище массивов, а если количество дочерних узлов больше 3, используйте хранилище карт. . NodeState используется для обозначения текущего узла (то есть, является ли текущий символ концом слова).
Имея эту структуру, давайте посмотрим, как слова загружаются из файла в память.Загрузка словаря выполняется в конструкторе конфигурации, который в конечном итоге вызовет метод DictSegment#fillSegment:
private synchronized void fillSegment(char[] charArray , int begin , int length , int enabled){
//获取字典表中的汉字对象
Character beginChar = Character.valueOf(charArray[begin]);
Character keyChar = charMap.get(beginChar);
//字典中没有该字,则将其添加入字典
if(keyChar == null){
charMap.put(beginChar, beginChar);
keyChar = beginChar;
}
//搜索当前节点的存储,查询对应keyChar的keyChar,如果没有则创建
DictSegment ds = lookforSegment(keyChar , enabled);
if(ds != null){
//处理keyChar对应的segment
if(length > 1){
//词元还没有完全加入词典树
ds.fillSegment(charArray, begin + 1, length - 1 , enabled);
}else if (length == 1){
//已经是词元的最后一个char,设置当前节点状态为enabled,
//enabled=1表明一个完整的词,enabled=0表示从词典中屏蔽当前词
ds.nodeState = enabled;
}
}
}
Это процесс рекурсивной вставки слов в словарь. Метод lookforSegment используется для поиска существующего слова в текущей карте. Если нет, создается DictSegment. Затем оценивается, обработано ли обрабатываемое в данный момент слово, если не обработано, то оно будет обработано рекурсивно, и после обработки слово будет помечено как конец слова.
4. Вырезать слова
После того, как у вас есть словарь и введенные предложения, вы можете выполнить сегментацию слов. Есть два основных способа вырезать слова в ik: один — это интеллектуальный режим, а другой — ik_max_word, не являющийся интеллектуальным режимом. Возьмем, к примеру, Baojianfeng из шлифовки:
Результаты причастия неинтеллектуального режима: Baojianfeng от стачивания, Baojianfeng, Baojian, от, Feng, от, заточки, вне Результат сегментации слов в интеллектуальном режиме: Baojianfeng не имеет резкости
Из результатов неумной сегментации слов видно, что существует множество способов сегментации предложения, Неумный — это выдать все возможные результаты сегментации слов. Интеллектуальный режим заключается в том, чтобы найти наиболее разумный метод сегментации слов среди этих нескольких режимов сегментации слов.
С точки зрения обработки, установка интеллектуального режима означает выбор сегментации слов после сегментации слов, что обычно называют устранением неоднозначности.
ik по умолчанию реализует три токенизатора, а именно CJKSegmenter (подсегментатор китайско-японско-корейского), CN_QuantifierSegmenter (сегментатор китайского квантификатора), LetterSegmenter (подсегментатор английских символов и арабских цифр).
Основная логика сегментации слов следующая: в форме, похожей на ленивую загрузку, сегментация слов выполняется только при первом вызове segmenter.next() для получения результата сегментации слов.
while((l = context.getNextLexeme()) == null ){
/*
* 从reader中读取数据,填充buffer
* 如果reader是分次读入buffer的,那么buffer要 进行移位处理
* 移位处理上次读入的但未处理的数据
*/
int available = context.fillBuffer(this.input);
if(available <= 0){
//reader已经读完
context.reset();
return null;
}else{
//初始化指针
context.initCursor();
do{
//遍历子分词器
for(ISegmenter segmenter : segmenters){
segmenter.analyze(context);
}
//字符缓冲区接近读完,需要读入新的字符
if(context.needRefillBuffer()){
break;
}
//向前移动指针
}while(context.moveCursor());
//重置子分词器,为下轮循环进行初始化
for(ISegmenter segmenter : segmenters){
segmenter.reset();
}
}
//对分词进行歧义处理
this.arbitrator.process(context, configuration.isUseSmart());
//将分词结果输出到结果集,并处理未切分的单个CJK字符
context.outputToResult();
//记录本次分词的缓冲区位移
context.markBufferOffset();
}
Основная логика причастий находится в цикле do, где сегментами являются три токенизатора. Текущее входное предложение сохраняется в контексте, и указатель цикла перемещается по одному, то есть по одному символу за раз, а затем проходит через три токенизатора и использует каждый токенизатор для обработки текущего слова.
Первый — LetterSegmenter, сегментатор английских слов относительно прост, то есть предназначен для сегментации последовательных английских символов или последовательных данных.
Затем есть CN_QuantifierSegmenter, сегментатор квантификатора. В основном это делается для того, чтобы определить, является ли текущий символ числительным и квантором, а связанные числительные и кванторы разделены на одно слово.
Наиболее важным является CJKSegmenter, основанный на сопоставлении словаря. Прежде чем вводить основную логику, нам нужно ввести класс Lexeme, который представляет собой результат сегментации слова, то есть элемент слова
public class Lexeme implements Comparable<Lexeme>{
//lexemeType常量
//未知
public static final int TYPE_UNKNOWN = 0;
//英文
public static final int TYPE_ENGLISH = 1;
//数字
public static final int TYPE_ARABIC = 2;
//英文数字混合
public static final int TYPE_LETTER = 3;
//中文词元
public static final int TYPE_CNWORD = 4;
//中文单字
public static final int TYPE_CNCHAR = 64;
//日韩文字
public static final int TYPE_OTHER_CJK = 8;
//中文数词
public static final int TYPE_CNUM = 16;
//中文量词
public static final int TYPE_COUNT = 32;
//中文数量词
public static final int TYPE_CQUAN = 48;
//词元的起始位移
private int offset;
//词元的相对起始位置
private int begin;
//词元的长度
private int length;
//词元文本
private String lexemeText;
//词元类型
private int lexemeType;
}
Основными полями этого класса в процессе сегментации слова являются начало и длина, где начало указывает начальную позицию элемента слова во входном предложении, а длина указывает длину элемента слова.
Основная логика сегментации слов заключается в методе CJKSegmenter#analyze.
public void analyze(AnalyzeContext context) {
if(CharacterUtil.CHAR_USELESS != context.getCurrentCharType()){
//优先处理tmpHits中的hit
if(!this.tmpHits.isEmpty()){
//处理词段队列
Hit[] tmpArray = this.tmpHits.toArray(new Hit[this.tmpHits.size()]);
for(Hit hit : tmpArray){
hit = Dictionary.getSingleton().matchWithHit(context.getSegmentBuff(), context.getCursor() , hit);
if(hit.isMatch()){
//输出当前的词
Lexeme newLexeme = new Lexeme(context.getBufferOffset() , hit.getBegin() , context.getCursor() - hit.getBegin() + 1 , Lexeme.TYPE_CNWORD);
context.addLexeme(newLexeme);
if(!hit.isPrefix()){//不是词前缀,hit不需要继续匹配,移除
this.tmpHits.remove(hit);
}
}else if(hit.isUnmatch()){
//hit不是词,移除
this.tmpHits.remove(hit);
}
}
}
//********************************* 上半部分
//********************************* 下半部分
//再对当前指针位置的字符进行单字匹配
Hit singleCharHit = Dictionary.getSingleton().matchInMainDict(context.getSegmentBuff(), context.getCursor(), 1);
if(singleCharHit.isMatch()){//首字成词
//输出当前的词
Lexeme newLexeme = new Lexeme(context.getBufferOffset() , context.getCursor() , 1 , Lexeme.TYPE_CNWORD);
context.addLexeme(newLexeme);
//同时也是词前缀
if(singleCharHit.isPrefix()){
//前缀匹配则放入hit列表
this.tmpHits.add(singleCharHit);
}
}else if(singleCharHit.isPrefix()){//首字为词前缀
//前缀匹配则放入hit列表
this.tmpHits.add(singleCharHit);
}
}else{
//遇到CHAR_USELESS字符
//清空队列
this.tmpHits.clear();
}
//判断缓冲区是否已经读完
if(context.isBufferConsumed()){
//清空队列
this.tmpHits.clear();
}
//判断是否锁定缓冲区
if(this.tmpHits.size() == 0){
context.unlockBuffer(SEGMENTER_NAME);
}else{
context.lockBuffer(SEGMENTER_NAME);
}
}
Давайте сначала посмотрим на вторую половину кода Общая идея состоит в том, чтобы взять символы в текущем цикле, а затем оценить, совпадают ли они.
1) Если совпадение указывает, что символ может совпадать с концом слова в словаре (т. е. красная точка, описанная в разделе 3), это означает, что текущий символ можно использовать в качестве конца слова, после чего добавление Смещение буфера, кэшированное ранее, может сделать вывод, что первая позиция слова неизвестна. Затем создайте новую лексему и поместите ее в контекст. 2) Если это префикс, то это означает, что текущий символ не является концом слова, а является префиксом слова, то он помещается в tmpHits, а tmpHits представляет собой символ, который можно использовать в качестве префикса слова слово в предыдущем процессе обхода. 3) Если его вообще нет в словаре, то очистить временную переменную.
С timpHits давайте посмотрим на первую половину кода: Сначала переберите все символы в timpHits. 1) Если комбинация текущего символа и префикса в tipHits может совпадать, новый токен сохраняется в контексте. 2) Если текущий символ добавлен, но это не префикс, то он будет удален из timpsHits
После серии обработок будет окончательно получен QuickSortSet орглексем в контексте.Сами лексемы реализуют интерфейс Comparable и сортируются по значению начала.Если начало одинаковое, то оно будет отсортировано по длине от большого до маленький. То есть позиция находится впереди, и слова с большей длиной будут расположены в первом ряду.
Взяв в качестве примера Баоцзяньфэн из гринда, мы в итоге получаем четыре лексикона: 0-7, 0-3, 0-2, 4-6. То есть Баоцзяньфэн происходит от четырех слов «заострение», «баоцзяньфэн», «баоцзянь» и «заострение». Вот только способ сегментации, и окончательный результат: Баоцзяньфэн от стачивания, Баоцзяньфэн, Баоцзянь, от, спереди, от, заточен, снаружи. Есть еще некоторые пробелы.
5. Вывод результатов
От сегментации слова до окончательного вывода на самом деле есть еще один шаг посередине, который является одним из процессов обработки.Когда для useSmart установлено значение true, четыре вышеуказанных токена будут устранены, и только один токен 0–7 останется в памяти. конец. Эта часть логики будет рассмотрена позже.Предположим, что useSmart не установлен в true, остаются еще четыре леммы, а затем приготовьтесь выводить результат, чтобы посмотреть, что делается посередине. Основная логика находится в методе AnalyzeContext#outputToResult.
void outputToResult(){
int index = 0;
for( ; index <= this.cursor ;){
//跳过非CJK字符
if(CharacterUtil.CHAR_USELESS == this.charTypes[index]){
index++;
continue;
}
//从pathMap找出对应index位置的LexemePath
LexemePath path = this.pathMap.get(index);
if(path != null){
//输出LexemePath中的lexeme到results集合
Lexeme l = path.pollFirst();
while(l != null){
this.results.add(l);
//字典中无单字,但是词元冲突了,切分出相交词元的前一个词元中的单字
int innerIndex = index + 1;
for (; innerIndex < index + l.getLength(); innerIndex++) {
Lexeme innerL = path.peekFirst();
if (innerL != null && innerIndex == innerL.getBegin()) {
this.outputSingleCJK(innerIndex - 1);
}
}
//将index移至lexeme后
index = l.getBegin() + l.getLength();
l = path.pollFirst();
if(l != null){
//输出path内部,词元间遗漏的单字
for(;index < l.getBegin();index++){
this.outputSingleCJK(index);
}
}
}
}else{//pathMap中找不到index对应的LexemePath
//单字输出
this.outputSingleCJK(index);
index++;
}
}
//清空当前的Map
this.pathMap.clear();
}
Основная логика этой части состоит в том, чтобы выводить словесные единицы в виде однословного вывода для тех позиций, которые не классифицируются по словесной сегментации. Внимательные читатели должны были заметить, что конечный результат: Баоцзяньфэн от заточки, Баоцзяньфэн, Баоцзянь, от, Фэн, от, заточил, вон. Есть два ведомых слова, и в этом операторе ввода есть только одно ведомое. Это вообще-то баг конкретной версии ик.К сожалению, в 6.8.1 этот баг есть. Об этом баге я напишу статью и подробно разберу позже. В основном есть//Слов нет в словаре, но слова конфликтуют, а слово в предыдущем слове пересекающегося слова сегментировано.Это замечание вызвано большим кодом, который также является пр ик , но последняя версия была закомментирована.
6. Многозначность
Давайте вернемся назад и посмотрим, что происходит, когда вы устанавливаете useSmart. Основная логика в методе IKArbitrator#process
void process(AnalyzeContext context , boolean useSmart){
QuickSortSet orgLexemes = context.getOrgLexemes();
Lexeme orgLexeme = orgLexemes.pollFirst();
LexemePath crossPath = new LexemePath();
while(orgLexeme != null){
if(!crossPath.addCrossLexeme(orgLexeme)){
//找到与crossPath不相交的下一个crossPath
if(crossPath.size() == 1 || !useSmart){
//crossPath没有歧义 或者 不做歧义处理
//直接输出当前crossPath
context.addLexemePath(crossPath);
}else{
//对当前的crossPath进行歧义处理
QuickSortSet.Cell headCell = crossPath.getHead();
LexemePath judgeResult = this.judge(headCell, crossPath.getPathLength());
//输出歧义处理结果judgeResult
context.addLexemePath(judgeResult);
}
//把orgLexeme加入新的crossPath中
crossPath = new LexemePath();
crossPath.addCrossLexeme(orgLexeme);
}
orgLexeme = orgLexemes.pollFirst();
}
//处理最后的path
if(crossPath.size() == 1 || !useSmart){
//crossPath没有歧义 或者 不做歧义处理
//直接输出当前crossPath
context.addLexemePath(crossPath);
}else{
//对当前的crossPath进行歧义处理
QuickSortSet.Cell headCell = crossPath.getHead();
LexemePath judgeResult = this.judge(headCell, crossPath.getPathLength());
//输出歧义处理结果judgeResult
context.addLexemePath(judgeResult);
}
}
Когда useSmart имеет значение true, выполняется обработка неоднозначности, если false, то не обрабатывается и выводится напрямую. Преобразование лемм в пути лемм LexemePath, LexemePath реализует интерфейс Comparable, леммы внутри LexemePath не хотят пересекаться, и они сортируются по правилам сортировки, правила следующие
public int compareTo(LexemePath o) {
//比较有效文本长度
if(this.payloadLength > o.payloadLength){
return -1;
}else if(this.payloadLength < o.payloadLength){
return 1;
}else{
//比较词元个数,越少越好
if(this.size() < o.size()){
return -1;
}else if (this.size() > o.size()){
return 1;
}else{
//路径跨度越大越好
if(this.getPathLength() > o.getPathLength()){
return -1;
}else if(this.getPathLength() < o.getPathLength()){
return 1;
}else {
//根据统计学结论,逆向切分概率高于正向切分,因此位置越靠后的优先
if(this.pathEnd > o.pathEnd){
return -1;
}else if(pathEnd < o.pathEnd){
return 1;
}else{
//词长越平均越好
if(this.getXWeight() > o.getXWeight()){
return -1;
}else if(this.getXWeight() < o.getXWeight()){
return 1;
}else {
//词元位置权重比较
if(this.getPWeight() > o.getPWeight()){
return -1;
}else if(this.getPWeight() < o.getPWeight()){
return 1;
}
}
}
}
}
}
return 0;
}
В соответствии с этим правилом ссылки леммы сортируются, и выбирается первая ссылка леммы, то есть метод сегментации последнего слова для устранения неоднозначности.
7. Резюме
Хотя токенизатор IK относительно прост в использовании, очень важно понимать идею его внутренних принципов, которые могут помочь анализировать и локализовать проблемы.
Для более интересного контента, пожалуйста, обратите внимание на общедоступный номер