Elasticsearch 全文检索工程教程:倒排索引、IK 分词器与 BM25 从原理到落地

1. 为什么 AI 应用还需要 Elasticsearch

很多人第一次做 RAG 或知识库检索时,会直接想到向量数据库:把文本切块,生成 embedding,写入 Milvus、Qdrant、pgvector 或其他向量存储,然后用向量相似度召回内容。这条链路当然重要,尤其适合处理"语义相近但字面不完全一致"的问题。

例如用户问"怎么让系统更稳定",向量检索可能召回"高可用架构""限流降级""熔断重试"等内容。它不要求用户输入和文档中的词完全一样,只要语义相近,就有机会被召回。

但纯向量检索也有明显短板:

  • 对精确实体不够敏感,比如订单号、函数名、类名、错误码、产品型号、专有名词。
  • 对关键词约束不强,比如用户明确搜"BM25",系统却召回一堆"相关性排序"的泛化内容。
  • 对短查询不稳定,短查询本身语义信息少,embedding 容易漂。
  • 对业务规则过滤不够自然,比如按作者、分类、时间、状态、权限过滤,关键词检索引擎更顺手。

这就是 Elasticsearch 仍然重要的原因。它不是向量数据库的替代品,而是关键词检索、结构化过滤、排序和聚合的强项工具。在真实 AI 应用里,常见做法不是"ES 或向量库二选一",而是把二者组合起来:

flowchart LR A[用户问题] --> B[查询理解] B --> C[关键词检索 Elasticsearch] B --> D[向量检索 Milvus] C --> E[候选文档集合] D --> E E --> F[重排或融合] F --> G[拼接上下文] G --> H[LLM 生成回答]

在这条链路里,Elasticsearch 的位置很清楚:它负责"字面关键词、精确实体、过滤条件、传统相关性排序"。向量数据库负责语义相似。LLM 负责理解、融合和生成。工程上要做的是让每个组件做自己擅长的事情,而不是把所有问题都交给某一个组件。

2. 当前项目的工程结构

当前项目目录比较轻,核心文件主要有三个:

text 复制代码
es-test
├── docker-compose.yml
├── elasticsearch
│   └── Dockerfile
├── es-test.md
├── es-test2.md
├── package.json
└── volumes

docker-compose.yml 里当前的 ES 服务是这样组织的:

yaml 复制代码
services:
  es:
    build:
      context: ./elasticsarch
      args:
        ES_IMAGE: ${ES_IMAGE:-docker.elastic.co/elasticsearch/elasticsearch:8.17.0}
    image: ${ES_BUILT_IMAGE:-es-test-elasticsearch-ik:8.17.0}
    container_name: es-dev
    ports:
      - "9200:9200"
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - xpack.security.http.ssl.enabled=false
      - xpack.security.transport.ssl.enabled=false
      - ES_JAVA_OPTS=-Xms512m -Xmx512m
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/es/data:/usr/share/elasticsearch/data
    restart: always

这段配置做了几件关键事情:

  • build.context 指向本地 Dockerfile 目录,说明我们不是直接跑官方 ES 镜像,而是先构建一个带 IK 插件的自定义镜像。
  • ES_IMAGE 作为构建参数传入 Dockerfile,默认使用 docker.elastic.co/elasticsearch/elasticsearch:8.17.0,避免走 Docker Hub 的 elasticsearch:8.17.0
  • image 命名为 es-test-elasticsearch-ik:8.17.0,这是一个本地自定义镜像名,表达它是"ES + IK"的结果,而不是官方原版镜像。
  • discovery.type=single-node 表示开发环境单节点运行,不需要集群发现。
  • xpack.security.enabled=false 关闭安全认证,方便本地学习和调试。
  • ES_JAVA_OPTS=-Xms512m -Xmx512m 限制 JVM 堆内存,否则 ES 默认内存占用会比较夸张。
  • volumes 把 ES 数据目录挂到本地,容器重建后索引数据不会丢。

Kibana 服务则负责可视化操作:

yaml 复制代码
kibana:
  image: ${KIBANA_IMAGE:-docker.elastic.co/kibana/kibana:8.17.0}
  container_name: kibana-dev
  ports:
    - "5601:5601"
  environment:
    - ELASTICSEARCH_HOSTS=http://es:9200
  volumes:
    - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/kibana:/usr/share/kibana/data
  restart: always
  depends_on:
    - es

