10 | 实现召回类节点 - 字段、指标和维度值

10 | 实现召回类节点 - 字段、指标和维度值

项目地址:github.com/frontzhm/n2...

每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。

这是一篇系列文,请按顺序阅读。

本文目标

前面已经完成生成阶段:

  1. MySQL meta 保存表、字段、指标和关联信息
  2. Qdrant 保存字段与指标向量
  3. Elasticsearch 保存低基数维度值
  4. 项目结构已经完成一次重构

运行阶段首先通过 Jieba 从用户问题中提取关键词:

python 复制代码
async def extract_keywords(
    state: State,
    runtime: Runtime[RuntimeContext],
) -> State:
    query = state.get("query", "")
    extracted_keywords = jieba.analyse.extract_tags(query, allowPOS=allow_pos)
    keywords = [
        query,
        *(keyword for keyword in extracted_keywords if keyword != query),
    ]
    return {"keywords": keywords}

但关键词本身不能直接生成 SQL。

例如:

text 复制代码
北京今年销售额是多少?

系统至少需要继续找到:

text 复制代码
字段:fact_order.order_amount
指标:GMV
维度值:dim_region.province = 北京市

本文实现流程图中的三个并行召回节点:

text 复制代码
抽取关键词
   │
   ├─ 召回字段  → Qdrant column_info_collection
   ├─ 召回指标  → Qdrant metric_info_collection
   └─ 召回值    → Elasticsearch dim_value
             │
             ▼
        合并召回信息

本文暂不实现后面的 LLM 过滤表、过滤指标和 SQL 生成。


第一部分:项目召回功能实现

项目实现按以下顺序讲解:

text 复制代码
一、理解整体方案
    → 明确字段、指标和维度值分别去哪里检索

二、构造检索 Query
    → 使用三类 Prompt 扩词,并在 LLM 失败时降级

三、实现检索服务
    → Qdrant 召回字段和指标,Elasticsearch 召回维度值

四、接入 LangGraph
    → 实现三个并行节点、State 汇合和依赖注入

五、运行与验证
    → 配置 TopK、观察 SSE 事件、运行测试和调用 API

前半部分只讲当前项目的设计、代码和操作;通用概念集中放在文章后半部分的"信息召回科普"中。

一、先理解项目的整体方案

1. 本项目的召回链路包含什么

当前项目需要完成以下处理:

  1. state.query 获取原始问题
  2. 使用 state.keywords 作为基础 Query
  3. 根据字段、指标和值三种目标分别扩词
  4. 并行查询 Qdrant 和 Elasticsearch
  5. 按业务 ID 去重,记录分数和命中 Query
  6. 分别截取 TopK 后写入 State
  7. 等待三个召回节点完成后统一汇合

实现对应关系:

通用步骤 当前实现
原始问题 state.query
基础关键词 Jieba state.keywords
Query 扩展 LLM + prompts/extend_keywords_for_*
语义检索 TEI + Qdrant
关键词检索 Elasticsearch + IK
多路合并 按业务 ID 去重
排序 保留候选的最高分
截断 column_top_k / metric_top_k / value_top_k
汇合 merge_retrieved_info

当前三个节点只负责产生宽松候选,不在这一阶段确定最终 SQL 所需的最小字段集合。

2. 为什么字段、指标和值使用不同检索方式
2.1 字段和指标使用语义向量检索

用户说:

text 复制代码
销售额

元数据中可能写的是:

text 复制代码
字段名:order_amount
描述:订单支付金额
别名:成交金额、收入

字面不完全一样,但语义相近。

因此字段和指标使用:

text 复制代码
查询文本
→ TEI Embedding
→ 查询向量
→ Qdrant Cosine Similarity
→ 相似字段或指标
2.2 维度值使用全文和精确检索

用户问题中的"北京"通常需要命中真实值"北京市"。

这里关注的是:

  • 精确值
  • 中文分词
  • 短文本包含关系
  • 原始值来自哪个字段

因此维度值使用 Elasticsearch:

text 复制代码
北京
→ keyword 精确匹配
+ IK match_phrase
+ IK match
→ 北京市

这是一种混合召回:

text 复制代码
元数据概念 → 向量检索
真实业务值 → 全文检索
3. 召回链路整体结构
text 复制代码
用户问题
"北京今年销售额是多少?"
              │
              ▼
        extract_keywords
              │
              ├─ query: 北京今年销售额是多少?
              └─ keywords: 完整问题、北京、今年、销售额
              │
       ┌──────┼───────────┐
       ▼      ▼           ▼
字段扩词    指标扩词      值扩词
       │      │           │
       ▼      ▼           ▼
TEI 向量   TEI 向量      ES 文本查询
       │      │           │
       ▼      ▼           ▼
Qdrant     Qdrant     Elasticsearch
       │      │           │
       └──────┼───────────┘
              ▼
     merge_retrieved_info

