
前面做 RAG 时,使用的一直是向量检索。它擅长"按意思找内容",即使用户的问法和原文不同,也有机会找到相关资料。
但遇到人名、专业术语、错误码这类内容时,我们关心的是某个词有没有准确出现。向量检索负责找"意思相近"的内容,关键词检索负责找"包含这个词"的内容,两种检索解决的问题并不一样。
Elasticsearch(下面简称 ES)就是用来做关键词检索的。可以把它理解成项目里的搜索服务:把文章的标题和正文放进去,搜索一个关键词,ES 会找出包含它的文章,并把更相关的结果排在前面。网站中的文章搜索、商品搜索和日志搜索,都可以使用 ES 实现。
实际项目中,MySQL、PostgreSQL 仍然负责保存业务数据,ES 只保存需要参与搜索的那部分内容。以文章为例,发布文章时先写入数据库,再把文章 ID、标题和正文同步给 ES;搜索时先让 ES 找到文章 ID,再回数据库读取完整文章。
text
写入:文章 → 数据库 → 同步到 ES
搜索:关键词 → ES → 文章 ID → 数据库 → 完整文章
简单来说:数据库负责存数据,ES 负责搜数据。
不过,把数据同步到 ES 只是第一步。ES 拿到标题和正文后,为什么能够快速找到包含关键词的文章,还能判断哪些结果更相关?
这背后主要依靠三个机制:文本写入后,分词器 会把句子拆成一个个可以搜索的词项;倒排索引 会记录每个词项出现在哪些文档中;用户搜索时,BM25 再计算文档与关键词的相关程度,把更相关的结果排在前面。
安装 Elasticsearch
采用 docker-compose 来进行安装,用来启动两个服务:
- Elasticsearch:存储和检索数据;
- Kibana:提供可视化页面和 Dev Tools,方便执行 ES 请求。
yml
services:
es:
image: elasticsearch:8.17.0 # 官方镜像
container_name: es-dev
ports:
- "9200:9200" # ES 访问端口
environment:
- discovery.type=single-node # 单节点运行(开发环境)
- xpack.security.enabled=false # 关闭安全认证,免密码访问
- xpack.security.http.ssl.enabled=false # 关闭 HTTPS 加密
- xpack.security.transport.ssl.enabled=false # 关闭节点传输加密
- ES_JAVA_OPTS=-Xms512m -Xmx512m # JVM 内存配置,避免占用过高
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/es/data:/usr/share/elasticsearch/data
healthcheck:
test:
[
"CMD-SHELL",
"curl -fs http://localhost:9200/_cluster/health || exit 1",
]
interval: 10s
timeout: 5s
retries: 12
restart: always
# Kibana 最新稳定版:8.17.0(必须与 ES 版本完全一致)
kibana:
image: kibana:8.17.0
container_name: kibana-dev
ports:
- "5601:5601" # Kibana 网页控制台端口
environment:
- ELASTICSEARCH_HOSTS=http://es:9200 # 连接 ES 容器内部地址
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/kibana:/usr/share/kibana/data
restart: always
depends_on:
- es # 等待 ES 启动完成后再启动 Kibana
networks:
default:
name: common-network
- ES 和 Kibana 的版本最好完全一致;
depends_on配合healthcheck,让 Kibana 等 ES 健康后再启动;- 使用命名卷保存数据,删除容器后数据仍然存在;
- JVM 堆内存设置为 512 MB,适合本地学习,不适合生产环境;
- 为了减少学习成本,这里关闭了身份认证和 HTTPS。
xpack.security.enabled=false只能用于本地开发。生产环境必须配置认证、权限和 TLS,不能把无认证的 9200 端口暴露到公网。
启动服务
bash
docker compose up -d --build

es 没有 sql 语句,都是通过一些 http 接口。所以打开 kibane 可视化控制台,打开 Dev Tools,发送请求测试一下(后续的请求都在 Dev Tools 进行)。

接下来,以旅游攻略为例,走一遍创建索引、管理文档和全文搜索的完整过程。
索引、文档与全文搜索
认识索引、Mapping 和文档
刚接触 ES 时,可以先使用数据库中的概念进行简单类比:
| Elasticsearch | 关系型数据库 | 本文示例 |
|---|---|---|
| Index(索引) | 表 | travel_guides |
| Document(文档) | 一行数据 | 一篇旅游攻略 |
| Field(字段) | 列 | title、content、city |
| Mapping | 表结构 | 每个字段的类型和分词方式 |
创建索引并定义 Mapping
HTTP
# 创建索引
PUT /travel_guides
{
"mappings": {
"properties": {
"title": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"content": {
"type": "text"
},
"city": {
"type": "keyword"
},
"created_at": {
"type": "date"
}
}
}
}
# 查看索引结构
GET /travel_guides/_mapping
# 删除索引
DELETE /travel_guides
这里有两种类型:text 和 keyword,这是初学 ES 时最容易混淆的地方。
text 会经过分词,适合全文搜索。
keyword 不会分词,通常把整个值当成一个词,适合:
- 城市或分类过滤;
- 标签过滤;
- 状态和编号;
- 精确匹配;
title.keyword 是 title 的子字段。这样,同一个标题既可以进行全文搜索,也可以进行精确匹配、排序或聚合。

