5 - 电商问数:Qdrant 与 ES 快速入门与接入


本章课程目标:

  • 理解 QdrantElasticsearch 在「电商问数」里的职责边界,而不是把它们都当成“检索组件”混在一起。
  • 掌握向量数据库、全文检索、mappingcollectionpayload 等核心概念。
  • 看懂项目里的 QdrantClientManagerESClientManager 是如何封装、初始化与测试的。

学习建议: Qdrant 和 ES 都叫检索,但不要把它们混成一个东西。读这篇时先记住分工:Qdrant 更适合语义相似度召回,ES 更适合全文匹配和精确过滤。跑 quickstart 的目的不是熟练所有 API,而是看清 collection / point / payload 和 index / document / mapping 分别在项目里对应什么。

对应代码分支: 05-qdrant-es


很多同学第一次看到这套项目时,都会问一个很自然的问题:

既然都是“检索”,为什么还要同时接 QdrantElasticsearch

答案是:它们解决的不是同一类问题。

在「电商问数」里,这几个基础设施的分工可以概括成下面这张表:

组件 在项目中的核心职责 最擅长解决的问题
MySQL 存储权威结构化元数据与数仓数据 表、字段、指标、关系、样例值等结构化信息怎么保存
Qdrant 存储字段和指标的向量索引 “语义上谁更像”
Elasticsearch 存储字段取值的全文索引 “文本上谁能匹配上”

把它们放回问数链路,职责会更清楚:

1
2
3
4
5
6
用户自然语言问题
-> Embedding 服务把文本转成向量
-> Qdrant 召回语义相关的字段 / 指标
-> Elasticsearch 检索可能相关的字段取值
-> MySQL 补全表结构、字段定义、指标定义
-> LLM 基于上下文生成 SQL

所以,本章真正要讲的不是两个彼此独立的客户端,而是「电商问数」的检索基础设施层:为什么需要它们、它们分别存什么、在项目里怎么接、后面又怎么被业务层调用。


1、Qdrant 客户端接入

1.1 Qdrant 简介

官方文档:https://qdrant.tech/documentation/overview/what-is-qdrant/ (英)

一句话来说,Qdrant 就是:一个专门用于保存向量并执行相似度检索的数据库。

但放到当前项目里,这句话还不够具体。更准确的说法应该是:

  • 字段信息会被向量化后写入 Qdrant
  • 指标信息也会被向量化后写入 Qdrant
  • 用户问题到来时,同样会先被向量化
  • 再用“问题向量”去搜索最相近的字段和指标

也就是说,Qdrant 在这里并不承担“保存全部元数据”的职责,而是承担:在海量字段和指标候选中,先快速缩小语义问题空间。

这一点非常关键。因为企业问数场景里,表和字段通常很多,如果没有这一步语义召回,大模型需要面对的候选上下文会非常多,后面的 SQL 生成更容易跑偏。

当前项目构建字段和指标向量索引时,不是只给每个实体写一条向量,而是会把名称、描述、别名分别向量化后再写入向量库。这样做的目的,是让系统既能听懂正式名,也能听懂业务别名和自然语言表达。

1.2 区分 OLTP、OLAP 和向量数据库

OLTP、OLAP 与向量数据库的数据组织方式对比图

理解 Qdrant 之前,一个常见误区是把它简单理解成“另一种 MySQL”。

如果直接结合上面这张图来看,它本质上是在对比三类数据库的数据组织方式

  • 左边 OLTP database

    • 重点是按“行”组织数据
    • 更适合支撑业务系统按一条记录去查、改、写
  • 中间 OLAP database

    • 重点是按“列”组织数据
    • 更适合做统计分析,因为分析场景经常需要一次扫描某一整列数据
  • 右边 Vector database

    • 核心单位不再是“表里的一行记录”,而是一个向量点
    • 一个点通常由 idvectorpayload 组成

所以这张图真正想表达的是:数据组织方式不同,决定了它们擅长解决的问题也不同。

先把三类数据库粗略理解成下面这样:

类型 典型代表 主要用途 数据组织方式
OLTP 数据库 MySQLPostgreSQLOracle 支撑日常业务交易 通常以表的行列结构为主,更偏按行读写
OLAP 数据库 HiveClickHouseDoris 支撑统计分析和报表计算 也是表结构,但更适合分析型查询,常见按列存储
向量数据库 Qdrant 支撑语义相似度检索 以向量点为核心数据单位

如果再压缩成一句最容易记忆的话,可以这样区分:

  • OLTP(Online Transaction Processing) 更偏业务数据库,适合查询某一行
  • OLAP(Online Analytical Processing) 更偏分析型数据库,适合查询某一列
  • 向量数据库更偏语义相似度检索

