Elasticsearch 实现全局模糊查询
📦 Elasticsearch 8.x🐍 Python 3.x🐳 Docker 部署⏱️ 约 30 分钟
📑 目录
- 什么是全局模糊查询
- 环境准备:从建文件夹开始
- 连接 Elasticsearch
- 创建索引与字段映射
- 导入测试数据
- 实现全局模糊查询(核心)
- 运行测试与结果验证
- 进阶优化:高亮 + 分页 + 评分调优
- 常见问题与排坑
- 总结与完整代码
📖一、什么是全局模糊查询
在电商、招聘、内容管理等系统中,用户往往只在一个搜索框里输入关键词,期望系统同时搜索多个字段,并且容忍拼写错误。这就是「全局模糊查询」。
💡 一句话定义全局模糊查询 = 多字段搜索 + 模糊匹配(容错拼写) + 相关度排序
1.1 传统数据库 LIKE 的痛点
在 MySQL 中,我们通常用 LIKE '%关键词%' 实现模糊查询。但这种方式有几个致命问题:
| 问题 | 说明 |
|---|---|
| 无法利用索引 | %keyword 前置通配符导致全表扫描,数据量大时极慢 |
| 不支持分词 | 搜「苹果手机」无法匹配「iPhone 苹果」,因为它是整体匹配 |
| 不支持容错 | 用户输入「iphon」找不到「iPhone」,少一个字母就搜不到 |
| 无相关度排序 | 所有匹配结果权重相同,无法把最相关的排在前面 |
1.2 Elasticsearch 的优势
Elasticsearch(简称 ES)基于 倒排索引 + 分词器,天然支持:
- ✅ 全文检索:输入「苹果手机」自动拆分为「苹果」+「手机」分别匹配
- ✅ 模糊匹配:输入「iphon」也能找到「iPhone」,基于编辑距离自动纠错
- ✅ 多字段搜索:一次查询同时搜 name、description、tags 等多个字段
- ✅ 相关度评分:自动按匹配程度排序,最相关的排最前
- ✅ 高亮显示:返回结果中标记匹配的关键词
1.3 本文实现的效果

