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,市面上常见的服务端组件还可以大致分成几类:
- 通用模型服务平台 例如
Xinference、Ollama、BentoML、Ray Serve - 偏大模型推理框架 例如
vLLM、TGI、SGLang、LMDeploy、Triton Inference Server - 云厂商托管服务 例如
OpenAI Embeddings API、Azure 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 模型共同继承的基类User、Address:是程序里的类,同时也分别对应数据库里的两张表__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())
这个管理器主要做了四件事:
- 保存数据库配置
- 创建
Engine - 创建
Session工厂 - 在程序退出时关闭连接池
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",
)
这段日志封装代码主要是在统一四类能力:
- 定义统一日志格式
- 决定日志输出到控制台还是文件
- 根据配置文件启用或关闭不同输出通道
- 给每条日志自动补充
request_id
整体逻辑:
移除默认配置 -> 判断是否启用 console -> 判断是否启用 file -> 分别添加自己的日志输出规则
- logger.remove() 先把默认日志配置移除掉
- 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 - 同时应用前面配置好的级别、格式、切分策略和保留策略
这块的设计意图其实很明确:不是让日志系统只能工作,而是让它能长期稳定地工作。

浙公网安备 33010602011771号