把这张图再映射回「电商问数」项目,分工就更清楚了:

  • MySQL 负责存元数据和数仓模拟数据
  • Qdrant 负责语义相似度召回,根据向量相似度召回相关字段和指标
  • Elasticsearch 不等同于这里的 OLAP,它更偏搜索引擎和全文检索系统;在本项目里主要负责字段取值检索

你可能会问,Elasticsearch 属于这三类数据库中的哪一种?

更具体地说,Elasticsearch 并不完全等同于这里的任何一类。它更适合被理解成:搜索引擎 / 全文检索系统

在「电商问数」项目里,它承担的职责很明确:

  • 根据用户问题里的自然语言表达
  • 去匹配真实存在的字段取值
  • 为后续 SQL 生成补充“值域层”的上下文信息

所以这里最重要的结论不是“谁替代谁”,而是:结构化存储、统计分析、语义召回、全文检索,往往需要不同类型的基础服务共同完成。

1.3 理解 Qdrant 核心概念

指标元数据按名称、描述、别名分别向量化并写入向量库的映射示例

Qdrant 官方对 collection 的描述很清楚:同一个 collection 里的 points 使用相同维度,并按同一种距离度量进行比较。

如果是第一次接触向量数据库,先掌握下面几个概念:

概念 可以怎样理解
collection 一组向量点的集合,可以类比成“同类向量的一张表”
point 集合中的一条记录
vector 这条记录对应的向量
payload 跟着向量一起保存的业务字段
distance 用什么方式比较“像不像”

一个 point 最常见的结构如下:

1
2
3
4
5
6
7
8
{
"id": "column-001",
"vector": [0.12, 0.98, 0.44, 0.31],
"payload": {
"name": "region_name",
"description": "地区名称"
}
}

这里有两个参数特别重要:

  • size:向量维度,必须和 Embedding 模型输出维度一致
  • distance:相似度 / 距离计算方式,当前项目使用 COSINE

1.4 Qdrant 快速入门案例

这份 quickstart 演示的是最基础的 4 步:

1
2
3
4
初始化客户端
-> 创建 collection
-> 写入 points
-> query_points 查询相似向量

把这条思路进一步落成一个最小可运行的 .py 示例:

项目对应文件路径:shopkeeper-agent/examples/qdrant_quickstart_demo.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import asyncio

from qdrant_client import AsyncQdrantClient, models

QDRANT_URL = "http://localhost:6333"
COLLECTION_NAME = "quickstart_demo"
VECTOR_SIZE = 4


async def recreate_collection(client):
"""为了让示例可重复运行,先删除旧集合,再重新创建"""
if await client.collection_exists(COLLECTION_NAME):
await client.delete_collection(COLLECTION_NAME)

# Create a collection:创建一个新的集合
await client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=models.VectorParams(
size=VECTOR_SIZE,
distance=models.Distance.COSINE,
),
)
print(f"1. 已创建集合:{COLLECTION_NAME}")


async def add_vectors(client):
"""
写入几个示例向量

这里同时带上 payload,方便读者理解:
在 Qdrant 里,一个 point 不只有 vector,还可以附带业务字段
"""
await client.upsert(
collection_name=COLLECTION_NAME,
points=[
models.PointStruct(
id=1,
vector=[0.05, 0.61, 0.76, 0.74],
payload={"name": "订单分析", "type": "report"},
),
models.PointStruct(
id=2,
vector=[0.19, 0.81, 0.75, 0.11],
payload={"name": "销量趋势", "type": "metric"},
),
models.PointStruct(
id=3,
vector=[0.36, 0.55, 0.47, 0.94],
payload={"name": "区域销售额", "type": "dimension"},
),
],
)
print("2. 已写入 3 个向量点。")


async def run_query(client):
"""
执行一次向量查询

查询向量会和集合里的点做相似度计算,
最终返回最相近的几个 point
"""
query_vector = [0.2, 0.1, 0.9, 0.7]
result = await client.query_points(
collection_name=COLLECTION_NAME,
query=query_vector,
limit=3,
with_payload=True,
)

print(f"3. 查询向量:{query_vector}")
print("4. 查询结果:")
for i, point in enumerate(result.points, start=1):
print(
f" {i}) id={point.id}, score={point.score:.4f}, payload={point.payload}"
)


async def main():
# 直接初始化客户端,方便单独学习 quickstart 的基本用法
client = AsyncQdrantClient(url=QDRANT_URL)

try:
await recreate_collection(client)
await add_vectors(client)
await run_query(client)
finally:
await client.close()


if __name__ == "__main__":
asyncio.run(main())

执行文件验证,成功:

1
2
3
4
5
6
7
8
9
(shopkeeper-agent) didilili@DidililiMacBook-Pro shopkeeper-agent % python3 -m examples.qdrant_quickstart_demo