新增的主要文件:

文件 职责
app/application/recall_service.py 扩词、多 Query 召回、去重排序
app/infrastructure/chat_model.py 创建 LangChain ChatModel
app/infrastructure/prompt_loader.py 加载 .prompt 模板
app/repositories/qdrant_repository.py Qdrant 向量查询
app/repositories/dim_value_repository.py ES 维度值查询
app/agent/state.py 保存三路查询和结果
app/agent/nodes.py 实现三个召回节点和汇合节点
app/main.py 创建长生命周期召回依赖
3.1 用一个问题完整走一遍字段召回

以用户问题为例:

text 复制代码
北京今年销售额是多少?

字段召回的目标不是立即确定最终字段,而是尽量把后续可能需要的字段放进候选集合。

完整调用链:

text 复制代码
recall_column()
  ├─ 从 RuntimeContext 获取召回依赖
  ├─ 组装基础 Query
  ├─ KeywordExpansionService 扩展字段概念
  └─ VectorRecallService 执行多 Query 向量召回
       ├─ EmbeddingClient 调用 TEI
       ├─ QdrantRepository 查询字段 Collection
       └─ 按字段业务 ID 去重、排序、截取 TopK

第一步,节点从 State 读取完整问题和 Jieba 关键词:

python 复制代码
state = {
    "query": "北京今年销售额是多少?",
    "keywords": [
        "北京今年销售额是多少?",
        "北京",
        "今年",
        "销售额",
    ],
}

extract_keywords 已经将完整问题放在第一位,因此召回节点可以直接使用这份基础 Query:

python 复制代码
base_queries = [
    "北京今年销售额是多少?",
    "北京",
    "今年",
    "销售额",
]

完整问题保留上下文,短关键词则更加聚焦,两种 Query 都有召回价值。

第二步,使用字段专用 Prompt 补充用户没有直接说出的字段概念:

text 复制代码
北京     → 可能需要"地区"字段
今年     → 可能需要"统计日期"字段
销售额   → 可能需要"订单金额"字段

合并 LLM 扩词后,最终查询可能是:

python 复制代码
queries = [
    "北京今年销售额是多少?",
    "北京",
    "今年",
    "销售额",
    "地区",
    "统计日期",
    "订单金额",
]

这里的 LLM 只负责生成字段检索表达,并不直接查询数据库。扩词失败时,节点会退回 base_queries 继续召回。

第三步,TEI 按 embedding.batch_size 将每条 Query 转成 1024 维向量:

text 复制代码
"销售额"   → [0.012, -0.034, 0.081, ...]
"地区"     → [0.027,  0.015, -0.042, ...]
"统计日期" → [-0.011, 0.063, 0.019, ...]

职责要区分清楚:

text 复制代码
LLM     → 补充检索词
TEI     → 把文本转换成向量
Qdrant  → 根据向量搜索相似字段

第四步,每个查询向量分别查询 column_info_collection

text 复制代码
Query:销售额
├─ fact_order.order_amount      0.81
├─ fact_order.order_quantity    0.52
└─ fact_order.discount_amount   0.48

Query:订单金额
├─ fact_order.order_amount      0.89
└─ fact_order.unit_price        0.55

Query:地区
├─ dim_region.province          0.84
├─ dim_region.city              0.79
└─ fact_order.region_id         0.66

同一个字段可能被多条 Query 命中,因此不能直接拼接所有结果。

第五步,VectorRecallService 按 payload 中的字段业务 ID 去重:

python 复制代码
# retrieved_column_infos
{
    "id": "fact_order.order_amount",
    "name": "order_amount",
    "table_id": "fact_order",
    "score": 0.89,
    "matched_queries": [
        "北京今年销售额是多少?",
        "销售额",
        "订单金额",
    ],
}

合并规则:

  1. 相同字段 ID 只保留一条
  2. 分数保留多次命中的最高值
  3. matched_queries 记录字段被哪些 Query 找到
  4. 按最高分降序排列
  5. 最终截取 column_top_k

第六步,节点把结果写回自己专用的 State 字段:

python 复制代码
return {
    "column_queries": queries,
    "retrieved_column_infos": results,
}

字段、指标和值召回是并行节点,因此字段分支不能覆盖公共 keywords。三个分支分别写入独立字段,最后由 merge_retrieved_info 汇合。

需要再次强调:字段召回得到的是宽松候选,例如其中可能仍有 customer_id 等噪声。后面的 filter_table 才负责选出生成 SQL 真正需要的最小字段集合。

text 复制代码
recall_column
→ 尽量不要漏掉正确字段

filter_table
→ 从候选中删除噪声并确定最终字段
3.2 指标召回和值召回有什么不同

指标召回与字段召回几乎是同一条链路:

text 复制代码
原问题 + 关键词
→ 使用指标专用 Prompt 扩词
→ TEI 生成 Query 向量
→ 查询 Qdrant 的指标 Collection
→ 按指标业务 ID 去重
→ 保留最高分并截取 TopK

它与字段召回共用 VectorRecallService,主要只有三个差别:

对比项 字段召回 指标召回
Prompt 字段概念扩展 指标名称和业务口径扩展
Qdrant Collection column_info_collection metric_info_collection
返回用途 确定表和字段 获得指标含义和计算口径

指标召回结果同样来自 Qdrant Point 的 payload,例如:

python 复制代码
{
    "id": "sales_amount",
    "name": "销售额",
    "description": "订单实付金额合计",
    "alias": ["成交额", "GMV"],
    "relevant_columns": ["fact_order.order_amount"],
    "score": 0.91,
    "matched_queries": ["销售额", "成交额"],
}

值召回的整体步骤也类似,但它不使用 TEI 和 Qdrant,而是直接查询 Elasticsearch:

text 复制代码
原问题 + 短关键词
→ 使用值召回专用 Prompt 扩词
→ Elasticsearch 精确匹配、短语匹配和分词匹配
→ 获取命中文档的 _source
→ 按文档业务 ID 去重
→ 保留最高分并截取 TopK

Qdrant 把附加元数据称为 payload,Elasticsearch 则把文档原始内容称为 _source。当前每个维度值文档只需保存三个业务字段:

python 复制代码
{
    "id": "dim_region.province.北京市",
    "column_id": "dim_region.province",
    "value": "北京市",
}

Elasticsearch Repository 先保留搜索分数和 _source

python 复制代码
{
    "document_id": "Elasticsearch 内部文档 ID",
    "score": 8.7,
    "source": {
        "id": "dim_region.province.北京市",
        "column_id": "dim_region.province",
        "value": "北京市",
    },
}

DimValueRecallService 合并多个 Query 的结果时,会将 source 展开,因此最终写入 State 的结果是:

python 复制代码
{
    "id": "dim_region.province.北京市",
    "column_id": "dim_region.province",
    "value": "北京市",
    "score": 8.7,
    "matched_queries": ["北京", "北京市"],
}

其中最关键的不只是 value,还有 column_id。它能将用户语言中的值关联回数据库字段:

text 复制代码
用户输入"北京"
→ 命中维度值"北京市"
→ 得知该值属于 dim_region.province
→ 为后续 SQL 提供过滤条件

后续生成的条件可能是:

sql 复制代码
WHERE dim_region.province = '北京市'

三路召回可以概括为:

召回类型 检索对象 存储系统 主要返回内容
字段召回 字段语义 Qdrant 字段 payload
指标召回 指标语义 Qdrant 指标 payload
值召回 真实维度值 Elasticsearch _source 中的 column_idvalue

二、构造三类检索 Query

4. 使用不同 Prompt 扩展三类 Query

项目已经准备了三个模板:

text 复制代码
prompts/
├── extend_keywords_for_column_recall.prompt
├── extend_keywords_for_metric_recall.prompt
└── extend_keywords_for_value_recall.prompt

同一个用户问题,在不同检索目标中应该扩展成不同概念。

问题:

text 复制代码
北京今年销售额是多少?

字段召回可以扩展:

json 复制代码
["地区", "统计时间", "订单金额"]

指标召回可以扩展:

json 复制代码
["销售额", "成交额", "GMV"]

维度值召回可以扩展:

json 复制代码
["北京", "北京市", "今年"]

如果只用一套通用扩词模板,字段名、指标名和值会混在一起,检索噪声会明显增加。

4.1 PromptLoader
python 复制代码
class PromptLoader:
    def __init__(self, prompt_dir: Path):
        self.prompt_dir = prompt_dir

    def load(self, name: str) -> str:
        path = self.prompt_dir / f"{name}.prompt"
        return path.read_text(encoding="utf-8")

Loader 会限制 Prompt 名称只能包含小写字母、数字和下划线,避免任意路径读取。

4.2 LangChain ChatModel

安装 LangChain 与 OpenAI 集成:

shell 复制代码
uv add "langchain[openai]"

项目不再自行拼装 /chat/completions 请求,而是创建 LangChain 标准 BaseChatModel

python 复制代码
import httpx
from dataclasses import dataclass
from langchain.chat_models import init_chat_model
from langchain_core.language_models.chat_models import BaseChatModel


@dataclass
class ChatModelResources:
    model: BaseChatModel
    http_client: httpx.Client
    http_async_client: httpx.AsyncClient

    async def close(self) -> None:
        self.http_client.close()
        await self.http_async_client.aclose()


