2-项目整体架构与智能体流程
2 - 电商问数:项目整体架构与智能体流程
本章课程目标:
- 从整体上理解「电商问数」的项目整体架构,知道元数据知识库、同步脚本、问数智能体分别解决什么问题。
- 理解元数据库、向量索引、全文索引三者的分工关系,以及它们如何共同支撑 SQL 生成。
- 按流程图掌握问数智能体的执行链路,并建立“架构图和代码目录基本一一对应”的工程感知。
学习建议: 这一章是全项目总图,别当知识点清单背。先分成两大段:离线构建元数据知识库,在线根据用户问题调用知识并生成 SQL。读到某个表、字段或节点觉得细时,先问它属于“构建知识”还是“查询使用知识”。能画出这两段链路,后面细节会自己归位。
1、整体架构概述
1.1 先看整体链路
第 1 章已经回答过「电商问数」是什么项目,也介绍了它为什么属于典型的 NL2SQL 场景。因此进入这一章后,我们重点看:这套系统在工程上到底是怎么跑起来的。
一句话来说这套系统的工作方式,可以概括成下面这句话:
先把数据仓库里的表、字段、指标、字段取值等知识整理成一套可检索的元数据知识库,再让问数智能体基于这套知识库生成、校验并执行 SQL。
可以把整条链路看作下面这个顺序:
- 用户先用自然语言提问
- 智能体先理解问题,并抽取适合检索的关键信息
- 系统再去元数据知识库里召回相关表、字段、指标和字段取值
- 把这些信息整理成更适合大模型理解的上下文
- 由大模型生成 SQL,并做校验与必要的纠错
- 最后到数据仓库执行 SQL,返回查询结果
这里最关键的设计思想是:不让大模型直接面对整个数仓“盲写 SQL”,而是先让它拿到经过整理和筛选的元数据上下文。
因为大模型即使懂 SQL,也天然不知道你当前数仓里:
- 有哪些表可以查
- 哪些字段代表销售额、地区、会员等级
- 用户说的“华北”“黄金会员”“成交总额”在库里到底对应什么
所以从系统本质上看,「电商问数」一直在做两件事:
- 先构建和维护元数据知识库
- 再基于元数据知识库实现问数智能体
前者解决的是“系统知道什么”,后者解决的是“系统怎么利用这些知识回答问题”。
1.2 本项目涉及的核心模块与基础服务
如果把整套系统拆开看,它大致可以分成两层:
- 上层是业务模块:数据仓库、同步脚本、元数据知识库、问数智能体
- 下层是基础服务:
MySQL、Qdrant、Elasticsearch、Embedding服务、大模型编排框架等
先看业务模块这一层:
- 数据仓库:负责存放真实分析数据。当前教学环境中没有真的部署
Hive,而是用MySQL来模拟数仓,里面主要是fact_order、dim_customer、dim_product、dim_region、dim_date这些表。 - 配置文件与同步脚本:负责规定同步范围,也就是要从数据仓库中抽取哪些表、哪些字段、哪些指标,并把它们同步到元数据知识库。
- 元数据知识库:负责把抽取出来的元数据组织成可检索、可解释、可召回的知识底座。
- 问数智能体:负责在用户提问时调用这套知识,完成召回、过滤、生成 SQL、校验 SQL 和执行 SQL。
再看支撑这些模块运行的基础服务:
| 基础服务 | 当前项目使用什么 | 主要作用 |
|---|---|---|
| 数据仓库 / 元数据库 | MySQL |
在当前项目里,MySQL 同时承担两类角色:一套是教学数仓库 dw,负责存放分析数据;另一套是 meta 元数据库,负责保存 table_info、column_info、metric_info、column_metric 等结构化元数据 |
| 向量数据库 | Qdrant |
保存字段信息和指标信息的向量,用于语义相似度检索 |
| 全文检索服务 | Elasticsearch |
保存字段取值索引,用于把“华北”“黄金会员”这类自然语言值映射到真实字段取值 |
| Embedding 服务 | TEI + BAAI/bge-large-zh-v1.5 |
负责把字段名、字段描述、指标描述、用户问题等文本转成向量,供 Qdrant 检索使用 |
| 智能体编排框架 | LangGraph |
负责把关键词抽取、多路召回、合并过滤、生成 SQL、校验 SQL、执行 SQL 串成可分步骤运行的工作流 |
如果只记一句话,也可以概括为:MySQL 管结构化存储,Qdrant 管语义召回,Elasticsearch 管字段取值检索,TEI 负责向量化,LangGraph 负责把整条问数流程组织起来。
1.3 对照架构图理解整条链路