1. 已创建集合:quickstart_demo
2. 已写入 3 个向量点。
3. 查询向量:[0.2, 0.1, 0.9, 0.7]
4. 查询结果:
1) id=1, score=0.8946, payload={'name': '订单分析', 'type': 'report'}
2) id=3, score=0.8387, payload={'name': '区域销售额', 'type': 'dimension'}
3) id=2, score=0.6660, payload={'name': '销量趋势', 'type': 'metric'}

这里最值得掌握的不是样例数据本身,而是三个 API:

  • create_collection(...):创建集合,并声明维度与距离度量
  • upsert(...):按 id 更新或插入 points
  • query_points(...):根据查询向量返回最相关的点

Qdrant 官方 API 文档里,query_points 还支持 score_thresholdlimitfilterwith_payloadwith_vector 等参数。当前项目最常用的是:

  • score_threshold:控制最低召回分数
  • limit:控制返回条数
  • with_payload:控制是否把业务字段一起带回

Qdrant 自带可视化页面,通常可以通过 http://localhost:6333/dashboard 打开控制台页面,查看当前有哪些 collection、点数据是否写入成功。这对开发阶段排查问题会比较方便。

Qdrant 控制台页面,用于查看集合与点数据是否写入成功

1.5 封装 Qdrant 客户端

理解了 Qdrant 的基本概念之后,再来看项目代码里的封装就顺了。

在这个项目里,我们并不是在每个地方都临时 new 一个客户端,而是专门写了一个 QdrantClientManager。这样做主要是为了统一处理这几件事:客户端初始化,客户端关闭,读取配置文件中的服务地址,保持项目里只使用同一套客户端对象。

另外,这一节还有一个很重要的演进点:前面快速入门时,示例代码先用同步客户端帮助我们理解流程;但真正放到项目里时,更合适的做法是改成异步客户端

原因也不复杂:

  • Qdrant 的读写本质上是网络 I/O
  • 网络请求发出去之后,程序往往需要等待服务端响应
  • 如果使用异步客户端,这段等待时间就可以把 CPU 让给其他协程

所以,项目最终采用 AsyncQdrantClient,本质上是为了和整套异步后端保持一致,减少阻塞,提高整体并发利用率。

项目对应文件路径:shopkeeper-agent/app/clients/qdrant_client_manager.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import asyncio
import random
from typing import Optional

from qdrant_client import AsyncQdrantClient, models

from app.conf.app_config import QdrantConfig, app_config


class QdrantClientManager:
def __init__(self, qdrant_config: QdrantConfig):
# 保存配置对象,后面初始化客户端时要从这里读取 host 和 port
self.qdrant_config = qdrant_config
# 先把 client 声明出来,真正初始化放到 init() 中进行
self.client: Optional[AsyncQdrantClient] = None

def _get_url(self):
# 根据配置文件拼出 Qdrant 服务地址
return f"http://{self.qdrant_config.host}:{self.qdrant_config.port}"

def init(self):
# 创建异步客户端
# 这里不在 __init__ 中直接初始化,是为了和项目的生命周期管理保持一致
self.client = AsyncQdrantClient(url=self._get_url())

async def close(self):
# 项目关闭时统一关闭客户端连接
await self.client.close()


# 创建一个全局的管理器对象
# 后续项目中的其他模块都通过它来获取同一套 Qdrant 客户端
qdrant_client_manager = QdrantClientManager(app_config.qdrant)


if __name__ == "__main__":
# 先初始化客户端,后面的测试逻辑才能真正访问 Qdrant
qdrant_client_manager.init()

async def test():
# 取出真正的 Qdrant 异步客户端
client = qdrant_client_manager.client

# 如果集合不存在,就先创建一个集合
if not await client.collection_exists("my_collection"):
await client.create_collection(
collection_name="my_collection",
vectors_config=models.VectorParams(
# 当前集合中的向量维度是 10
size=10,
# 使用余弦相似度作为距离计算方式
distance=models.Distance.COSINE,
),
)

# 向集合中写入 100 个随机 point
# 每个 point 都有一个 id 和一个 10 维向量
await client.upsert(
collection_name="my_collection",
points=[
models.PointStruct(
id=i,
vector=[random.random() for _ in range(10)],
)
for i in range(100)
],
)

# 用一个随机生成的查询向量做相似度检索
# limit=10 表示最多返回 10 条结果
# score_threshold=0.8 表示只保留分数不低于 0.8 的结果
res = await client.query_points(
collection_name="my_collection",
query=[random.random() for _ in range(10)], # type: ignore
limit=10,
score_threshold=0.8,
)

# 打印查询结果,便于观察 point 的 id、score 等信息
print(res)

# 运行异步测试函数
asyncio.run(test())