def create_chat_model_resources(settings: LLMSettings) -> ChatModelResources:
    http_client = httpx.Client(
        trust_env=False,
        timeout=settings.timeout,
    )
    http_async_client = httpx.AsyncClient(
        trust_env=False,
        timeout=settings.timeout,
    )
    model = init_chat_model(
        model=settings.model_name,
        model_provider="openai",
        base_url=settings.base_url,
        api_key=settings.api_key,
        timeout=settings.timeout,
        http_client=http_client,
        http_async_client=http_async_client,
        http_socket_options=(),
    )
    return ChatModelResources(model, http_client, http_async_client)

标准 ChatModel 支持:

  • invoke() / ainvoke()
  • stream() / astream()
  • LCEL 管道
  • OutputParser
  • Tool Calling
  • LangGraph messages 流模式

这里没有设置 temperature=0,因为部分 GPT-5 兼容端点不接受该参数。

配置中的原模型:

text 复制代码
gpt-5.2-codex

已经被当前代理下线,因此改成代理仍然支持的:

yaml 复制代码
llm:
  model_name: gpt-5.2
4.3 KeywordExpansionService
python 复制代码
async def expand(
    self,
    query: str,
    base_queries: list[str],
    prompt_name: str,
) -> list[str]:
    prompt = PromptTemplate.from_template(
        self.prompt_loader.load(prompt_name)
    )
    chain = prompt | self.chat_model | JsonOutputParser()
    expanded_queries = await chain.ainvoke({"query": query})

    if not isinstance(expanded_queries, list) or not all(
        isinstance(item, str) for item in expanded_queries
    ):
        raise ValueError("LLM 扩词结果必须是字符串数组")

    return self.unique_queries(
        [*base_queries, *expanded_queries]
    )[: self.max_queries]

它负责:

  1. 加载正确的 Prompt
  2. 填入用户问题
  3. 调用 LLM
  4. 通过 JsonOutputParser 解析 JSON 字符串数组
  5. 与基础关键词合并
  6. 保持原顺序去重
  7. 限制最大 Query 数量

为什么限制 Query 数量?

因为每增加一条向量 Query,就会增加一次 Qdrant 查询;每增加一个值 Query,也会增加一次 ES 查询。

Query 不是越多越好,过度扩展会增加延迟和噪声。

5. LLM 扩词失败时的降级处理

LLM 扩词是召回增强,不是基础召回的唯一入口。

可能的失败包括:

  • LLM 服务超时
  • 模型下线
  • 返回内容不是 JSON
  • 返回了 JSON 对象而不是数组
  • API 临时限流

节点使用:

python 复制代码
async def _expand_or_fallback(...):
    try:
        return await recall.keyword_expansion_service.expand(...)
    except Exception as error:
        runtime.stream_writer(
            {
                "type": "warning",
                "message": f"扩展关键词失败,改用基础关键词:{error}",
            }
        )
        return KeywordExpansionService.unique_queries(base_queries)

也就是说:

text 复制代码
LLM 正常
→ 基础关键词 + LLM 扩词

LLM 失败
→ 只使用基础关键词

Qdrant 或 Elasticsearch 查询失败则不会静默忽略,因为此时核心召回已经不可用,继续生成 SQL 风险更高。

三、实现检索与结果合并

6. Qdrant Repository 增加向量查询

生成阶段已经有:

python 复制代码
reset_collection()
upsert()

运行阶段增加:

python 复制代码
async def search(
    self,
    collection_name: str,
    vector: list[float],
    limit: int,
    score_threshold: float | None = None,
) -> list[dict[str, Any]]:
    response = await self._client.query_points(
        collection_name=collection_name,
        query=vector,
        limit=limit,
        with_payload=True,
    )

返回内容同时保留:

python 复制代码
{
    "point_id": "Qdrant UUID",
    "score": 0.82,
    "payload": {
        "id": "fact_order.order_amount",
        "name": "order_amount",
        "description": "订单金额",
        ...
    },
}

这里真正用于业务去重的是 payload 中的业务 ID,不是 Qdrant UUID。

7. 多 Query 向量召回怎样合并

字段和指标共用 VectorRecallService

7.1 批量生成查询向量
python 复制代码
vectors: list[list[float]] = []
for start in range(0, len(queries), self.embedding_batch_size):
    vectors.extend(
        await self.embedding_client.aembed_documents(
            queries[start : start + self.embedding_batch_size]
        )
    )

每个批次一次发送多条文本,比每条文本单独请求更高效;同时复用 embedding.batch_size 限制批次,避免扩词较多时让 CPU TEI 单批压力过大。

7.2 并发查询 Qdrant
python 复制代码
result_groups = await asyncio.gather(
    *(
        self.repository.search(
            collection_name=collection_name,
            vector=vector,
            limit=top_k,
        )
        for vector in vectors
    )
)

假设查询词是:

text 复制代码
北京今年销售额
销售额
GMV
订单金额

同一个字段可能被多条 Query 召回:

text 复制代码
fact_order.order_amount
├─ 销售额:0.81
├─ GMV:0.76
└─ 订单金额:0.89

合并后:

python 复制代码
{
    "id": "fact_order.order_amount",
    "score": 0.89,
    "matched_queries": ["销售额", "GMV", "订单金额"],
    ...
}

规则是:

  1. 按业务 ID 去重
  2. 保存命中过它的所有 Query
  3. 排序分数取最高值
  4. 最终再截取 TopK

matched_queries 对后续调试非常有用,可以看到候选为什么被召回。

8. Elasticsearch 维度值召回

维度值 Repository 使用三个 should 查询:

python 复制代码
"should": [
    {
        "term": {
            "value.keyword": {
                "value": keyword,
                "boost": 8,
            }
        }
    },
    {
        "match_phrase": {
            "value": {
                "query": keyword,
                "boost": 4,
            }
        }
    },
    {
        "match": {
            "value": {
                "query": keyword,
                "operator": "and",
            }
        }
    },
]

三种查询分别解决:

查询 作用
term value.keyword 完全相同的值优先
match_phrase value 中文短语连续匹配
match value IK 分词后的全文匹配

例如"北京"召回:

python 复制代码
{
    "id": "dim_region.province.北京市",
    "column_id": "dim_region.province",
    "value": "北京市",
    "score": 18.5893,
    "matched_queries": ["北京"],
}

注意:ES 的 _score 与 Qdrant Cosine score 不是同一种分数,不能直接放在一起比较大小。

四、接入 LangGraph 运行时

9. 三个 LangGraph 节点怎样实现
9.1 字段召回
python 复制代码
async def recall_column(state, runtime):
    recall = _get_recall_dependencies(runtime)
    query = state.get("query", "")
    base_queries = state.get("keywords", []) or [query]

    queries = await _expand_or_fallback(
        runtime,
        recall,
        query,
        base_queries,
        "extend_keywords_for_column_recall",
    )
    results = await recall.vector_recall_service.recall(
        recall.column_collection,
        queries,
        recall.column_top_k,
    )
    return {
        "column_queries": queries,
        "retrieved_column_infos": results,
    }
9.2 指标召回

指标同样走向量召回,但使用:

text 复制代码
extend_keywords_for_metric_recall.prompt
metric_info_collection
metric_top_k
9.3 维度值召回

维度值不把完整疑问句作为首选 ES Query,而是优先使用提取词和 LLM 生成的值候选:

python 复制代码
base_queries = [
    keyword
    for keyword in state.get("keywords", [])
    if keyword != query
] or [query]

因为下面这样的完整问句不太可能是数据库真实值:

text 复制代码
北京今年销售额是多少?

而这些更可能是真实值:

text 复制代码
北京
北京市
10. 并行节点不能共同修改同一个 State 字段

三个召回节点并行执行。

错误做法:

python 复制代码
# 三个并行节点都返回 keywords
return {
    "keywords": expanded_keywords,
    "retrieved_column_infos": results,
}

LangGraph 可能无法判断同一轮应该保留哪一个 keywords

当前实现让每个分支写独立字段:

text 复制代码
recall_column
├─ column_queries
└─ retrieved_column_infos

recall_metric
├─ metric_queries
└─ retrieved_metric_infos

recall_value
├─ value_queries
└─ retrieved_value_infos

State:

python 复制代码
class State(TypedDict, total=False):
    query: str
    keywords: list[str]
    column_queries: list[str]
    metric_queries: list[str]
    value_queries: list[str]
    retrieved_column_infos: list[dict[str, Any]]
    retrieved_metric_infos: list[dict[str, Any]]
    retrieved_value_infos: list[dict[str, Any]]
    retrieved_info: dict[str, list[dict[str, Any]]]
11. 正确声明并行汇合

原来分别添加三条普通边:

python 复制代码
.add_edge("recall_column", "merge_retrieved_info")
.add_edge("recall_metric", "merge_retrieved_info")
.add_edge("recall_value", "merge_retrieved_info")

现在明确告诉 LangGraph:等待三个节点都完成后再汇合。

python 复制代码
.add_edge(
    ["recall_column", "recall_metric", "recall_value"],
    "merge_retrieved_info",
)

合并节点:

python 复制代码
async def merge_retrieved_info(state, runtime):
    return {
        "retrieved_info": {
            "columns": state.get("retrieved_column_infos", []),
            "metrics": state.get("retrieved_metric_infos", []),
            "values": state.get("retrieved_value_infos", []),
        }
    }
12. 使用 RuntimeContext 注入召回依赖

节点不能在每次请求中重复创建 TEI、Qdrant、ES Client 和 ChatModel。

因此定义:

python 复制代码
@dataclass
class RecallDependencies:
    keyword_expansion_service: KeywordExpansionService
    vector_recall_service: VectorRecallService
    dim_value_recall_service: DimValueRecallService
    column_collection: str
    metric_collection: str
    column_top_k: int
    metric_top_k: int
    value_top_k: int

FastAPI 启动时创建一次:

python 复制代码
@asynccontextmanager
async def lifespan(application: FastAPI):
    embedding_client = EmbeddingClient(...)
    qdrant_client = QdrantClient(...)
    es_client = ESClient(...)
    chat_model_resources = create_chat_model_resources(app_config.llm)

    application.state.runtime_context = {
        "recall": recall_dependencies
    }

    try:
        yield
    finally:
        await asyncio.gather(
            embedding_client.close(),
            qdrant_client.close(),
            es_client.close(),
            chat_model_resources.close(),
        )

LangChain ChatModel 没有公开 close(),因此项目显式创建同步和异步 httpx Client 后注入 ChatModel,再由 ChatModelResources.close() 释放连接池。 trust_env=False 用于避免本机 HTTP 代理影响连接,不会关闭 TLS 证书校验。

执行 Graph 时传入:

python 复制代码
graph.astream(
    initial_state,
    context=request.app.state.runtime_context,
    stream_mode="custom",
)

节点中获取:

python 复制代码
recall = runtime.context["recall"]

这保证一个 FastAPI 进程只维护一套长生命周期 HTTP Client 和 SDK Client。

12.1 后续怎样使用 LCEL 流式管道

SQL 生成可以直接组合 Prompt、ChatModel 和字符串解析器:

python 复制代码
chain = prompt | chat_model | StrOutputParser()

async for token in chain.astream(inputs):
    runtime.stream_writer(
        {
            "type": "llm_token",
            "content": token,
        }
    )

也可以让 LangGraph 使用 stream_mode="messages" 统一获取图中模型产生的 Token。当前召回阶段只需要结构化 JSON,因此使用 chain.ainvoke()

五、配置、观测与运行验证

13. 配置 TopK 和查询数量

新增配置:

yaml 复制代码
recall:
  column_top_k: 10
  metric_top_k: 5
  value_top_k: 20
  max_queries: 8
  vector_score_threshold: null

含义:

配置 含义
column_top_k 字段最终最多保留 10 条
metric_top_k 指标最终最多保留 5 条
value_top_k 维度值最终最多保留 20 条
max_queries 基础关键词与 LLM 扩词合并后最多 8 条
vector_score_threshold Qdrant 最低分数,可暂不设置

为什么暂时不设置固定向量阈值?

不同 Embedding 模型、文本长度和元数据写法会影响分数分布。没有评测集之前直接写死 0.7,可能会把真正需要的候选全部过滤掉。

初期更稳妥的方法是:

  1. 先使用 TopK
  2. 记录真实查询分数
  3. 建立测试问题集
  4. 再根据分布设置阈值
14. SSE 中新增召回事件

除了 progress,召回节点还会推送:

json 复制代码
{
  "type": "recall",
  "target": "column",
  "queries": ["北京今年销售额", "销售额", "订单金额"],
  "count": 5,
  "data": []
}

三个 target:

text 复制代码
column
metric
value

前端后续可以展示:

  • 实际使用了哪些扩展 Query
  • 每路召回多少条
  • 候选分数
  • 候选为什么被召回

扩词失败时会推送:

json 复制代码
{
  "type": "warning",
  "step": "extend_keywords_for_column_recall",
  "message": "扩展关键词失败,改用基础关键词:..."
}
15. 实际验证结果

查询:

text 复制代码
北京销售额

字段向量召回:

text 复制代码
fact_order.order_amount      0.4317
fact_order.order_quantity    0.4038
fact_order.customer_id       0.3311

指标向量召回:

text 复制代码
GMV    0.4938
AOV    0.4253

维度值召回:

text 复制代码
dim_region.province → 北京市    18.5893

LLM 字段扩词验证:

输入:

text 复制代码
北京今年销售额

输出:

text 复制代码
北京今年销售额
北京
销售额
地区
统计时间

这些结果说明:

  • Qdrant 能通过语义找到订单金额字段和 GMV 指标
  • Elasticsearch 能把"北京"匹配到真实值"北京市"
  • Prompt 能根据字段召回目标补充"地区、统计时间"

字段召回中仍然存在 customer_id 等噪声,这是正常的候选召回现象,后续"过滤表"节点会继续裁剪。

16. 运行与测试
16.1 确认元数据已经生成
shell 复制代码
uv run python scripts/rebuild_metadata.py
16.2 运行单元测试
shell 复制代码
uv run python -m unittest discover -s tests -v

当前:

text 复制代码
Ran 14 tests

