Langgraph项目 一(mysql,es,qdrant,embedding模块)

项目总览:

数据库表可以通过hive转为数据仓库的维度表,我们只需要写sql,就可以通过hive转为可以查数据仓库的语句。

基于事实表+维度表。

比如用户下单这一个事实(业务事件),用户a,在北京,晚上12点,下单了2个篮球,总共花费120元。

转换成事实表就是:

sql 复制代码
order_id //事实表id
user_id //用户id
localtion_id //地点id
date_id //时间id
produce_id //商品id 
order_quantity // 数量
order_amount // 花费

维度就是,时间维度,用户维度,地点维度,商品维度,分别对应上述的id。

比如用户维度

python 复制代码
user_id
user_name
grader //性别
member_level //级别

除了用户下单这一事实表,还可能有加入购物城等,他们可能共用这些维度表。

项目效果:

输入统计各地区销量排名前三的商品

那么得 select地区表 join 事实表 + join 商品维度表 + count(order_amount) + order排序 得到。

我们可以通过大模型转为sql直接查询数据库数据内容。

思路:
  • 1 将所有数据库表的名字+字段描述统统作为prompt交给大模型,让大模型自己分析;这样做有缺点:如果表太多了,字段太多了,都作为prompt导致上下文太长,如果提示词太多,包含太多无关信息,大模型会对其中的某个字段"忘记",导致解析报错。
  • 2 正确思路:使用RAG,建立一下metadata元数据的知识库,里面包含数仓里面的信息和表的字段描述(记录有哪些表,是事实表还是维度表,有哪些字段等),然后给予问题,去知识库里面,检索跟问题密切的表相关信息,将这些表信息作为上下文交给LLm,让其生成sql。解决上下文太长问题。

基于Langgraph的数据仓库,以数据仓库的元数据为核心,使用mysql存储结构话的元数据信息,结合Odrant构建语义向量检索,Elasticsearch构建全文索引,形成统一的元数据知识库,查询过程中,根据用户的自然语言问题进行多路召回,筛选相关表,字段以及指标定义,将元数据信息和用户问题,共同作为prompt传给大模型,生成SQL,最终完成自动查询与结果返回,确保生成的准确性和可控性。

使用docker启动需要的服务

yaml 复制代码
services:

  mysql:
    image: mysql:8.0
    container_name: mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: xxx
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./mysql:/docker-entrypoint-initdb.d
    command:
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_general_ci

  elasticsearch:
    build: ./elasticsearch
    container_name: elasticsearch
    restart: unless-stopped
    environment:
      discovery.type: single-node
      xpack.security.enabled: "false"
      ES_JAVA_OPTS: "-Xms512m -Xmx512m"
    ports:
      - "9200:9200"
    volumes:
      - es_data:/usr/share/elasticsearch/data
  kibana:
    image: kibana:8.19.10
    container_name: kibana
    restart: unless-stopped
    environment:
      ELASTICSEARCH_HOSTS: http://elasticsearch:9200
    ports:
      - "5601:5601"
    depends_on:
      - elasticsearch

  qdrant:
    image: qdrant/qdrant:v1.16
    container_name: qdrant
    restart: unless-stopped
    ports:
      - "6333:6333"   # HTTP
      - "6334:6334"   # gRPC
    volumes:
      - qdrant_data:/qdrant/storage

  embedding:
    image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.8
    platform: linux/amd64
    container_name: embedding
    restart: unless-stopped
    ports:
      - "8081:80"
    environment:
      - MODEL_ID=BAAI/bge-small-zh-v1.5
      - MAX_CONCURRENT_REQUESTS=16
      - MAX_BATCH_TOKENS=16384
      #- HF_ENDPOINT=https://hf-mirror.com
    volumes:
        - ./embedding_cache:/data

volumes:
  mysql_data:
  es_data:
  qdrant_data:

这里需要几个服务,mysql数据库embedding向量化服务,这里我们选择自己部署向量化模型,数据安全qdrant向量数据库,elasticsearch全文检索

qdrant向量数据库

官网介绍,在传统的 OLTP 和 OLAP 数据库中(如上图所示),数据以行和列的形式组织(这些被称为表),查询是根据这些列中的值执行的。

Qrdant是一个向量数据库,他对比传统OLTP数据库,如msqql,是基于一行一列的。

OLAP数据库,主要是针对于列的,比如查销售总和等数据.

而向量数据库,主要是由id,向量数组,payload(存放一些元数据)组合,可以通过比对向量相似性,获取较为相似的前n条数据。