这里 ELASTICSEARCH_HOSTS=http://es:9200 用的是容器服务名 es,不是 localhost。这是 Docker Compose 网络里非常容易踩坑的一点:在 Kibana 容器内部,localhost 指的是 Kibana 自己,不是 ES 容器。服务之间通信应该使用 Compose service name。

3. 先把环境跑起来

如果你只想启动 ES 并验证 IK 插件,推荐先不要一次性启动所有服务,因为当前 compose 里还有 Milvus、MinIO、etcd。第一次拉镜像会比较耗时。可以先只构建和启动 ES:

bash 复制代码
docker compose build es
docker compose up -d --build es

构建时会执行 elasticsarch/Dockerfile

dockerfile 复制代码
# 官方 ES 基础镜像
ARG ES_IMAGE=docker.elastic.co/elasticsearch/elasticsearch:8.17.0
FROM ${ES_IMAGE}

# 安装 IK 分词(版本严格和 ES 一致)
RUN elasticsearch-plugin install --batch \
    https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.17.0.zip

这段 Dockerfile 有三个工程判断:

第一,基础镜像使用 docker.elastic.co/elasticsearch/elasticsearch:8.17.0。不要写成 FROM elasticsearch:8.17.0,因为那会解析到 Docker Hub 的 docker.io/library/elasticsearch:8.17.0。在国内网络环境下,Docker Hub 的认证和元数据请求很容易超时。

第二,IK 插件版本必须和 ES 版本严格对应。当前 ES 是 8.17.0,所以插件也安装 elasticsearch-analysis-ik-8.17.0.zip。搜索引擎插件和宿主版本不匹配,轻则安装失败,重则容器启动时报错。

第三,把 IK 插件装进镜像,而不是进入容器后手动安装。手动安装的问题是不可复现:下次重建容器、换机器、换同事环境,都要重新操作。写进 Dockerfile 以后,环境本身就是代码的一部分。

启动后验证 ES:

bash 复制代码
curl http://localhost:9200

正常会返回类似信息:

json 复制代码
{
  "name": "688fd3c9688e",
  "cluster_name": "docker-cluster",
  "version": {
    "number": "8.17.0",
    "build_flavor": "default",
    "build_type": "docker",
    "lucene_version": "9.12.0"
  },
  "tagline": "You Know, for Search"
}

验证 IK 插件:

http 复制代码
GET /_cat/plugins?v

如果插件安装成功,会看到类似 analysis-ik 的记录。也可以直接用 _analyze API 测分词效果:

http 复制代码
POST /_analyze
{
  "analyzer": "ik_smart",
  "text": "Elasticsearch RAG 混合检索知识库"
}

这个 API 是学习 ES 分词最有价值的工具之一。因为倒排索引的构建依赖分词结果,查询的召回也依赖分词结果。看不懂分词,就很难判断为什么某些文档能搜出来,某些搜不出来。

4. 从数据库思维切换到搜索引擎思维

做后端开发的人通常熟悉 MySQL:库、表、行、列、索引、SQL。Elasticsearch 的概念和关系型数据库不完全一样,但可以先做一个粗略类比:

MySQL Elasticsearch 说明
Database Cluster 或业务命名空间 ES 不直接按 database 工作,通常用集群和索引组织数据
Table Index 索引是文档集合,也是检索的基本单位
Row Document 每条 JSON 文档类似一行业务数据
Column Field 文档里的字段
Schema Mapping 字段类型、分词器、索引方式
SQL Query Query DSL ES 使用 JSON DSL 查询

这个类比只能帮助入门,不能完全等价。最大的差异在于:MySQL 的主要目标是事务一致性和结构化查询,Elasticsearch 的主要目标是文本检索、相关性排序、聚合分析和近实时搜索。

因此不要把 ES 当成主库。典型工程实践是:

flowchart TD A[业务写入 MySQL] --> B[事务提交] B --> C[同步任务或消息队列] C --> D[写入 Elasticsearch] D --> E[关键词检索和聚合] A --> F[业务详情读取仍走 MySQL]

也就是说,MySQL 是事实数据源,ES 是面向检索的冗余索引。这样设计的好处是职责清晰:业务写入和强一致读写交给数据库,复杂全文检索交给搜索引擎。

如果你直接把 ES 当主库,会遇到很多问题:事务能力弱、复杂关系建模不自然、强一致更新不适合、权限和数据修复成本高。ES 很强,但它强在搜索,不强在所有数据管理问题。

5. 倒排索引:为什么 ES 能做全文检索