这张图如果第一次看,最容易晕的地方就是箭头很多、模块很多。更简单的看法是:整套系统其实只有两条主线。
- 构建期:先把知识准备好
- 查询期:再拿这些知识去回答问题
先看第一条主线,也就是构建期。
- 配置文件先告诉同步脚本:这次要纳入元数据知识库的有哪些表、哪些字段、哪些指标。
- 同步脚本再结合配置文件和数据仓库查询结果,组织出最终需要构建的元数据内容。其中,表和字段的业务语义、角色、别名等信息主要来自配置文件;字段类型、字段示例值以及部分真实取值,则由脚本再到数据仓库中查询补齐。
- 这些内容不会直接交给大模型,而是会先被整理并写入元数据知识库。
- 写入时会分三路落地:
meta MySQL保存结构化元数据,Qdrant保存字段和指标经过向量化后的语义索引,Elasticsearch保存部分字段的真实取值,方便后续全文检索。
这条链路做完之后,系统就不再只是“知道有一堆数仓表”,而是已经拥有了一套可检索、可解释、可供问数智能体调用的元数据知识库。
再看第二条主线,也就是查询期。
- 用户先提一个自然语言问题,比如“统计华北地区的销售总额”。
- 问数智能体先理解问题,再到元数据知识库里召回相关字段、相关指标和相关字段取值。
- 系统把这些召回结果整理成更适合大模型理解的上下文。
- 大模型基于这些上下文生成 SQL。
- 系统再做 SQL 校验,必要时纠错。
- SQL 通过后,才会真正到数据仓库执行,并把结果返回给用户。
所以这套系统最关键的点,不是“大模型会不会写 SQL”,而是:在写 SQL 之前,系统有没有先把正确的上下文准备好。
如果你更喜欢用“模块 + 技术栈”的方式去记,可以直接看下面这张表:
| 模块 | 作用 | 当前项目使用什么 |
|---|---|---|
| 数据仓库 | 存放真实分析数据 | MySQL(教学环境中用来模拟数仓) |
| 同步脚本 | 从数据仓库抽取元数据 | Python 脚本 + 配置驱动 |
| 元数据库 | 保存结构化元数据 | MySQL |
| 向量检索 | 召回相关字段和指标 | Qdrant |
| 全文检索 | 检索字段真实取值 | Elasticsearch |
| 向量化服务 | 把字段描述、指标描述、用户问题转成向量 | TEI + BAAI/bge-large-zh-v1.5 |
| 智能体编排 | 把召回、过滤、生成 SQL、校验、执行串起来 | LangGraph |
把它们连成一句最容易记住的话,就是:
构建期用 MySQL + Qdrant + Elasticsearch + TEI 把元数据知识库建好,查询期再由 LangGraph 驱动问数智能体调用这套知识,最终生成并执行 SQL。
2、元数据知识库
2.1 先理解什么是元数据知识库
上一节已经从整体上看过:问数智能体真正依赖的,不是整个数据仓库本身,而是经过整理、可检索、可解释的数仓元数据。顺着这个思路往下看,元数据知识库其实就是整套系统的“语义底座”。
这里先区分两个很容易混淆的概念:
- 数据仓库存的是订单、商品、地区、时间这类真实分析数据。
- 元数据知识库存的是“有哪些表和字段、字段是什么意思、字段可能有哪些取值、指标怎么定义”这类关于数据的数据。
也就是说,数据仓库负责“提供可查询的数据”,元数据知识库负责“帮助智能体理解这些数据”。
还要再区分一层:元数据库不等于元数据知识库,但它是元数据知识库的一部分。
在当前项目里,元数据知识库并不是某一个单独的库,而是由下面三部分协作组成的:
- 元数据库:用
MySQL保存最完整、最权威的结构化元数据。 - 向量索引:用
Qdrant负责字段和指标的语义召回。 - 全文索引:用
Elasticsearch负责字段取值的关键词匹配。
如果没有这层知识库,大模型虽然可能知道 SQL 语法,但它依然不知道:
- 当前数仓里到底有哪些表和字段
- 某个指标在这个项目里的业务定义是什么
- 用户问题里的自然语言表达,应该落到哪个字段、哪个字段值上
所以元数据知识库的作用,并不是直接替大模型生成 SQL,而是在生成 SQL 之前,先把“应该关注哪些表、哪些字段、哪些取值、哪些指标”准备好。没有这一步,后续 SQL 生成就很容易不稳定。
如果一定要用一个便于记忆的比喻,可以这样记:数据仓库更像“数据仓库房”,元数据知识库更像“这间仓库的说明书、索引卡和检索系统”。
如果你更习惯用“表格”来记忆,先看下面这个总表:
| 组件 | 主要存什么 | 解决什么问题 | 典型使用时机 |
|---|---|---|---|
MySQL 元数据库 |
表信息、字段信息、指标信息、字段与指标关系 | 保存最完整、最权威的结构化元数据 | 召回完成后回表取完整信息 |
Qdrant 向量索引 |
字段与指标的向量表示 | 解决“用户换个说法也能召回”的问题 | 字段召回、指标召回 |
Elasticsearch 全文索引 |
字段的实际取值 | 解决“问题里的自然语言值如何映射到真实字段值”的问题 | 字段值检索 |
2.2 元数据库
上面已经知道了:元数据知识库并不是一个单体,而是 MySQL + Qdrant + Elasticsearch 的协作结果。接下来我们先从其中最基础的一层开始,也就是元数据库。
元数据库负责存放完整、权威的结构化元数据。它不是业务数据本身,而是“关于数据的数据”。在当前项目中,这部分主要放在 MySQL 里,并包含四张核心表,用于保存数据仓库中的表信息、字段信息、指标信息以及字段与指标之间的关联关系。

