用 ElasticSearch 实现中文模糊搜索(分词 + 同义词搜索)

Devops · 2023-06-14

一、ElasticSearch 快速入门

快速入门见阮一峰的文章:全文搜索引擎 Elasticsearch 入门教程

二、环境安装

环境: 虚拟机 Debian10(X86_64),将虚拟机占用内存改成 2G ,且将虚拟机的 9200 端口和 8080 端口映射到宿主机。

(1) 安装 openjdk

sudo apt-get update
sudo apt-get install default-jdk

(2) 查看 openjdk 安装目录

sudo update-alternatives --config java

(3) 设置 JAVA_HOME 环境变量

# 打开 /etc/environment 文件
sudo nano /etc/environment

# 增加下面一行(安装目录见第 2 步)
JAVA_HOME="/usr/lib/jvm/java-11-openjdk-amd64"

# 运行该文件
source /etc/environment

# 查看变量
echo $JAVA_HOME

三、安装 ElasticSearch

下载 ElasticSearch5.5.1 ,解压,直接运行

cd ~
mkdir doc-search
cd doc-search
wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-5.5.1.zip
unzip elasticsearch-5.5.1.zip
cd elasticsearch-5.5.1/
./bin/elasticsearch

如果报错 "max virtual memory areas vm.maxmapcount [65530] is too low",则运行下面的命令:

sudo sysctl -w vm.max_map_count=262144

如果报错 "error='Not enough space' (errno=12)" ,则打开 elasticsearch-5.5.1/config/jvm.options ,修改堆内存大小,改为 1G :

# -Xms4g
# -Xmx4g
-Xms1g
-Xmx1g

如果正常启动,访问 localhost:9200 ,确认 elastcisearch 服务的信息:

curl localhost:9200
{
    "name" : "BK_LpKY",
    "cluster_name" : "elasticsearch",
    "cluster_uuid" : "XXIq-TxkQa2D-d9ZyfkNaw",
    "version" : {
        "number" : "5.5.1",
        "build_hash" : "19c13d0",
        "build_date" : "2017-07-18T20:44:24.823Z",
        "build_snapshot" : false,
        "lucene_version" : "6.6.0"
    },
    "tagline" : "You Know, for Search"
}

四、安装中文分词插件 ik ,配置扩展词典

(1) 下载安装中文分词插件 ik

./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v5.5.1/elasticsearch-analysis-ik-5.5.1.zip

(2) 配置扩展词典

打开 elasticsearch-5.5.1/config/analysis-ik 目录,将 extra_single_word.dic 另存为 my_extra_word.dic ,打开 my_extra_word.dic ,增加自定义词,比如: "溢洪" 等词。

打开同目录下的 IKAnalyzer.cfg.xml 文件,在 properties 下增加扩展词典:

<entry key="ext_dict">my_extra_word.dic</entry>

重启 elasticsearch 。

五、配置同义词字典,建立索引及分词器

在 elasticsearch-5.5.1/config 目录下创建一个名为 "synonyms.dic" 的文件,输入同义词词组,格式如下:

西红柿,番茄 => 西红柿,番茄
社保,公积金 => 社保,公积金
溢洪,泄洪 => 泄洪,溢洪

创建索引:

# 删除原索引
curl -X DELETE 'localhost:9200/smarthydro?pretty=true'

# 创建索引
curl -X PUT 'localhost:9200/smarthydro?pretty=true' -d '{
    "settings": {
        "analysis": {
            "analyzer": {
                "by_smart": {
                    "type": "custom",
                    "tokenizer": "ik_smart",
                    "filter": ["by_tfr", "by_sfr"],
                    "char_filter": ["by_cfr"]
                }
            },
            "filter": {
                "by_tfr": {
                    "type": "stop",
                    "stopwords": [" "]
                },
                "by_sfr": {
                    "type": "synonym",
                    "synonyms_path": "synonyms.dic"
                }
            },
            "char_filter": {
                "by_cfr": {
                    "type": "mapping",
                    "mappings": ["| => |"]
                }
            }
        }
    },
    "mappings": {
        "archive": {
            "properties": {
                "filename": {
                    "type": "text",
                    "analyzer": "ik_max_word",
                    "search_analyzer": "by_smart"
                },
                "content": {
                    "type": "text",
                    "analyzer": "ik_max_word",
                    "search_analyzer": "by_smart"
                },
                "category": {
                    "type": "text"
                },
                "module": {
                    "type": "text"
                },
                "author": {
                    "type": "text"
                },
                "path": {
                    "type": "text"
                }
            }
        }
    }
}'

以上命令创建了一个名为 smarthydro 的索引,并在其下创建了一个类型为映射(mapping)的字符过滤器 "by_cfr" ,一个类型为停止(stop)的过滤器 "by_tfr" ,一个同义词过滤器 "by_sfr" (同义词在 elasticsearch-5.5.1/config/synonyms.dic 文件中指定),一个自定义分词器 "by_smart" (利用 ik_smart 分词,并应用过滤器 "by_tfr", "by_sfr" 以及字符过滤器 "by_cfr")。

