📦 Elasticsearch 8.x🐍 Python 3.x🐳 Docker 部署⏱️ 约 30 分钟

📑 目录

  1. 什么是全局模糊查询
  2. 环境准备:从建文件夹开始
  3. 连接 Elasticsearch
  4. 创建索引与字段映射
  5. 导入测试数据
  6. 实现全局模糊查询(核心)
  7. 运行测试与结果验证
  8. 进阶优化:高亮 + 分页 + 评分调优
  9. 常见问题与排坑
  10. 总结与完整代码

📖一、什么是全局模糊查询

在电商、招聘、内容管理等系统中,用户往往只在一个搜索框里输入关键词,期望系统同时搜索多个字段,并且容忍拼写错误。这就是「全局模糊查询」。

💡 一句话定义全局模糊查询 = 多字段搜索 + 模糊匹配(容错拼写) + 相关度排序

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(不允许模糊)
  • 字段类型是 keywordkeyword 类型不支持 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(分页)

下一步学习方向

  1. 聚合查询(Aggregations):搜索后按分类、价格区间统计,实现搜索结果筛选面板
  2. 同义词配置:让「手机」和「移动电话」互相匹配
  3. 拼音搜索:安装 pinyin 分词器,支持输入「pingguo」搜索「苹果」
  4. ES 集群部署:多节点、多分片的生产环境部署方案
  5. 数据同步: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
Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