MySQL和Embedding

当前项目选择的不是“在业务代码里直接加载 Embedding 模型权重”,而是: 服务端组件:Text Embeddings Inference,简称 TEI 嵌入模型:BAAI/bge-large-zh-v1.5 Text Embeddings Inference,通常简称 TEI。它本质上就是一个专门用来部署 Embedding 模型、并通过 HTTP 接口对外提供向量推理能力的服务框架。

也就是说:

  • BAAI/bge-large-zh-v1.5 是真正负责把文本转成向量的模型
  • TEI 负责把这个模型跑起来,并暴露成一个可调用的服务
  • EmbeddingClientManager 负责在项目里把这层服务接进来

在当前项目中,TEI 还是以一个独立服务的方式运行的。更具体地说,它不是写在后端代码里的普通 Python 类,而是作为这套项目 Docker 基础服务环境中的一个容器服务启动起来,后端再通过配置里的 host 和 port 去调用它。

常见服务端组件说明:

当前项目使用的服务端组件是 Text Embeddings Inference(TEI)  

除了 TEI,市面上常见的服务端组件还可以大致分成几类:

  • 通用模型服务平台 例如 XinferenceOllamaBentoMLRay Serve
  • 偏大模型推理框架 例如 vLLMTGISGLangLMDeployTriton Inference Server
  • 云厂商托管服务 例如 OpenAI Embeddings APIAzure OpenAI阿里云百炼火山方舟百度千帆AWS Bedrock

如果只从当前项目的需求出发,这里最重要的判断是:

  • 如果希望本地或自托管部署 Embedding 服务TEI 是很贴合的选择
  • 如果希望统一托管多类模型服务Xinference 这类平台会更常见
  • 如果希望直接调用云端 Embedding API,则会走各家云厂商或模型服务商提供的托管接口

所以这一章用 TEI,不是因为它是唯一方案,而是因为它最贴合当前项目“部署 Embedding 服务 -> 暴露 HTTP 接口 -> 后端统一接入”这条链路。

TEL的访问方式:

TEI 的推理访问主要有 3 种方式

  • 直接调用原生 HTTP 接口:例如用 curl 直接请求 /embed
  • 使用 Hugging Face 的 Python 客户端:也就是 huggingface_hub 里的 InferenceClient
  • 使用 OpenAI 兼容方式访问:也就是把 TEI 暴露出来的接口当成 OpenAI 风格的 /v1/embeddings 来调用

但在当前项目里,并没有直接采用这些原生调用方式,而是选择了更贴近整套教程技术栈的方案:使用 LangChain 提供的 HuggingFaceEndpointEmbeddings

 

封装Embedding客户端

import asyncio
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
        # 保存 Embedding 服务配置,供 init() 时组装服务访问地址使用
        self.config = config

    def _get_url(self):
        # 当前项目通过 host + port 访问外部已启动的 Embedding 推理服务
        return f"http://{self.config.host}:{self.config.port}"

    def init(self):
        # 在应用启动阶段显式调用,完成真正的客户端初始化
        self.client = HuggingFaceEndpointEmbeddings(model=self._get_url())
        # 值得注意的是:model= 传进去的并不是 Hugging Face 上的模型名称,而是本地已经部署好的 Embedding 服务地址。
        
# 模块级单例,供其他模块按需复用同一个客户端管理器
embedding_client_manager = EmbeddingClientManager(app_config.embedding)   

if __name__ == "__main__":
    # 本地调试入口:初始化客户端后执行一次最小化向量化调用
        embedding_client_manager.init()
        client = embedding_client_manager.client
        async def test():
            # 使用示例文本验证 Embedding 服务是否可正常响应
            text = "What is deep learning?"
            query_result = await cliet.aembed_query(text)
             # 只打印前 3 个维度,便于快速确认返回结果结构正确
            print(query_result[:3])
        
        # 运行调试测试
        asyncio.run(test())


        

也就是说,这个客户端的调用方式可以理解成:

  • 不直接去联网访问 Hugging Face
  • 不自己加载模型权重
  • 而是把请求发给本地已经启动好的 TEI 服务