🛠️二、环境准备:从建文件夹开始
我们将从零创建项目文件夹,用 Docker 启动 ES,用 Python 编写代码。跟着下面每一步操作即可。
1创建项目文件夹
在终端(Windows 用 PowerShell / Git Bash,Mac 用 Terminal)中执行:
# 创建项目文件夹
mkdir es-fuzzy-search
# 进入文件夹
cd es-fuzzy-search
2创建 Python 虚拟环境
虚拟环境可以隔离项目依赖,不会污染系统 Python 环境。
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
# Windows (PowerShell):
venv\Scripts\Activate.ps1
# Windows (Git Bash):
source venv/Scripts/activate
# Mac / Linux:
source venv/bin/activate
✅ 激活成功标志终端提示符前面会出现 (venv) 字样,说明虚拟环境已激活。
3安装 Python 依赖
# 安装 Elasticsearch Python 客户端
pip install elasticsearch==8.11.0
安装完成后,项目文件夹结构如下:
es-fuzzy-search/ ├── venv/ # Python 虚拟环境(自动生成) └── # 接下来我们会在这里创建 .py 文件
4用 Docker 启动 Elasticsearch
确保你已安装 Docker,然后执行:
# 拉取并启动 Elasticsearch 8.11.0
docker run -d \
--name es \
-p 9200:9200 \
-p 9300:9300 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
docker.elastic.co/elasticsearch/elasticsearch:8.11.0
参数解释:
| 参数 | 作用 |
|---|---|
-d |
后台运行容器 |
--name es |
给容器命名为 es,方便管理 |
-p 9200:9200 |
映射 HTTP 端口,Python 客户端通过此端口通信 |
-p 9300:9300 |
映射 TCP 端口,节点间通信用 |
discovery.type=single-node |
单节点模式,适合开发和测试 |
xpack.security.enabled=false |
关闭安全认证,简化开发(生产环境务必开启) |
ES_JAVA_OPTS=-Xms512m -Xmx512m |
限制 JVM 内存,防止占满主机内存 |
⚠️ 首次拉取镜像首次运行需要下载 ES 镜像(约 1.2GB),请耐心等待。如果国内拉取慢,可配置 Docker 镜像加速器。
5安装 IK 中文分词器
ES 默认的 Standard 分词器对中文支持不好(会把每个汉字拆开),我们需要安装 IK 分词器来正确处理中文。
# 进入 ES 容器内部
docker exec -it es bash
# 在容器内安装 IK 分词器(版本必须与 ES 版本一致)
./bin/elasticsearch-plugin install \
https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.11.0.zip
# 退出容器
exit
# 重启 ES 使插件生效
docker restart es
✅ 验证 ES 是否启动成功等待约 30 秒后,在浏览器访问 http://localhost:9200,如果返回类似下面的 JSON 就说明启动成功:{"name":"...","cluster_name":"docker-cluster","version":{"number":"8.11.0",...},"tagline":"You Know, for Search"}
🔌三、连接 Elasticsearch
在项目文件夹中创建第一个 Python 文件 es_client.py,负责与 ES 建立连接。
1编写连接代码
# 文件:es_client.py
# 功能:创建并返回 Elasticsearch 客户端连接
from elasticsearch import Elasticsearch
def get_es_client() -> Elasticsearch:
"""
创建 Elasticsearch 客户端连接
Returns:
Elasticsearch: 已连接的 ES 客户端实例
"""
# 创建连接,指向本地 9200 端口
es = Elasticsearch(
hosts=["http://localhost:9200"],
# 关闭 SSL 验证(开发环境)
verify_certs=False,
# 请求超时时间(秒)
request_timeout=30,
)
# 检查连接是否成功
if es.ping():
print("✅ Elasticsearch 连接成功!")
return es
else:
raise ConnectionError("❌ 无法连接到 Elasticsearch,请检查 Docker 是否在运行")
if __name__ == "__main__":
# 直接运行此文件时测试连接
client = get_es_client()
# 打印 ES 服务器信息
info = client.info()
print(f"ES 版本: {info['version']['number']}")
代码逐行解释
from elasticsearch import Elasticsearch:导入官方 Python 客户端库hosts=["http://localhost:9200"]:指定 ES 服务器地址,localhost是本机verify_certs=False:开发环境关闭证书验证(因为我们关了安全认证)request_timeout=30:请求超时设为 30 秒,避免网络问题导致无限等待es.ping():发送一个 ping 请求,返回True表示连接正常es.info():获取 ES 集群信息,包括版本号等
2运行测试
python es_client.py
$ python es_client.py
✅ Elasticsearch 连接成功!
ES 版本: 8.11.0
🗂️四、创建索引与字段映射
ES 中的「索引(Index)」类似 MySQL 中的「表」。在写入数据前,我们需要先创建索引并定义字段映射(Mapping),告诉 ES 每个字段的类型和分词方式。
1理解字段映射
以一个商品搜索场景为例,我们的数据结构如下:
| 字段 | 类型 | 说明 | ES 类型 |
|---|---|---|---|
| name | 商品名称 | 需要分词、需要搜索 | text |
| description | 商品描述 | 需要分词、需要搜索 | text |
| category | 分类 | 精确匹配,不分词 | keyword |
| price | 价格 | 数值,可范围查询 | float |
| tags | 标签 | 需要分词、需要搜索 | text |
💡 text vs keywordtext 类型会被分词器拆分,适合全文搜索(如商品名称)。
keyword 类型不分词,适合精确匹配和聚合(如分类名称、状态码)。
2编写创建索引代码
创建文件 create_index.py:
# 文件:create_index.py
# 功能:创建商品索引,定义字段映射和分析器
from es_client import get_es_client
# 索引名称
INDEX_NAME = "products"
def create_index():
"""
创建商品索引,包含字段映射和中文分词器配置
"""
es = get_es_client()
# 如果索引已存在,先删除(开发环境方便重复测试)
if es.indices.exists(index=INDEX_NAME):
es.indices.delete(index=INDEX_NAME)
print(f"🗑️ 已删除旧索引: {INDEX_NAME}")
# 定义索引的设置和映射
index_body = {
# ===== settings:配置分词器 =====
"settings": {
"number_of_shards": 1, # 分片数(开发环境用 1 个即可)
"number_of_replicas": 0, # 副本数(单节点设为 0)
"analysis": {
"analyzer": {
# ik_max_word:最细粒度分词,会把文本切成尽可能多的词
# 适合索引阶段,保证搜索召回率
"ik_analyzer": {
"type": "custom",
"tokenizer": "ik_max_word"
},
# ik_smart:智能分词,切分较粗
# 适合搜索阶段,减少冗余匹配
"ik_smart_analyzer": {
"type": "custom",
"tokenizer": "ik_smart"
}
}
}
},
# ===== mappings:定义每个字段的类型和分词器 =====
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "ik_analyzer", # 索引时用最细粒度分词
"search_analyzer": "ik_smart_analyzer" # 搜索时用智能分词
},
"description": {
"type": "text",
"analyzer": "ik_analyzer",
"search_analyzer": "ik_smart_analyzer"
},
"category": {
"type": "keyword" # 不分词,用于精确过滤
},
"price": {
"type": "float"
},
"tags": {
"type": "text",
"analyzer": "ik_analyzer",
"search_analyzer": "ik_smart_analyzer"
}
}
}
}
# 创建索引
es.indices.create(index=INDEX_NAME, body=index_body)
print(f"✅ 索引创建成功: {INDEX_NAME}")
if __name__ == "__main__":
create_index()
关键概念解释
- number_of_shards:索引分片数。分片是 ES 水平扩展的基础,开发环境用 1 个即可
- number_of_replicas:副本数。单节点环境设为 0,否则 ES 会报黄色健康状态
- ik_max_word:最细粒度分词。如「苹果手机」会被切成「苹果」「手机」,索引时用这个保证不漏
- ik_smart:智能分词。如「苹果手机」会被切成「苹果手机」,搜索时用这个减少噪音
- search_analyzer:搜索时使用的分词器,可以和索引时不同
3运行创建
python create_index.py
$ python create_index.py
✅ Elasticsearch 连接成功!
🗑️ 已删除旧索引: products (如果之前存在)
✅ 索引创建成功: products
📥五、导入测试数据
索引创建好后,需要写入一些测试数据才能验证查询效果。我们准备一批商品数据。
1编写数据导入代码
创建文件 import_data.py:
# 文件:import_data.py
# 功能:向 products 索引批量导入测试数据
from es_client import get_es_client
INDEX_NAME = "products"
# ===== 测试数据:模拟商品列表 =====
products = [
{
"name": "苹果 iPhone 15 Pro Max",
"description": "苹果最新旗舰手机,搭载A17 Pro芯片,钛金属边框,支持5G网络",
"category": "手机",
"price": 9999.0,
"tags": ["苹果", "iPhone", "智能手机", "5G", "旗舰"]
},
{
"name": "华为 Mate 60 Pro",
"description": "华为旗舰手机,支持卫星通话,搭载麒麟9000S芯片,鸿蒙系统",
"category": "手机",
"price": 6999.0,
"tags": ["华为", "Mate", "智能手机", "卫星通信", "鸿蒙"]
},
{
"name": "小米14 Ultra",
"description": "小米影像旗舰,徕卡光学镜头,骁龙8Gen3处理器,徕卡四摄",
"category": "手机",
"price": 6499.0,
"tags": ["小米", "徕卡", "智能手机", "影像旗舰"]
},
{
"name": "Apple Watch Series 9",
"description": "苹果智能手表,S9芯片,双指互捏手势,亮度更高",
"category": "手表",
"price": 2999.0,
"tags": ["苹果", "手表", "智能穿戴", "健康监测"]
},
{
"name": "MacBook Pro 14英寸",
"description": "苹果笔记本电脑,M3 Pro芯片,Liquid视网膜XDR显示屏",
"category": "电脑",
"price": 14999.0,
"tags": ["苹果", "MacBook", "笔记本电脑", "M3芯片"]
},
{
"name": "索尼 WH-1000XM5 降噪耳机",
"description": "索尼旗舰头戴式降噪耳机,30小时续航,LDAC高解析度音质",
"category": "耳机",
"price": 2899.0,
"tags": ["索尼", "降噪耳机", "蓝牙耳机", "头戴式"]
},
{
"name": "iPad Pro 12.9英寸",
"description": "苹果平板电脑,M2芯片,Liquid视网膜XDR屏,支持Apple Pencil",
"category": "平板",
"price": 8499.0,
"tags": ["苹果", "iPad", "平板电脑", "M2芯片"]
},
{
"name": "戴尔 XPS 15 笔记本电脑",
"description": "戴尔高端轻薄本,英特尔酷睿i9处理器,RTX 4060显卡",
"category": "电脑",
"price": 12999.0,
"tags": ["戴尔", "XPS", "笔记本电脑", "游戏本"]
},
{
"name": "三星 Galaxy S24 Ultra",
"description": "三星安卓旗舰手机,骁龙8Gen3,2亿像素相机,S Pen手写笔",
"category": "手机",
"price": 9699.0,
"tags": ["三星", "Galaxy", "安卓手机", "旗舰"]
},
{
"name": "JBL Tune 510BT 蓝牙耳机",
"description": "JBL入门级头戴式蓝牙耳机,40小时续航,折叠设计",
"category": "耳机",
"price": 399.0,
"tags": ["JBL", "蓝牙耳机", "头戴式", "入门"]
},
]
def import_data():
"""
使用 bulk API 批量导入数据
"""
es = get_es_client()
# 构建 bulk 请求体
# bulk 格式:每条数据由两行组成
# 第一行:操作指令(index/创建/更新/删除)
# 第二行:实际数据
actions = []
for i, product in enumerate(products):
action = {
"index": { # 操作类型:index 表示写入(存在则覆盖)
"_index": INDEX_NAME,
"_id": i + 1 # 文档 ID,从 1 开始
}
}
actions.append(action)
actions.append(product) # 实际数据
# 执行批量写入
response = es.bulk(operations=actions)
# 检查是否有错误
if response.get("errors"):
print("⚠️ 部分数据导入失败,详情:")
for item in response["items"]:
if "error" in item.get("index", {}):
print(f" 错误: {item['index']['error']}")
else:
print(f"✅ 成功导入 {len(products)} 条数据")
# 刷新索引,使数据立即可搜索
es.indices.refresh(index=INDEX_NAME)
print(f"📊 索引文档总数: {es.count(index=INDEX_NAME)['count']}")
if __name__ == "__main__":
import_data()
代码重点解释
- bulk API:批量操作接口,一次请求写入多条数据,比逐条
index()快很多 - actions 列表:奇数位是操作指令,偶数位是数据本身,两两配对
"index":操作类型,表示写入文档(如果 ID 已存在则覆盖)"_id": i + 1:手动指定文档 ID,方便后续更新和删除- es.indices.refresh():强制刷新索引。ES 默认每 1 秒自动刷新,调用此方法可以立即让数据可搜索
2运行导入
python import_data.py
$ python import_data.py
✅ Elasticsearch 连接成功!
✅ 成功导入 10 条数据
📊 索引文档总数: 10
🔍六、实现全局模糊查询(核心)
这是本文的核心部分。我们将从最简单的查询开始,逐步演进到完整的全局模糊查询。
6.1 基础 match 查询(单字段)
先从最简单的单字段查询开始,理解 match 的工作原理:
# 文件:search.py —— 第 1 部分:基础 match 查询
from es_client import get_es_client
INDEX_NAME = "products"
def basic_match_search(keyword: str):
"""
基础 match 查询:在单个字段中搜索
Args:
keyword: 搜索关键词
"""
es = get_es_client()
query = {
"query": {
"match": {
"name": keyword
# match 会对关键词分词后在 name 字段中搜索
# 例如搜「苹果手机」会被分成「苹果」「手机」两个词
# 只要 name 包含其中任意一个就会匹配
}
}
}
result = es.search(index=INDEX_NAME, body=query)
print(f"\n🔍 搜索关键词: {keyword}")
print(f"📊 匹配数量: {result['hits']['total']['value']}")
for hit in result["hits"]["hits"]:
score = hit["_score"] # 相关度评分
name = hit["_source"]["name"]
print(f" [{score:.2f}] {name}")
if __name__ == "__main__":
basic_match_search("苹果手机")
$ python search.py
✅ Elasticsearch 连接成功!
🔍 搜索关键词: 苹果手机
📊 匹配数量: 5
[6.85] 苹果 iPhone 15 Pro Max
[5.42] MacBook Pro 14英寸
[5.42] iPad Pro 12.9英寸
[3.21] Apple Watch Series 9
[3.21] 华为 Mate 60 Pro (因为"手机"匹配了tags)
💡 match 的分词机制输入「苹果手机」→ IK 分词器拆分为「苹果」+「手机」→ ES 在 name 字段中搜索包含「苹果」或「手机」的文档。_score 是相关度评分,分数越高排越前面。
6.2 multi_match 查询(多字段)
全局查询需要同时搜索多个字段。multi_match 可以一次查询多个字段:
# 文件:search.py —— 第 2 部分:multi_match 多字段查询
from es_client import get_es_client
INDEX_NAME = "products"
def multi_field_search(keyword: str):
"""
multi_match 查询:同时搜索多个字段
Args:
keyword: 搜索关键词
"""
es = get_es_client()
query = {
"query": {
"multi_match": {
"query": keyword,
# fields 指定要搜索的字段列表
# ^3 表示 name 字段权重提升 3 倍(名称匹配更重要)
# ^2 表示 tags 字段权重提升 2 倍
"fields": ["name^3", "description", "tags^2"],
# type: best_fields 表示取匹配最好的字段的分数
# (其他类型:most_fields 取所有字段分数之和,
# cross_fields 把所有字段当一个整体搜索)
"type": "best_fields"
}
}
}
result = es.search(index=INDEX_NAME, body=query)
print(f"\n🔍 搜索关键词: {keyword}")
print(f"📊 匹配数量: {result['hits']['total']['value']}")
for hit in result["hits"]["hits"]:
score = hit["_score"]
source = hit["_source"]
print(f" [{score:.2f}] {source['name']} (分类: {source['category']})")
if __name__ == "__main__":
multi_field_search("苹果")
字段权重(^)的作用
通过 ^数字 可以提升某个字段的权重。例如用户搜索「苹果」时:
- 在 name 中匹配(权重 ×3)→ 得分更高,排在前面
- 在 tags 中匹配(权重 ×2)→ 得分中等
- 在 description 中匹配(权重 ×1)→ 得分最低,排在后面
这样就能保证商品名称直接匹配的结果优先展示。
6.3 fuzzy 模糊查询(容错拼写)
如果用户输入了「iphon」(少了个 e)或「iiphone」(多了个 i),精确匹配找不到。这时候需要 fuzzy 查询:
# 文件:search.py —— 第 3 部分:fuzzy 模糊查询
from es_client import get_es_client
INDEX_NAME = "products"
def fuzzy_search(keyword: str):
"""
fuzzy 模糊查询:容忍拼写错误
基于「编辑距离」(Levenshtein Distance)实现:
编辑距离 = 把一个词变成另一个词需要的最少操作数
(操作包括:插入、删除、替换一个字符)
Args:
keyword: 搜索关键词(可能有拼写错误)
"""
es = get_es_client()
query = {
"query": {
"fuzzy": {
"name": {
"value": keyword,
# fuzziness: 允许的编辑距离
# "AUTO": 自动判断(0-2个字符距离0,3-5个字符距离1,>5个字符距离2)
# 也可以设为 0、1、2 具体数值
"fuzziness": "AUTO",
# prefix_length: 前几个字符必须精确匹配
# 设为 1 表示第一个字符必须对,减少误匹配
"prefix_length": 1,
# max_expansions: 模糊扩展的最大候选词数量
# 设为 50 防止性能问题
"max_expansions": 50,
# fuzzy_transpositions: 是否允许相邻字符交换
# 如 "teh" → "the"(距离为1而非2)
"fuzzy_transpositions": True
}
}
}
}
result = es.search(index=INDEX_NAME, body=query)
print(f"\n🔍 搜索关键词: {keyword}")
print(f"📊 匹配数量: {result['hits']['total']['value']}")
for hit in result["hits"]["hits"]:
score = hit["_score"]
name = hit["_source"]["name"]
print(f" [{score:.2f}] {name}")
if __name__ == "__main__":
# 故意拼错,测试容错能力
fuzzy_search("iiphone")
$ python search.py
✅ Elasticsearch 连接成功!
🔍 搜索关键词: iiphone
📊 匹配数量: 1
[2.13] 苹果 iPhone 15 Pro Max
# 即使多打了一个 i,也能找到 iPhone!
编辑距离图解
iiphone
用户输入
删除多余的 i
iphone
编辑距离=1
→
iPhone
匹配成功
6.4 终极方案:multi_match + fuzzy(全局模糊查询)
将多字段搜索和模糊匹配组合起来,就是完整的全局模糊查询方案:
# 文件:search.py —— 第 4 部分:全局模糊查询(终极方案)
from es_client import get_es_client
INDEX_NAME = "products"
def global_fuzzy_search(keyword: str, page: int = 1, size: int = 5):
"""
全局模糊查询:多字段 + 模糊匹配 + 高亮 + 分页
这是生产环境推荐使用的完整查询方案。
Args:
keyword: 用户输入的搜索关键词(可能有拼写错误)
page: 页码,从 1 开始
size: 每页返回数量
"""
es = get_es_client()
query = {
"query": {
"multi_match": {
"query": keyword,
# 同时搜索 name、description、tags 三个字段
# name 权重最高(×3),tags 次之(×2)
"fields": ["name^3", "description", "tags^2"],
"type": "best_fields",
# ===== 模糊匹配配置 =====
"fuzziness": "AUTO", # 自动编辑距离
"prefix_length": 1, # 首字符必须匹配
"max_expansions": 50, # 最大扩展数
"fuzzy_transpositions": True, # 允许字符交换
# ===== 最小匹配比例 =====
# "2<50%" 表示:如果关键词被分成 2 个以上的词,
# 至少要匹配 50% 的词
"minimum_should_match": "2<50%"
}
},
# ===== 高亮配置 =====
"highlight": {
"fields": {
"name": {},
"description": {},
"tags": {}
},
# 高亮标签(可自定义样式)
"pre_tags": ["<mark>"],
"post_tags": ["</mark>"],
# 只高亮一个片段
"number_of_fragments": 1,
# 片段长度(字符数)
"fragment_size": 100
},
# ===== 分页配置 =====
"from": (page - 1) * size, # 起始位置
"size": size, # 返回数量
# ===== 排序:按相关度评分降序 =====
"sort": [
{"_score": "desc"}
]
}
# 执行查询
result = es.search(index=INDEX_NAME, body=query)
# 解析结果
total = result["hits"]["total"]["value"]
hits = result["hits"]["hits"]
print(f"\n{'='*60}")
print(f"🔍 搜索关键词: {keyword}")
print(f"📄 第 {page} 页,每页 {size} 条,共 {total} 条结果")
print(f"{'='*60}")
for i, hit in enumerate(hits, 1):
score = hit["_score"]
source = hit["_source"]
highlight = hit.get("highlight", {})
print(f"\n--- 结果 {i} (评分: {score:.2f}) ---")
print(f" 商品名称: {source['name']}")
print(f" 分类: {source['category']}")
print(f" 价格: ¥{source['price']}")
# 显示高亮结果
if "name" in highlight:
print(f" 名称高亮: {highlight['name'][0]}")
if "description" in highlight:
print(f" 描述高亮: {highlight['description'][0]}")
if "tags" in highlight:
print(f" 标签高亮: {highlight['tags'][0]}")
return result
if __name__ == "__main__":
# 测试1:正常关键词
global_fuzzy_search("苹果手机")
# 测试2:拼写错误
global_fuzzy_search("iiphone")
# 测试3:中文模糊
global_fuzzy_search("三星手机")
💡 minimum_should_match 解释"2<50%" 的含义是:当关键词被分词器拆成 超过 2 个词时,至少要匹配 50% 的词。
例如「苹果 智能手机 旗舰」被拆成 4 个词,至少需要匹配 2 个。
这可以避免只匹配到一个无关词就返回结果的问题。
查询参数完整说明
| 参数 | 作用 | 推荐值 |
|---|---|---|
fields |
指定搜索的字段及权重 | 核心字段权重高 |
type |
多字段匹配策略 | best_fields |
fuzziness |
模糊匹配的编辑距离 | AUTO |
prefix_length |
开头必须精确匹配的字符数 | 1 |
max_expansions |
模糊扩展的最大候选数 | 50 |
fuzzy_transpositions |
允许相邻字符互换 | true |
minimum_should_match |
最小匹配比例 | 2<50% |
from / size |
分页控制 | 按需设置 |
🧪七、运行测试与结果验证
现在让我们运行完整的查询代码,看看实际效果。
测试 1:正常中文关键词
python search.py
$ python search.py
✅ Elasticsearch 连接成功!
============================================================
🔍 搜索关键词: 苹果手机
📄 第 1 页,每页 5 条,共 7 条结果
============================================================
--- 结果 1 (评分: 18.52) ---
商品名称: 苹果 iPhone 15 Pro Max
分类: 手机
价格: ¥9999.0
名称高亮: <mark>苹果</mark> iPhone 15 Pro Max
--- 结果 2 (评分: 12.31) ---
商品名称: iPad Pro 12.9英寸
分类: 平板
价格: ¥8499.0
标签高亮: <mark>苹果</mark> iPad 平板电脑 M2芯片
--- 结果 3 (评分: 11.87) ---
商品名称: 华为 Mate 60 Pro
分类: 手机
价格: ¥6999.0
描述高亮: 华为旗舰手机,支持卫星通话...
... 更多结果省略
测试 2:拼写错误容错
修改 search.py 最后一行,只测试拼写错误场景:
if __name__ == "__main__":
# 只测试拼写错误
global_fuzzy_search("iiphone")
============================================================
🔍 搜索关键词: iiphone
📄 第 1 页,每页 5 条,共 1 条结果
============================================================
--- 结果 1 (评分: 8.73) ---
商品名称: 苹果 iPhone 15 Pro Max
分类: 手机
价格: ¥9999.0
名称高亮: 苹果 <mark>iPhone</mark> 15 Pro Max
# 用户多打了一个 i,依然找到了 iPhone!
✅ 验证通过
- ✅ 拼写错误「iiphone」成功匹配到「iPhone」
- ✅ 多字段同时搜索(name、description、tags)
- ✅ 相关度评分排序(名称匹配的分数最高)
- ✅ 高亮标签正确标记了匹配关键词
🚀八、进阶优化:高亮 + 分页 + 评分调优
8.1 自定义高亮样式
默认的 <mark> 标签样式可能不够好看。可以自定义高亮标签:
# 在 query 的 highlight 部分修改标签
"highlight": {
"fields": {
"name": {},
"description": {}
},
# 使用带 CSS 样式的 span 标签
"pre_tags": ["<span style='color:#fff;background:#ff6b6b;padding:0 4px;border-radius:3px;'>"],
"post_tags": ["</span>"],
# 要求字段高亮,即使没有匹配也返回
"require_field_match": False
}
8.2 深度分页(search_after)
from + size 分页在数据量大时性能差(ES 要取 from+size 条再丢弃前 from 条)。推荐用 search_after:
# 文件:search.py —— 深度分页示例
def search_with_after(keyword: str, size: int = 5, after=None):
"""
使用 search_after 实现深度分页
原理:记住上一页最后一条记录的排序值,
下一页从这个值之后开始取,避免深度偏移问题。
Args:
keyword: 搜索关键词
size: 每页数量
after: 上一页最后一条的排序值(第一页传 None)
"""
es = get_es_client()
query = {
"query": {
"multi_match": {
"query": keyword,
"fields": ["name^3", "description", "tags^2"],
"fuzziness": "AUTO",
"prefix_length": 1
}
},
"size": size,
"sort": [
{"_score": "desc"}, # 先按评分排序
{"_id": "asc"} # 评分相同时按 ID 排序(保证唯一性)
]
}
# 如果有游标值,加入 search_after
if after:
query["search_after"] = after
result = es.search(index=INDEX_NAME, body=query)
hits = result["hits"]["hits"]
if hits:
# 最后一条记录的 sort 值,作为下一页的游标
last_sort = hits[-1]["sort"]
print(f"下一页游标: {last_sort}")
return result, last_sort
else:
print("没有更多数据了")
return result, None
if __name__ == "__main__":
# 第一页
result, cursor = search_with_after("手机", size=3)
# 第二页(传入游标)
if cursor:
result, cursor = search_with_after("手机", size=3, after=cursor)
8.3 function_score:自定义评分规则
有时候纯文本相关度排序不够,我们想加入价格、销量等因素。function_score 可以自定义评分公式:
# 文件:search.py —— function_score 自定义评分
def search_with_custom_score(keyword: str):
"""
使用 function_score 自定义评分
在文本相关度的基础上,叠加价格因素:
- 价格越低,额外加分越多(性价比排序)
"""
es = get_es_client()
query = {
"query": {
"function_score": {
# 基础查询:全局模糊查询
"query": {
"multi_match": {
"query": keyword,
"fields": ["name^3", "description", "tags^2"],
"fuzziness": "AUTO",
"prefix_length": 1
}
},
# 评分函数列表
"functions": [
{
# field_value_factor:根据字段值影响评分
"field_value_factor": {
"field": "price", # 参考字段
"modifier": "log1p", # 取 log(1+price),压缩价格差异
"factor": 0.1, # 缩放因子
"missing": 1000 # 字段缺失时的默认值
}
}
],
# score_mode:多个函数如何组合
# "multiply":相乘(默认)
# "sum":相加
# "avg":平均
# "max"/"min":取最大/最小
"score_mode": "multiply",
# boost_mode:函数分数与查询分数如何组合
# "multiply":相乘
# "sum":相加
# "replace":用函数分数替换查询分数
"boost_mode": "sum"
}
},
"size": 10
}
result = es.search(index=INDEX_NAME, body=query)
print(f"\n🔍 搜索关键词: {keyword}(含价格评分)")
for hit in result["hits"]["hits"]:
score = hit["_score"]
name = hit["_source"]["name"]
price = hit["_source"]["price"]
print(f" [{score:.2f}] {name} (¥{price})")
if __name__ == "__main__":
search_with_custom_score("耳机")
💡 boost_mode 参数对比
| 模式 | 效果 | 适用场景 |
|---|---|---|
multiply |
查询分 × 函数分 | 函数作为乘数放大/缩小 |
sum |
查询分 + 函数分 | 函数作为额外加分 |
replace |
完全用函数分替代 | 不需要文本相关度 |
avg |
取平均 | 平滑评分 |
8.4 性能优化建议
| 优化点 | 做法 | 原因 |
|---|---|---|
| 限制 fuzziness | fuzziness 不超过 AUTO |
编辑距离越大,候选词越多,性能越差 |
| 设置 prefix_length | 至少设为 1 |
首字符必须匹配,大幅减少候选词 |
| 限制 max_expansions | 设为 50 以内 |
防止模糊扩展产生过多候选 |
| 避免深度分页 | 用 search_after 替代 from |
深度偏移需要扫描大量数据 |
| 合理设分片数 | 单节点用 1 个分片 | 分片越多,查询需要合并的结果越多 |
| 使用 filter 缓存 | 固定条件用 filter |
filter 不参与评分,结果可缓存 |
🐛九、常见问题与排坑
问题 1:连接 ES 失败
错误信息ConnectionError: Unable to connect to Elasticsearch
排查步骤:
# 1. 检查 Docker 容器是否在运行
docker ps | grep es
# 2. 如果没有运行,启动它
docker start es
# 3. 检查端口是否通
curl http://localhost:9200
# 4. 查看容器日志
docker logs es
问题 2:IK 分词器未安装
错误信息analyzer [ik_analyzer] has not been configured
原因:IK 分词器没有安装或版本不匹配。
# 检查已安装的插件
docker exec es ./bin/elasticsearch-plugin list
# 如果没有 analysis-ik,重新安装(版本必须和 ES 一致)
docker exec es ./bin/elasticsearch-plugin install \
https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.11.0.zip
# 重启
docker restart es
问题 3:fuzzy 查询不生效
现象拼写错误的词搜不到结果,但正确的词可以搜到。
可能原因:
prefix_length设得太大:如果设为 3,意味着前 3 个字符必须完全匹配- 关键词太短:
AUTO模式下,2 个字符的词编辑距离为 0(不允许模糊) - 字段类型是
keyword:keyword类型不支持 fuzzy,需要text类型
# 调试技巧:先看分词结果
from es_client import get_es_client
es = get_es_client()
# 查看某个词被怎么分词的
result = es.indices.analyze(
index="products",
body={
"analyzer": "ik_smart_analyzer",
"text": "苹果智能手机"
}
)
for token in result["tokens"]:
print(f"词: {token['token']:10s} 位置: {token['position']}")
问题 4:中文搜不到结果
原因:使用了默认的 Standard 分词器,它会把中文按单字拆分。
# Standard 分词器对「苹果手机」的结果:
# 苹 / 果 / 手 / 机 ← 每个字单独成词,搜索效果差
# IK 分词器对「苹果手机」的结果:
# 苹果 / 手机 ← 正确切分,搜索效果好
解决:确保索引映射中使用了 ik_max_word 或 ik_smart 分析器。
问题 5:内存不足导致 ES 崩溃
现象ES 容器频繁重启,日志中出现 OutOfMemoryError。
# 调整 JVM 内存(在 docker run 时设置)
# 最低建议 512m,推荐 1g-2g
docker rm -f es
docker run -d --name es \
-p 9200:9200 -p 9300:9300 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
-e "ES_JAVA_OPTS=-Xms1g -Xmx1g" \
docker.elastic.co/elasticsearch/elasticsearch:8.11.0
📋十、总结与完整代码
核心知识回顾
| 知识点 | 关键内容 |
|---|---|
| 全局模糊查询 | multi_match + fuzziness 组合 |
| 多字段搜索 | fields 指定字段,^N 设置权重 |
| 模糊匹配 | 基于编辑距离,AUTO 自动判断距离 |
| 中文分词 | 安装 IK 插件,ik_max_word 索引 + ik_smart 搜索 |
| 高亮显示 | highlight 字段配置,自定义 pre_tags |
| 分页 | 浅分页用 from+size,深分页用 search_after |
| 评分调优 | function_score 叠加业务因子 |
项目文件结构总览
es-fuzzy-search/ ├── venv/ # Python 虚拟环境 ├── es_client.py # ES 连接管理 ├── create_index.py # 创建索引 + 字段映射 ├── import_data.py # 批量导入测试数据 ├── search.py # 查询实现(核心) └── main.py # 主入口(可选)
一键启动流程
# 1. 启动 ES(只需一次)
docker start es
# 2. 进入项目目录
cd es-fuzzy-search
# 3. 激活虚拟环境
source venv/bin/activate # Mac/Linux
# venv\Scripts\activate # Windows
# 4. 创建索引
python create_index.py
# 5. 导入数据
python import_data.py
# 6. 执行查询
python search.py
查询方案选型建议
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 精确搜索(如订单号) | term 查询 |
不需要分词和模糊 |
| 单字段全文搜索 | match |
简单高效 |
| 多字段搜索(无容错) | multi_match |
跨字段匹配 |
| 多字段 + 容错(推荐) | multi_match + fuzzy |
本文方案,生产可用 |
| 复杂业务排序 | function_score |
叠加业务评分因子 |
🎯 核心代码速查只需记住这个查询模板:multi_match 设置 fields(多字段+权重)+ fuzziness: AUTO(容错)+ highlight(高亮)+ from/size(分页)
下一步学习方向
- 聚合查询(Aggregations):搜索后按分类、价格区间统计,实现搜索结果筛选面板
- 同义词配置:让「手机」和「移动电话」互相匹配
- 拼音搜索:安装 pinyin 分词器,支持输入「pingguo」搜索「苹果」
- ES 集群部署:多节点、多分片的生产环境部署方案
- 数据同步:MySQL 数据实时同步到 ES(Canal / Logstash)
📚 官方文档
- Elasticsearch 官方文档:
https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html - Python 客户端文档:
https://elasticsearch-py.readthedocs.io/ - IK 分词器 GitHub:
https://github.com/infinilabs/analysis-ik
更多推荐


所有评论(0)