执行文件验证,成功:

1
2
(shopkeeper-agent) didilili@DidililiMacBook-Pro shopkeeper-agent % python3 -m app.clients.qdrant_client_manager
points=[ScoredPoint(id=33, version=4, score=0.9287777, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=45, version=4, score=0.9019778, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=58, version=4, score=0.90187067, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=75, version=4, score=0.8799222, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=66, version=4, score=0.87921834, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=87, version=4, score=0.8753782, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=73, version=4, score=0.86765623, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=27, version=4, score=0.86216307, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=16, version=4, score=0.8618801, payload={}, vector=None, shard_key=None, order_value=None), ScoredPoint(id=69, version=4, score=0.8546801, payload={}, vector=None, shard_key=None, order_value=None)]

代码分析:

1.5.1 理解文件的两层职责

这一节可以按两个层次来理解这个文件。第一个层次,是把 Qdrant 客户端封装成项目里的基础服务。第二个层次,是用文件底部的测试代码演示一条最小可运行链路。这样看时,这个文件的职责就很清楚了:上半部分负责管理客户端,下半部分负责说明客户端怎么用

1.5.2 上半部分:QdrantClientManager 在做什么

先看上半部分的管理器代码。这里的核心目标不是“多写一个类”,而是把 Qdrant 客户端的初始化和关闭统一收口,不让项目里每个地方都自己去创建和销毁连接。

这一部分最值得注意的是下面几个位置:

  • self.qdrant_config:保存 Qdrant 配置对象,后面要从这里读取 hostport 等参数
  • _get_url():根据配置拼出完整服务地址,例如 http://localhost:6333
  • init():真正初始化 AsyncQdrantClient
  • close():在程序退出时统一关闭客户端连接
  • qdrant_client_manager = QdrantClientManager(app_config.qdrant):在模块加载时创建一份全局可复用的管理器对象

1.5.3 下半部分:测试代码演示的最小链路

再看下半部分的测试代码,它并不是为了写一段“临时脚本”,而是在演示这个客户端封装好之后,最基本的使用流程是什么。它对应的就是下面这条链路:

  1. 判断 collection 是否存在
  2. 如果不存在,就调用 create_collection(...)
  3. 通过 upsert(...) 写入一批向量点
  4. 通过 query_points(...) 做相似度查询

把这四步看懂,这个文件就理解了一大半。

  • collection_name="my_collection":指定当前操作的是哪一个集合
  • models.VectorParams(size=10, distance=models.Distance.COSINE):指定向量维度和距离计算方式
  • models.PointStruct(...):表示单个点数据,里面至少会有 idvector
  • limit=10:表示最多返回前 10 个最相近结果
  • score_threshold=0.8:表示只保留分数不低于 0.8 的结果

1.5.4 异步写法和真实项目里的向量来源

因为这里使用的是异步客户端,所以代码写法上还有两个同步示例里不会出现的点:和服务端交互的方法,都要通过 await 调用;测试逻辑要先放进 async def test() 这样的异步函数里,再通过 asyncio.run(test()) 执行。

这里为了演示流程,向量是随机生成的。真实项目里,写入 Qdrant 的向量并不是随便生成的,而是来自 Embedding 模型对字段说明、指标说明或用户问题的编码结果。


2、Elasticsearch 客户端接入

2.1 ES 简介

Elasticsearch 在当前项目里不是用来替代 MySQL,也不是用来替代 Qdrant。

它最核心的职责是:为字段取值建立全文索引,让系统能把用户的自然语言表达映射到真实存在的值域。

例如:

  • 用户说“华北地区”
  • 用户说“数码品类”
  • 用户说“直营门店”

这些表达不一定是字段名,更可能是某个维度字段里的具体取值。这个时候,光靠向量召回字段和指标还不够,还要补一层“值域检索”,这就是 ES 在当前项目里的位置。

2.2 ES 基础概念

官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/8.19/documents-indices.html (英)

在 Elasticsearch 里,先掌握四个词:

概念 可以怎样理解
index 一组同类文档的逻辑容器
document index 中的一条 JSON 文档
field document 中的一个字段
mapping 定义字段结构、字段类型以及索引行为

如果和其他基础设施做类比:

系统 结构类比
MySQL 表 -> 行 -> 列
Qdrant collection -> point -> vector / payload
Elasticsearch index -> document -> field

也就是说,虽然三者都在“存数据”,但它们组织数据的方式并不一样:

  • MySQL 更偏结构化记录
  • Qdrant 更偏向量点
  • Elasticsearch 更偏 JSON 文档

如果用最熟悉的 JSON 形式来理解,一个 document 大致就像这样:

1
2
3
4
5
{
"id": "v_001",
"value": "华北地区",
"column_id": "dim_region.region_name"
}