这也是为什么这一层更像“服务接入”,而不是“模型训练”或“模型加载”。

MySql客户端为什么是两套

答案很简单,因为它们承担的是两种不同职责:

配置项 连接到哪里 主要职责
db_meta 元数据库 保存表信息、字段信息、指标信息、字段与指标关系等元数据
db_dw 数仓模拟库 提供真实业务查询要访问的数据,用来验证 SQL、执行 SQL

一个负责“系统知道库里有什么”,一个负责“系统最终去查什么”。

 

SQLAIChemy

  • ORM:数据库表和 Python 类之间的翻译层。你在代码里操作类和对象,ORM 会把这些操作翻译成数据库能执行的 SQL。
  • Engine:程序连接数据库的总入口,负责和数据库建立连接,并维护一组可复用的连接。
  • Session:一次具体数据库操作的工作窗口,这一轮查询、写入和提交通常都通过它完成。

案例:

# 官方示例为了学习方便,使用的是:同步写法,SQLite 内存数据库。
# 而我们的项目最终使用的是:异步写法,MySQL。也就是说,场景不同,但核心概念是一致的

from typing import List, Optional

from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "user_account"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(30))
    fullname: Mapped[Optional[str]]

    addresses: Mapped[List["Address"]] = relationship(
        back_populates="user",
        cascade="all, delete-orphan",
    )


class Address(Base):
    __tablename__ = "address"

    id: Mapped[int] = mapped_column(primary_key=True)
    email_address: Mapped[str]
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))

    user: Mapped["User"] = relationship(back_populates="addresses")

理解它在表达什么:

  • Base:是所有 ORM 模型共同继承的基类
  • UserAddress:是程序里的类,同时也分别对应数据库里的两张表
  • __tablename__:指定这个类映射到数据库中的哪张表
  • mapped_column(...):定义字段类型、主键等信息
  • relationship(...):用来描述表和表之间的关联关系

接着,创建 Engine

from sqlalchemy import create_engine

engine = create_engine("sqlite://", echo=True)

Engine 不是“某次查询”,它更像数据库连接层的核心对象,底层会帮我们维护连接池

再往后,直接根据模型去建表:

Base.metadata.create_all(engine)

根据前面声明好的模型,自动生成对应的数据表结构。不过这里也要顺手提醒一下:在我们的项目里,并没有走这条“由 ORM 自动建表”的路线。因为当前项目的数据库和表结构在前面的环境准备阶段就已经初始化好了,所以这里更多是帮助你理解 SQLAlchemy 的能力边界,而不是项目运行时真正依赖的建表方式。

然后是写数据:

from sqlalchemy.orm import Session
with Session(engine) as session:
    spongebob = User(
        name="spongebob",
        fullname="Spongebob Squarepants",
        addresses=[Address(email_address="spongebob@sqlalchemy.org")],
    )
    sandy = User(
        name="sandy",
        fullname="Sandy Cheeks",
        addresses=[
            Address(email_address="sandy@sqlalchemy.org"),
            Address(email_address="sandy@squirrelpower.org"),
        ],
    )
    patrick = User(
        name="patrick",
        fullname="Patrick Star"
    )
    session.add_all([spongebob, sandy, patrick])
    session.commit()
  • 往数据库写数据时,真正和数据库交互的是 Session
  • ORM 的写法看起来像在操作 Python 对象,但底层最终还是会生成 SQL 并发给数据库

最后,做一次简单查询:

from sqlalchemy import select

session = Session(engine)

stmt = select(User).where(User.name.in_(["spongebob", "sandy"]))

for user in session.scalars(stmt):
    print(user)

这段代码表达的是:

  • 先构造一条查询语句 stmt
  • 再通过 Session 执行这条语句
  • 最终把查出来的 ORM 对象遍历出来

这里你也能看到,官方 quickstart 更偏向展示 ORM 风格的查询写法。这里的 ORM 风格,可以理解成:不自己手写 SQL 字符串,而是用 Python 里的类、属性和方法去表达“我要查什么”。但真正落到当前项目时,并不会强制把所有查询都写成这种形式。因为对于复杂 SQL,尤其是数据仓库查询场景,直接写原生 SQL 往往更直观,也更方便控制细节