OK
16.3 启动 API
shell 复制代码
uv run fastapi dev app/main.py
16.4 调用查询接口
shell 复制代码
curl -N -X POST http://127.0.0.1:8000/api/query \
  -H "Content-Type: application/json" \
  -d '{"query":"北京今年销售额是多少?"}'

前几个事件应该依次出现:

text 复制代码
抽取关键词 running/success
召回字段 running
召回指标 running
召回值 running
recall column/metric/value
合并召回信息 running/success

第二部分:信息召回科普

本部分只讲通用概念,不依赖当前项目的代码结构。如果只想跟着项目完成召回节点,可以先跳过这一部分。

1. 什么是召回

召回,英文通常是 Retrieval 或 Recall。

它表示从大量数据中快速取回一批"可能相关"的候选。

例如 Qdrant 中有 10 万个字段向量,用户问"销售额",系统不可能把 10 万个字段全部交给 LLM。

召回先把范围缩小:

text 复制代码
10 万个字段
→ 向量召回 Top 20
→ LLM 过滤 5 个
→ 生成 SQL 使用 3 个

召回阶段追求的是:

正确答案尽量不要漏掉。

它允许候选中暂时存在少量无关结果。

2. 召回率和准确率

召回率 Recall:

text 复制代码
真正相关的内容中,有多少被找回来了

公式:

text 复制代码
Recall = 找回的相关结果 / 全部相关结果

准确率 Precision:

text 复制代码
找回的内容中,有多少确实相关

公式:

text 复制代码
Precision = 找回的相关结果 / 全部找回结果

例如真正需要 4 个字段:

text 复制代码
系统召回 10 个字段
其中 3 个是真正需要的

那么:

text 复制代码
Recall = 3 / 4 = 75%
Precision = 3 / 10 = 30%

召回阶段通常优先提高 Recall,后续过滤和重排再提高 Precision。

3. 什么是 TopK

TopK 表示只保留分数最高的 K 条结果。

text 复制代码
TopK = 5
→ 只返回最相关的 5 条

K 太小:

  • 延迟低
  • 噪声少
  • 容易漏掉正确结果

K 太大:

  • 召回率可能提高
  • 延迟和 Token 消耗增加
  • 后续 LLM 更容易被噪声干扰

因此 TopK 需要通过真实问题集调优,不存在适用于所有项目的固定值。

4. 什么是分数阈值

分数阈值表示低于某个分数的结果直接丢弃:

text 复制代码
score_threshold = 0.6

但不同模型的余弦分数不可直接套用经验值。

例如:

  • 模型 A 的相关结果集中在 0.75~0.9
  • 模型 B 的相关结果集中在 0.35~0.55

对模型 B 使用 0.6 会导致零召回。

正确做法:

  1. 收集真实查询
  2. 标注相关候选
  3. 观察正负样本分数分布
  4. 选择阈值
  5. 持续评测

5. 什么是 Query Expansion

Query Expansion 就是查询扩展。

用户说:

text 复制代码
客单价

可以扩展为:

text 复制代码
平均订单金额
AOV
订单均价
每单消费金额

扩词能处理:

  • 同义词
  • 缩写
  • 中英文写法
  • 隐含字段需求
  • 不同业务表达

扩词的风险:

  • LLM 可能发散
  • 查询数量增加
  • 引入无关概念

所以需要专门 Prompt、去重、数量限制和后续过滤。

6. 什么是多路召回

多路召回表示使用不同方式同时寻找候选:

text 复制代码
向量召回
关键词召回
精确匹配
规则召回
历史行为召回

本项目当前有:

text 复制代码
Qdrant 语义向量召回
Elasticsearch 精确 + 全文召回

以后还可以增加:

  • 字段名精确匹配
  • 别名 BM25 检索
  • 指标 ID 精确匹配
  • 表关联规则补充 JOIN 字段

多路召回后必须统一去重,并保留候选来源。

7. 什么是混合检索

混合检索 Hybrid Search 通常指组合语义向量和关键词检索。

两者优势不同:

方法 优势 弱点
向量检索 理解同义表达和语义 精确 ID、短词可能不稳定
关键词检索 精确、可解释 同义词和隐含语义较弱

例如:

text 复制代码
"GMV"精确匹配指标别名
+
"成交规模"向量匹配 GMV 描述

组合后通常比只使用一种方式稳健。

8. 为什么不同检索分数不能直接相加

Qdrant Cosine 分数和 ES BM25 _score 的意义不同:

text 复制代码
Qdrant:向量方向相似程度
ES:词频、逆文档频率、字段长度、Boost 等综合结果

本次真实结果:

text 复制代码
Qdrant 字段分数:0.4317
ES 维度值分数:18.5893

不能得出"ES 结果比 Qdrant 结果相关 43 倍"。

如果以后要跨来源统一排序,需要:

  • 分数归一化
  • Rank Fusion
  • Reciprocal Rank Fusion(RRF)
  • 学习排序模型
  • LLM/Reranker 重排

