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. 当前项目的工程结构

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

es-test
├── docker-compose.yml
├── elasticsearch
│   └── Dockerfile
├── es-test.md
├── es-test2.md
├── package.json
└── volumes

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

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 服务则负责可视化操作:

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:

docker compose build es
docker compose up -d --build es

构建时会执行 elasticsarch/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:

curl http://localhost:9200

正常会返回类似信息:

{
  "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 插件:

GET /_cat/plugins?v

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

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

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

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

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

MySQLElasticsearch说明
DatabaseCluster 或业务命名空间ES 不直接按 database 工作,通常用集群和索引组织数据
TableIndex索引是文档集合,也是检索的基本单位
RowDocument每条 JSON 文档类似一行业务数据
ColumnField文档里的字段
SchemaMapping字段类型、分词器、索引方式
SQL QueryQuery DSLES 使用 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,最关键的是理解倒排索引。

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

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

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

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

检索 -> 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。

下面创建一个文章索引:

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:

GET /article/_mapping

删除索引:

DELETE /article

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

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

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

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

也可以指定 ID:

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 文档,不会产生重复数据。

查询单条:

GET /article/_doc/1001

局部更新:

POST /article/_update/1001
{
  "doc": {
    "viewCount": 999
  }
}

全量覆盖:

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

删除文档:

DELETE /article/_doc/1001

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

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

查询全部文档:

GET /article/_search
{
  "query": {
    "match_all": {}
  }
}

全文查询使用 match

GET /article/_search
{
  "query": {
    "match": {
      "content": "RAG 向量 检索"
    }
  }
}

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

精确匹配使用 term

GET /article/_search
{
  "query": {
    "term": {
      "category": "AI工程"
    }
  }
}

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

组合查询使用 bool

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

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

分页和排序:

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 对比:

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

再看 IK:

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

以及:

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

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

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

一个常用配置就是:

{
  "type": "text",
  "analyzer": "ik_max_word",
  "search_analyzer": "ik_smart"
}

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

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

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

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"
      }
    }
  }
}

写入几条数据:

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

查询“健康 作息 旅行”:

GET /life_note/_search
{
  "query": {
    "match": {
      "content": "健康 作息 旅行"
    }
  }
}

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

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

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

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

高亮结果:

GET /life_note/_search
{
  "query": {
    "match": {
      "content": "健康 作息"
    }
  },
  "highlight": {
    "fields": {
      "content": {}
    }
  }
}

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

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

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

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

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

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

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

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

GET /life_note/_explain/3001
{
  "query": {
    "match": {
      "content": "健康 作息"
    }
  }
}

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

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

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 可以这样设计:

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"
      }
    }
  }
}

查询时可以这样:

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. 常见踩坑:从这次项目修复说起

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

failed to fetch oauth token: Post "https://auth.docker.io/token": i/o timeout

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

FROM elasticsearch:8.17.0

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

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

ARG ES_IMAGE=docker.elastic.co/elasticsearch/elasticsearch:8.17.0
FROM ${ES_IMAGE}

再通过 compose 注入:

build:
  context: ./elasticsarch
  args:
    ES_IMAGE: ${ES_IMAGE:-docker.elastic.co/elasticsearch/elasticsearch:8.17.0}

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

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

image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0

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

image: es-test-elasticsearch-ik:8.17.0

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

第三个坑是网络名:

networks:
  default:
    name: common-network

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

a network with name common-network exists but was not created for project

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

networks:
  default:
    name: common-network
    external: true

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

第四个坑是数据卷进 Git。当前项目的 volumes/ 目录是容器运行时数据,启动 ES、Milvus、MinIO 后会产生大量文件变动。这类目录一般不应该提交到代码仓库。更好的做法是在 .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,后台重建数据,验证通过后把别名切到新索引。这样可以避免直接修改线上索引导致不可控风险。

示例:

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”,结果召回很差,你应该先检查:

POST /_analyze
{
  "analyzer": "ik_smart",
  "text": "Agentic RAG"
}

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

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

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

先检查服务:

GET /
GET /_cat/plugins?v
GET /_cat/indices?v

创建索引:

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"
      }
    }
  }
}

写入文档:

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

普通全文查询:

GET /tutorial_article/_search
{
  "query": {
    "match": {
      "content": "中文 分词 检索"
    }
  }
}

多字段加权查询:

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

过滤加全文查询:

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

查看解释:

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

清理索引:

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 知识库,关键词检索和向量检索大概率都要有。

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