各表具体内容示例如下。为了便于快速建立直觉,先把这几张表的中文含义记住:
metric_info:指标信息表column_metric:字段与指标关联表column_info:字段信息表table_info:表信息表
这里还需要先澄清一个很容易误解的点:元数据库里的这四张表,虽然最终都是由同步脚本写进去的,但它们的来源并不完全一样。
更具体地说,它们通常是由“配置文件 + 数据仓库查询结果”共同组织出来的:
table_info和column_info主要围绕数据仓库中的表和字段构建metric_info和column_metric则更多来自配置文件中定义的指标信息及其字段关联关系
也就是说,这四张表并不都是“直接从数据仓库原封不动同步”过来的,而是同步脚本在读取配置、查询数仓、整理关系之后,统一写入元数据库的结果。
如果再展开一点,可以这样理解:
table_info:表名、表角色、表说明这类信息,通常主要来自配置文件中对表的定义。同步脚本会根据这些配置,生成最终的表信息记录。column_info:字段名、字段角色、字段说明、字段别名这类业务语义信息,通常主要来自配置文件;字段类型和部分示例值,则会由同步脚本再去数据仓库中实际查询并补齐。所以它既依赖配置,也依赖数仓中的真实结构。metric_info:指标名、指标说明、指标别名、相关字段这类信息,通常不是直接从数据仓库表结构里扫描出来的,而是来自配置文件中的指标定义。因为像GMV、AOV这种指标,本身更偏业务口径,而不是数据库天然自带的信息。column_metric:这张表保存的是“指标和字段之间的关系”。它通常也是由同步脚本根据配置中的指标定义和relevant_columns整理出来的,而不是靠扫描数据仓库自动推导出来。
metric_info 示例(指标信息表):
| id(指标 ID) | name(指标名) | description(指标说明) | relevant_columns(相关字段) | alias(指标别名) |
|---|---|---|---|---|
| AOV | AOV | 全称 Average Order Value,表示所有订单的成交金额平均值。 | [“fact_order.order_quantity”] | [“平均单价”, “平均订单金额”] |
| GMV | GMV | 全称 Gross Merchandise Value,表示所有订单的成交金额总和。 | [“fact_order.order_amount”] | [“成交总额”, “订单总额”] |
column_metric 示例(字段与指标关联表):
| column_id(字段 ID) | metric_id(指标 ID) |
|---|---|
| fact_order.order_amount | GMV |
| fact_order.order_quantity | AOV |
column_info 示例(字段信息表):
| id(列编号) | name(列名称) | type(数据类型) | role(字段角色) | examples(示例值) | description(字段说明) | alias(字段别名) | table_id(所属表 ID) |
|---|---|---|---|---|---|---|---|
| dim_customer.customer_id | customer_id | varchar(64) | primary_key | [“C001”, “C002”, “C003”, “C004”, “C005”, “C006”, “C007”, “C008”, “C009”, “C010”] | 客户唯一标识。 | [“客户ID”, “用户ID”] | dim_customer |
| dim_customer.customer_name | customer_name | varchar(128) | dimension | [“李伟”, “王芳”, “张敏”, “刘洋”, “陈静”, “赵磊”, “黄秀英”, “吴斌”, “周燕”, “徐浩”] | 客户名称。 | [“客户名称”, “用户名称”] | dim_customer |
| dim_customer.gender | gender | varchar(16) | dimension | [“男”, “女”] | 客户性别。 | [“性别”] | dim_customer |
| dim_customer.member_level | member_level | varchar(32) | dimension | [“黄金”, “白银”, “青铜”, “铂金”] | 客户会员等级。 | [“会员等级”, “用户等级”] | dim_customer |
| dim_date.date_id | date_id | int | primary_key | [20250101, 20250102, 20250103, 20250104, 20250105, 20250106, 20250107, 20250108, 20250109, 20250110] | 日期唯一标识,格式 yyyyMMdd。 | [“日期ID”, “日期”] | dim_date |
| dim_date.day | day | int | dimension | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] | 日。 | [“日”, “天”] | dim_date |
| dim_date.month | month | int | dimension | [1, 2, 3] | 月份。 | [“月”, “月份”] | dim_date |
| dim_date.quarter | quarter | varchar(8) | dimension | [“Q1”] | 季度。 | [“季度”] | dim_date |
| dim_date.year | year | int | dimension | [2025] | 年份。 | [“年”, “年份”] | dim_date |
| dim_product.brand | brand | varchar(64) | dimension | [“苹果”, “三星”, “华为”, “戴森”, “美的”, “耐克”, “阿迪达斯”, “优衣库”, “李维斯”, “雀巢”] | 商品品牌名称。 | [“品牌”, “品牌名称”] | dim_product |
| dim_product.category | category | varchar(64) | dimension | [“手机数码”, “家用电器”, “鞋靴”, “服饰”, “食品饮料”, “休闲零食”] | 商品所属品类。 | [“商品类别”, “品类”, “分类”] | dim_product |
| dim_product.product_id | product_id | varchar(64) | primary_key | [“P001”, “P002”, “P003”, “P004”, “P005”, “P006”, “P007”, “P008”, “P009”, “P010”] | 商品唯一标识。 | [“商品ID”, “产品ID”] | dim_product |
| dim_product.product_name | product_name | varchar(128) | dimension | [“iPhone 15 Pro”, “Galaxy S24 Ultra”, “Mate 60 Pro”, “戴森 V15 吸尘器”, “美的空调 KFR-35GW”, “耐克 Air Max 270 运动鞋”, “阿迪达斯 Ultraboost 跑鞋”, “优衣库 Heattech 保暖夹克”, “李维斯 501 牛仔裤”, “雀巢金牌速溶咖啡”] | 商品名称。 | [“商品名称”, “产品名称”] | dim_product |
| dim_region.country | country | varchar(64) | dimension | [“中国”] | 地区所属国家名称。 | [“国家”, “国家名称”] | dim_region |
| dim_region.province | province | varchar(64) | dimension | [“广东省”, “浙江省”, “四川省”, “北京市”, “上海市”, “湖北省”] | 订单所属的省份名称。 | [“省份”, “省”, “所在省份”] | dim_region |
| dim_region.region_id | region_id | varchar(64) | primary_key | [“R001”, “R002”, “R003”, “R004”, “R005”, “R006”] | 地区唯一标识。 | [“地区ID”, “区域ID”] | dim_region |
| dim_region.region_name | region_name | varchar(64) | dimension | [“华南”, “华东”, “西南”, “华北”, “华中”] | 订单所属的大区名称,如华东、华南等。 | [“地区”, “区域”, “大区”] | dim_region |
| fact_order.customer_id | customer_id | varchar(64) | foreign_key | [“C001”, “C005”, “C003”, “C008”, “C012”, “C015”, “C002”, “C007”, “C010”, “C019”] | 关联客户维度的外键。 | [“客户ID”, “用户ID”] | fact_order |
| fact_order.date_id | date_id | int | foreign_key | [20250101, 20250102, 20250103, 20250104, 20250105, 20250106, 20250107, 20250108, 20250109, 20250110] | 关联时间维度的外键。 | [“日期”, “下单日期”] | fact_order |
| fact_order.order_amount | order_amount | decimal(10,2) | measure | [8999.0, 6999.0, 125.0, 899.0, 60.0, 1399.0, 40.0, 299.0, 200.0, 9499.0] | 订单金额。 | [“销售额”, “订单金额”, “收入”] | fact_order |
| fact_order.order_id | order_id | varchar(64) | primary_key | [“ORD20250101001”, “ORD20250101002”, “ORD20250102001”, “ORD20250102002”, “ORD20250103001”, “ORD20250103002”, “ORD20250104001”, “ORD20250105001”, “ORD20250105002”, “ORD20250106001”] | 订单唯一标识。 | [“订单ID”] | fact_order |
| fact_order.order_quantity | order_quantity | int | measure | [1, 5, 12, 8, 2, 10, 3, 6, 25, 4] | 订单中商品的购买数量。 | [“销量”, “购买数量”, “件数”] | fact_order |
| fact_order.product_id | product_id | varchar(64) | foreign_key | [“P001”, “P003”, “P010”, “P006”, “P011”, “P014”, “P012”, “P008”, “P002”, “P013”] | 关联商品维度的外键。 | [“商品ID”, “产品ID”] | fact_order |
| fact_order.region_id | region_id | varchar(64) | foreign_key | [“R001”, “R005”, “R002”, “R004”, “R003”, “R006”] | 关联地区维度的外键。 | [“地区ID”, “区域ID”] | fact_order |
table_info 示例(表信息表):
| id(表 ID) | name(表名) | role(表角色) | description(表说明) |
|---|---|---|---|
| dim_customer | dim_customer | dim | 客户维度表,描述下单客户的基本属性。 |
| dim_date | dim_date | dim | 时间维度表,用于多时间粒度分析。 |
| dim_product | dim_product | dim | 商品维度表,描述商品的基本属性信息。 |
| dim_region | dim_region | dim | 地区维度表,用于描述订单发生的地理区域信息。 |
| fact_order | fact_order | fact | 订单事实表,记录订单数量和金额等核心指标。 |
从设计思路上看,这四张表并不是随意拆出来的,而是各自承担了不同职责。与其把它们混在一起记,不如结合上面的示例表格,按表来理解。
table_info表负责回答:有哪些表,这些表是做什么的结合上面的示例,你会看到
fact_order被标记为fact,而dim_customer、dim_product、dim_region、dim_date被标记为dim。这里的role用来区分事实表和维度表,description用来解释这张表的业务作用。这样当大模型拿到table_info时,就不只是知道“有一张叫fact_order的表”,还知道它是一张订单事实表,主要记录订单数量和金额等核心指标。column_info表负责回答:表里有哪些字段,这些字段应该怎么理解和使用这是四张表里最关键的一张。比如在
dim_region.region_name这条记录里,系统不仅知道字段名是region_name,还知道它的类型是varchar(64),角色是dimension,示例值里可能有华南、华东、华北,描述是“大区名称”,别名还可能有“地区”“区域”“大区”。有了这些信息,系统后面才能理解:当用户问“华北地区销售额”时,华北很可能要落到这个字段上。column_info里几个字段尤其重要:type决定 SQL 应该怎么写,例如字符串、整数、日期的处理方式并不一样。role用来区分主键、外键、维度字段和度量字段。维度字段更适合分组和过滤,度量字段更适合sum、avg、count这类聚合,主键和外键则关系到多表join。description帮助大模型理解这个字段的业务含义。alias帮助系统在向量召回时匹配不同说法。比如“会员等级”和“用户等级”其实都可能指向member_level。examples则帮助模型理解真实取值格式。例如用户问“统计河北省的销售额”,系统必须知道库里到底存的是河北还是河北省;又比如性别字段,到底存的是男/女、male/female,还是1/0。这些差异都会直接影响 SQL 能不能写对。
当然,
examples只适合保存部分示例值,而不是所有值。像性别、季度这类取值很少的字段,全部放进去问题不大;但品牌名、商品名这类字段取值很多,就更适合交给后面的全文索引去处理。metric_info负责回答:有哪些业务指标,这些指标是什么意思比如上面的
GMV和AOV。系统不能只知道“有这个缩写”,还要知道它的业务含义、别名,以及它和哪些字段相关。因为真实业务里很多指标都是公司内部约定的,大模型未必天然理解。如果用户问“成交总额”或“平均订单金额”,系统就需要依赖metric_info去理解这其实是在问GMV或AOV。这里还有一个设计点值得注意:
metric_info里保存了relevant_columns。从规范化角度看,这个信息似乎已经可以通过关系表去表达,但把它直接冗余在这里,会让系统在组装提示词时更方便地拿到“某个指标依赖哪些字段”,减少额外查询成本。column_metric负责回答:某个指标和哪些字段有关这张表结构最简单,但作用很清楚,就是把字段和指标之间的关系单独保存出来。例如
fact_order.order_amount对应GMV,fact_order.order_quantity对应AOV。这样做的价值在于,它把“指标和字段之间的多对多关系”表达得更清楚,也方便后续扩展。
如果把这四张表再压缩成一句话,可以这样记:
table_info管“表”column_info管“字段”metric_info管“指标”column_metric管“字段和指标的关系”
有了这部分结构化元数据,系统才知道“销售额”对应的可能是哪个字段,“地区”可能对应哪张维度表中的哪个列,“GMV”到底是什么意思,也才有可能进一步做精准召回和 SQL 生成。
如果把这一节再总结成一句话,可以这样理解:元数据库的设计目标,不只是“把表和字段记下来”,而是要尽量把那些会影响 SQL 生成准确性的关键信息都提前整理好,等到真正生成 SQL 时,大模型才能“有据可依”。进一步说,SQL 生成质量通常取决于两个核心因素:模型自身的基础能力,以及元数据知识库的设计质量。模型能力决定理解、推理和生成上限,而元数据设计则决定模型在生成 SQL 时能不能拿到足够准确、完整、贴近业务语义的上下文信息。
2.3 向量索引
仅仅依赖精确匹配,往往很难满足自然语言问数场景。比如用户说“成交总额”,而字段说明中写的是“订单金额”;用户说“用户等级”,而字段元数据中写的是“会员等级”。这时如果只靠关键词逐字匹配,就很容易召回失败。
因此,需要把元数据库中部分字段信息和指标信息进一步构建成语义向量索引,让系统具备“语义相近也能找到”的能力。
向量索引主要作用于两类信息:column_info,也就是字段信息;metric_info,也就是指标信息。
这里需要特别说明一点:本项目并没有对 table_info 单独建立向量索引。原因并不是表信息不重要,而是字段信息本身已经带有 table_id。也就是说,只要把字段召回出来,我们就可以根据字段所属的 table_id 反查出它对应的表信息。因此在实际设计中,没有必要再额外对表信息做一套独立的向量召回。
也就是说,本项目的思路是:
- 先召回字段信息和指标信息
- 再根据字段里的
table_id回到元数据库中拿到对应表信息
这样设计既能减少重复索引,也能让整体检索逻辑更集中。
- column_info
column_info 中需要建立向量索引的字段如下图所示:

具体示例如下图所示:

这里的核心思想不是“给一整行字段信息只建一个向量”,而是对同一行中的多个关键文本分别建向量。以 column_info 为例,一条字段记录通常会对应多个向量点:
- 字段名
name会生成一个向量 - 字段描述
description会生成一个向量 - 每一个字段别名
alias也都会各自生成一个向量
这意味着,column_info 中的一行记录,在向量数据库里往往不是一个点,而是多个点。这样做的好处是,不管用户问题更接近字段原名、字段描述,还是字段别名,系统都有更大概率把正确字段召回出来,从而提高召回率。
这里的“召回率”可以简单理解为:对于一个用户问题,本来真正相关的字段或指标有若干个,如果系统能够把这些相关对象尽可能多地找出来,召回率就高;如果明明相关,但系统没有把它们检索出来,召回率就低。也正因为如此,我们才会同时对 name、description 和 alias 建立向量索引,目的就是尽量减少“相关信息没有被找出来”的情况。
再进一步看,每个向量点除了会保存向量本身,还会带上对应的 payload。你可以把 payload 看作“这个向量点附带的业务信息”。在本项目里,payload 中保存的是完整的字段信息,而不只是一个简单的字段 ID。这样当系统在 Qdrant 中检索到最相近的向量点后,就可以直接从 payload 中取出完整字段信息,少做一次额外的数据库反查。
当然,这也是一种工程上的取舍。如果只保存 ID,向量库里的数据会更轻,但召回后还要回数据库再查一次;如果直接保存完整字段信息,召回后读取更方便,但当元数据库中的字段信息发生变化时,也要同步更新向量库中的 payload。本项目选择的是后者,目的是让查询链路更直接。
- metric_info
metric_info 中需要建立向量索引的字段如下图所示:

具体示例如下图所示:

metric_info 的思路和 column_info 完全类似。对于一条指标记录,我们同样会对 name、description 和 alias 分别建立向量索引。这样当用户提问中出现某个业务指标的简称、别称,或者一种更口语化的表达时,系统也更容易把正确指标召回出来。
在实际实现中,我们会把字段名、字段描述、字段别名,以及指标名、指标描述、指标别名等文本分别送入 Embedding 模型,得到向量后存入 Qdrant,用于后续的语义召回。
如果把这部分再总结成一句话,可以这样理解:向量索引的目标,不是机械地“存一堆 embedding”,而是尽量让用户问题能够从不同表达方式出发,都能更稳定地召回到真正相关的字段和指标。
2.4 全文索引
除了字段和指标,问数系统还有一类非常关键的信息需要检索,那就是字段的实际取值。
例如用户提到:华北地区,黄金会员,某个具体商品名称。
这类信息往往不是“字段名”,而是“字段里的值”。为了让系统能把自然语言中的这些描述准确映射到数据库中的真实取值,我们还需要建立全文索引。
这一点其实和前面 column_info.examples 的设计是连起来的。examples 的作用,是先告诉大模型某个字段大概会有哪些取值格式;但如果一个字段的实际取值特别多,比如品牌名、商品名、地区名、品类名等,就不可能把所有取值都直接塞进 examples 里交给大模型。这样不仅上下文会变得很长,也会拖慢整体生成效率。
因此,本项目采用了一种更适合工程落地的做法:对于那些将来很可能会出现在 SQL 过滤条件中的字段取值,单独建立全文索引。当用户问题中出现“华北”“黄金会员”“手机数码”这类文本时,系统会先去全文索引中检索最相关的真实取值,再把这些检索结果补充回后续的提示词和 SQL 生成流程中。
这里还需要理解一个判断标准:并不是所有字段都适合做全文索引。一般来说,**全文索引主要针对的是“文本类型的维度字段”,因为这类字段最有可能作为过滤条件出现。**例如地区、省份、品牌、品类、商品名称、会员等级等,都很适合建立全文索引。
相反,像 order_amount、order_quantity 这类度量字段,通常更多是拿来做聚合统计,而不是拿来做关键词过滤;像 year、month、day 这种数值型字段,虽然也可能参与过滤,但它们并不适合按全文检索的方式处理。因此,全文索引并不是覆盖所有字段,而是有选择地覆盖那些“文本型、维度型、可能作为过滤条件”的字段。
具体示例如下图所示:

在项目实现中,这部分能力由 Elasticsearch 提供。每一个字段取值都会作为一条独立索引记录写入 ES,其中至少包括三个核心信息:
id:该取值的唯一标识value:字段的实际取值column_id:这个取值属于哪个字段
这样做的好处是,当系统检索到一个值之后,不仅知道“匹配到了什么文本”,还知道“这个值属于哪个字段”,于是就可以把检索结果重新挂回到对应字段上。
例如,用户问题里提到了“华南”,全文索引检索到这个值后,系统不仅知道它命中了文本 华南,还知道它属于 dim_region.region_name。于是后续在组织字段信息时,就可以把这个值补充到对应字段的候选取值中,为生成 WHERE region_name = '华南' 这样的 SQL 条件提供依据。
从更理想的方案来看,字段取值检索其实可以进一步做成“混合检索”,也就是同时结合全文检索和向量检索。因为某些特殊情况下,用户问题里的写法和数据库里的真实取值不一定完全一致,可能会涉及别称、拼音、同义表达等问题。在这种情况下,单纯依赖全文检索有时会漏掉结果,而混合检索通常会更稳。
不过在本项目中,这部分先做了工程上的简化:字段取值检索主要使用全文检索。这是因为在大多数常见场景下,字段取值更关注的是“文本是否相近”,而不是“语义是否抽象相近”,所以全文检索已经能够覆盖绝大多数需求。后续如果要进一步提升效果,再在这个基础上叠加向量检索,会是更自然的优化方向。
如果把这一节再总结成一句话,可以这样理解:全文索引解决的,不是“字段叫什么”的问题,而是“字段里到底存了什么值”的问题。它的核心作用,就是帮助系统把用户问题中的自然语言描述,映射成数据库中真实存在的过滤值。
2.5 元数据同步脚本
前面几小节讲的是“元数据知识库里有什么”。接下来这一小节要回答的是另一个工程问题:这些内容是怎么进去的。
在实际工程里,元数据知识库不是手工一点点录入的,而是要通过同步脚本来构建和维护。本项目中,有一项非常重要的基础工作,就是编写一个“元数据同步脚本”。它的作用可以概括为一句话:根据配置文件,把指定表、指定字段和指定指标同步到元数据知识库中。
如果你想先直观看到这些原始数仓表长什么样,可以回看第 1 章中的 数据仓库表示例。同步脚本读取的,就是这类事实表和维度表中的结构信息、字段信息和部分示例值。
这个过程大致包括:
- 读取配置文件,确定需要同步哪些表、哪些字段、哪些指标
- 从数据仓库中读取对应表结构、字段类型和部分字段取值示例
- 将完整元数据写入
MySQL - 为字段信息和指标信息构建向量索引
- 为适合检索的字段取值构建全文索引
这样设计的好处是,整个知识库的构建过程是可配置、可重复执行、可增量扩展的。
举个例子:
- 如果最开始数仓里有 100 张表,我们可以通过配置文件一次性把这 100 张表同步进知识库
- 如果后面又新增了 10 张表,只需要更新配置文件,再运行一次同步脚本,就可以把新增内容补进去
也就是说,元数据知识库并不是静态写死的,而是能够随着数仓变化持续演进的。
如果你后面要对照代码来看,这里先知道有两个相关文件:
app/scripts/build_meta_knowledge.pyapp/services/meta_knowledge_service.py
这一部分在后面的章节里还会继续展开,这里先有一个印象即可。
3、问数智能体
真正把用户问题变成查询结果的,是项目中的问数智能体。
这个智能体基于 LangGraph 实现,不是单轮“Prompt + LLM”的简单模式,而是一个可分步骤执行、可观测、可纠错的图结构流程。

如果顺着这张流程图往下看,整个问数智能体本质是一条“先理解问题、再召回信息、最后生成并执行 SQL”的工作流。
为了先抓住主线,也可以把这一整段流程先压缩成 3 个阶段:
- 理解问题
先从用户问题里提取适合检索的关键词,并保留原始问题作为语义兜底。 - 准备 SQL 所需上下文
先粗召回相关字段、指标和值,再把它们整理、补齐、过滤成更适合生成 SQL 的上下文。 - 生成并执行 SQL
在上下文齐备后生成 SQL,再做校验、必要时纠错,最后真正执行。
带着这个“三阶段”视角再往下看,就会更容易理解为什么中间会有“召回、合并、过滤”这几步,它们其实都是在为生成 SQL 做准备。
3.1 第一步:抽取关键词

流程图最上方的第一个节点是“抽取关键词”。
这一步的作用,是先把用户问题中真正对查询有帮助的核心词抽出来,尽量去掉一些无关紧要的表达。比如用户说:
- 帮我统计一下华北地区的销售总额
系统不一定要原封不动拿整句话去做后续检索,而是可以优先提取出更关键的词,例如:华北地区、销售总额。这样做的目的,是让后面的检索更聚焦、更干净。
在实现上,这一步会借助分词和关键词提取工具来完成。当前项目这里使用了 jieba 提供的关键词提取能力。jieba 是一个常用的 Python 中文分词与关键词提取库,它底层使用的是 TF-IDF 思路。
TF-IDF 可以做一个直观理解:它不是简单统计“哪个词出现得多”,而是评估“哪个词在当前这句话里更重要、更有代表性”。一个词如果在当前问题里出现得比较关键,但又不是那种在各种句子里到处都出现的普通词,那么它更有可能被识别为关键词。反过来,像“的”“了”“一下”这类在很多句子里都会反复出现的词,通常就不适合当作关键词。
所以在这个项目里,TF-IDF 的作用可以简单理解为:从用户问题中尽量提炼出真正对数据库查询有帮助的核心词,把那些无关紧要、干扰检索的词先过滤掉,从而让后面的字段召回、指标召回和字段取值检索更准确。
当然,关键词抽取不是百分之百完美的,理论上存在漏掉重要信息的可能。所以实际流程里,也会把原始问题本身一并保留下来,作为一种补充保险。这样做可以避免只依赖关键词而导致信息丢失。
这里需要特别理解一点:提取关键词的意义,在于先把问题中最适合检索的核心词提炼出来,提高后续召回的效率和准确性;而保留原始问题,则是为了避免关键词抽取过程中遗漏语义细节,作为完整语义的兜底信息。也就是说,关键词和原始问题并不是二选一的关系,而是“一个负责聚焦检索,一个负责补充完整语义”。
3.2 第二步:并行召回三类信息
从“抽取关键词”往下,流程图同时分出了三条支路:
- 召回字段信息
- 召回指标信息
- 召回字段取值
这三步之间没有严格先后依赖关系,所以它们可以并行执行。并行的好处是,一方面节省时间,另一方面也符合实际查询逻辑,因为一个问题往往同时涉及字段、指标和字段值。
这里可以把这一步看作一次粗召回。也就是说,这一步的目标不是立刻得到最终答案,而是先尽量把“可能相关的材料”多找一些出来,宁可先宽一点,也不要一开始就漏掉真正相关的信息。
- 召回字段信息

这一步主要去向量索引中检索与问题最相关的字段。比如问题里有“地区”“销售总额”这类表达,系统就要尽量把 region_name、order_amount 等可能相关的字段找出来。
为了提高召回效果,系统并不只依赖最初抽出来的关键词,还会借助大模型进一步做“关键词扩展”。也就是说,系统会让模型从“我要查字段信息”这个目标出发,补充一些更适合用于字段检索的词,例如把“销售总额”扩展成“销售金额”之类的表达。这样做的本质,是为了提高字段召回率。
- 召回指标信息

