11 | 合并过滤召回结果 - 确定需要的表、字段和指标

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 做的是确定性数据整理:

  1. 保留原始召回结果,便于 SSE 展示和调试
  2. 收集三种来源中出现的字段 ID
  3. 按表对字段分组
  4. 将维度值挂到所属字段
  5. 保留召回分数、命中 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": [...],
}

这种结构存在三个问题:

  1. 值与它所属的字段分开,后续节点还需要自行关联 column_id
  2. 字段没有按表分组,过滤 Prompt 难以直接理解 Schema
  3. 指标的 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_idsunresolved_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 结果做代码级校验:

  1. 返回值必须是 JSON 对象
  2. 表 ID 必须存在于候选集合
  3. 字段 ID 必须属于对应候选表
  4. 每张表至少选择一个字段
  5. 重复字段按首次出现顺序去重

如果 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() 校验:

  1. 结果必须是字符串数组
  2. 每个指标 ID 必须存在于候选集合
  3. 指标 ID 按首次出现顺序去重
  4. 问题不需要指标时允许返回空数组

如果候选指标本身为空,服务不会调用 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

新增测试覆盖:

  1. 字段、指标关系和命中值的联合归一化
  2. 按表对候选字段分组
  3. 保留无法解析的字段引用
  4. 按业务 ID 过滤表、字段和指标
  5. 拒绝 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,给人展示时再使用 namedescriptionalias

7. Prompt 约束能代替代码校验吗

不能。

Prompt 是对模型的指令,不是安全边界。即使 Prompt 明确要求"只能从候选中选择",代码仍应该执行:

text 复制代码
类型校验
→ ID 白名单校验
→ 表字段归属校验
→ 去重
→ 输出统一对象

这条原则后续同样适用于 SQL 生成和 SQL 安全校验。

本文小结

本文完成了:

  1. 将字段、指标和值三路召回结果归一化
  2. 将值挂到所属字段
  3. 通过指标关系和值召回补充漏掉的字段候选
  4. 按表组织字段候选
  5. 并行过滤表字段和指标
  6. 校验 LLM 只能返回候选中的业务 ID

下一篇将根据过滤后的 ID,批量查询完整表、字段和关联信息,再补充日期与数据库方言,组装出最终 SQL 生成上下文。

相关推荐
肥晨16 小时前
CodeBuddy 自定义模型配置完整指南
人工智能
用户75621013325316 小时前
03. 从0开始学习硅基智能 - buddy-mlir DeepSeekR1模型导入编译与调度总结
人工智能
映翰通网络16 小时前
TI开发板上的边缘AI工具链:先认清OpenCV、GStreamer和TIDL
人工智能·嵌入式硬件·opencv·计算机视觉·ti
张彦峰ZYF16 小时前
全球开源大模型生态-从开放权重到开放智能系统:发展、进展、主力模型成就与方向分析
人工智能·开源·llm·agent
QYR-分析16 小时前
具身智能风口下,机器人AI计算机市场发展现状、痛点与趋势解析
人工智能·机器人
The_Ticker17 小时前
大宗商品行情API接入教程
开发语言·python·websocket·程序人生·区块链
用户83562907805117 小时前
如何使用 Python 合并多个 Excel 文件
后端·python
greenbbLV17 小时前
中小公司积分商城选型:SaaS与私有化优劣对比分析
大数据·运维·人工智能
一点一木17 小时前
🚀 2026 年 7 月 GitHub 十大热门项目排行榜 🔥
人工智能·github