在包括图像识别、自然语言处理和推荐系统在内的某些应用中,数据通常表示为高维空间中的向量,这些向量,加上一个 id 和一个有效负载,我们称之为。这些点是我们存储在 Qdrant 等向量数据库中的一种称为集合的元素。
集合可以类比为就是

向量相似度最常用的检索相似方式:

最常用的就是余弦相似度。

Qrant架构:

Qrant的概念:

*集合:集合是一组已命名点(带有有效载荷的向量),您可以在其中进行搜索。同一集合中每个点的向量必须具有相同的维度,并且通过单一指标进行比较。命名向量可用于在单个点中包含多个向量,每个向量可以有自己的维度和指标要求,集合就好比mysql的一张表

  • 距离度量:这些用于衡量向量之间的相似性,并且必须在创建集合的同时选择。度量的选择取决于向量的获取方式,特别是将用于编码新查询的神经网络。 选取用什么方式判断
  • :点是 Qdrant 操作的中心实体,它们由一个向量和一个可选的 id 和有效载荷组成。点可以类比为myql的一行数据
    id:向量的唯一标识符。
    向量:数据的高维表示,例如图像、声音、文档、视频等。
    Payload:Payload 是一个 JSON 对象,其中包含您可以添加到向量中的额外数据。
  • 存储:Qdrant 可以使用两种存储选项之一,内存存储(将所有向量存储在 RAM 中,速度最快,因为仅需要磁盘访问才能持久化),或者内存映射存储(创建与磁盘文件关联的虚拟地址空间)。
  • 客户端:可用于连接 Qdrant 的编程语言。Qrant官方提供的qdrant_client,可以通过它操作qrant数据库。
使用demo

因为正常链接等操作是同步的,会阻塞主线程工作,所以我们需要用异步的api

先创建一个manager单例

python 复制代码
import asyncio
from typing import Optional

from qdrant_client import AsyncQdrantClient, models

from app.conf.app_config import QdrantConfig, app_config


class QrantClientManager:
    def __init__(self, qdrant_config: QdrantConfig):
        self.qdrant_config = qdrant_config
        # Optional[a] = a | None
        self.client: Optional[AsyncQdrantClient] = None
        pass
    def _get_url(self):
        return f"http://{self.qdrant_config.host}:{self.qdrant_config.port}"
    def init(self):
        # 使用异步api
        self.client = AsyncQdrantClient(url=self._get_url())
    async def close(self):
        # close是异步的,需要await
        await self.client.close()


qdrant_client_manager = QrantClientManager(app_config.qdrant)

负责进行数据库的统一操作。

测试:

python 复制代码
if __name__ == "__main__":
    # 先初始化连接客户端
    qdrant_client_manager.init()

    async def _test():
        client = qdrant_client_manager.client
        # Create a collection create_collection创建向量
        await client.create_collection(
            collection_name="my_collection",
            # size=4 --- 每个向量的维度是 4(实际项目中通常是 384、768、1024 等,取决于 embedding 模型)
            # 使用余弦相似度来计算向量之间的距离,越接近 1 越相似
            vectors_config=models.VectorParams(size=4, distance=models.Distance.COSINE),
        )

        # Insert a vector upsert插入向量
        await client.upsert(
            collection_name="my_collection",
            points=[
                models.PointStruct(
                    id="5c56c793-69f3-4fbf-87e6-c4bf54c28c26",
                    payload={
                        "color": "red",
                    },
                    vector=[0.9, 0.1, 0.1, 0.5],
                ),
            ],
        )

        # query_points查询
        points = await client.query_points(
            collection_name="my_collection",
            query=[0.9, 0.1, 0.1, 0.5],
            limit=2,
        )

        print(points.points)

    asyncio.run(_test())

如上,create_collection负责创建集合,需要指定集合名,以及向量维度和距离度量,就是vectors_config,上述我们使用了余弦相似度。
upsert负责插入点,而query_points负责查询点,他们都是异步的

结果:

python 复制代码
[ScoredPoint(id='5c56c793-69f3-4fbf-87e6-c4bf54c28c26', version=3, score=1.0,
 payload={'color': 'red'}, vector=None, shard_key=None, order_value=None)]
  • id --- 该向量记录的唯一 ID(UUID 格式)
  • version --- 数据的版本号(第 3 次更新)
  • score=1.0 --- 余弦相似度得分,1.0 表示完全匹配(和查询向量一模一样)
  • payload={'color': 'red'} --- 存储时附带的业务数据
  • vector=None --- 未返回原始向量(默认不返回,节省带宽)
  • shard_key=None --- 未使用分片
  • order_value=None --- 未使用自定义排序

