04 | 召回前置准备:搭好召回所需的四个数据库
项目地址:github.com/frontzhm/n2...
每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。
这是一篇系列文,请按照顺序阅读。
终态回顾:召回三步分别需要什么
回到流程图,关键词抽取完之后紧接着三路并行召回:
markdown
抽取关键词 → ┌─ recall_column --- 用关键词在字段向量库里搜→ 找到相关字段
├─ recall_metric --- 用关键词在指标向量库里搜→ 找到相关指标
└─ recall_value --- 用关键词在 ES 里搜 → 找到相关枚举值
倒推一下,要跑通这三步,必须提前准备好:
| 召回步骤 | 需要的数据库 | 存什么 | 怎么搜 |
|---|---|---|---|
| recall_column | Qdrant(向量库) | 字段的中文描述向量 | 关键词 → 向量 → 余弦相似度 TOP20 |
| recall_metric | Qdrant(向量库) | 指标的中文描述向量 | 同上 |
| recall_value | Elasticsearch | 维度表的枚举值文本 | 关键词 → 模糊匹配 |
| (所有召回的数据来源) | MySQL 元数据库 | 表结构、字段定义、指标定义 | SQL 精确查询 |
再倒推一层:向量库里的字段/指标向量,以及 ES 里的枚举值,又是从哪来的?
→ 都来自 MySQL 元数据库。元数据库是"数据的源头",存的是"数据库里有哪些表、每个表有哪些字段、每个字段叫什么、有没有别名,还有哪些预定义的指标,以及指标和字段的关系"。向量库和 ES 里的数据,全部从元数据库导入。
所以这篇文章要做的,就是按依赖顺序把这三个库建好:
markdown
① MySQL 元数据库(源头)
↓ 导入数据
② Qdrant 向量库(存字段向量 + 指标向量)
③ Elasticsearch(存维度枚举值)
另外还有 ④ MySQL 数据仓库(dw)------这是最终执行 SQL 的目标库,放业务数据。本文先搭它的空壳表结构,写入一些测试数据,后续执行 SQL 时用。
这四个数据库全用 Docker 启动,一行 docker-compose up -d 搞定。
全景架构图
┌─────────────────────────────────────────────────┐
│ Docker │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ MySQL │ │ Qdrant │ │ Elastic │ │
│ │ :3306 │ │ :6333 │ │ :9200 │ │
│ │ │ │ │ │ │ │
│ │ meta: │ │ 字段向量 │ │ 枚举值 │ │
│ │ 表/字段 │ │ 指标向量 │ │ 文本索引 │ │
│ │ 指标定义│ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ dw: │ │ │ │ │ │
│ │ 业务数据│ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────┐ │
│ │ BGE Embedding │ ← 中文嵌入模型 │
│ │ :8081 │ 文本 → 向量 │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────┘
第一步:拷贝 Docker 文件 + 配置环境变量
Docker 相关文件全部放在 docker/ 目录下。完整文件请从仓库拷贝:
ruby
https://github.com/frontzhm/n2sql-agent/tree/main/docker
目录结构一览:
python
docker/
├── docker-compose.yml # 一键启动四个服务
├── README.md # 启动说明
├── mysql/
│ ├── meta.sql # meta 库 DDL(元数据库表结构)
│ └── dw.sql # dw 库 DDL + 测试数据
├── elasticsearch/
│ ├── Dockerfile # 带 IK 中文分词插件的 ES 镜像
│ └── plugins/
│ └── elasticsearch-analysis-ik-*.zip
└── embedding/ # BGE 模型数据卷
四个服务一览:
| 服务 | 端口 | 用途 |
|---|---|---|
| MySQL 8.0 | 3306 | 元数据库 meta + 数据仓库 dw |
| Qdrant v1.16 | 6333 | 字段向量库 + 指标向量库 |
| Elasticsearch 8.19(含 IK 分词) | 9200 | 维度枚举值全文检索 |
| Kibana | 5601 | ES 可视化管理(调试用) |
| BGE Embedding(text-embeddings-inference) | 8081 | 中文文本 → 1024 维向量 |
配置环境变量
MySQL 的密码通过 .env 文件管理(不硬编码在 docker-compose.yml 里):
bash
# 项目根目录下,复制模板文件
cp .env.example .env
# 编辑 .env,填上你的密码(本地开发用简单密码即可)
# MYSQL_ROOT_PASSWORD=Yan.123
# MYSQL_PASSWORD=Yan.123
.env 会被 .gitignore 排除,不会提交到仓库,密码安全有保障。
下载 BGE 中文嵌入模型
embedding 服务需要 BAAI/bge-large-zh-v1.5 模型(约 1.3GB)。用 uv 一行命令下载:
bash
# 通过 huggingface_hub 下载模型到 docker/embedding/ 下
uv run --with huggingface_hub huggingface-cli download \
BAAI/bge-large-zh-v1.5 \
--local-dir docker/embedding/bge-large-zh-v1.5
uv run --with huggingface_hub 不需要提前安装依赖,uv 会自动创建临时环境执行命令,用完即走。
BGE(BAAI General Embedding)是智源研究院开源的中文嵌入模型,
bge-large-zh-v1.5把中文文本编码为 1024 维向量,是国内中文语义搜索的标配。
第二步:设计 MySQL 元数据库(meta)
要存什么?
元数据库的本质是用结构化数据描述"数据库里有什么"。需要三张核心表:
| 表 | 内容 | 谁用 |
|---|---|---|
table_info |
每张表的名称、角色(dim 维度 / fact 事实)、描述 | 合并节点按表分组、过滤表 |
column_info |
每个字段的列名、类型、角色(PK/FK/维度/度量)、描述、中文别名 | 召回字段、生成 SQL |
metric_info |
每个指标的名称、描述、别名、依赖哪些字段 | 召回指标、生成 SQL |
加上 column_metric 关联表(一个指标可能关联多个字段)。
从 meta_config.yaml 到建表 SQL
复制 conf的两个文件。
/meta_config.yaml,这是元数据的配置文件------一篇 YAML 描述了 5 张表和 2 个指标。代码会读取它,写入 MySQL。
后续会读取它,构建元数据库。
另一个app_config.yaml,也后续用到,先放这吧。
第三步:设计 Qdrant 向量数据库
要存什么?
向量库需要存两个 Collection(集合),分别用于字段召回和指标召回。
| Collection | 每条记录代表 | 向量内容 | 元信息(payload) |
|---|---|---|---|
columns |
一个数据库字段 | 字段中文描述(description + alias 拼接)的 1024 维向量 |
字段名、表名、类型、角色、别名 |
metrics |
一个业务指标 | 指标中文描述(description + alias 拼接)的 1024 维向量 |
指标名、描述、别名、依赖字段列表 |
向量是怎么生成的?
yaml
字段的元数据(来自 meta 库):
name: order_amount
description: 订单金额
alias: [销售额, 订单金额, 收入]
↓ 拼接成文本
"订单金额 销售额 订单金额 收入"
↓ 调用 BGE Embedding 服务 (localhost:8081)
→ [0.023, -0.451, 0.789, ..., 0.112] ← 1024 个浮点数
↓ 存入 Qdrant
同样地,用户输入的关键词(如 "销售额")也会走同样的流程变成向量,然后在 Qdrant 里找余弦相似度最高的 TOP 20 条记录。
Qdrant 是不需要预先建表的,Collection 在程序第一次导入数据时自动创建。
第四步:设计 Elasticsearch 值索引
要存什么?
把维度表中 sync: true 的字段的所有不同取值同步到 ES。
以 meta_config.yaml 为例:
| 维度表 | 字段 | sync | ES 中存什么 |
|---|---|---|---|
| dim_region | province | true | 省份的所有值(北京、上海...) |
| dim_region | region_name | true | 大区的所有值(华东、华南...) |
| dim_customer | gender | true | 性别的所有值(男、女) |
| dim_product | category | true | 品类的所有值(服装、电子...) |
| dim_product | brand | true | 品牌的所有值(苹果、华为...) |
| dim_region | region_id | false | 不同步(主键没有召回价值) |
ES 索引结构设计
json
// 索引名: data_agent(对应 app_config.yaml 里 es.index_name)
// 每条记录代表一个维度值
{
"id": "dim_region_province_北京", // 唯一ID: {表名}_{字段名}_{值}
"table_name": "dim_region", // 所属表
"column_name": "province", // 所属字段
"value": "北京", // 枚举值
"column_description": "订单所属的省份名称" // 字段描述(辅助理解)
}
搜索行为
用户输入 "上海":
css
ES 搜索: value 字段模糊匹配 "上海"
↓
返回: [
{ "table_name": "dim_region", "column_name": "province", "value": "上海" },
{ "table_name": "dim_region", "column_name": "region_name", "value": "华东" }
]
召回的值会被挂到对应字段的 examples 列表中,这样 LLM 在生成 SQL 的 WHERE 条件时,就能引用真实存在的值,而非自己编造。
第五步:设计 MySQL 数据仓库(dw)
最终 SQL 在这里执行。
docker/mysql/dw.sql--- 建表 + 灌数据一条龙(4 张维度表 + 1 张事实表,含 95 条订单记录)
mySQL启动的时候,就已经有测试数据了。
第六步:一键启动所有服务
安装并打开 Docker 桌面软件。
shell
cd docker
docker compose up -d
首次启动会拉取镜像(MySQL ~500MB、Qdrant ~200MB、ES ~1GB),需要几分钟。后续启动就很快了。
检查各服务是否正常:
shell
# MySQL(-p 后面跟 .env 里设的密码)
docker exec mysql mysql -uyan -pYan.123 -e "SHOW DATABASES;"
# 应该看到 meta 和 dw
# Qdrant
curl http://localhost:6333/health
# 返回 {"title":"qdrant - vector search engine","version":"..."}
# Elasticsearch
curl http://localhost:9200
# 返回集群信息
# Kibana(ES 可视化管理界面)
open http://localhost:5601
# BGE Embedding
curl http://localhost:8081/embed \
-H "Content-Type: application/json" \
-d '{"inputs":"测试"}'
# 返回一个 1024 维的向量数组
总结:四个数据库的分工
bash
用户说:"上个月北京男女销售额对比"
① MySQL meta(元数据库)
→ 知道有哪些表、哪些字段、哪些指标
→ 是一切数据的"说明书"
② Qdrant(向量库)
→ 把字段和指标的描述变成向量存起来
→ 用户的"销售额" ➔ 向量 ➔ 命中 order_amount 字段
③ Elasticsearch
→ 把维度表里 sync=true 的取值全同步过来
→ 用户的"北京" ➔ 模糊匹配 ➔ 命中 dim_region.province
④ MySQL dw(数据仓库)
→ 存放实际业务数据
→ 生成的 SQL 在这里执行,返回查询结果
现在四个数据库都就绪了,接下来的文章就可以正式开始写根据配置同步meta数据库和召回节点的代码------从元数据库读数据、灌入 Qdrant 和 ES、再用关键词去查。
当前目录结构
bash
n2sql-agent/
│ ├── conf/
│ │ ├── meta_config.yaml # 元数据定义:表、字段、指标
│ │ └── app_config.yaml # 连接配置:各服务的 host/port
│ └── ...
├── docker/
│ ├── docker-compose.yml # 一键启动四个服务
│ ├── mysql/
│ │ ├── meta.sql # meta 库 DDL(元数据库表结构)
│ │ └── dw.sql # dw 库 DDL + 测试数据
│ ├── qdrant/
│ ├── elasticsearch/
│ └── embedding/
├── app/
├── main.py
├── .env.example
└── ...
科普 Docker
一句话理解
Docker 是一个"打包工具"------把程序连同它依赖的所有环境(操作系统库、配置文件、启动脚本)打成一个镜像,在任何机器上都能一键启动,环境完全一致。
打个比方:你写了一份菜谱(Dockerfile / docker-compose.yml),Docker 按菜谱做出一道菜(container),不管在谁的厨房(Windows / Mac / Linux),做出来的味道一模一样。
核心概念
| 概念 | 说明 | 本项目对应 |
|---|---|---|
| 镜像 Image | 程序的"安装包"快照,包含代码 + 运行环境 | mysql:8.0、qdrant/qdrant:v1.16 |
| 容器 Container | 镜像运行起来的实例,一个隔离的小虚拟机 | docker compose up -d 后跑起来的 mysql 进程 |
| Dockerfile | 自定义镜像的配方文件 | elasticsearch/Dockerfile:在官方 ES 基础上装了 IK 分词插件 |
| docker-compose.yml | 编排多个容器一起启动 | 一键启动 MySQL + Qdrant + ES + Embedding |
| Volume 数据卷 | 容器删了数据不丢的持久化存储 | mysql_data、es_data 等,数据库文件存在宿主机上 |
本项目用到了哪些
- 四个服务全部用 Docker 跑,不用你手动装 MySQL、配置 ES
- 唯一需要手动下载的只有 BGE 模型文件(太大,不适合打进镜像)
- 开发环境随便折腾,删了
docker compose down -v就恢复出厂设置
科普 MySQL
一句话理解
MySQL 是全世界最流行的关系型数据库。你把数据按照"表(行 × 列)"的方式存进去,然后用 SQL 语句去查。
核心概念
| 概念 | 说明 | 本项目对应 |
|---|---|---|
| 数据库 Database | 一个独立的数据隔离单元,不同业务放不同库 | meta(元数据)、dw(数据仓库) |
| 表 Table | 按行列组织的数据集合,每行一条记录 | dim_customer(客户维度表)、fact_order(订单事实表) |
| 主键 Primary Key | 每行数据的唯一身份证号 | customer_id、order_id |
| 外键 Foreign Key | 关联到另一张表的指针 | fact_order.customer_id → dim_customer.customer_id |
| DDL | "建表语句",定义表长什么样子 | meta.sql、dw.sql |
| DML | "增删改查",操作数据 | INSERT INTO dim_customer VALUES (...) |
本项目用 MySQL 做什么
sql
meta 库(元数据库)
└─ 存的是"说明书":有哪些表、每个表有哪些字段、字段的中文别名、预定义的指标
→ 代码从这里读取元数据,导入 Qdrant 和 ES
dw 库(数据仓库)
└─ 存的是"实际数据":客户、商品、订单......
→ 最终生成的 SQL 在这里执行,返回查询结果给用户
科普 Elasticsearch
一句话理解
Elasticsearch(简称 ES)是一个搜索引擎。你丢进去一堆文本,它帮你建好索引,然后你能用关键词模糊搜索、拼音搜索、分词搜索,秒级返回结果。
如果把 MySQL 比作 Excel(精确查找),ES 就像 Google(模糊搜索)。
核心概念
| 概念 | 说明 | 本项目对应 |
|---|---|---|
| 索引 Index | 相当于 MySQL 的"表",存一类文档 | data_agent:存所有维度枚举值 |
| 文档 Document | 索引中的一条记录,JSON 格式 | {"table_name": "dim_region", "column_name": "province", "value": "上海"} |
| 分词器 Analyzer | 把文本切成词语的工具,中文分词的灵魂 | IK 分词器 :把"上海市浦东新区"切成 上海 / 浦东 / 新区 |
| 倒排索引 | ES 快的秘密------从词倒推它在哪些文档里 | 搜"上海" → 立刻知道第 3、7、15 号文档包含这个词 |
本项目用 ES 做什么
用户说"上海",ES 在维度值索引里搜到 dim_region.province = '上海'。这个值被挂到字段的 examples 列表里,LLM 写 SQL 的 WHERE 条件时就能用真实的数据库值,不会瞎编。
为什么不用 MySQL 做这件事? MySQL 的 LIKE '%上海%' 是全表扫描,百万行数据能卡半分钟;ES 的倒排索引是 O(1) 查词定位,百万行也毫秒返回。
科普 Embedding
一句话理解
Embedding(嵌入 / 向量化)是把文字变成一串数字(向量)的技术。文字越相似,它们对应的向量在数学空间里距离越近。
arduino
"销售额" → [0.023, -0.451, 0.789, ..., 0.112]
"订单金额" → [0.019, -0.438, 0.801, ..., 0.105]
"上海" → [0.891, 0.234, -0.156, ..., -0.678]
"销售额"和"订单金额"的向量距离很近(因为它们语义相近),但和"上海"距离很远。
核心概念
| 概念 | 说明 | 本项目对应 |
|---|---|---|
| 向量 Vector | 一串浮点数,代表文字在语义空间里的坐标 | BGE 模型输出 1024 维向量 |
| 嵌入模型 Embedding Model | 把文字变成向量的 AI 模型 | BAAI/bge-large-zh-v1.5 |
| 语义相似度 | 两段文字的向量越近,语义越像 | "销售额" ↔ "订单金额" 余弦相似度 0.98 |
| 跨语言匹配 | 中文查询 → 命中英文字段名 | "订单金额" → 命中 order_amount |
本项目用 Embedding 做什么
这是召回字段和指标的核心:
ruby
① 初始化时:
把 meta 库里每个字段的中文描述(description + alias)→ BGE → 向量 → 存入 Qdrant
② 用户查询时:
用户输入"销售额" → BGE → 向量 → 在 Qdrant 里找余弦最相似的 TOP 20 个字段
→ 其中 `order_amount`(描述:"订单金额")排第一
为什么不用 ES? ES 做的是"字面匹配",搜"销售额"找不到 order_amount(因为没有一个字相同)。Embedding 做的是"语义匹配"------知道"销售额"说的就是 order_amount。