在这个例子里:

  • 整个 JSON 对象就是一个 document
  • idvaluecolumn_id 就是 field
  • 这些文档会被存进某个 index

如果把它和前面讲过的 Qdrant 再对一下,会更容易理解:

  • Qdrant 里,我们会说“往某个 collection 里写入一个 point
  • Elasticsearch 里,我们会说“往某个 index 里写入一个 document

这也解释了为什么这里要特别强调:index 在这里是名词,表示一个索引容器;而不是后面 API 里的那个“写入动作”。另外,Elasticsearch 返回结果里经常会出现一些以下划线开头的字段,例如 _index_id_source,这些属于元数据字段,属于 ES 自带的字段。

这里先做一个简要对应:

  • _index 表示这条数据属于哪个索引
  • _id 表示这条数据在索引中的唯一标识
  • _source 里存放的,才是我们真正写进去的业务数据

2.3 理解 mapping

mapping 本质是 Elasticsearch 里的“表结构说明”。

如果之前接触过关系型数据库、数据建模,或者在别的技术栈里见过 schema 这个词,这里可以直接把两者对应起来:mappingElasticsearch 里的说法,而 schema 是很多数据库或数据系统里更常见的说法。

它们在这里表达的核心意思是一样的:用一套结构定义,描述这一类数据有哪些字段、字段是什么类型、应该按什么方式被索引和存储。

这里还要特别区分两种常见方式:

  • dynamic mapping:让 ES 根据写入的数据自动推断字段类型
  • explicit mapping:由我们显式定义字段结构和字段类型

从工程角度看,当前项目更适合使用 显式定义 的方式,因为这样更可控,也更方便我们精确设计检索行为。

2.4 text 和 keyword 区别

mapping 不只是定义“有哪些字段”,还要定义“字段的数据类型是什么”。而 textkeyword,就是 ES 里两种非常常见、但处理方式完全不同的字段类型。

也就是说,

  1. 先有 index
  2. index 里存的是 document
  3. document 里有很多 field
  4. mapping 要定义这些 field 的类型
  5. 当字段类型是字符串时,最常见的两种类型就是 textkeyword

text 类型更偏全文检索。它适合保存一段可以被自然语言搜索的文本内容。为了支持这类搜索,ES 往往会先对文本做分词,再建立倒排索引。这意味着什么?意味着你存进去的不是一个“必须原样完全相等”的字符串,而是一段可以被拆开、被搜索、被匹配的文本。

例如:"华北地区""数码品类""近三个月销售额"。这类值如果定义成 text,用户在查询时不一定非得一模一样地输入整串文本,而是可以通过自然语言方式去匹配它们。

keyword 更偏精确匹配。它不会像 text 那样做分词,更适合存那些“必须按原值整体处理”的字段。

例如:主键 id、字段标识 column_id、状态码、类别编码。这类值通常不是拿来做全文搜索的,而是要么相等,要么不相等,所以更适合定义成 keyword

字段类型不同,ES 后续建立索引和执行检索的方式也不同。这正是 mapping 为什么重要的原因。我们并不是随便把字段声明成某个类型,而是在提前告诉 ES:这个字段将来打算怎么被搜索。

放到「电商问数」项目里,这个区别就非常具体了:字段取值里的 value 适合定义为 text。因为用户会用自然语言去搜它,例如“华北地区”“数码品类”。idcolumn_id 更适合定义为 keyword。因为它们更像结构化标识,不需要分词,只需要精确匹配。

这一点在项目真实代码里也能看到,对应文件:shopkeeper-agent/app/repositories/es/value_es_repository.py

其中索引映射大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
index_mappings = {
"dynamic": False,
"properties": {
"id": {"type": "keyword"},
"value": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_max_word",
},
"column_id": {"type": "keyword"},
},
}

这里还有一个细节值得注意:value 字段使用了 ik_max_word,这说明项目在中文文本检索时,会配合中文分词器来提高匹配效果。这个设置本身也再次说明:value 这个字段不是按“原样完全相等”去检索的,而是按全文检索的思路来处理的。

2.5 ES 常见使用方式

使用 Elasticsearch 大致有两种常见方式:

  1. 直接调用 REST API
  2. 在程序里使用语言客户端

如果只是做测试或调试,第一种方式非常方便。例如,我们可以在 KibanaDev Tools 里直接执行接口,验证索引是否创建成功、文档是否写入成功、查询语句是否返回预期结果。

在这个项目里,更常见的真实开发方式是第二种,也就是在 Python 代码中使用 elasticsearch 客户端库。这也解释了为什么我们接下来会专门封装一个 ESClientManager

2.6 ES 快速入门案例

官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/8.19/full-text-filter-tutorial.html (英)

