跳到主要内容

02 · 在图上找到相关的表

本章是全项目最精华的一支:find()(api/graph.py:279)。它把「graph-powered schema understanding」这句宣传语落到实处——一个问题如何变成图上几张精准的表。看完这一章,你就抓住了 QueryWeaver 区别于普通 Text2SQL 的核心。

2.1 它要解决的小问题

向量检索能命中「跟问题语义最像的表」,但光靠语义会漏 join 路径

举例:问「北京客户的订单总额」。向量检索大概率命中 customersorders,但如果这两张表不是直接外键相连,而是通过 order_items 中转,那 order_items 语义上跟问题一点不像、向量检索命中不了——可 SQL 里少了它就 join 不起来。

思路: 语义检索负责「找到起点」,图扩散负责「补齐路径」。两者结合,既准又全。

2.2 思路 / 直觉:先点亮,再扩散

第 1 步 LLM 把问题拆成「表描述」和「列描述」(各 ≤5 条)
↓ embedding
第 2 步 向量检索:每条描述在图里找最相似的 3 个 Table / Column 节点
↓ 得到「种子表」
第 3 步 从种子表出发,三路并行扩散:
├─ sphere :一跳邻居(外键直连的表)
├─ route :种子表两两之间的最短路,捞出路上的中间表和主键
└─ by-column:列命中 → 反查该列所属的表

第 4 步 按表名去重,合并成最终的一小片 schema

关键直觉:种子由「语义」决定,补全由「结构」决定。 语义找到你关心的表,结构保证你能把它们 join 起来。

2.3 第 1 步:LLM 生成检索用的描述

不是直接拿用户原话去做 embedding,而是先让 LLM 把问题翻译成 schema 语言——生成若干「可能相关的表描述 / 列描述」(api/graph.py:303-322,用 FIND_SYSTEM_PROMPT,api/config.py:142)。prompt 明确要求「泛化的描述、别带具体值、每类最多 5 条、按相关性排序」。

为什么绕这一步?因为图里存的是表/列的描述向量,用「描述」去匹配「描述」,比用「原始问题」去匹配「描述」语义对齐得多。表描述和列描述一起 embedding,再按数量切回两组(api/graph.py:331-335)。

2.4 第 2 步:向量检索命中种子

对每条描述向量,调 FalkorDB 的向量检索取 top-3 节点(api/graph.py:119-130 _find_tables):

CALL db.idx.vector.queryNodes('Table','embedding',3,vecf32($embedding))
YIELD node, score
MATCH (node)-[:BELONGS_TO]-(columns)
RETURN node.name, node.description, node.foreign_keys, collect({...列信息...})

命中表后顺手把它的列都 collect 出来。列描述那一路 _find_tables_by_columns(api/graph.py:141)类似,但命中的是 Column 节点,再 -[:BELONGS_TO]- 反查到表。多条 embedding 的检索用 asyncio.gather 并行跑。

2.5 第 3 步:三路图扩散(核心)

只有当第 2 步找到了种子表,才跑扩散(api/graph.py:354-362)。三路各解决一类遗漏:

(a) sphere —— 一跳邻居

_find_tables_sphere(api/graph.py:181):从种子表出发,沿「列→REFERENCES→列→所属表」走一跳,捞出外键直连的表

MATCH (node:Table {name: $name})
MATCH (node)-[:BELONGS_TO]-(column)-[:REFERENCES]-()-[:BELONGS_TO]-(table_ref)
-- 返回 table_ref 及其列

直觉:你问客户,大概率也要它直接关联的订单/地址。

(b) route —— 成对最短路,捞中间表

_find_connecting_tables(api/graph.py:219)是最妙的一步。把种子表两两配对,求每对之间的所有最短路(最长 6 跳),把路径上的节点收集起来:

UNWIND $pairs AS pair
MATCH (a:Table {name: pair[0]}), (b:Table {name: pair[1]})
MATCH p = allShortestPaths((a)-[*..6]-(b))
UNWIND nodes(p) AS path_node
WHERE 'Table' IN labels(path_node)
OR ('Column' IN labels(path_node) AND path_node.key_type = 'PRI')
-- 把路径上的 Table、以及主键列所属的 Table 都收进来

这一步专治开头那个例子:customersorders 之间夹着的 order_items,靠最短路被捞回来——哪怕它语义上跟问题毫不相关。combinations 生成两两配对(api/graph.py:233),这条查询超时给到 500 秒(api/graph.py:271),因为图上多源最短路可能较重。

(c) by-column —— 列命中反查表

即 2.4 里的 _find_tables_by_columns。当问题更贴近某个具体字段(如「按 SKU 统计」),表级检索可能不灵,但列级检索能命中 sku 列,再反查它所属的表。

2.6 第 4 步:去重合并

四路结果(表检索 + 列检索 + route + sphere)拼一起,_get_unique_tables(api/graph.py:370)按表名去重,同名只留第一次出现的,并顺手把外键 JSON 前面加上 "Foreign keys: " 前缀、把列的 OrderedDict 转成普通 dict。

最终 find 返回的是一个「表信息列表」,每项形如 [表名, 表描述, 外键串, 列信息列表]——这正是 03 章 AnalysisAgent _format_schema 期望的形状。

2.7 原理演示(示意,非源码)

# 示意,非源码:find 的骨架
async def find(question, graph):
# 1) LLM 把问题拆成 schema 描述
descs = llm_describe(question) # ["客户表", "订单金额列", ...]
vecs = embed(descs)
# 2) 向量检索命中种子表 / 列
seed_tables = vector_query(graph, "Table", vecs) # 语义最像的表
col_tables = vector_query(graph, "Column", vecs) # 列命中反查的表
names = [t.name for t in seed_tables]
# 3) 图扩散补齐 join 路径
sphere = one_hop_neighbors(graph, names) # 直连表
route = shortest_paths_between_pairs(graph, names) # 中间表/主键
# 4) 去重合并
return dedup_by_name(seed_tables + col_tables + sphere + route)
# 重点看:种子靠语义,route/sphere 靠图结构补全,二者缺一不可

2.8 边界与坑

  • top-3 是硬编码:每条描述只取 3 个节点(api/graph.py:120),描述最多各 5 条,所以种子表数量有上界——大而模糊的问题可能漏表。
  • 最短路 6 跳上限:[*..6],超过 6 跳能连上的表补不回来。
  • 扩散依赖外键真实存在:如果库里逻辑关联但没建外键约束,图上就没有 REFERENCES 边,route/sphere 全失效。QueryWeaver 只信 information_schema 里的真外键。
  • sphere / route 出错时吞异常返回空(api/graph.py:209-216:270-274),宁可少扩散也不让整个 find 崩。

2.9 代码地图

主题文件符号
找表主流程api/graph.pyfind
表向量检索api/graph.py_find_tables
列向量检索反查表api/graph.py_find_tables_by_columns
一跳邻居扩散api/graph.py_find_tables_sphere
成对最短路捞中间表api/graph.py_find_connecting_tables
按表名去重api/graph.py_get_unique_tables
找表用的 LLM promptapi/config.pyConfig.FIND_SYSTEM_PROMPT
embedding 封装api/config.pyEmbeddingsModel.embed