理解 Elasticsearch,最关键的是理解倒排索引。

普通数据库常见的是正向思维:一条记录里有哪些字段,字段里有哪些内容。例如:

text 复制代码
doc1: title = "Elasticsearch 全文检索入门"
doc2: title = "RAG 混合检索实战"
doc3: title = "IK 中文分词器实践"

如果用普通字符串模糊匹配搜索"检索",系统可能要扫描每条记录,判断字段里是否包含"检索"。数据量小没问题,数据量大了就不可接受。

倒排索引反过来组织数据:不是从文档找词,而是从词找文档。简化后可以理解为:

text 复制代码
检索 -> doc1, doc2
全文 -> doc1
RAG  -> doc2
IK   -> doc3
分词 -> doc3

用户搜索"全文检索"时,ES 会先对 query 分词,然后查倒排表,找到包含这些词的文档,再合并、打分、排序。

这个过程可以画成下面这样:

flowchart LR A[原始文档] --> B[Analyzer 分词] B --> C[Token 词项] C --> D[倒排索引] E[用户查询] --> F[Search Analyzer 分词] F --> G[查倒排索引] G --> H[BM25 打分] H --> I[返回排序后的文档]

这里的 Analyzer 不是一个简单的 split 函数。它通常包含字符过滤、分词、大小写归一化、停用词过滤、同义词处理等步骤。对英文来说,空格和标点天然提供了词边界;对中文来说,词边界并不明显,所以分词器质量会直接影响搜索质量。

6. Mapping:索引结构不是随便建的

在 ES 里创建索引时,最重要的是 mapping。mapping 决定字段类型、是否分词、使用什么 analyzer、查询时怎么分析 query。

下面创建一个文章索引:

http 复制代码
PUT /article
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "content": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "author": {
        "type": "keyword"
      },
      "category": {
        "type": "keyword"
      },
      "createTime": {
        "type": "date"
      },
      "viewCount": {
        "type": "integer"
      }
    }
  }
}

这段 mapping 里有几个关键点。

titlecontent 使用 text 类型,因为它们要做全文检索。text 字段会被 analyzer 分词,并写入倒排索引。用户搜索"混合检索"时,ES 可以按词项匹配,而不是只做完整字符串匹配。

authorcategory 使用 keyword 类型,因为它们更适合精确匹配和聚合。比如筛选 category = "AI工程",不需要把"AI工程"拆成词。keyword 字段通常用于过滤、排序、聚合、精确查询。

analyzersearch_analyzer 分开配置,是中文搜索里很常见的策略。入库时使用 ik_max_word,尽量切得细,让倒排索引覆盖更多可能词项;查询时使用 ik_smart,切得更稳,减少查询词过度拆分带来的噪音。

这背后的取舍是:索引阶段宁愿多存一些词,查询阶段宁愿更接近用户真实意图。

如果把 author 错配成 text,你用 term 查询可能会查不到,因为字段已经被分词。如果把 content 错配成 keyword,全文检索又会失效,因为它只把整段内容当成一个词项。这就是 mapping 设计的核心:字段类型要服务查询方式。

查看 mapping:

http 复制代码
GET /article/_mapping

删除索引:

http 复制代码
DELETE /article

开发阶段反复修改 mapping 很正常。生产环境要谨慎,因为很多字段类型创建后不能直接修改,通常需要新建索引再 reindex。

7. 文档写入:ES 存的是面向检索的 JSON

写入文档可以自动生成 ID:

http 复制代码
POST /article/_doc
{
  "title": "Elasticsearch 全文检索入门",
  "content": "ES 基于倒排索引与 BM25 实现全文搜索,适用于文本检索场景",
  "author": "后端开发",
  "category": "搜索引擎",
  "createTime": "2026-05-24T10:00:00+08:00",
  "viewCount": 128
}

也可以指定 ID:

http 复制代码
PUT /article/_doc/1001
{
  "title": "RAG 混合检索实战",
  "content": "Elasticsearch 负责关键词检索,Milvus 负责向量语义检索,二者结合能提升知识库召回质量",
  "author": "AI开发",
  "category": "AI工程",
  "createTime": "2026-05-24T11:00:00+08:00",
  "viewCount": 256
}

工程里更推荐指定业务 ID。比如文章表主键是 1001,写入 ES 时也使用 1001 作为文档 ID。这样 MySQL 和 ES 的数据同步更容易做幂等:同一条业务数据多次同步,只会覆盖同一个 ES 文档,不会产生重复数据。