当前项目按来源分别保留结果,不直接比较跨来源分数。

9. 什么是去重和 Rank Fusion

同一个字段可能被多条 Query 命中:

text 复制代码
销售额 → order_amount
GMV → order_amount
订单金额 → order_amount

不能把它当成三条不同字段交给 LLM。

最简单的融合方法:

text 复制代码
按业务 ID 去重
分数取最大值
记录 matched_queries

更复杂的方法可以考虑 RRF:

text 复制代码
RRF score = Σ 1 / (k + rank)

RRF 不直接依赖不同检索器的原始分数,而是使用每个结果的排名,适合融合多个分数体系不同的召回来源。

10. 召回、过滤和重排怎样配合

推荐流程:

text 复制代码
宽召回
→ 规则过滤
→ LLM 或 Reranker 重排
→ 生成最终上下文

在本项目流程图中:

text 复制代码
召回字段 ┐
召回指标 ├→ 合并召回信息 → 过滤表/指标 → 添加上下文
召回值   ┘

召回节点不应该擅自完成所有过滤,否则每路都可能过早删除对其他分支有用的信息。

11. 什么是降级策略

降级表示某个增强能力失败时,系统使用较基础但仍可工作的方案。

当前策略:

text 复制代码
LLM 扩词失败
→ 使用 Jieba 基础关键词继续召回

不适合降级的情况:

text 复制代码
Qdrant 不可用
ES 不可用
Embedding 维度错误

这些属于核心数据源异常,应该明确失败并记录错误,而不是假装召回成功。

好的降级策略需要回答:

  1. 哪个能力是增强项
  2. 哪个能力是核心项
  3. 降级后结果是否仍然可信
  4. 用户或监控能否知道发生了降级

12. 怎样评测召回效果

不要只用一两个示例判断召回是否好。

可以建立问题集:

用户问题 必须召回字段 必须召回指标 必须召回值
北京今年销售额 order_amount、时间、地区字段 GMV 北京市
各渠道客单价 渠道、订单金额、订单数 AOV 各渠道值
女性客户数量 性别、客户 ID 客户数

常用指标:

  • Recall@K
  • Precision@K
  • MRR
  • NDCG
  • 空召回率
  • 平均召回延迟
  • LLM 扩词失败率

对 NL2SQL 来说,还应该最终观察:

  • 正确表是否进入候选
  • 正确字段是否进入候选
  • 正确指标口径是否进入候选
  • SQL 执行正确率

本文小结

本文完成了运行阶段的三路召回:

  1. 使用专用 Prompt 扩展字段、指标和值 Query
  2. 使用 TEI 批量生成查询向量
  3. 使用 Qdrant 召回字段和指标
  4. 使用 Elasticsearch 精确匹配与 IK 全文召回维度值
  5. 多 Query 结果按业务 ID 去重、保留最高分和命中来源
  6. 三个 LangGraph 节点写入独立 State 字段
  7. 使用列表边等待三个并行节点完成后统一汇合
  8. 使用 FastAPI lifespan 和 RuntimeContext 管理共享客户端
  9. LLM 扩词失败时退回基础关键词
  10. 使用 TopK 和最大 Query 数控制延迟与噪声

当前链路:

text 复制代码
自然语言问题
→ 抽取关键词
→ 三路扩词与召回
→ 合并候选上下文

下一步可以实现"过滤表"和"过滤指标",从宽召回候选中选出生成 SQL 真正需要的最小集合。

相关推荐
颜酱14 小时前
15 | 安全执行 SQL 并返回查询结果
人工智能
神经蛙199614 小时前
🌍 别再硬编码中文了!Python Web 项目国际化(i18n)完全指南
后端·python
新芒14 小时前
海尔洗衣机智慧洗护:AI赋能洗烘护全面进化
人工智能
掘金酱15 小时前
「TRAE Work 实战帮」征文启动!你沉淀的经验,值得被看见!
前端·人工智能·后端
颜酱15 小时前
14 | 验证并修正 LLM 生成的 SQL
人工智能·python
AI创界者15 小时前
AIGC进阶】Sulphur-2 视频生成大模型离线实战:文生视频/图生视频本地一键部署整合包解压即用与调优指南
人工智能·aigc·音视频
颜酱15 小时前
13 | 使用 LangChain 生成 SQL
人工智能·python·langchain
LDZKKJ15 小时前
OpenAI暂停GPT-6训练:AI行业从“竞速“到“刹车“的分水岭
人工智能·gpt·语言模型·chatgpt·transformer
四方云15 小时前
录音转文字完整技术原理(ASR自动语音识别)技术文档
人工智能·机器人·语音识别·外呼系统·销售成长·拓客
奥莱维15 小时前
酒店客房智能控制如何提升睡眠与入住体验
大数据·人工智能