10 | 实现召回类节点 - 字段、指标和维度值
项目地址:github.com/frontzhm/n2...
每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。
这是一篇系列文,请按顺序阅读。
本文目标
前面已经完成生成阶段:
- MySQL meta 保存表、字段、指标和关联信息
- Qdrant 保存字段与指标向量
- Elasticsearch 保存低基数维度值
- 项目结构已经完成一次重构
运行阶段首先通过 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. 本项目的召回链路包含什么
当前项目需要完成以下处理:
- 从
state.query获取原始问题 - 使用
state.keywords作为基础 Query - 根据字段、指标和值三种目标分别扩词
- 并行查询 Qdrant 和 Elasticsearch
- 按业务 ID 去重,记录分数和命中 Query
- 分别截取 TopK 后写入 State
- 等待三个召回节点完成后统一汇合
实现对应关系:
| 通用步骤 | 当前实现 |
|---|---|
| 原始问题 | 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": [
"北京今年销售额是多少?",
"销售额",
"订单金额",
],
}
合并规则:
- 相同字段 ID 只保留一条
- 分数保留多次命中的最高值
matched_queries记录字段被哪些 Query 找到- 按最高分降序排列
- 最终截取
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_id 和 value |
二、构造三类检索 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]
它负责:
- 加载正确的 Prompt
- 填入用户问题
- 调用 LLM
- 通过
JsonOutputParser解析 JSON 字符串数组 - 与基础关键词合并
- 保持原顺序去重
- 限制最大 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", "订单金额"],
...
}
规则是:
- 按业务 ID 去重
- 保存命中过它的所有 Query
- 排序分数取最高值
- 最终再截取 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,可能会把真正需要的候选全部过滤掉。
初期更稳妥的方法是:
- 先使用 TopK
- 记录真实查询分数
- 建立测试问题集
- 再根据分布设置阈值
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 会导致零召回。
正确做法:
- 收集真实查询
- 标注相关候选
- 观察正负样本分数分布
- 选择阈值
- 持续评测
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 维度错误
这些属于核心数据源异常,应该明确失败并记录错误,而不是假装召回成功。
好的降级策略需要回答:
- 哪个能力是增强项
- 哪个能力是核心项
- 降级后结果是否仍然可信
- 用户或监控能否知道发生了降级
12. 怎样评测召回效果
不要只用一两个示例判断召回是否好。
可以建立问题集:
| 用户问题 | 必须召回字段 | 必须召回指标 | 必须召回值 |
|---|---|---|---|
| 北京今年销售额 | order_amount、时间、地区字段 |
GMV | 北京市 |
| 各渠道客单价 | 渠道、订单金额、订单数 | AOV | 各渠道值 |
| 女性客户数量 | 性别、客户 ID | 客户数 | 女 |
常用指标:
- Recall@K
- Precision@K
- MRR
- NDCG
- 空召回率
- 平均召回延迟
- LLM 扩词失败率
对 NL2SQL 来说,还应该最终观察:
- 正确表是否进入候选
- 正确字段是否进入候选
- 正确指标口径是否进入候选
- SQL 执行正确率
本文小结
本文完成了运行阶段的三路召回:
- 使用专用 Prompt 扩展字段、指标和值 Query
- 使用 TEI 批量生成查询向量
- 使用 Qdrant 召回字段和指标
- 使用 Elasticsearch 精确匹配与 IK 全文召回维度值
- 多 Query 结果按业务 ID 去重、保留最高分和命中来源
- 三个 LangGraph 节点写入独立 State 字段
- 使用列表边等待三个并行节点完成后统一汇合
- 使用 FastAPI lifespan 和 RuntimeContext 管理共享客户端
- LLM 扩词失败时退回基础关键词
- 使用 TopK 和最大 Query 数控制延迟与噪声
当前链路:
text
自然语言问题
→ 抽取关键词
→ 三路扩词与召回
→ 合并候选上下文
下一步可以实现"过滤表"和"过滤指标",从宽召回候选中选出生成 SQL 真正需要的最小集合。