ES客户端管理

我们使用了ES进行全文检索,也就是elasticsearch

Elasticsearch 是一个开源的分布式搜索和分析引擎,专为速度、扩展和 AI 应用而打造。作为一个检索平台,它可以实时存储结构化非结构化和向量数据,提供快速的混合和向量搜索,支持可观测性安全分析,并以高性能高准确性高相关性实现 AI 驱动的应用。

上述我们启动了一个es的docker服务,在生产中,es服务可能有多个。

ES的核心概念

  • Cluter集群,一个或者多个节点的集合,共同保存整个数据,提供跨越所有节点的联合索引和搜索功能。
  • Node 节点:集群的一台服务器,比如上面我们启动的es服务。
  • Index(索引):包含有一对相似结构的文档数据,类比为qrdant的集合
  • Document(文档): Es中的最小数据单元,文档以JSON格式表示(类比为qrdant的点
  • Field(字段):文档中的属性,类比为mysql的每一列
  • Mapping(映射):定义文档中字段的类型(Text,keyword等),类似于表架构schema
  • 分片和复制 :ES提供了将索引划分成多份的能力,这些份就叫做分片。当你创建一个索引的时候,你可以指定你想要的分片的数量。每个分片本身也是一个功能完善并且独立的"索引",这个"索引"可以被放置到集群中的任何节点上。当我你有一份10亿的文档数据,就可以进行分片,放到不同节点去。复制主要是防止一个节点故障导致无法检索,所以对索引进行复制放置不同节点。

主要应用场景:

  • 全文搜索:例如电商网站的商品搜索、维基百科的文章搜索。它支持分词、同义词、拼写纠错、高亮显示等强大的搜索功能。
  • 日志和指标分析:通过 ELK 架构(Elasticsearch, Logstash/Beats, Kibana),集中收集和分析服务器、应用和网络设备的日志与性能指标。
  • 应用性能监控 (APM):监控应用程序的代码级性能问题。
  • 地理空间数据分析:支持处理经纬度等空间数据,常用于打车软件、物流追踪等寻找"附近"资源的场景
使用demo

依然使用异步的api来操作

管理端

python 复制代码
import asyncio
import datetime
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):
        self.es_config = es_config
        self.client: Optional[AsyncElasticsearch] = None
        pass

    def _get_url(self):
        return f"http://{self.es_config.host}:{self.es_config.port}"

    def init(self):
        # 单节点
        self.client = AsyncElasticsearch(hosts=[self._get_url()])
        pass
    async def close(self):
        await self.client.close()


es_client_manager = EsClientManager(app_config.es)

测试

python 复制代码
if __name__ == "__main__":
    es_client_manager.init()

    async def _test():
        client = es_client_manager.client

        # 索引名称
        index_name = "async_products"

        # 判断是否存在,存在先删除
        if await client.indices.exists(index=index_name):
            await client.indices.delete(index=index_name)
            print(f"索引 {index_name} 已删除。")

       # 创建索引
        await client.indices.create(
            index=index_name,
            mappings={
                "dynamic": False, #不要动态推算字段类型
                "properties": {
                    "product_id": {
                        "type": "keyword", # 关键词,不分词
                    },
                    "title": {
                        "type": "text",
                        # 使用标准分析器分词,规则是按空格和标点拆分,并转小写
                        #"analyzer": "standard",
                    },
                    "price": {
                        "type": "double",
                    },
                    "date": {
                        "type": "date",
                        "format": "yyyy-MM-dd"
                    }
                }

            }
        )

        # put_maping插入单条,bluk插入多条
        bulk_resp = await client.bulk(
            operations=[
                {
                    "index": {
                        "_index": index_name,
                    },
                },
                {
                    "product_id": 1,
                    "title": "test_1",
                    "price": 2,
                    "date": "2020-01-01",
                },
                {
                    "index": {
                        "_index": index_name,
                    }
                },
                {
                    "product_id": 2,
                    "title": "test_2",
                    "price": 3,
                    "date": "2020-01-02",
                }
            ],
            refresh=True,
        )

        # 搜索
        resp = await client.search(
            index=index_name,
            query={
                "match": {
                    "title": "test_1"
                }
            }
        )



        for hit in resp["hits"]["hits"]:
            print(hit["_source"])
            print("\n")

        resp1 = await client.search(
                index=index_name,
                query={
                    "term": {
                        "product_id": 2
                    }
                }
            )
        for hit in resp1["hits"]["hits"]:
            print(hit["_source"])
            print("\n")


        resp2 = await client.search(
            index=index_name,
            query={
                "range": {
                    "price": {
                        # <=3
                        "lte": 3
                    }
                }
            }
        )

        for hit in resp2["hits"]["hits"]:
            print(hit["_source"])
            print("\n")

        await es_client_manager.close();

    asyncio.run(_test())