这份教程讲的不是 Python 客户端封装,而是直接通过 REST API 走一遍 Elasticsearch 的典型使用流程。通常最适合在 KibanaDev Tools 里执行,所以它非常适合拿来建立直觉。

如果把它翻译成一条更好理解的链路,大致就是:

1
2
3
4
5
创建 index
-> 定义 mapping
-> 批量写入文档
-> 使用 _search 做全文检索
-> 再叠加 filter 做精确过滤

先看完整案例代码。它把“清理旧索引、创建索引、定义映射、批量写入文档、全文检索、叠加过滤”都串起来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# 如果之前已经创建过 cooking_blog,可以删掉旧索引
DELETE /cooking_blog

# 创建索引
PUT /cooking_blog

# 定义字段结构
PUT /cooking_blog/_mapping
{
"properties": {
"title": { "type": "text" },
"description": { "type": "text" },
"author": { "type": "text" },
"date": { "type": "date", "format": "yyyy-MM-dd" },
"category": { "type": "text" },
"tags": { "type": "text" },
"rating": { "type": "float" }
}
}

# 批量写入文档
POST /cooking_blog/_bulk?refresh=wait_for
{"index":{"_id":"1"}}
{"title":"Perfect Pancakes","description":"Fluffy pancakes for breakfast","author":"Maria Rodriguez","date":"2023-05-01","category":"Breakfast","tags":["pancakes","breakfast"],"rating":4.8}
{"index":{"_id":"2"}}
{"title":"Vegetarian Curry","description":"A spicy and comforting curry","author":"Aisha Khan","date":"2023-06-10","category":"Dinner","tags":["vegetarian","curry","spicy"],"rating":4.7}
{"index":{"_id":"3"}}
{"title":"Berry Smoothie Bowl","description":"A healthy breakfast with fresh berries","author":"Liam Turner","date":"2023-07-03","category":"Breakfast","tags":["smoothie","berries","healthy"],"rating":4.6}
{"index":{"_id":"4"}}
{"title":"Chocolate Lava Cake","description":"Rich chocolate cake with a gooey center","author":"Sophia Chen","date":"2023-08-12","category":"Dessert","tags":["chocolate","cake","dessert"],"rating":4.9}

# 全文检索
GET /cooking_blog/_search
{
"query": {
"match": {
"description": "fluffy pancakes"
}
}
}

# 全文检索 + 过滤查询
GET /cooking_blog/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"description": "breakfast"
}
}
],
"filter": [
{
"range": {
"rating": {
"gte": 4.7
}
}
}
]
}
}
}

这个案例最适合帮助初学者掌握下面这几件事:

  1. 创建 index
  2. 定义 mapping
  3. 批量写入 document
  4. _search 做全文检索
  5. 在全文检索的基础上叠加精确过滤

如果把这套官方示例映射回当前项目,理解会更顺一些:

  • cooking_blog 只是教学用的 index
  • 食谱文档只是教学用的 document
  • matchfilter_search 才是我们真正要掌握的能力

也就是说,这个示例虽然用的是做菜博客场景,但它真正想教会我们的,是 ES 最基础、最通用的工作方式。后面放到「电商问数」项目里,食谱会换成字段值、标签或元数据信息,但底层思路不会变。

如果这组代码能在 Dev Tools 中顺利执行,并且 _search 能查到结果,就说明你已经把 ES 的最小使用链路走通了。

Kibana Dev Tools 中执行 cooking_blog 示例的响应结果

2.7 理解 bulk 和 match

快速入门里还有两个非常常用的 API,需要单独理解一下。

2.7.1 bulk

bulk 用来做批量写入,适合一次性写入多条文档。它的结构第一次看会有点绕,因为它采用的是“操作说明 + 数据本体”交替出现的形式。例如:

1
2
3
4
5
6
7
8
await client.bulk(
operations=[
{"index": {"_index": "my-books"}},
{"name": "1984", "author": "George Orwell"},
{"index": {"_index": "my-books"}},
{"name": "Brave New World", "author": "Aldous Huxley"},
],
)

这里的结构其实很简单:

  • 先说明“我要执行一次 index 写入”
  • 再给出“这次写入的数据”
  • 然后继续下一次操作

2.7.2 match

match 是 ES 中最常见的全文检索查询方式之一。例如:

1
2
3
4
5
6
7
8
await client.search(
index="my-books",
query={
"match": {
"name": "brave"
}
},
)

这段代码表达的意思是:

  • my-books 这个索引中查找
  • 重点搜索 name 字段
  • 查询词是 brave

ES 会根据这个查询词去做文本匹配,并返回最相关的结果。放到「电商问数」项目里,这个思路后面就会变成:在字段值索引里搜索 value;查询词可能是“华北地区”“数码品类”这类自然语言。