另外,在 smarthydro 索引下创建了名为 archive 的类型,共有 filename, content, category, module, author, path 六个字段,其中 filename 和 content 字段的分词器为 ik_max_word 、搜索关键字的分词器为 by_smart 。

以下介绍各分词器,这是实现中文模糊搜索的重要概念。

(1) ik 插件自带的分词器 ik_smart

ik_smart 分词器针对汉语分词,能够智能地识别出汉语句子中的词语,并将其划分为有意义的词语组合。其特点是能够自动识别出单个汉字与词语之间的关系,并在分词时将其合理地分隔开来,同时不会将完整词语进行拆分。

例如,利用 ik_smart 对 "溢洪结构" 进行分词,结果为: "溢洪"、"结构" 。

curl  'localhost:9200/smarthydro/_analyze?pretty=true' -d '{
    "analyzer": "ik_smart",
    "text": "溢洪结构"
}'
{
    "tokens" : [
        {
            "token" : "溢洪",
            "start_offset" : 0,
            "end_offset" : 2,
            "type" : "CN_WORD",
            "position" : 0
        },
        {
            "token" : "结构",
            "start_offset" : 2,
            "end_offset" : 4,
            "type" : "CN_WORD",
            "position" : 1
        }
    ]
}

(2) ik 插件自带的分词器 ik_max_word

ik_max_word 分词器能够将文本切分为所有可能成词的组合,即将文本中的每个单位(汉字、数字、英文单词等)都尝试组合成词语,从而得到更全面的分词结果。相比 ik_smart 分词器, ik_max_word 更倾向于将较长的文本分解为更多的词语,适用于全文检索等需要更大范围的搜索场景。

例如,利用 ik_max_word 对 "溢洪结构" 进行分词,结果为: "溢洪"、"溢"、"洪"、"结构"、"结"、"构" 。

curl  'localhost:9200/smarthydro/_analyze?pretty=true' -d '{
    "analyzer": "ik_max_word",
    "text": "溢洪结构"
}'
{
    "tokens" : [
        {
            "token" : "溢洪",
            "start_offset" : 0,
            "end_offset" : 2,
            "type" : "CN_WORD",
            "position" : 0
        },
        {
            "token" : "溢",
            "start_offset" : 0,
            "end_offset" : 1,
            "type" : "CN_WORD",
            "position" : 1
        },
        {
            "token" : "洪",
            "start_offset" : 1,
            "end_offset" : 2,
            "type" : "CN_WORD",
            "position" : 2
        },
        {
            "token" : "结构",
            "start_offset" : 2,
            "end_offset" : 4,
            "type" : "CN_WORD",
            "position" : 3
        },
        {
            "token" : "结",
            "start_offset" : 2,
            "end_offset" : 3,
            "type" : "CN_WORD",
            "position" : 4
        },
        {
            "token" : "构",
            "start_offset" : 3,
            "end_offset" : 4,
            "type" : "CN_WORD",
            "position" : 5
        }
    ]
}

这里要注意的是,要实现单字分词("溢"、"洪"等),需要在 my_extra_word.dic 里面加入所有需要的单字。

一般来说 ik_max_word 用于字段内容分词、并插入索引,ik_smart 用于搜索关键字的分词。

(3) 自定义分词器 by_smart

by_smart 分词器先用 ik_smart 进行分词,之后,加入分词结果的所有同义词。例如:利用 by_smart 对 "溢洪结构" 进行分词,结果为: "泄洪"、"溢洪"、"结构" 。

curl  'localhost:9200/smarthydro/_analyze?pretty=true' -d '{
    "analyzer": "by_smart",
    "text": "溢洪结构"
}'
{
    "tokens" : [
        {
            "token" : "泄洪",
            "start_offset" : 0,
            "end_offset" : 2,
            "type" : "SYNONYM",
            "position" : 0
        },
        {
            "token" : "溢洪",
            "start_offset" : 0,
            "end_offset" : 2,
            "type" : "SYNONYM",
            "position" : 0
        },
        {
            "token" : "结构",
            "start_offset" : 2,
            "end_offset" : 4,
            "type" : "CN_WORD",
            "position" : 1
        }
    ]
}

六、插入数据、搜索数据

(1) 插入数据

用 POST /{index_name}/{type_name} 向 elasticsearch 服务插入数据:

curl -X POST 'localhost:9200/smarthydro/archive' -d '{
    "filename": "ANSYS 结构有限元高级分析方法与范例应用.pdf",
    "path": "ANSYS 结构有限元高级分析方法与范例应用.pdf",
    "content": ""
}'
curl -X POST 'localhost:9200/smarthydro/archive' -d '{
    "filename": "Design of gravity dam,USBR-垦务局重力坝设计中文.pdf",
    "path": "Design of gravity dam,USBR-垦务局重力坝设计中文.pdf",
    "content": ""
}'
curl -X POST 'localhost:9200/smarthydro/archive' -d '{
    "filename": "K078.天荒坪公司7#施工支洞渗水通道分析.pdf",
    "path": "2013大坝年会/后加文章PDF/K078.天荒坪公司7#施工支洞渗水通道分析.pdf",
    "content": ""
}'
curl -X POST 'localhost:9200/smarthydro/archive' -d '{
    "filename": "K082.桐柏抽水蓄能电站下水库坝身溢洪道泄槽底板锚固筋型式试验研究.pdf",
    "path": "2013大坝年会/后加文章PDF/K082.桐柏抽水蓄能电站下水库坝身溢洪道泄槽底板锚固筋型式试验研究.pdf",
    "content": ""
}'
curl -X POST 'localhost:9200/smarthydro/archive' -d '{
    "filename": "K083.深水垫条件下高拱坝护岸不护底水垫塘可行性研究 - 中文.pdf",
    "path": "2013大坝年会/后加文章PDF/K083.深水垫条件下高拱坝护岸不护底水垫塘可行性研究 - 中文.pdf",
    "content": ""
}'

完整的数据见 add-all.sh

插入数据时,elasticsearch 会用 analyzer 指定的分词器(本项目中是 ik_max_word)对字段文本进行分词,然后用分词结果作为键值插入数据索引中。

(2) 查询数据

用 POST /{index_name}/{type_name}/_search 查询数据,在 query 参数中指定搜索字段,如下:

curl -X POST 'localhost:9200/smarthydro/archive/_search?pretty=true'  -d '{
    "query": {
        "match": {
            "filename": "溢洪结构"
        }
    },
    "from": 0,
    "size": 1000,
    "highlight": {
        "fields": {
            "filename": {}
        }
    },
    "_source": {
        "includes": [
            "filename",
            "content",
            "category",
            "module",
            "author",
            "path"
        ]
    }
}'
{
    "took" : 39,
    "timed_out" : false,
    "_shards" : {
        "total" : 5,
        "successful" : 5,
        "failed" : 0
    },
    "hits" : {
        "total" : 38,
        "max_score" : 3.9375143,
        "hits" : [
            {
                "_index" : "smarthydro",
                "_type" : "archive",
                "_id" : "AYi4q8QaJN1s5R57PpU5",
                "_score" : 3.9375143,
                "_source" : {
                    "path" : "规范/溢洪道设计规范.txt",
                    "filename" : "溢洪道设计规范.txt",
                    "content" : ""
                },
                "highlight" : {
                    "filename" : [
                        "<em>溢洪</em>道设计规范.txt"
                    ]
                }
            },
            {
                "_index" : "smarthydro",
                "_type" : "archive",
                "_id" : "AYi4q8QLJN1s5R57PpU4",
                "_score" : 3.8857627,
                "_source" : {
                    "path" : "规范/溢洪道设计规范.pdf",
                    "filename" : "溢洪道设计规范.pdf",
                    "content" : ""
                },
                "highlight" : {
                    "filename" : [
                        "<em>溢洪</em>道设计规范.pdf"
                    ]
                }
            }
        ]
    }
}

前端页面如下:

elastic-qr

七、高级使用问题

(1) 排序问题

有时会有匹配的 token 数更少但是排序在前的现象:

doc-search-01

这个和 ES 中的匹配相关性分数计算方式有关,可参考 xguox 的文章 搜索引擎 ElasticSearch 的分数 (_score) 是怎么计算得出 。简单来说,相关性分数和匹配长度正相关,但和文档长度负相关。也就是说,匹配长度越大,分数越高,但另一方面,文档长度越大,分数越低。因此,若需要将搜索结果严格匹配长度排列,可在建立索引时,关闭 norms ,如下:

PUT /my_index
{
  "mappings": {
    "doc": {
      "properties": {
        "text": {
          "type": "string",
          "norms": { "enabled": false }
        }
      }
    }
  }
}

(2) 数字、年份问题

有时会有 “2022” 可以匹配到 “2022年7月27日xxx” 内容,但是 “2022年” 匹配不到。或者反过来, “2022年” 能匹配到,但是 “2022” 匹配不到。解决方法有两个:一个是在 my_extra_word.dic 中加入 “1000年、1001年、...、2999年、1月、...、12月、1日、...、31日” 等自定义词,然后重启 elasticsearch 服务,并重建索引;另一个方法是针对文本建两个字段,一个用 ik_max_word 分词,另一个用 ik_smart 分词,搜索时,关键字用 by_smart 分词,匹配到任意一个字段均可。