如上,通过client.indices.create创建索引,可以指定mapping,keyword表示精确索搜索,默认不会分词。

通过bluk插入多条数据。

通过search查询数据,match就是全文检索,模糊搜索,而term则是精确搜索,比如搜索关键词keyword那些,range则是对范围的过滤。

python 复制代码
response = await client.search(
            index=index_name,
            query={
                "bool": {
                    "must": [
                        {"term": {"manufacturer": "Apple"}}
                    ],
                    "filter": [
                        {"range": {"price": {"gt": 10000.0}}}
                    ]
                }
            }
        )

如上,bool可以进行复合查询,must表示必须满足条件,且会计算得分sorce。filter则表示需要满足条件,但不会影响得分sorce。

ES 会对 filter 的结果进行缓存,因此执行效率非常高。通常用于价格范围、日期范围、状态过滤等精确条件。

search返回结果

python 复制代码
{'took': 0, 'timed_out': False, 
'_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 

'hits': {'total': {'value': 1, 'relation': 'eq'},
 'max_score': 0.6931471,

 'hits': [{'_index': 'async_products', '_id': '3V0mdZ0BBFefatfR557V', '_score': 0.6931471, '_source': {'product_id': 1, 'title': 'test_1', 'price': 2, 'date': '2020-01-01'}}]}}

字段解析:

  • took: 0 --- 查询耗时 0 毫秒
  • timed_out: False --- 未超时
  • _shards --- 分片信息:1 个分片,全部成功
  • hits.total.value: 1 --- 命中 1 条记录
  • max_score: 0.6931471 --- 最高相关性得分

命中的文档:

  • _index --- 所在索引 async_products
  • _id --- ES 自动生成的文档 ID
  • _score: 0.6931471 --- 该文档的相关性得分(基于 TF-IDF/BM25 算法计算,越高越相关)
  • _source --- 文档原始内容:product_id=1, title=test_1, price=2, date=2020-01-01

有一个最高相似性得分。

Embedding 客户端管理

我们通过docker部署了文本嵌入模型,客户端使用langchain提供的HuggingFaceEndpointEmbeddings

比较简单,直接写clientManager

python 复制代码
from typing import Optional

from langchain_huggingface import HuggingFaceEndpointEmbeddings

from app.conf.app_config import EmbeddingConfig, app_config


class EmbeddingClientManager:
    def __init__(self, config: EmbeddingConfig):
        self.client:Optional[HuggingFaceEndpointEmbeddings] = None
        self.config = config

    def _get_url(self):
        return f"http://{self.config.host}:{self.config.port}"

    def init(self):
        # 使用自己部署的模型
        self.client = HuggingFaceEndpointEmbeddings(model=self._get_url())


embedding_client_manager = EmbeddingClientManager(app_config.embedding)

if __name__ == "__main__":
    embedding_client_manager.init()
    client = embedding_client_manager.client
    text = "What is deep learning?"
    query_result = client.embed_query(text)
    print(f"query_result = {len(query_result)}")

将对应的文字转为向量。

Mysql客户端

Mysql客户端使用SQLAlchemy

SQLAlchemy核心两个:ORM,对象跟表的映射。Core主要关键Engine,Engine就是用来操作数据库的,其中,Engine会维护数据库的连接池。

用法:

上述代码将执行如下sql,这里session.add_all后,不会真正执行sql,只有调用commit之后才会执行sql。

还是建立客户端代码

python 复制代码
import asyncio
from typing import Optional

from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine

from app.conf.app_config import DBConfig, app_config

class MysqlClientManager:
    def __init__(self, db_config: DBConfig):
        self.db_config = db_config
        self.engine: Optional[AsyncEngine] = None
        self.session_factory = None

    def _get_url(self):
        return f"mysql+asyncmy://{self.db_config.user}:{self.db_config.password}@{self.db_config.host}:{self.db_config.port}/{self.db_config.database}?charset=utf8mb4"
    def init(self):
        self.engine = create_async_engine(url=self._get_url(),
                                          # 连接池数量
                                          pool_size=10,
                                          # 从连接池拿到一个连接之后,需要先ping一下保证能通
                                          pool_pre_ping=True)
        # session对象,sqlalchemy用来真正对sql进行处理的对象,async_sessionmaker返回可执行的session对象
        self.session_factory = async_sessionmaker(self.engine,
                                                  # 所有查询操作之前会flush,session写入的时候,并不会马上
                                                  # 写入数据库,这时候使用get肯定是拿不到的,所以需要调用flush一下,提交数据
                                                  autoflush=True,
                                                  # 默认为True,每次commit之后所有实例都会过期
                                                  # 在事务完成后进行所有属性/对象访问都将从最新的数据库状态加载。
                                                  # 异步不支持expire_on_commit
                                                  expire_on_commit=False,
                                                  #
                                                  autobegin=True)


    async def close(self):
            await self.engine.dispose()