2.8 封装 ES 客户端

和前面的 Qdrant 一样,Elasticsearch 客户端也不适合在每次使用时临时创建。更合理的做法是统一封装一个管理器,负责:从配置文件读取 hostport,初始化客户端,在程序退出时关闭客户端,让整个项目共享同一套客户端对象。

项目对应文件路径:shopkeeper-agent/app/clients/es_client_manager.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import asyncio
from typing import Optional

from elasticsearch import AsyncElasticsearch

from app.conf.app_config import ESConfig, app_config


class ESClientManager:
def __init__(self, es_config: ESConfig):
# 保存 ES 配置对象,后面初始化客户端时会从这里读取 host 和 port
self.es_config = es_config
# 先把 client 声明出来,真正初始化放到 init() 中进行
self.client: Optional[AsyncElasticsearch] = None

def _get_url(self):
# 根据配置文件拼出 ES 服务地址
return f"http://{self.es_config.host}:{self.es_config.port}"

def init(self):
# 创建异步 ES 客户端
# hosts 之所以是列表,是为了兼容 ES 常见的集群连接方式
self.client = AsyncElasticsearch(hosts=[self._get_url()])

async def close(self):
# 在程序退出时统一关闭客户端连接
await self.client.close()


# 创建一个全局可复用的 ES 客户端管理器对象
es_client_manager = ESClientManager(app_config.es)

if __name__ == "__main__":
# 先初始化客户端,后面的测试逻辑才能真正访问 ES
es_client_manager.init()

async def test():
# 取出真正的 AsyncElasticsearch 客户端
client = es_client_manager.client

# 创建索引
# 这里同时显式定义了字段结构
# dynamic=False 表示关闭动态映射,要求写入数据必须符合当前定义
await client.indices.create(
index="my-books",
mappings={
"dynamic": False,
"properties": {
# 书名和作者适合做全文检索,所以定义为 text
"name": {"type": "text"},
"author": {"type": "text"},
# 日期字段按日期类型处理
"release_date": {"type": "date", "format": "yyyy-MM-dd"},
# 页数字段按整数处理
"page_count": {"type": "integer"},
},
},
)

# 插入数据
# bulk 采用“操作说明 + 数据本体”交替出现的格式
# 适合一次性写入多条文档
await client.bulk(
operations=[
{"index": {"_index": "my-books"}},
{
"name": "Revelation Space",
"author": "Alastair Reynolds",
"release_date": "2000-03-15",
"page_count": 585,
},
{"index": {"_index": "my-books"}},
{
"name": "1984",
"author": "George Orwell",
"release_date": "1985-06-01",
"page_count": 328,
},
{"index": {"_index": "my-books"}},
{
"name": "Fahrenheit 451",
"author": "Ray Bradbury",
"release_date": "1953-10-15",
"page_count": 227,
},
{"index": {"_index": "my-books"}},
{
"name": "Brave New World",
"author": "Aldous Huxley",
"release_date": "1932-06-01",
"page_count": 268,
},
{"index": {"_index": "my-books"}},
{
"name": "The Handmaids Tale",
"author": "Margaret Atwood",
"release_date": "1985-06-01",
"page_count": 311,
},
],
)

# 搜索
# 在 name 字段上执行 match 查询
# 这里演示的是最基础的全文检索能力
resp = await client.search(
index="my-books",
query={"match": {"name": "brave"}},
)

# 打印查询结果,便于观察 hits 和返回结构
print(resp)

# 测试结束后关闭客户端连接
await es_client_manager.close()

# 运行异步测试函数
asyncio.run(test())

执行文件验证,成功:

1
2
(shopkeeper-agent) didilili@DidililiMacBook-Pro shopkeeper-agent % python3 -m app.clients.es_client_manager
{'took': 5, 'timed_out': False, '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'max_score': 1.2067741, 'hits': [{'_index': 'my-books', '_id': 'SDB_np0BrzSqcXAMSMVk', '_score': 1.2067741, '_source': {'name': 'Brave New World', 'author': 'Aldous Huxley', 'release_date': '1932-06-01', 'page_count': 268}}]}}

ESClientManager 测试代码运行成功后的返回结果示意

2.8.1 这一层封装在做什么

这个文件实际上也在完成两层工作:

  1. 封装一个全局可复用的 ES 异步客户端
  2. 用文件底部的测试代码演示 ES 的最小使用流程

2.8.2 _get_url()、init()、close() 说明

先看上半部分的管理器代码。它和前面的 QdrantClientManager 思路是一致的,核心也是三件事:

  1. 保存 ES 配置
  2. init() 中创建异步客户端
  3. close() 中关闭客户端

这里可以重点看下面几个位置:

  • self.es_config:保存配置对象,里面有 hostportindex_name 等参数
  • _get_url():把配置中的地址拼成完整 URL,例如 http://localhost:9200
  • init():创建 AsyncElasticsearch 客户端
  • close():在程序退出或生命周期结束时关闭连接
  • es_client_manager = ESClientManager(app_config.es):创建全局可复用的客户端管理器对象

这里最重要的结论其实和 Qdrant 一样:整个项目只保留这一份 ES 客户端管理器,后面其他地方都复用它。这样可以避免重复创建连接,也能让基础服务层的管理方式保持一致。

2.8.3 文件底部的测试代码演示了怎样的最小链路

再看下半部分的测试代码,它对应的就是 ES 的最小使用流程:

1
2
3
4
初始化客户端
-> 创建 index
-> 写入 document
-> search 查询文档

如果按动作拆开看,主要就是下面这几步:

  • client.indices.create(...):创建一个名为 my-books 的索引,并显式指定字段结构
  • client.bulk(...):批量写入多条文档
  • client.search(...):使用 match 查询在 name 字段里搜索 "brave"
  • await es_client_manager.close():在测试结束后关闭客户端,避免留下未关闭连接

2.8.4 关键参数理解

另外,因为这里用的是异步客户端,所以测试代码也要遵循和前面 Qdrant 一样的规则:

  • 具体测试逻辑写在 async def test()
  • 调用客户端方法时通过 await
  • 最外层用 asyncio.run(test()) 启动整个异步流程

这一段虽然是一个演示示例,但它已经把 ES 的核心使用方式串起来了:先有索引结构,再写入文档,最后根据查询条件检索数据。

你可能会问,为什么官方示例有时会传 api_key,而我们这里的代码只传了 hosts?原因是教学项目当前使用的是本地 Docker 环境,并且关闭了安全认证,所以本地开发时只需要连接地址即可。也就是说,这一版代码更接近:单节点本地开发环境,关闭认证,方便快速调试。

如果放到更完整的生产环境里,ES 往往会:

  • 以集群方式部署,而不是单节点
  • 开启安全认证
  • 额外提供 api_key 或其他认证信息

这里还可以继续往下理解:

  • hosts 之所以是复数写法,不是随便起的名字,而是因为 Elasticsearch 天然支持集群部署
  • 一个 ES 集群可以包含多个节点,每个节点通常运行在不同的服务器或容器上
  • 一个 index 里的数据,可以按 分片(shard) 分散到多个节点上存储
  • 为了提高可用性,还可以给这些分片再准备 副本(replica)

所以从运维视角看,ES 客户端不只是“连某一台服务器”,而是可能在和一组节点组成的集群打交道。

不过这一层在当前项目里先知道结论即可:

  • 我们这个教学环境是 单节点
  • 所以 hosts 虽然是列表,但里面通常只有一个地址
  • 分片、副本、故障转移这些能力,是 ES 作为分布式搜索引擎本身具备的背景能力
  • 当前这章的重点仍然是“把客户端接进项目并完成基础使用”,不是深入讲集群运维

因此,这里用 hosts=[self._get_url()],并不是说 ES 只能这么连,而是因为当前教程环境做了简化。

这里还要再往前走一步,理解它在真实项目里的用途。上面的 my-books 示例主要是帮助我们快速看懂客户端写法;真正放到「电商问数」项目里,ES 更重要的作用是为字段取值建立全文索引。

也就是说,真实项目里写进去的数据不会是书名和作者,而更可能是这种结构:

1
2
3
4
5
{
"id": "value_001",
"value": "华北地区",
"column_id": "dim_region.region_name"
}

这样当用户问题里出现“华北地区”这类自然语言表达时,系统就可以通过 ES 找到真实字段取值,再把它反馈给后面的 SQL 生成流程。也就是说,示例里的 my-books 只是一个教学用数据集,项目真正要复用的,是这套 封装客户端 -> 建立索引 -> 写入文档 -> 执行检索 的基本思路。这里和 第 6 章《MySQL、Embedding 接入与日志管理》 中的 Embedding 服务接入是前后衔接的。


本章小结:

  • Qdrant 负责向量存储与语义相似度召回,核心概念是 collection / point / vector / payload
  • Elasticsearch 负责全文检索与字段值匹配,核心概念是 index / document / field / mapping
  • 两者在项目里并不是二选一,而是分别从语义层文本层为后续问数流程提供候选结果。
  • 从代码组织上看,项目通过 QdrantClientManagerESClientManager 统一管理异步客户端,这和前一篇讲到的配置体系是完全衔接的。

下一篇会继续补齐另外几块基础服务:MySQL、Embedding 与日志管理。其中 MySQL 负责结构化存储,Embedding 负责向量生成,日志则负责把整套后端运行过程变得可观察、可排查。