Qdrant 和 Elasticsearch
Qdrant 更适合语义相似度召回,ES 更适合全文匹配和精确过滤
在「电商问数」里,这几个基础设施的分工可以概括成下面这张表:
| 组件 | 在项目中的核心职责 | 最擅长解决的问题 |
|---|---|---|
MySQL |
存储权威结构化元数据与数仓数据 | 表、字段、指标、关系、样例值等结构化信息怎么保存 |
Qdrant |
存储字段和指标的向量索引 | “语义上谁更像” |
Elasticsearch |
存储字段取值的全文索引 | “文本上谁能匹配上” |
用户自然语言问题
-> Embedding 服务把文本转成向量
-> Qdrant 召回语义相关的字段 / 指标
-> Elasticsearch 检索可能相关的字段取值
-> MySQL 补全表结构、字段定义、指标定义
-> LLM 基于上下文生成 SQL
Qdrant
一个专门用于保存向量并执行相似度检索的数据库。
更准确的说法应该是:
- 字段信息会被向量化后写入
Qdrant - 指标信息也会被向量化后写入
Qdrant - 用户问题到来时,同样会先被向量化
- 再用“问题向量”去搜索最相近的字段和指标
也就是说,Qdrant 在这里并不承担“保存全部元数据”的职责,而是承担:在海量字段和指标候选中,先快速缩小语义问题空间
这一点非常关键。因为企业问数场景里,表和字段通常很多,如果没有这一步语义召回,大模型需要面对的候选上下文会非常多,后面的 SQL 生成更容易跑偏。
区分OLTP,OLAP,向量数据库
| 类型 | 典型代表 | 主要用途 | 数据组织方式 |
|---|---|---|---|
OLTP 数据库 |
MySQL、PostgreSQL、Oracle |
支撑日常业务交易 | 通常以表的行列结构为主,更偏按行读写 |
OLAP 数据库 |
Hive、ClickHouse、Doris |
支撑统计分析和报表计算 | 也是表结构,但更适合分析型查询,常见按列存储 |
| 向量数据库 | Qdrant |
支撑语义相似度检索 | 以向量点为核心数据单位 |
如果再压缩成一句最容易记忆的话,可以这样区分:
OLTP(Online Transaction Processing)更偏业务数据库,适合查询某一行OLAP(Online Analytical Processing)更偏分析型数据库,适合查询某一列- 向量数据库更偏语义相似度检索
把这张图再映射回「电商问数」项目,分工就更清楚了:
MySQL负责存元数据和数仓模拟数据Qdrant负责语义相似度召回,根据向量相似度召回相关字段和指标Elasticsearch不等同于这里的OLAP,它更偏搜索引擎和全文检索系统;在本项目里主要负责字段取值检索
Qdrant核心概念
Qdrant 官方对 collection 的描述很清楚:同一个 collection 里的 points 使用相同维度,并按同一种距离度量进行比较。
| 概念 | 可以怎样理解 |
|---|---|
collection |
一组向量点的集合,可以类比成“同类向量的一张表” |
point |
集合中的一条记录 |
vector |
这条记录对应的向量 |
payload |
跟着向量一起保存的业务字段 |
distance |
用什么方式比较“像不像” |
Qdrant快速入门:
初始化客户端
-> 创建 collection
-> 写入 points
-> query_points 查询相似向量
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"},
),
]
)
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())
"""
create_collection(...):创建集合,并声明维度与距离度量
upsert(...):按 id 更新或插入 points
query_points(...):根据查询向量返回最相关的点
Qdrant 官方 API 文档里,query_points 还支持 score_threshold、limit、filter、with_payload、with_vector 等参数。当前项目最常用的是:
score_threshold:控制最低召回分数
limit:控制返回条数
with_payload:控制是否把业务字段一起带回
Qdrant 自带可视化页面,通常可以通过 http://localhost:6333/dashboard 打开控制台页面,查看当前有哪些 collection、点数据是否写入成功
"""
封装Qdrant客户端:
并不是在每个地方都临时 new 一个客户端,而是专门写了一个 QdrantClientManager。这样做主要是为了统一处理这几件事:客户端初始化,客户端关闭,读取配置文件中的服务地址,保持项目里只使用同一套客户端对象
还有一个很重要的演进点:前面快速入门时,示例代码先用同步客户端帮助我们理解流程;但真正放到项目里时,更合适的做法是改成异步客户端
原因也不复杂:
Qdrant的读写本质上是网络I/O- 网络请求发出去之后,程序往往需要等待服务端响应
- 如果使用异步客户端,这段等待时间就可以把
CPU让给其他协程
项目最终采用 AsyncQdrantClient,本质上是为了和整套异步后端保持一致,减少阻塞,提高整体并发利用率
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 = 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 = awiat client.query_points(
collection_name="my_collection",
query=[random.random() for _ in range(10)], # type: ignore
limit=10,
score_threshold=0.8,
)
print(res)
# 运行异步测试函数
asyncio.run(test())
使用流程 :
- 判断
collection是否存在 - 如果不存在,就调用
create_collection(...) - 通过
upsert(...)写入一批向量点 - 通过
query_points(...)做相似度查询
把这四步看懂,这个文件就理解了一大半。
collection_name="my_collection":指定当前操作的是哪一个集合models.VectorParams(size=10, distance=models.Distance.COSINE):指定向量维度和距离计算方式models.PointStruct(...):表示单个点数据,里面至少会有id和vectorlimit=10:表示最多返回前 10 个最相近结果score_threshold=0.8:表示只保留分数不低于 0.8 的结果
Elasticsearch:
核心的职责是:为字段取值建立全文索引,让系统能把用户的自然语言表达映射到真实存在的值域
在 Elasticsearch 里,先掌握四个词:
| 概念 | 可以怎样理解 |
|---|---|
index |
一组同类文档的逻辑容器 |
document |
index 中的一条 JSON 文档 |
field |
document 中的一个字段 |
mapping |
定义字段结构、字段类型以及索引行为 |
组织数据的方式
- MySQL 更偏结构化记录
- Qdrant 更偏向量点
- Elasticsearch 更偏 JSON 文档
{
"id": "v_001",
"value": "华北地区",
"column_id": "dim_region.region_name"
}
在这个例子里:
- 整个 JSON 对象就是一个
document id、value、column_id就是field- 这些文档会被存进某个
index
index 在这里是名词,表示一个索引容器;而不是后面 API 里的那个“写入动作”。另外,Elasticsearch 返回结果里经常会出现一些以下划线开头的字段,例如 _index、_id、_source,这些属于元数据字段,属于 ES 自带的字段
这里先做一个简要对应:
_index表示这条数据属于哪个索引_id表示这条数据在索引中的唯一标识_source里存放的,才是我们真正写进去的业务数据
mapping
本质是 Elasticsearch 里的“表结构说明
mapping 是 Elasticsearch 里的说法,而 schema 是很多数据库或数据系统里更常见的说法。它们在这里表达的核心意思是一样的:用一套结构定义,描述这一类数据有哪些字段、字段是什么类型、应该按什么方式被索引和存储。
还要特别区分两种常见方式:
dynamic mapping:让ES根据写入的数据自动推断字段类型explicit mapping:由我们显式定义字段结构和字段类型
text和keyWord区别:
mapping 不只是定义“有哪些字段”,还要定义“字段的数据类型是什么”。而 text 和 keyword,就是 ES 里两种非常常见、但处理方式完全不同的字段类型。
也就是说,
- 先有
index index里存的是documentdocument里有很多fieldmapping要定义这些field的类型- 当字段类型是字符串时,最常见的两种类型就是
text和keyword
text 类型更偏全文检索。它适合保存一段可以被自然语言搜索的文本内容。例如:"华北地区"、"数码品类"、"近三个月销售额"。这类值如果定义成 text,用户在查询时不一定非得一模一样地输入整串文本,而是可以通过自然语言方式去匹配它们。
而keyword 更偏精确匹配。它不会像 text 那样做分词,更适合存那些“必须按原值整体处理”的字段。例如:主键 id、字段标识 column_id、状态码、类别编码。这类值通常不是拿来做全文搜索的,而是要么相等,要么不相等,所以更适合定义成 keyword。
字段类型不同,ES 后续建立索引和执行检索的方式也不同。这正是 mapping 为什么重要的原因。我们并不是随便把字段声明成某个类型,而是在提前告诉 ES:这个字段将来打算怎么被搜索。
放到「电商问数」项目里,这个区别就非常具体了:字段取值里的 value 适合定义为 text。因为用户会用自然语言去搜它,例如“华北地区”“数码品类”。id 和 column_id 更适合定义为 keyword。因为它们更像结构化标识,不需要分词,只需要精确匹配
BULK和match
bulk 用来做批量写入,适合一次性写入多条文档。它的结构第一次看会有点绕,因为它采用的是“操作说明 + 数据本体”交替出现的形式。例如:
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写入” - 再给出“这次写入的数据”
- 然后继续下一次操作
match 是 ES 中最常见的全文检索查询方式之一。例如:
await client.search(
index="my-books",
query={
"match": {
"name": "brave"
}
},
)
这段代码表达的意思是:
- 在
my-books这个索引中查找 - 重点搜索
name字段 - 查询词是
brave
封装ES客户端:
合理的做法是统一封装一个管理器,负责:从配置文件读取 host 和 port,初始化客户端,在程序退出时关闭客户端,让整个项目共享同一套客户端对象。
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())
这里最重要的结论其实和 Qdrant 一样:整个项目只保留这一份 ES 客户端管理器,后面其他地方都复用它。这样可以避免重复创建连接,也能让基础服务层的管理方式保持一致。
如果按动作拆开看,主要就是下面这几步:
client.indices.create(...):创建一个名为my-books的索引,并显式指定字段结构client.bulk(...):批量写入多条文档client.search(...):使用match查询在name字段里搜索"brave"await es_client_manager.close():在测试结束后关闭客户端,避免留下未关闭连接
小结:
- Qdrant 负责向量存储与语义相似度召回,核心概念是
collection / point / vector / payload。 - Elasticsearch 负责全文检索与字段值匹配,核心概念是
index / document / field / mapping。 - 两者在项目里并不是二选一,而是分别从语义层和文本层为后续问数流程提供候选结果。

浙公网安备 33010602011771号