这一步和字段召回类似,也是走向量检索,只不过目标换成了 metric_info 中的业务指标。比如用户问“销售总额”,系统不仅要想到具体字段 order_amount,也要有机会召回 GMV 这样的指标定义。
这里同样会做关键词扩展。因为用户问题里的说法,未必和指标名一模一样。通过扩展关键词,系统能更容易把“问题的说法”和“元数据里的指标定义”对上。
- 召回字段取值

这一步主要走全文检索,目标是找到问题中提到的那些真实字段取值。比如:华北地区;黄金会员;手机数码。
这些信息不是字段名,而是字段里存的值,所以要去全文索引里查。
从理想方案来看,字段取值检索其实做成“全文检索 + 向量检索”的混合检索会更好,因为有些场景可能存在别称、拼音、不同书写方式等问题。但在当前项目中,为了先把主流程做清楚,这一步做了简化,主要采用的是全文检索。后续如果需要继续优化,再往这一步叠加混合检索能力会比较自然。
3.3 第三步:合并召回信息

三路粗召回完成后,系统拿到的是三份相对分散的结果:
- 一批字段信息
- 一批指标信息
- 一批字段取值
但这些信息此时还不能直接丢给大模型。因为它们是散的、碎的、不成体系的,而大模型在生成 SQL 时更需要的是结构化、成组的信息。
所以流程图的下一步是“合并召回信息”。
这一步的核心目标,是把零散的召回结果整理成两份更适合后续使用的上下文:
- 表信息及其字段信息
- 指标信息及其相关字段说明
这里要特别理解几个关键动作:
- 召回到字段之后,可以根据字段里的
table_id把字段重新按表归组,整理成“某张表有哪些可用字段”的结构。 - 召回到指标之后,要把指标相关字段也考虑进来,因为一个指标往往依赖一个或多个字段。
- 召回到字段取值之后,要把这些值重新挂回到它所属字段上,让字段的
examples或候选取值信息更完整。
这一步可以看作:前面的召回是在“捞零件”,而这一步是在“拼装上下文”。如果说 3.2 是先把相关材料尽量找全,那么 3.3 做的就是把这些材料按 SQL 生成真正需要的形态组织起来。
这里还需要特别理解一种很重要的结果形态:当这些信息被整理好之后,表信息通常会被组织成类似 yaml/table_infos.yaml 这样的结构。也就是说,系统并不是把“零散字段列表”直接交给大模型,而是把它们先整理成“按表分组、表下挂字段”的层次化信息。
这种结构的好处是非常直观的:
- 大模型能一眼看出有哪些表
- 每张表是什么角色,是事实表还是维度表
- 每张表下面有哪些字段
- 每个字段的类型、角色、示例值、描述和别名分别是什么
结合当前这个示例,还可以进一步理解“合并召回信息”为什么不是简单拼接:
- 同一张表下的字段会被重新归组,而不是继续散落在一个列表里。
- 字段的
examples、description、alias等信息也会一起保留下来,方便后续生成 SQL 时同时理解“字段叫什么、字段里可能有什么值、用户还可能怎么称呼它”。 fact_order这种事实表和dim_region这种维度表会同时出现,因为大模型后面不仅要知道统计哪个指标,还要知道从哪个维度去分析。- 像
customer_id、date_id、product_id、region_id这类外键字段也会被一起保留,因为它们在后续多表关联时经常是必需的。
除了表信息,合并之后的指标信息也会被整理成一个单独的层次化结果文件,通常类似 yaml/metric_infos.yaml 这样的结构。
结合这个示例,可以更容易理解“合并召回信息”阶段的指标整理逻辑:
- 系统不会只保留一个孤立的指标名,而是会把指标的
description、relevant_columns、alias一并整理出来。 description的作用,是告诉大模型这个指标在业务上到底是什么意思,避免模型只看到缩写却不知道含义。relevant_columns会把指标和具体字段关联起来,例如GMV对应fact_order.order_amount,这样后续生成 SQL 时更容易知道应该聚合哪个字段。alias会保留用户常见说法,例如“成交总额”“订单总额”“平均单价”,这样系统在理解自然语言问题时就不会只依赖指标原名。
也就是说,合并召回信息这一步的真正目标,不只是“把结果拼起来”,而是把表信息和指标信息都整理成一种适合大模型阅读和理解的层次化上下文。这里展示 yaml/table_infos.yaml 和 yaml/metric_infos.yaml,本质是为了说明这一点。
3.4 第四步:过滤指标信息与过滤表格信息
即使经过合并,当前拿到的信息也不一定就是最终最合适的结果。因为前一步的目标是“先组织起来”,而不是“已经足够精确”。通常这时还会存在两类问题:
- 有些字段、表、指标虽然被召回了,但和当前问题关系并不强
- 有些字段是为了补全主外键关系被带进来的,但本身并不是问题关注的重点
因此,流程图接下来又分成两步筛选:
- 过滤指标信息
- 过滤表格信息

这两步本质上都是一次精筛。系统会借助大模型,结合用户原始问题,对召回结果再做一次语义判断,把无关项、冗余项剔除掉,只保留和当前查询意图最相关的表、字段和指标。
这一步非常重要,因为前面的召回更像是“宁可多召回一些,也不要漏太多”,而这里则是把“多出来的部分”再清洗一遍。你可以把这一段连起来记成:先粗召回,再结构化整理,最后精筛保留。
过滤完成之后,指标信息也会被整理成一个更精简的结果文件。例如,当前项目中的 yaml/filtered_metric_infos.yaml,就是“过滤指标信息”这一步的示例结果。
结合这个示例,可以更容易理解“过滤指标信息”这一步主要做了什么:
- 它不会把前面召回到的所有指标定义全部保留下来,而是只保留和当前问题真正相关的指标。
- 如果当前问题是在统计“销售总额”,那么最后保留下来的很可能就是
GMV这样的核心指标。 description会告诉大模型这个指标的业务含义,避免模型只认识名字、不理解定义。relevant_columns会把这个指标和具体字段关联起来,例如这里的fact_order.order_amount,这样后续生成 SQL 时就更容易定位到应该聚合哪个字段。alias则保留了“成交总额”“订单总额”这类不同说法,方便系统把用户问题里的表达和真实指标定义对齐。
另外还有一个工程细节值得注意:有些主键、外键字段在向量召回时并不容易自然被召回出来,但在真正写 SQL 时,多表关联又经常离不开它们。所以在实现里,系统还会通过元数据库把必要的主外键补回来,再在过滤阶段统一处理。也就是说,过滤阶段不是单纯做“删减”,它还会顺手保证真正生成 SQL 所需的关键结构没有丢。

