11 | 合并过滤召回结果 - 确定需要的表、字段和指标
项目地址:github.com/frontzhm/n2...
每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。
这是一篇系列文,请按顺序阅读。
本文目标
上一篇已经完成三路召回:
text
字段召回 → Qdrant payload
指标召回 → Qdrant payload
值召回 → Elasticsearch _source
但三种结果的结构不同,而且都是"可能相关"的宽松候选,不能直接交给 SQL 生成节点。
本文完成:
text
三路原始召回结果
↓
统一为"表 → 字段 → 命中值"的候选上下文
↓
┌─────────┐
↓ ↓
过滤表字段 过滤指标
└────┬────┘
↓
汇合后进入下一节点
先看这一步的输入和输出
本文包含"合并"和"过滤"两个阶段,每个阶段的数据形状不同:
| 阶段 | 输入 | 输出 |
|---|---|---|
| 合并召回信息 | 字段、指标和值三个原始数组 | retrieved_info 统一候选结构 |
| 过滤表字段 | retrieved_info.tables |
table_infos |
| 过滤指标 | retrieved_info.metrics |
metric_infos |
1. 输入:三路原始召回结果
merge_retrieved_info 从 State 读取:
python
{
"query": "北京今年销售额是多少?",
"retrieved_column_infos": [
{
"id": "fact_order.order_amount",
"name": "order_amount",
"table_id": "fact_order",
"column_type": "decimal",
"role": "measure",
"description": "订单实付金额",
"alias": ["订单金额"],
"examples": ["99.00"],
"score": 0.89,
"matched_queries": ["销售额", "订单金额"],
}
],
"retrieved_metric_infos": [
{
"id": "GMV",
"name": "GMV",
"description": "成交金额",
"alias": ["销售额"],
"relevant_columns": ["fact_order.order_amount"],
"score": 0.91,
"matched_queries": ["销售额", "GMV"],
}
],
"retrieved_value_infos": [
{
"id": "dim_region.province.北京市",
"column_id": "dim_region.province",
"value": "北京市",
"score": 18.58,
"matched_queries": ["北京", "北京市"],
}
],
}
这时字段、指标和值仍然是三个相互独立的数组。
2. 合并输出:统一候选上下文
merge_retrieved_info 返回:
为了突出数据层级,下例省略了部分值为
None、空字符串或空数组的字段。
python
{
"retrieved_info": {
"tables": [
{
"id": "fact_order",
"name": "fact_order",
"columns": [
{
"id": "fact_order.order_amount",
"name": "order_amount",
"table_id": "fact_order",
"type": "decimal",
"role": "measure",
"description": "订单实付金额",
"alias": ["订单金额"],
"examples": ["99.00"],
"recall_score": 0.89,
"matched_queries": ["销售额", "订单金额"],
"candidate_sources": [
"column_recall",
"metric_relation",
],
"matched_values": [],
}
],
},
{
"id": "dim_region",
"name": "dim_region",
"columns": [
{
"id": "dim_region.province",
"name": "province",
"table_id": "dim_region",
"candidate_sources": ["value_recall"],
"matched_values": [
{
"id": "dim_region.province.北京市",
"value": "北京市",
"score": 18.58,
"matched_queries": ["北京", "北京市"],
}
],
}
],
},
],
"metrics": [
{
"id": "GMV",
"name": "GMV",
"description": "成交金额",
"alias": ["销售额"],
"relevant_columns": ["fact_order.order_amount"],
"recall_score": 0.91,
"matched_queries": ["销售额", "GMV"],
}
],
"unresolved_column_ids": [],
"unresolved_values": [],
}
}
这是后续两个过滤节点共用的候选输入。
3. 过滤输出:最小必要集合
filter_table 返回过滤后的完整候选对象,不只返回 ID:
过滤只删除未选中的表、字段和指标,不会删除已选对象的召回分数与命中证据。下例仅省略这些未变化字段。
python
{
"table_infos": [
{
"id": "fact_order",
"name": "fact_order",
"columns": [
{
"id": "fact_order.order_amount",
"name": "order_amount",
"description": "订单实付金额",
"matched_values": [],
}
],
},
{
"id": "dim_region",
"name": "dim_region",
"columns": [
{
"id": "dim_region.province",
"name": "province",
"matched_values": [
{"value": "北京市"}
],
}
],
},
]
}
filter_metric 返回:
python
{
"metric_infos": [
{
"id": "GMV",
"name": "GMV",
"description": "成交金额",
"relevant_columns": ["fact_order.order_amount"],
}
]
}
两个过滤节点是并行分支,所以它们不返回同一个字段。当两个节点都完成后,State 中会同时存在:
python
{
"table_infos": [...],
"metric_infos": [...],
}
本文不补全主外键、完整表结构、日期和数据库方言,这些属于下一篇"组装 SQL 生成上下文"。
第一部分:项目实现
1. 先明确三个节点的责任
1.1 合并召回信息
merge_retrieved_info 做的是确定性数据整理:
- 保留原始召回结果,便于 SSE 展示和调试
- 收集三种来源中出现的字段 ID
- 按表对字段分组
- 将维度值挂到所属字段
- 保留召回分数、命中 Query 和候选来源
这个节点不调用 LLM,同样的输入必须得到同样的结果。
1.2 过滤表和字段
filter_table 使用 LLM 从候选表和字段中选择最小必要集合。
例如:
text
候选:order_amount、customer_id、order_quantity、province
问题:北京今年销售额是多少?
保留:order_amount、province
删除:customer_id、order_quantity
1.3 过滤指标
filter_metric 从候选指标中选择问题真正需要的业务口径。
text
候选:GMV、AOV
问题:北京今年销售额是多少?
保留:GMV
删除:AOV
2. 为什么不能只合并三个数组
原始的合并结果是:
python
{
"columns": [...],
"metrics": [...],
"values": [...],
}
这种结构存在三个问题:
- 值与它所属的字段分开,后续节点还需要自行关联
column_id - 字段没有按表分组,过滤 Prompt 难以直接理解 Schema
- 指标的
relevant_columns可能没有被字段向量召回
因此候选字段不能只来自字段召回,而应该取三种来源的并集:
text
字段召回的 id
+ 值召回的 column_id
+ 指标的 relevant_columns
= 完整候选字段集合
3. 实现 CandidateContextBuilder
新建:
text
app/application/filter_service.py
CandidateContextBuilder 不依赖 Qdrant、Elasticsearch 或 LLM,只负责将已经召回的结果整理成统一格式。
3.1 先处理字段召回
字段 Qdrant payload 信息最完整,因此先用它创建候选字段:
python
for result in column_results:
column = ensure_column(result["id"], "column_recall")
column.update(
{
"name": result["name"],
"table_id": result["table_id"],
"description": result["description"],
"recall_score": result["score"],
"matched_queries": result["matched_queries"],
}
)
3.2 再补充指标关联字段
指标已经保存 relevant_columns:
python
for metric in metric_results:
for column_id in metric.get("relevant_columns", []):
ensure_column(column_id, "metric_relation")
如果 fact_order.order_date 没有被字段向量召回,但被候选指标引用,仍会进入字段候选集合。
3.3 把命中值挂到字段
python
for result in value_results:
column = ensure_column(result["column_id"], "value_recall")
column["matched_values"].append(
{
"id": result["id"],
"value": result["value"],
"score": result["score"],
"matched_queries": result["matched_queries"],
}
)
整理后,"北京"不再是一条孤立的 ES 命中记录:
python
{
"id": "dim_region.province",
"name": "province",
"table_id": "dim_region",
"candidate_sources": ["value_recall"],
"matched_values": [
{
"value": "北京市",
"score": 18.58,
"matched_queries": ["北京", "北京市"],
}
],
}
3.4 按表对字段分组
CandidateContextBuilder.build() 最终输出:
python
{
"tables": [
{
"id": "fact_order",
"name": "fact_order",
"columns": [
{
"id": "fact_order.order_amount",
"name": "order_amount",
"description": "订单实付金额",
"candidate_sources": [
"column_recall",
"metric_relation",
],
"matched_values": [],
}
],
},
{
"id": "dim_region",
"name": "dim_region",
"columns": [
{
"id": "dim_region.province",
"name": "province",
"matched_values": [
{"value": "北京市"}
],
}
],
},
],
"metrics": [
{
"id": "GMV",
"name": "GMV",
"relevant_columns": ["fact_order.order_amount"],
}
],
"unresolved_column_ids": [],
"unresolved_values": [],
}
unresolved_column_ids 和 unresolved_values 不交给 SQL 生成,但会保留数据异常,避免无声丢失。
4. 为什么现在不查 MySQL
由值召回或指标关系补入的字段,当前可能只有:
python
{
"id": "dim_region.province",
"name": "province",
"table_id": "dim_region",
"description": "",
"type": None,
}
这是刻意的职责边界:
text
合并召回信息
→ 整理候选和召回证据
过滤候选
→ 确定真正需要的 ID
添加额外上下文
→ 只对已选 ID 批量查询完整元数据
这样可以避免先查询大量完整 Schema,然后又被过滤节点删除。
5. 实现表字段过滤
项目使用:
text
prompts/filter_table_info.prompt
输出不使用可能重名的展示名称,而是使用稳定业务 ID:
json
{
"fact_order": ["fact_order.order_amount"],
"dim_region": ["dim_region.province"]
}
MetadataFilterService.filter_tables() 先调用 LCEL 管道:
python
prompt = PromptTemplate.from_template(
prompt_loader.load("filter_table_info")
)
chain = prompt | chat_model | JsonOutputParser()
result = await chain.ainvoke(
{
"query": query,
"table_infos": table_infos_json,
}
)
然后对 LLM 结果做代码级校验:
- 返回值必须是 JSON 对象
- 表 ID 必须存在于候选集合
- 字段 ID 必须属于对应候选表
- 每张表至少选择一个字段
- 重复字段按首次出现顺序去重
如果 LLM 编造:
json
{
"fact_order": ["fact_order.not_exists"]
}
服务会直接报错:
text
LLM 返回了表 fact_order 候选之外的字段:fact_order.not_exists
不能只在 Prompt 里要求"不要编造",代码必须完成最终校验。
6. 实现指标过滤
项目使用:
text
prompts/filter_metric_info.prompt
输出是指标 ID 数组:
json
["GMV"]
MetadataFilterService.filter_metrics() 校验:
- 结果必须是字符串数组
- 每个指标 ID 必须存在于候选集合
- 指标 ID 按首次出现顺序去重
- 问题不需要指标时允许返回空数组
如果候选指标本身为空,服务不会调用 LLM,直接返回空数组。
7. 在 LangGraph 节点中使用
7.1 合并节点
python
retrieved_info = candidate_context_builder.build(
column_results=state.get("retrieved_column_infos", []),
metric_results=state.get("retrieved_metric_infos", []),
value_results=state.get("retrieved_value_infos", []),
)
return {"retrieved_info": retrieved_info}
原始三路召回信息仍保留在 State:
text
retrieved_column_infos
retrieved_metric_infos
retrieved_value_infos
retrieved_info 则是交给后续节点的统一候选结构。
7.2 两个过滤节点
python
async def filter_table(state, runtime):
tables = state["retrieved_info"]["tables"]
table_infos = await service.filter_tables(state["query"], tables)
return {"table_infos": table_infos}
async def filter_metric(state, runtime):
metrics = state["retrieved_info"]["metrics"]
metric_infos = await service.filter_metrics(state["query"], metrics)
return {"metric_infos": metric_infos}
两个节点并行执行,并写入不同 State 字段:
text
filter_table → table_infos
filter_metric → metric_infos
因此不会产生 LangGraph 并行写冲突。
Graph 中的边保持为:
python
.add_edge("merge_retrieved_info", "filter_table")
.add_edge("merge_retrieved_info", "filter_metric")
.add_edge(
["filter_table", "filter_metric"],
"add_extra_context",
)
add_extra_context 会等待两个过滤节点都完成。
8. 注入长生命周期依赖
python
@dataclass
class FilterDependencies:
candidate_context_builder: CandidateContextBuilder
metadata_filter_service: MetadataFilterService
FastAPI 启动时创建:
python
filter_dependencies = FilterDependencies(
candidate_context_builder=CandidateContextBuilder(),
metadata_filter_service=MetadataFilterService(
chat_model=chat_model_resources.model,
prompt_loader=prompt_loader,
),
)
context = {
"recall": recall_dependencies,
"filtering": filter_dependencies,
}
召回和过滤共用同一个 ChatModel、PromptLoader 和 HTTP 连接池,不会在每次请求时重复创建 Client。
9. SSE 观测事件
归一化完成后推送:
json
{
"type": "candidates",
"table_count": 2,
"metric_count": 1,
"data": {}
}
过滤节点推送:
json
{
"type": "filter",
"target": "table",
"count": 2,
"data": []
}
target 有两种:
text
table
metric
这些事件可以帮助观察:
- 三路召回最终涉及哪些表
- 某个字段是被向量、值还是指标关系补入
- LLM 删除了哪些噪声候选
10. 运行测试
shell
uv run python -m unittest discover -s tests -p 'test_*.py' -v
当前:
text
Ran 18 tests
OK
新增测试覆盖:
- 字段、指标关系和命中值的联合归一化
- 按表对候选字段分组
- 保留无法解析的字段引用
- 按业务 ID 过滤表、字段和指标
- 拒绝 LLM 编造的字段 ID
第二部分:候选整理与过滤科普
本部分只讲通用概念,不依赖当前项目的具体文件结构。
1. 什么是数据归一化
数据归一化是将不同来源、不同形状的数据转换成统一内部格式。
text
Qdrant payload
Elasticsearch _source
MySQL row
↓
统一领域对象
后续业务代码只依赖统一对象,不需要知道数据来自哪个存储系统。
这类边界有时也被称为"防腐层":它防止外部系统的数据格式渗透到核心业务逻辑。
2. 什么是候选证据
只保留"这是一个候选"还不够,系统最好同时保留它为什么成为候选:
text
column_recall → 字段语义与问题相似
metric_relation → 被候选指标引用
value_recall → 用户的值命中该字段
证据可以用于:
- 调试错误召回
- 解释候选为什么出现
- 后续设计更精细的重排分数
- 建立召回评测数据集
3. 召回和过滤的关系
text
召回
→ 尽量不要漏掉正确候选
过滤
→ 从候选中删除与当前问题无关的信息
如果召回阶段已经漏掉正确字段,过滤节点无法凭空把它找回来。因此通常先进行宽召回,再进行严格过滤。
4. 过滤和 Rerank 是一回事吗
不完全是。
text
过滤
→ 决定保留或删除
Rerank
→ 为候选重新计分和排序
LLM 可以直接输出最终保留集合,也可以给每个候选打分后再根据阈值过滤。当前项目的候选量较小,使用"直接选择最小必要集合"更简单。
5. 什么是结构化输出
结构化输出表示模型不是返回一段自然语言,而是返回程序可解析的固定数据结构:
json
{
"fact_order": ["fact_order.order_amount"]
}
它的优点是:
- 代码可以稳定读取
- 容易做类型校验
- 容易拒绝候选之外的内容
- 适合写入 LangGraph State
但 JsonOutputParser 只能保证内容可被解析为 JSON,不能保证表和字段真实存在,因此仍然需要业务校验。
6. 为什么要使用 ID
展示名称可能重复:
text
fact_order.status
dim_customer.status
它们的字段名都可能是 status,但业务 ID 不同。
因此系统之间传递稳定 ID,给人展示时再使用 name、description 和 alias。
7. Prompt 约束能代替代码校验吗
不能。
Prompt 是对模型的指令,不是安全边界。即使 Prompt 明确要求"只能从候选中选择",代码仍应该执行:
text
类型校验
→ ID 白名单校验
→ 表字段归属校验
→ 去重
→ 输出统一对象
这条原则后续同样适用于 SQL 生成和 SQL 安全校验。
本文小结
本文完成了:
- 将字段、指标和值三路召回结果归一化
- 将值挂到所属字段
- 通过指标关系和值召回补充漏掉的字段候选
- 按表组织字段候选
- 并行过滤表字段和指标
- 校验 LLM 只能返回候选中的业务 ID
下一篇将根据过滤后的 ID,批量查询完整表、字段和关联信息,再补充日期与数据库方言,组装出最终 SQL 生成上下文。