文档的增删改查
添加文档
指定文档 ID 为 1:
http
PUT /travel_guides/_doc/1?refresh=wait_for
{
"title": "成都三日游攻略",
"content": "第一天游览武侯祠和锦里,第二天前往大熊猫繁育研究基地,第三天在宽窄巷子品尝成都小吃。",
"city": "成都",
"created_at": "2026-08-08"
}
refresh=wait_for 表示等待这次写入可以被搜索到后再返回。它很适合学习和测试,但大量写入时频繁刷新会影响性能,生产代码中不要给每一次写入都强制刷新。
如果不需要自己指定 ID,可以使用:
http
POST travel_guides/_doc
{
"title": "重庆洪崖洞夜游攻略",
"content": "傍晚可以从解放碑步行前往洪崖洞,亮灯后适合在千厮门大桥附近欣赏夜景。",
"city": "重庆",
"created_at": "2026-08-08"
}
根据 ID 读取文档
http
GET travel_guides/_doc/1
文档内容位于响应的 _source 字段中。

也可以只返回部分字段:
http
GET travel_guides/_doc/1?_source=title,city
更新文档
只修改指定字段:
http
POST travel_guides/_update/1?refresh=wait_for
{
"doc": {
"title": "成都三日旅游新攻略"
}
}
_update 适合部分更新。如果再次使用 PUT travel_guides/_doc/1,则表示提交一份新的完整文档内容,遗漏的字段可能不再保留。
删除文档
http
DELETE travel_guides/_doc/1?refresh=wait_for
使用 Query DSL 搜索文档
1. 搜索单个字段
http
GET travel_guides/_search
{
"query": {
"match": {
"content": "熊猫"
}
}
}
match 查询会先对查询内容进行分词, =。
2. 同时搜索标题和正文
http
GET travel_guides/_search
{
"query": {
"multi_match": {
"query": "西湖断桥",
"fields": ["title^2", "content"]
}
}
}
title^2 表示标题的权重是正文的两倍。也就是说,相同关键词出现在标题中时,我们认为它更重要。
3. 只返回需要的字段
http
GET travel_guides/_search
{
"_source": ["title", "city"],
"query": {
"multi_match": {
"query": "故宫",
"fields": ["title^2", "content"]
}
}
}
这里的 _source 不会改变索引中保存的数据,只会控制本次查询返回哪些字段。
4. 按城市精确过滤
http
GET travel_guides/_search
{
"_source": ["title", "city"],
"query": {
"bool": {
"must": [
{
"match": {
"content": "美食"
}
}
],
"filter": [
{
"term": {
"city": "成都"
}
}
]
}
}
}
must负责"内容是否相关",会参与相关度评分;filter负责"条件是否满足",通常不参与相关度评分;term一般用于keyword、数字、布尔值等精确值;match一般用于经过分词的text字段。
不要对中文 text 字段随意使用 term 查询,因为 term 不会分析查询文本,很可能得不到预期结果。
5. 高亮关键词
http
GET travel_guides/_search
{
"_source": ["title", "content"],
"query": {
"multi_match": {
"query": "北京故宫",
"fields": ["title", "content"]
}
},
"highlight": {
"fields": {
"title": {},
"content": {}
}
}
}
响应中的 highlight 会使用 <em> 标签标记命中的内容,适合在搜索页面中展示。
6. 分页
http
GET travel_guides/_search
{
"from": 0,
"size": 10,
"_source": ["title", "city"],
"query": {
"match_all": {}
}
}
from:从第几条结果开始;size:返回多少条结果。
from + size 适合普通分页。数据量很大、页码很深时,应考虑 search_after,避免深分页带来的性能问题。
倒排索引
先来看看什么是正向思维?
假设有三篇文档:
text
文档 1:成都大熊猫基地适合亲子游
文档 2:成都宽窄巷子可以品尝美食
文档 3:北京故宫适合历史文化游
如果逐篇查看每篇文档是否包含"成都",数据越多,速度就越慢。
而针对倒排索引 ES 会先对文本分词,然后建立类似下面的结构:
text
成都 → 文档 1、文档 2
大熊猫 → 文档 1
亲子游 → 文档 1
宽窄巷子 → 文档 2
美食 → 文档 2
北京、故宫 → 文档 3
当用户搜索"成都"时,ES 不需要扫描所有攻略,只需要在词表中找到"成都",就能知道它出现在文档 1 和文档 2 中。
这个"从词找到文档"的结构,就是倒排索引。