过滤完成之后,表格信息通常会被整理成一个更精简的结果文件。例如,当前项目中的 yaml/filtered_table_infos.yaml,就是“过滤表格信息”这一步的示例结果。
结合这个示例,可以更容易理解“过滤表格信息”这一步主要做了什么:
- 它不会把前面召回到的所有表和所有字段原封不动保留下来,而是只保留当前问题真正需要的那一部分。
- 如果当前问题和“地区销售额”相关,那么最终可能只留下
fact_order和dim_region这两张核心表。 order_amount和region_name这种和问题直接相关的字段会被保留下来。region_id虽然不是用户问题里直接提到的字段,但因为它承担了主外键关联作用,所以也会一并保留。
也就是说,过滤表格信息这一步的目标,不只是“删掉无关内容”,而是把真正生成 SQL 必需的表结构上下文保留下来,并整理成更适合后续使用的 YAML 结构。到这一步为止,系统才算真正得到了“既相关、又完整、又能拿去生成 SQL”的上下文材料。
3.5 第五步:增加额外上下文

过滤之后,系统已经拿到了比较干净的表信息、字段信息和指标信息,但在真正生成 SQL 之前,流程图里还有一步“增加额外上下文”。这一步做的事情是:在“业务上下文”已经齐备之后,再把“环境上下文”补齐。
这一步补充的,主要是一些不直接属于表结构,但会影响 SQL 正确性的环境信息,例如:
- 当前时间
- 当前数据库类型
- 当前数据库版本
这一步看起来不起眼,其实很实用。例如,用户如果问“统计去年的销售总额”或者“统计上个月的销售额”,那模型必须知道“当前时间”是什么,才有可能正确推导出时间范围。
再比如,不同数据库或数据仓库引擎的 SQL 方言并不完全一致。即使整体都是 SQL,它们在函数、语法细节、版本支持上也可能存在差异。所以如果能够提前把数据库类型和版本告诉大模型,它生成的 SQL 往往会更贴近实际执行环境。
3.6 第六步:生成 SQL

到这一步,系统终于具备了生成 SQL 所需的主要上下文:
- 用户原始问题
- 经过筛选后的表信息、字段信息和相关指标说明
- 可能需要的字段取值,以及当前时间、数据库环境等补充上下文
这时再把这些信息一起交给大模型,才算真正具备了较高质量生成 SQL 的基础。
也可以说,前面那么多步骤,最终都是在为这一刻服务:让模型在“知道该查什么、知道能用哪些表字段、知道值该怎么写、知道环境是什么”的前提下生成 SQL。
3.7 第七步:校验 SQL
SQL 生成出来之后,流程图并没有立刻执行,而是先进入“校验 SQL”。这一步的目的,是在真正执行之前先做一次预检查,尽早发现语法问题或明显错误。
在当前项目里,这一步主要通过 EXPLAIN 来完成。它不会真正执行 SQL 查询本身,而是先让数据库分析这条 SQL。如果 SQL 语法没有问题,通常就能顺利通过;如果 SQL 本身存在错误,数据库就会直接报错。
所以这一步可以看作:先不急着查数,先看看这条 SQL 至少能不能被数据库接受。
3.8 第八步:校正 SQL
如果“校验 SQL”通过,流程图就会直接往下执行。如果校验失败,流程图右侧会走到“校正 SQL”。
这一步会把前面生成 SQL 时用到的上下文,再加上数据库返回的报错信息,一起重新交给大模型,让它基于错误信息去修正这条 SQL。
从更完整的工程设计看,校正后的 SQL 理论上还可以再回到校验步骤做一次循环校验,这样会更稳。但当前项目在这里做了简化:校正之后直接进入执行阶段,没有继续做多轮循环,以避免流程过于复杂。
3.9 第九步:执行 SQL
当 SQL 通过校验,或者经过校正后准备执行时,流程图的最后一步就是“执行 SQL”。这一步会真正把 SQL 发给数据库执行,得到结果后再返回给用户。到这里,整条问数链路才算完整闭环。
3.10 本节小结
如果用更凝练的话来概括这张图,可以这样理解:
- 先理解问题
- 再并行召回字段、指标和值
- 把召回结果整合并筛干净
- 补齐环境上下文
- 生成 SQL
- 校验、必要时纠正
- 最后执行并返回结果
如果用更偏工程化的表述,这个智能体的核心工作其实可以浓缩成一句话:根据用户问题,召回相关字段、字段取值和指标信息,再让大模型基于这些信息生成 SQL。
这样的设计有几个明显好处:
- 每一步职责清晰,便于理解与维护
- 过程可观察,方便前端实时展示智能体执行状态
- 出错时可以在局部节点修正,因此整体上比单次大模型生成更稳定,也更适合真实工程场景
你可以把它理解成:我们不是把“大模型”直接当成最终答案,而是把它放进一条经过设计的工作流里,让它在可控的上下文和规则下完成问数任务。
本章小结:
- 「电商问数」的核心不是“直接把问题交给大模型”,而是“先构建元数据知识库,再让智能体围绕问题调用这些知识”。
- 元数据知识库由元数据库、向量索引、全文索引三部分组成,分别负责结构化存储、语义召回和字段取值匹配。
- 问数智能体不会一次性生成 SQL,而是经过关键词抽取、多路召回、合并、过滤、补充上下文、生成、校验、纠错、执行等步骤。
换个更整体的角度来看,「电商问数」始终围绕两大模块展开:一部分负责把表、字段、指标和值整理成可检索的元数据知识库,另一部分负责围绕用户问题调用这些知识,最终生成并执行 SQL。到这里,你已经对「电商问数」项目的业务背景、数仓基础和整体架构有了一个完整的认识。接下来的章节,我们就会正式进入开发环境、基础服务和代码实现部分,一步一步把这套系统搭建出来。