1. 为什么 AI 应用还需要 Elasticsearch
很多人第一次做 RAG 或知识库检索时,会直接想到向量数据库:把文本切块,生成 embedding,写入 Milvus、Qdrant、pgvector 或其他向量存储,然后用向量相似度召回内容。这条链路当然重要,尤其适合处理"语义相近但字面不完全一致"的问题。
例如用户问"怎么让系统更稳定",向量检索可能召回"高可用架构""限流降级""熔断重试"等内容。它不要求用户输入和文档中的词完全一样,只要语义相近,就有机会被召回。
但纯向量检索也有明显短板:
- 对精确实体不够敏感,比如订单号、函数名、类名、错误码、产品型号、专有名词。
- 对关键词约束不强,比如用户明确搜"BM25",系统却召回一堆"相关性排序"的泛化内容。
- 对短查询不稳定,短查询本身语义信息少,embedding 容易漂。
- 对业务规则过滤不够自然,比如按作者、分类、时间、状态、权限过滤,关键词检索引擎更顺手。
这就是 Elasticsearch 仍然重要的原因。它不是向量数据库的替代品,而是关键词检索、结构化过滤、排序和聚合的强项工具。在真实 AI 应用里,常见做法不是"ES 或向量库二选一",而是把二者组合起来:
在这条链路里,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 当成主库。典型工程实践是:
也就是说,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 分词,然后查倒排表,找到包含这些词的文档,再合并、打分、排序。
这个过程可以画成下面这样:
这里的 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 里有几个关键点。
title 和 content 使用 text 类型,因为它们要做全文检索。text 字段会被 analyzer 分词,并写入倒排索引。用户搜索"混合检索"时,ES 可以按词项匹配,而不是只做完整字符串匹配。
author 和 category 使用 keyword 类型,因为它们更适合精确匹配和聚合。比如筛选 category = "AI工程",不需要把"AI工程"拆成词。keyword 字段通常用于过滤、排序、聚合、精确查询。
analyzer 和 search_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": {}
}
}
如果是深分页,不建议无限增大 from。from + 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 通常不是单独使用,而是和向量检索组合。一个比较稳妥的工程链路如下:
这里 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 里的价值往往体现在这些场景:
- 搜错误码:
ECONNREFUSED、ETIMEDOUT、ORA-00001。 - 搜类名、函数名、配置项:
search_analyzer、docker 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 生命周期管理。
- 没有监控告警。
- 没有索引模板和别名切换策略。
生产环境至少要考虑这些问题:
尤其是索引别名非常关键。比如线上使用 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 延迟是否符合要求。
一个简单的调试流程是:
不要一上来就改算法。很多搜索问题其实来自 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 区分
text和keyword字段。 - 用
ik_max_word和ik_smart处理中文索引与查询。 - 用 match、term、bool、multi_match 演示常见查询。
- 用 BM25 解释为什么搜索结果有相关性排序。
- 把 ES 放回 RAG 和 AI 工程链路里,说明它与向量数据库的关系。
最后给一个工程判断:如果你的系统只做简单 ID 查询和结构化筛选,MySQL 足够;如果你要做海量文本关键词搜索、中文分词、高亮、相关性排序、聚合分析,Elasticsearch 很合适;如果你要做语义相似召回,向量数据库更合适;如果你在做严肃的 AI 知识库,关键词检索和向量检索大概率都要有。
真正的工程能力不是迷信某个组件,而是知道每个组件解决什么问题,也知道它不能解决什么问题。