实际的倒排索引还会保存更多信息,例如:
- 词出现在哪些文档中;
- 词在一篇文档中出现了几次;
- 词出现在文档的什么位置;
- 词在多少篇文档中出现过。
这些信息不仅帮助 ES 快速查找文档,也会参与相关度评分。
分词器
分词器的作用,是把一段完整文本转换成一个个可以建立索引的词项(Token)。
ES 默认的分词器是 standard 的, 可以使用 _analyze API 直接观察分词结果。
http
POST _analyze
{
"analyzer": "standard",
"text": "成都大熊猫繁育研究基地"
}
Standard 分词器对英文和常见符号的处理比较通用,但它不理解中文词语的实际含义。中文文本经常会被拆成单个汉字或不符合业务预期的片段。

中文都被拆分成单个汉字,对于检索没有任何意义,更希望得到:
text
成都、大熊猫、繁育、研究、基地
那我们就需要就是另外一个分词器:ik
那么我们在安装 ES 时,就要指定插件。这里通过 Dockerfile 的方式
yml
# 官方 ES 基础镜像
FROM elasticsearch:8.17.0
# 安装 IK 分词(版本严格和 ES 一致)
RUN elasticsearch-plugin install --batch \
https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.17.0.zip
在 docker-compose 修改一下 image 改成 build,从 docker file 中构建,拉取镜像:
yml
services:
# Elasticsearch 8.17.0 + IK 中文分词(内置到镜像)
es:
build: ./elasticsearch # 从本地 Dockerfile 构建镜像(自带IK)
container_name: es-dev
ports:
- "9200:9200" # ES 访问端口
....(其他的不变)
在重新启动服务
bash
docker compose down
docker compose up -d --build
可以检查下,插件安装成功与否:
HTTP
GET /_cat/plugins?v

安装成功之后,来看看他是如何拆词的。

可以发现就不再是单独的汉字了。
并且 ik 还有两种模式: ik_smart 和 ik_max_word
ik_smart倾向于进行较粗 粒度的切分,词的数量相对较少。它适合搜索阶段,因为用户输入通常比较短,我们希望查询词保持相对完整。ik_max_word会尝试拆出更多可能的词,粒度更细。它适合索引阶段,因为更多词项通常能提高召回率。但词项越多,索引占用的空间也会增加,还可能召回更多不相关结果。
简单记忆就是:ik_smart 搜索时用,ik_max_word 添加索引时用。
当然,创建索引(定义 mappings )时,有点变动,要指定分词器
HTTP
PUT /travel_guides
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"content": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
},
"city": {
"type": "keyword"
},
"created_at": {
"type": "date"
}
}
}
}
理解 BM25
搜索结果通常不是随意排序的。ES 会给每个匹配文档计算一个 _score,分数越高,一般表示文档和查询越相关。
Elasticsearch 的文本相关度默认使用 BM25(Best Matching 25) 算法。
可以把 BM25 的核心思路通俗地理解为以下三点。
1. 查询词在文档中出现得越多,通常越相关
用户搜索"故宫",一篇攻略多次介绍故宫,通常比只顺带提到一次的攻略更相关。
但出现次数并不是越多越无限加分。BM25 会控制词频带来的收益,避免通过机械重复关键词获得过高分数。
2. 越少见的词,通常越重要
"的""是"在很多文档中都会出现,区分能力很低。
"雷峰塔""宽窄巷子"出现得相对少,区分能力更强,所以通常会获得更高权重。
这就是逆文档频率的基本思想。
3. 文档长度会影响评分
一个词出现在 100 字的短文中,和出现在 10 万字的长文中,意义可能不同。
BM25 会对文档长度进行归一化,避免长文仅仅因为字数多、命中关键词的机会多,就总是排在前面。
查看相关度分数
http
GET travel_guides/_search
{
"_source": ["title"],
"query": {
"multi_match": {
"query": "北京故宫",
"fields": ["title^2", "content"]
}
}
}
每条结果中都会包含 _score:
json
{
"_index": "travel_guides",
"_id": "3",
"_score": 2.31,
"_source": {
"title": "北京故宫游览指南"
}
}
总之,BM25 算法是公平、高效、稳定的关键词排序算法,ES 默认使用它。
总结
至此,ES 的基础搜索链路就已经清楚了:
- 数据写入后经过分词并建立倒排索引
- 查询时通过关键词找到文档
- 再使用 BM25 对结果进行排序。
- 后面将它放进 RAG 时,就可以用 ES 补充向量检索不擅长的专业名词和精确关键词搜索。
理解了倒序索引 + BM25 算法 + IK 分词器,那么就理解了 ElasticSearch 的核心了。