查询单条:

http 复制代码
GET /article/_doc/1001

局部更新:

http 复制代码
POST /article/_update/1001
{
  "doc": {
    "viewCount": 999
  }
}

全量覆盖:

http 复制代码
PUT /article/_doc/1001
{
  "title": "RAG 混合检索高级实战",
  "content": "关键词检索与向量检索不是替代关系,而是互补关系",
  "author": "AI开发",
  "category": "AI工程",
  "createTime": "2026-05-24T11:00:00+08:00",
  "viewCount": 300
}

删除文档:

http 复制代码
DELETE /article/_doc/1001

局部更新和全量覆盖的区别很重要。局部更新适合只改少量字段,业务上不容易漏字段。全量覆盖适合你明确知道完整文档结构,并希望以当前数据源完整重建 ES 文档。同步系统里,两种方式都常见,但要统一策略,避免某些字段被意外清空。

8. 查询 DSL:match、term、bool 的边界

查询全部文档:

http 复制代码
GET /article/_search
{
  "query": {
    "match_all": {}
  }
}

全文查询使用 match

http 复制代码
GET /article/_search
{
  "query": {
    "match": {
      "content": "RAG 向量 检索"
    }
  }
}

match 会对查询词做分析。也就是说 "RAG 向量 检索" 会经过 search_analyzer 变成词项,再去倒排索引里找匹配文档。它适合 text 字段。

精确匹配使用 term

http 复制代码
GET /article/_search
{
  "query": {
    "term": {
      "category": "AI工程"
    }
  }
}

term 不会分析查询词,它拿原始词项直接匹配倒排索引。它适合 keyword、数字、布尔值等字段。如果你对 text 字段乱用 term,很可能查不到,因为 text 字段入库时已经被分词。

组合查询使用 bool

http 复制代码
GET /article/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "content": "混合检索"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category": "AI工程"
          }
        }
      ],
      "must_not": [
        {
          "term": {
            "author": "测试账号"
          }
        }
      ]
    }
  }
}

这里 must 会参与相关性打分,filter 通常不参与打分并且可缓存,适合精确过滤。工程上不要把所有条件都塞进 must。比如分类、状态、租户、权限、时间范围,大多应该放在 filter。这样语义更清楚,性能也更稳定。

分页和排序:

http 复制代码
GET /article/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "createTime": {
        "order": "desc"
      }
    }
  ],
  "query": {
    "match_all": {}
  }
}

如果是深分页,不建议无限增大 fromfrom + size 越大,ES 需要跳过和排序的结果越多。生产系统里常用 search_after 或滚动查询来处理深分页和批量导出。

9. IK 分词:为什么中文搜索不能只用 standard

ES 默认的 standard analyzer 对英文比较友好,但对中文搜索不够理想。我们可以直接用 _analyze 对比:

http 复制代码
POST /_analyze
{
  "analyzer": "standard",
  "text": "Elasticsearch RAG 混合检索知识库"
}

再看 IK:

http 复制代码
POST /_analyze
{
  "analyzer": "ik_max_word",
  "text": "Elasticsearch RAG 混合检索知识库"
}

以及:

http 复制代码
POST /_analyze
{
  "analyzer": "ik_smart",
  "text": "Elasticsearch RAG 混合检索知识库"
}

ik_max_word 会尽可能细粒度拆分,适合索引阶段。例如"知识库"可能拆出"知识库""知识""库"等多个词项。这样用户搜索不同粒度的词时,都有机会命中文档。

ik_smart 会尽量做较粗粒度、较智能的切分,适合查询阶段。查询词过度拆分会带来噪音。例如用户搜索"混合检索",如果切得太散,可能把"混合"和"检索"分别召回很多弱相关文档。

一个常用配置就是:

json 复制代码
{
  "type": "text",
  "analyzer": "ik_max_word",
  "search_analyzer": "ik_smart"
}

这不是绝对规则,但适合大多数中文内容检索入门场景。更复杂的业务还会加自定义词典、停用词、同义词。例如医疗、法律、金融、代码检索都需要维护领域词表,否则分词器很可能把专业术语切坏。

10. 生活笔记案例:从建索引到中文检索

下面用一个更完整的生活笔记索引演示中文检索。先创建索引:

http 复制代码
PUT /life_note
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "content": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "type": {
        "type": "keyword"
      },
      "author": {
        "type": "keyword"
      },
      "record_time": {
        "type": "date"
      }
    }
  }
}