# 单一实例
dw_mysql_client_manager = MysqlClientManager(app_config.db_dw)
meta_mysql_client_manager = MysqlClientManager(app_config.db_meta)

这里使用异步创建engine,而且engine得保持单例模式,而session,是SQLAlchemy用来操作数据库的会话,只有创建了session,才能操作对应的数据库,如上async_sessionmaker每次都会返回一个可执行对象,执行后返回一个新的session。

使用demo

python 复制代码
if __name__ == '__main__':
    meta_mysql_client_manager.init()
    

    async def test():
        async with meta_mysql_client_manager.session_factory() as session:
            result = await session.execute(text("select * from table_info limit 10"))
            rows = result.fetchall()
            print(rows)


    asyncio.run(test())

如果不使用with写法,就需要

python 复制代码
session = Session(engine) # 1. 手动创建会话
try:
    spongebob = User(...)
    # ... 中间是一堆业务逻辑
    session.add_all([spongebob, sandy, patrick])
    session.commit()      # 2. 提交事务
except Exception as e:
    session.rollback()    # 3. 发生错误时回滚事务
    raise e
finally:
    session.close()   # 4. 无论是否报错,最后必须手动关闭会话释放资源

使用with写法,可以自动帮助我们close,而且报错的时候,会自动rollback。

日志模块

上述我们的配置文件中有一处

yaml 复制代码
logging:
  file:
    enable: true
    level: INFO
    path: logs
    rotation: "10 MB"
    retention: "7 days"
  console:
    enable: true
    level: INFO

对应的logger模块

python 复制代码
import sys
from pathlib import Path

from loguru import logger

from app.conf.app_config import app_config

log_format = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level>"
)

#  移动默认配置
logger.remove()

# 使用配置文件的配置
if app_config.logging.console.enable:
    # 添加输出到控制台
    logger.add(sink=sys.stdout, level=app_config.logging.console.level, format=log_format)

if app_config.logging.file.enable:
    path = Path(app_config.logging.file.path)
    path.mkdir(parents=True, exist_ok=True)# 创建文件夹
    logger.add(
        sink= path / "app.log",
        level=app_config.logging.file.level,
        format=log_format,
        rotation=app_config.logging.file.rotation,
        retention=app_config.logging.file.retention,
        encoding="utf-8",
    )


if __name__ == "__main__":
    logger.info("测试")
    logger.error("测试error")
    logger.warning("测试warning")

通过配置决定日志打印在哪里,还能设置过期时间,大小,超过大小会自动创建新的

测试:

app.log文件会有:

python 复制代码
2026-04-15 09:46:31.429 | INFO     | __main__:<module>:37 - 测试
2026-04-15 09:46:31.430 | ERROR    | __main__:<module>:38 - 测试error
2026-04-15 09:46:31.430 | WARNING  | __main__:<module>:39 - 测试warning
相关推荐
倒流时光三十年3 小时前
Elasticsearch SearchRequest 构建备忘录
大数据·elasticsearch
弹简特3 小时前
【Linux命令饲养指南】CentOS 安装 MySQL【AI辅助实现】
linux·mysql·centos
Chasing__Dreams3 小时前
Mysql--基础知识点--100-- insert VS select...for update 加锁
数据库·mysql
爱莉希雅&&&4 小时前
MySQL 高可用实战:PXC + HAProxy + Keepalived 完整版笔记
运维·数据库·mysql·haproxy·数据库同步·pxc
船长Talk4 小时前
SQL聚合函数与分组统计:数据分析核心技能
mysql
fly spider4 小时前
MySQL之优化
数据库·mysql·oracle
Elastic 中国社区官方博客5 小时前
多大才算太大?Elasticsearch 容量规划最佳实践
大数据·运维·数据库·elasticsearch·搜索引擎·全文检索
弹简特5 小时前
【Linux命令饲养指南】Ubuntu 安装 MySQL【AI辅助实现】
linux·mysql·ubuntu
试试勇气5 小时前
MySQL--数据库基础
数据库·mysql