Qdrant 和 Elasticsearch

Qdrant 更适合语义相似度召回,ES 更适合全文匹配和精确过滤

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

组件 在项目中的核心职责 最擅长解决的问题
MySQL 存储权威结构化元数据与数仓数据 表、字段、指标、关系、样例值等结构化信息怎么保存
Qdrant 存储字段和指标的向量索引 “语义上谁更像”
Elasticsearch 存储字段取值的全文索引 “文本上谁能匹配上”
用户自然语言问题
  -> Embedding 服务把文本转成向量
  -> Qdrant 召回语义相关的字段 / 指标
  -> Elasticsearch 检索可能相关的字段取值
  -> MySQL 补全表结构、字段定义、指标定义
  -> LLM 基于上下文生成 SQL

Qdrant

一个专门用于保存向量并执行相似度检索的数据库。

更准确的说法应该是:

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

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

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

区分OLTP,OLAP,向量数据库

类型 典型代表 主要用途 数据组织方式
OLTP 数据库 MySQLPostgreSQLOracle 支撑日常业务交易 通常以表的行列结构为主,更偏按行读写
OLAP 数据库 HiveClickHouseDoris 支撑统计分析和报表计算 也是表结构,但更适合分析型查询,常见按列存储
向量数据库 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())           

使用流程 :

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

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

  • collection_name="my_collection":指定当前操作的是哪一个集合
  • models.VectorParams(size=10, distance=models.Distance.COSINE):指定向量维度和距离计算方式
  • models.PointStruct(...):表示单个点数据,里面至少会有 id 和 vector
  • limit=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
  • idvaluecolumn_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 里两种非常常见、但处理方式完全不同的字段类型。

也就是说,

  1. 先有 index
  2. index 里存的是 document
  3. document 里有很多 field
  4. mapping 要定义这些 field 的类型
  5. 当字段类型是字符串时,最常见的两种类型就是 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
  • 两者在项目里并不是二选一,而是分别从语义层文本层为后续问数流程提供候选结果。

 

posted @ 2026-05-21 15:35  幻影之舞  阅读(8)  评论(0)    收藏  举报