写入几条数据:

http 复制代码
POST /life_note/_doc
{
  "title": "周末城市短途旅行攻略",
  "content": "周末适合周边短途出行,打卡公园、小吃街,放松日常工作压力,出行尽量避开早晚高峰",
  "type": "旅行生活",
  "author": "日常记录",
  "record_time": "2026-05-24"
}
http 复制代码
PUT /life_note/_doc/3001
{
  "title": "健康饮食与居家养生",
  "content": "规律作息、清淡饮食,多吃蔬菜水果,减少熬夜,合理运动才能保持身体健康",
  "type": "健康生活",
  "author": "生活达人",
  "record_time": "2026-05-24"
}
http 复制代码
PUT /life_note/_doc/3002
{
  "title": "居家办公效率提升",
  "content": "居家办公要规划任务清单,减少消息打扰,固定运动时间,保持专注和稳定作息",
  "type": "工作生活",
  "author": "效率笔记",
  "record_time": "2026-05-24"
}

查询"健康 作息 旅行":

http 复制代码
GET /life_note/_search
{
  "query": {
    "match": {
      "content": "健康 作息 旅行"
    }
  }
}

这个查询会召回包含这些词项的文档,并按相关性排序。你可能会看到"健康饮食与居家养生"和"居家办公效率提升"都被召回,因为它们都包含"作息"或"健康"相关词项;旅行文档也可能被召回,因为 query 里有"旅行"。

如果希望必须匹配更多词,可以使用 operator

http 复制代码
GET /life_note/_search
{
  "query": {
    "match": {
      "content": {
        "query": "健康 作息 旅行",
        "operator": "and"
      }
    }
  }
}

operator: and 会提高匹配要求,但也可能让召回变少。搜索系统的设计永远是在召回率和精确率之间取平衡。知识库问答一般更重视召回,后台管理搜索可能更重视精确。

高亮结果:

http 复制代码
GET /life_note/_search
{
  "query": {
    "match": {
      "content": "健康 作息"
    }
  },
  "highlight": {
    "fields": {
      "content": {}
    }
  }
}

高亮不是核心检索能力,但对产品体验很重要。用户看到命中的片段,才知道为什么这条结果被返回。

11. BM25:为什么搜索结果有前后顺序

倒排索引解决的是"哪些文档包含查询词"。但搜索系统还必须回答另一个问题:哪些文档更相关?

Elasticsearch 默认使用 BM25 作为相关性打分算法。BM25 可以理解成 TF-IDF 的改进版本,但不要只把它背成公式。工程上更重要的是理解它的几个直觉。

第一,词出现得多,通常更相关,但不是无限加分。比如一篇文章里出现一次"Elasticsearch",可能只是随便提到;出现十次,相关性更强。但如果有人故意堆一百次,不能让它无限领先。这就是词频饱和。

第二,稀有词更重要。如果"的""是""我们"这种词出现很多,并不能说明文档更相关。相反,"IK 分词器""BM25""倒排索引"这种相对稀有的词,区分度更高。这就是 IDF 的思想。

第三,文档长度要归一化。一个两万字文档包含某个词很正常,一个两百字文档多次提到某个词则更可能主题集中。BM25 会考虑文档长度,避免长文档天然占便宜。

可以用 _explain 看一条文档为什么得这个分:

http 复制代码
GET /life_note/_explain/3001
{
  "query": {
    "match": {
      "content": "健康 作息"
    }
  }
}

_explain 不适合线上高频使用,因为它会产生额外开销。但它非常适合调试搜索质量。当业务方问"为什么这条排第一"时,你不能只说"ES 算的",而应该能解释:哪些词命中了、这些词权重如何、字段长度如何影响得分。

如果要进一步调整排序,可以使用字段加权:

http 复制代码
GET /life_note/_search
{
  "query": {
    "multi_match": {
      "query": "健康 作息",
      "fields": ["title^3", "content"]
    }
  }
}

title^3 表示标题命中的权重更高。这符合很多业务直觉:标题通常比正文更能代表主题。AI 知识库里也类似,文档标题、章节标题、标签、摘要字段都可以给更高权重。

12. ES 和 RAG:关键词检索如何进入 AI 链路

在 RAG 中,ES 通常不是单独使用,而是和向量检索组合。一个比较稳妥的工程链路如下:

flowchart TD A[文档导入] --> B[文本清洗] B --> C[切块] C --> D[写 MySQL 保存元数据] C --> E[写 Elasticsearch 保存关键词索引] C --> F[生成 Embedding] F --> G[写 Milvus 保存向量] H[用户问题] --> I[查询改写] I --> J[ES 关键词召回] I --> K[Milvus 语义召回] J --> L[融合候选] K --> L L --> M[重排] M --> N[构造 Prompt] N --> O[LLM 回答]

这里 ES 保存的内容通常包括:

  • chunk 文本,用于关键词检索。
  • 文档标题、章节标题、标签,用于加权匹配。
  • 租户 ID、权限字段、业务状态,用于 filter。
  • 文档 ID、chunk ID,用于回查 MySQL 或对象存储。
  • 更新时间、版本号,用于同步和排查。

一个面向 RAG 的索引 mapping 可以这样设计:

http 复制代码
PUT /knowledge_chunk
{
  "mappings": {
    "properties": {
      "doc_id": {
        "type": "keyword"
      },
      "chunk_id": {
        "type": "keyword"
      },
      "title": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "content": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "tags": {
        "type": "keyword"
      },
      "tenant_id": {
        "type": "keyword"
      },
      "permission_group": {
        "type": "keyword"
      },
      "updated_at": {
        "type": "date"
      }
    }
  }
}

查询时可以这样:

http 复制代码
GET /knowledge_chunk/_search
{
  "size": 20,
  "_source": ["doc_id", "chunk_id", "title", "content"],
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "IK 分词器 BM25 倒排索引",
            "fields": ["title^3", "content"]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "tenant_id": "tenant_a"
          }
        },
        {
          "terms": {
            "permission_group": ["public", "engineering"]
          }
        }
      ]
    }
  }
}

这段查询在 RAG 链路中的职责不是直接生成答案,而是召回一批候选 chunk。后面还可以做向量召回、RRF 融合、reranker 重排,最后再交给 LLM。

关键词检索在 RAG 里的价值往往体现在这些场景:

  • 搜错误码:ECONNREFUSEDETIMEDOUTORA-00001
  • 搜类名、函数名、配置项:search_analyzerdocker compose build
  • 搜产品名和型号:内部系统名称、接口编号、SKU。
  • 搜短问题:用户只输入"BM25"或"IK 分词"。
  • 做权限过滤:先按租户和权限缩小候选,再排序。

这些场景用向量检索不是不能做,但效果和可控性通常不如 ES。

13. 常见踩坑:从这次项目修复说起

当前项目之前遇到过一个典型错误:

text 复制代码
failed to fetch oauth token: Post "https://auth.docker.io/token": i/o timeout

表面上看是"代理问题",实际根因是 Dockerfile 里写了:

dockerfile 复制代码
FROM elasticsearch:8.17.0

这个镜像名没有 registry 前缀,Docker 默认去 Docker Hub 找,也就是 docker.io/library/elasticsearch:8.17.0。国内网络下 Docker Hub 的 token 服务和 registry 元数据请求都容易超时。

修复方式不是盲目换代理,而是先把镜像来源写准确:

dockerfile 复制代码
ARG ES_IMAGE=docker.elastic.co/elasticsearch/elasticsearch:8.17.0
FROM ${ES_IMAGE}

再通过 compose 注入:

yaml 复制代码
build:
  context: ./elasticsarch
  args:
    ES_IMAGE: ${ES_IMAGE:-docker.elastic.co/elasticsearch/elasticsearch:8.17.0}

这样做的好处是:默认走 Elastic 官方 registry;如果某天官方 registry 慢,可以在 .env 里替换 ES_IMAGE,不用改 Dockerfile。

另一个坑是把自定义镜像命名成官方镜像:

yaml 复制代码
image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0

如果你同时用了 build 和这个 image,构建出来的镜像可能会被标记成官方镜像名,看起来像官方原版,实际里面装了 IK 插件。这会造成认知混乱。更好的命名方式是:

yaml 复制代码
image: es-test-elasticsearch-ik:8.17.0

镜像名表达事实:这是本地项目构建的 ES + IK 镜像。

第三个坑是网络名:

yaml 复制代码
networks:
  default:
    name: common-network

如果别的 Compose 项目已经创建了 common-network,启动时可能出现 warning:

text 复制代码
a network with name common-network exists but was not created for project

这不一定是错误,但说明多个项目正在复用同名网络。开发环境可以接受,团队环境建议明确:

yaml 复制代码
networks:
  default:
    name: common-network
    external: true

前提是这个网络确实由你手动或其他基础设施创建。否则 Compose 不会自动创建 external 网络。

第四个坑是数据卷进 Git。当前项目的 volumes/ 目录是容器运行时数据,启动 ES、Milvus、MinIO 后会产生大量文件变动。这类目录一般不应该提交到代码仓库。更好的做法是在 .gitignore 里忽略:

gitignore 复制代码
volumes/

如果你想保留目录结构,可以提交 .gitkeep,但不要提交 ES 的 segment、translog、state 文件。

14. 生产环境和开发环境不要混为一谈

当前 compose 很适合本地学习和 demo,但不能直接当生产配置。

原因很简单:

  • xpack.security.enabled=false 关闭了安全认证,生产环境不能裸奔。
  • 单节点 discovery.type=single-node 没有高可用。
  • JVM 只给了 512MB,适合本地,不适合真实数据量。
  • 数据卷挂在项目目录,方便调试,但不适合生产运维。
  • 没有快照备份策略。
  • 没有 ILM 生命周期管理。
  • 没有监控告警。
  • 没有索引模板和别名切换策略。

生产环境至少要考虑这些问题:

flowchart TD A[索引设计] --> B[字段类型和分词器] A --> C[索引模板] A --> D[别名和版本切换] E[运行保障] --> F[安全认证] E --> G[备份快照] E --> H[监控告警] E --> I[容量规划] J[数据同步] --> K[幂等写入] J --> L[失败重试] J --> M[重建索引]

尤其是索引别名非常关键。比如线上使用 article_search 作为读别名,真实索引是 article_v1。当 mapping 需要升级时,新建 article_v2,后台重建数据,验证通过后把别名切到新索引。这样可以避免直接修改线上索引导致不可控风险。

示例:

http 复制代码
POST /_aliases
{
  "actions": [
    {
      "remove": {
        "index": "article_v1",
        "alias": "article_search"
      }
    },
    {
      "add": {
        "index": "article_v2",
        "alias": "article_search"
      }
    }
  ]
}

这是搜索系统工程化里非常基础但非常重要的一步。

15. 如何判断 ES 搜索质量

跑通 API 不等于搜索质量好。真正上线前,你至少要准备一组评测问题和期望结果。

可以从几个维度评估:

  • 召回率:应该出现的文档有没有出现。
  • 精确率:返回结果里噪音多不多。
  • 排序质量:最相关的是否排在前面。
  • 字段权重:标题命中是否比正文命中更重要。
  • 分词效果:业务术语有没有被切坏。
  • 过滤正确性:租户、权限、状态、时间范围是否严格生效。
  • 性能:P95/P99 延迟是否符合要求。

一个简单的调试流程是:

flowchart LR A[发现搜索结果不对] --> B[用 analyze 看分词] B --> C[检查 mapping] C --> D[用 explain 看打分] D --> E[调整字段权重或查询 DSL] E --> F[补充业务词典] F --> G[回归评测集]

不要一上来就改算法。很多搜索问题其实来自 mapping 设计不合理、字段类型错了、查询 DSL 写错了、分词器没按预期工作。

例如,用户搜"Agentic RAG",结果召回很差,你应该先检查:

http 复制代码
POST /_analyze
{
  "analyzer": "ik_smart",
  "text": "Agentic RAG"
}

如果分词没有问题,再检查文档里是否真的有这个词,字段是否是 text,查询是否搜了正确字段。最后才考虑同义词、重排、混合检索。

16. 一套完整的本地练习脚本

下面是一套建议你在 Kibana Dev Tools 里按顺序执行的练习。它覆盖了索引创建、写入、查询、分词、相关性和清理。

先检查服务:

http 复制代码
GET /
GET /_cat/plugins?v
GET /_cat/indices?v

创建索引:

http 复制代码
PUT /tutorial_article
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "content": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "scene": {
        "type": "keyword"
      },
      "level": {
        "type": "keyword"
      },
      "created_at": {
        "type": "date"
      }
    }
  }
}

写入文档:

http 复制代码
PUT /tutorial_article/_doc/1
{
  "title": "Elasticsearch 倒排索引入门",
  "content": "倒排索引把词项映射到文档集合,是全文检索能够高效工作的基础。",
  "scene": "搜索引擎",
  "level": "入门",
  "created_at": "2026-05-24"
}
http 复制代码
PUT /tutorial_article/_doc/2
{
  "title": "IK 分词器在中文搜索中的应用",
  "content": "中文文本没有天然空格边界,IK 分词器可以把句子切成更符合中文语义的词项。",
  "scene": "中文检索",
  "level": "进阶",
  "created_at": "2026-05-24"
}
http 复制代码
PUT /tutorial_article/_doc/3
{
  "title": "BM25 相关性排序原理",
  "content": "BM25 综合词频、逆文档频率和文档长度归一化,决定搜索结果的相关性排序。",
  "scene": "排序算法",
  "level": "进阶",
  "created_at": "2026-05-24"
}

普通全文查询:

http 复制代码
GET /tutorial_article/_search
{
  "query": {
    "match": {
      "content": "中文 分词 检索"
    }
  }
}

多字段加权查询:

http 复制代码
GET /tutorial_article/_search
{
  "query": {
    "multi_match": {
      "query": "BM25 排序",
      "fields": ["title^3", "content"]
    }
  }
}

过滤加全文查询:

http 复制代码
GET /tutorial_article/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "content": "检索"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "level": "进阶"
          }
        }
      ]
    }
  }
}

查看解释:

http 复制代码
GET /tutorial_article/_explain/3
{
  "query": {
    "multi_match": {
      "query": "BM25 排序",
      "fields": ["title^3", "content"]
    }
  }
}

清理索引:

http 复制代码
DELETE /tutorial_article

这一套练习的目的不是背 API,而是建立搜索系统的直觉:字段类型决定能不能查,分词器决定怎么查,查询 DSL 决定查哪些,BM25 决定谁排前面。

17. 总结

Elasticsearch 的核心不是"一个能存 JSON 的数据库",而是围绕全文检索构建的一整套索引和排序系统。

它解决的问题是:在大量文本里,快速找到包含某些关键词或相关表达的文档,并按相关性排序返回。它在 AI 应用链路中的位置也很明确:和向量检索互补,承担关键词、精确实体、过滤条件和传统相关性排序。

这篇文章基于当前项目实际实现,完成了几件事:

  • 用 Docker Compose 启动 Elasticsearch 和 Kibana。
  • 用自定义 Dockerfile 安装 IK 中文分词插件。
  • 修正基础镜像来源,避免误走 Docker Hub。
  • 用 mapping 区分 textkeyword 字段。
  • ik_max_wordik_smart 处理中文索引与查询。
  • 用 match、term、bool、multi_match 演示常见查询。
  • 用 BM25 解释为什么搜索结果有相关性排序。
  • 把 ES 放回 RAG 和 AI 工程链路里,说明它与向量数据库的关系。

最后给一个工程判断:如果你的系统只做简单 ID 查询和结构化筛选,MySQL 足够;如果你要做海量文本关键词搜索、中文分词、高亮、相关性排序、聚合分析,Elasticsearch 很合适;如果你要做语义相似召回,向量数据库更合适;如果你在做严肃的 AI 知识库,关键词检索和向量检索大概率都要有。

真正的工程能力不是迷信某个组件,而是知道每个组件解决什么问题,也知道它不能解决什么问题。

相关推荐
JouYY8 小时前
Agent记忆进阶——从一个实际例子学习知识图谱
llm·agent
SuniaWang8 小时前
《Agentx专栏》02-技术选型:预算有限时如何做出正确的技术决策
java·spring·架构·langchain·milvus·agenx·opl
唐璜Taro9 小时前
LangChain与LangGraph多Agent实战:从工具链到工作流编排(上)
langchain·agent·langgraph
努力发光的程序员9 小时前
互联网大厂Java面试问答及技术分析(涵盖Spring Boot及微服务)
java·微服务·面试·springboot·技术问答
@Murphy9 小时前
java 面试
java·开发语言·面试
Yunzenn10 小时前
深度解析字节前沿研究-Cola DLM第 04 章:Cola DLM 架构全景 —— 三层解耦的设计哲学
java·linux·python·深度学习·面试·github·transformer
冬奇Lab10 小时前
Agent系列(三):Plan-and-Solve——先想清楚,再动手
人工智能·llm·agent
冬奇Lab10 小时前
每日一个开源项目 #110:ai-engineering-from-scratch - 从零构建 AI 工程全栈能力
人工智能·深度学习·llm
deephub10 小时前
推理 → 行动 → 观察:用 LangChain + Python 实现一个智能体循环
人工智能·python·langchain·大语言模型·agent