封装MySQL客户端:

补一个课程里容易被忽略、但代码里已经真实用到的点:asyncmy 是当前项目使用的 MySQL 异步驱动。更具体地说,SQLAlchemy 负责提供数据库抽象层,而真正和 MySQL 异步通信的底层驱动,是 asyncmy

import asyncio

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, config: DBConfig):
        # Engine 是数据库连接层核心对象,底层会维护连接池
        self.engine: AsyncEngine | None = None
        # session_factory 用来按需创建新的 AsyncSession
        self.session_factory = None
        # 保存数据库配置,后面拼接连接地址要用
        self.config = config

    def _get_url(self):
        # mysql+asyncmy 表示:连接 MySQL,并使用 asyncmy 作为异步驱动
        return f"mysql+asyncmy://{self.config.user}:{self.config.password}@{self.config.host}:{self.config.port}/{self.config.database}?charset=utf8mb4"

    def init(self):
        # 创建异步 Engine,相当于先把“数据库连接能力”准备好
        self.engine = create_async_engine(
            self._get_url(), pool_size=10, pool_pre_ping=True
        )
        # 基于 Engine 创建 Session 工厂,后面真正查库时再拿 session
        self.session_factory = async_sessionmaker(
            self.engine, autoflush=True, expire_on_commit=False
        )

    async def close(self):
        # 程序结束时释放连接池资源
        await self.engine.dispose()

# 一套连元数据库,一套连数仓模拟库
meta_mysql_client_manager = MySQLClientManager(app_config.db_meta)
dw_mysql_client_manager = MySQLClientManager(app_config.db_dw)


if __name__ == "__main__":
    # 这里演示的是数仓库查询,所以先初始化 dw 这一套客户端
    dw_mysql_client_manager.init()
    async def test():
        # 通过 session_factory 创建一次数据库会话
        # 这里的 session_factory 可以看作:一个专门用来批量、统一、稳定地创建 AsyncSession 的工厂
        async with dw_mysql_client_manager.session_factory() as session:
            sql = "select * from fact_order limit 10"
            # text(sql) 表示把原生 SQL 语句交给 SQLAlchemy 执行
            result = await session.execute(text(sql))

            # mappings().fetchall() 会把结果转成“按列名访问”的行对象列表
            rows = result.mappings().fetchall()

            # 下面三行只是为了帮助观察返回结果的结构
            print(type(rows))
            print(type(rows[0]))
            print(rows[0]["order_id"])

    asyncio.run(test())

这个管理器主要做了四件事:

  1. 保存数据库配置
  2. 创建 Engine
  3. 创建 Session 工厂
  4. 在程序退出时关闭连接池

Engine 参数 和 Session 参数 

  Engine:

self.engine = create_async_engine(
    url=self._get_url(),
    pool_size=10, # 表示连接池里默认维持多少个可复用连接。这个值不是越大越好,而是要结合你的并发量、数据库承载能力和部署环境去权衡
    pool_pre_ping=True, # 表示在取出连接时,先做一次可用性检测。因为数据库服务端有时会主动断开长时间闲置的连接。从连接池里取到一个“看起来还在、实际上已经失效”的连接,后续查询就会报错
)

  Session:

self.session_factory = async_sessionmaker(
    self.engine,
    autoflush=True, # 在执行查询之前,框架会自动帮我们做一次 flush,避免出现“明明前面已经 add 了对象,但后面查询时还看不到”的困惑
    expire_on_commit=False, # 你后面再次访问对象属性时,ORM 可能会尝试重新查数据库。但在异步项目里,这种隐式重新查询并不适合作为默认行为,所以这里显式设成 False,是为了让使用过程更稳定、更容易预测
    autobegin=True, # 表示需要时自动开启事务。这样在大多数业务代码里,不需要每次都手动先写一段“开始事务”的样板代码。
)
# 顺手再区分一下:
# flush:把当前 Session 中待写入的变更同步到数据库连接层
# commit:在 flush 的基础上真正提交事务
"""
几个参数合在一起看,它们其实分别在解决三类问题:
    pool_pre_ping=True:解决连接池里旧连接失效的问题
    autoflush=True:解决“写了对象但查询看不到”的一致性体验问题
    expire_on_commit=False:解决异步场景下对象属性访问的不确定性问题

"""

日志管理:

from loguru import logger

logger.info("starting")
logger.warning("warning")
logger.error("error")

直接运行后,就能看到带时间、级别、位置、颜色的日志输出。

配置文件中的日志描述:

logging:
  file:    # 输出到日志文件
    enable: true # 决定输出通道是否启用
    level: INFO # 设定这个输出通道的最低日志级别
    path: logs # 指定日志文件目录
    rotation: "10 MB" # 指定多大体积后切分新文件
    retention: "7 days"  # 指定日志保留多久 
  console:   # 输出到控制台
    enable: true
    level: INFO

封装日志配置:

  

import sys
from pathlib import Path

from loguru import logger

from app.conf.app_config import app_config
from app.core.context import request_id_ctx_var

# 统一日志展示格式,包含时间、级别、请求 ID 和调用位置等关键信息
log_format = (
    "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
    "<level>{level: <8}</level> | "
    "<magenta>request_id - {extra[request_id]}</magenta> | "
    "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
    "<level>{message}</level>"
)
"""
其中几个字段最重要:

{time:YYYY-MM-DD HH:mm:ss.SSS}:时间
{level: <8}:日志级别,并按固定宽度对齐
{name}:模块名
{function}:函数名
{line}:行号
{message}:具体日志内容
{extra[request_id]}:额外注入的请求标识
"""

# 通过 Loguru patch 钩子,把上下文中的 request_id 写入每条日志的 extra 字段
def inject_request_id(record):
    request_id = request_id_ctx_var.get()
    record["extra"]["request_id"] = request_id
    
# 移除 Loguru 默认的输出目标,避免和项目自定义配置重复打印
logger.remove()

# 生成带 request_id 注入能力的 logger,后续业务代码统一使用这个实例
logger = logger.patch(inject_request_id)

# 根据配置决定是否输出控制台日志,适合本地开发和容器标准输出采集
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",
    )    

这段日志封装代码主要是在统一四类能力:

  1. 定义统一日志格式
  2. 决定日志输出到控制台还是文件
  3. 根据配置文件启用或关闭不同输出通道
  4. 给每条日志自动补充 request_id

整体逻辑:

移除默认配置 -> 判断是否启用 console -> 判断是否启用 file -> 分别添加自己的日志输出规则

  1. logger.remove()  先把默认日志配置移除掉
  2. logger.add(...)  把我们自己的输出通道加回来

这套日志能力的核心目标就是“可配置”。不是写死“永远打印到控制台”,也不是写死“永远输出到文件”,而是让用户通过配置文件决定到底要启用哪些输出通道。

 request_id注入机制:

from contextvars import ContextVar

request_id_ctx_var = ContextVar("request_id", default="1")

在日志模块里,通过下面这段逻辑把它写进日志的 extra 字段:

def inject_request_id(record):
    request_id = request_id_ctx_var.get()
    record["extra"]["request_id"] = request_id

这一层非常重要。在并发场景下,如果多个请求同时在跑,日志会交错在一起。如果没有一个统一标识,你很难把“同一个请求”的所有日志串起来。

所以 request_id 的作用可以概括成一句话:用来把同一次请求相关的所有日志串成一条链路。

文件日志输出策略

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",
)

它表达的意思是:

  • 先从配置文件里读取日志目录
  • 如果目录不存在,就自动创建
  • 再把日志写到这个目录下的 app.log
  • 同时应用前面配置好的级别、格式、切分策略和保留策略

这块的设计意图其实很明确:不是让日志系统只能工作,而是让它能长期稳定地工作。

posted @ 2026-05-21 17:33  幻影之舞  阅读(7)  评论(0)    收藏  举报