Skip to content

V2|混合检索与 Schema Linking

实现状态:已完成。本页记录真实代码入口、调用关系、实测结果和面试复述骨架。

这一期学会什么:表字段召回、概念链接和 Join 正确性是三种不同问题;它们需要分层评测,不能把所有错误归给大模型。

本期链路

text
Intent → Domain → Hybrid Table Search → Rerank → Column Search → Schema/Value Linking → Join Graph → SQL

真实代码入口

模块文件核心类/函数
意图解析server/domain/intent.pyQueryIntentTimeRangeMetricMention
域路由server/domain/router.pyDomainRouter.route(question, now)
检索仓储server/search/repository.pySearchRepository(Protocol)、CandidateInMemorySearchRepository
OpenSearch BM25server/search/opensearch.pyOpenSearchRetriever.search_schema()
Milvus Denseserver/search/milvus.pyMilvusRetriever.search_schema()
混合检索server/search/hybrid.pyHybridSearchRepository_merge_and_fuse()
RRF 融合server/search/fusion.pyRRFFusion.fuse(bm25, dense, top_k)
Rerankerserver/search/reranker.pyBGERerankerFakeReranker_ensure_required_fields()
两级召回server/search/retrieval.pyTwoLevelRetriever.retrieve()SchemaContext
Schema Linkingserver/linking/schema.pySchemaLinker.link()SchemaLinkLinkType
Value Linkingserver/linking/value.pyValueLinker.link_values()TypedValue_ALIAS_DICT
Join Graphserver/linking/join_graph.pyJoinGraphTableRelationship(ORM)、JoinPath
检索评测server/evaluation/retrieval.pyRetrievalEvaluator.run()RetrievalReport.summary()
检索工作台 APIserver/api/retrieval_workbench.pyPOST /api/v1/admin/retrieval/inspect
数据库迁移migrations/versions/a1b2c3d4e5f6_v2_s05_表关系定义.pytable_relationship

调用顺序(完整请求链路)

text
用户问题

DomainRouter.route(question, now=date.today())
  → QueryIntent(含 primary_domain、time_range、filters、metric_mentions)

TwoLevelRetriever.retrieve(question, intent, datasource_id)
  ├── Level 1: SearchRepository.search_schema(object_types=["table"])
  │     ├── OpenSearchRetriever → BM25 top_k
  │     └── MilvusRetriever    → Dense top_k
  │     → HybridSearchRepository._merge_and_fuse()
  │       → RRFFusion.fuse(bm25, dense)  [去重 + RRF 分]
  │     → Reranker.rerank(question, candidates, top_n)  [精排]
  │     → table_candidates

  ├── Level 2: SearchRepository.search_schema(object_types=["column"], filter by tables)
  │     → 同上流程
  │     → _budget_trim()  [token 预算裁剪,保护主外键]
  │     → column_candidates

  → SchemaContext(tables + columns + estimated_tokens)

SchemaLinker.link(question, intent, schema_context)
  → SchemaLinkResult(links: metric→指标ID, filter→列, dimension→表)

ValueLinker.link_values(intent, schema_context, repository)
  → ValueLinkResult(typed_values: column_id + value + evidence)

JoinGraph.complete_schema(selected_tables)
  → JoinGraphResult(complete_tables 含桥接表,paths,pre_aggregation_required)

SchemaContext.to_prompt_schema()  → SQL Generator Context

测试入口

bash
# 意图解析 + 域路由(V2-S01 全部验收场景)
pytest tests/test_v2_intent_and_routing.py -v

# 检索、融合、降级、Reranker、两级召回、Token 预算(V2-S02/S03)
pytest tests/test_v2_search_and_fusion.py -v

# Schema Linking、Value Linking 消歧、Join Graph(V2-S04/S05)
pytest tests/test_v2_linking.py -v

# 全部测试(含 V0/V1 回归)
pytest tests/ -q

实测结果(2026-09-10,本地 Python 3.12.9,无外部服务)

271 passed in 5.72s

所有 V2 新增测试 88 个全部通过,V0/V1 共 183 个历史测试无回退。


三个典型案例

案例一:检索成功——"华东地区今年销售额同比"

text
输入:华东地区今年销售额同比增长多少

DomainRouter.route() 输出:
  primary_domain = "sales"
  domain_candidates = {"sales": 1.0, "product": 0.5, "store": 0.5}
  time_range = TimeRange(start=2026-01-01, end=2026-12-31, label="今年",
                         comparison_start=2025-01-01, comparison_end=2025-12-31)
  filters = [FilterCondition(concept="区域", value="华东")]
  metric_mentions = [MetricMention(text="销售额", resolved_metric_id="net_sales")]
  intent_type = COMPARISON

RRF 融合:
  BM25 命中 fact_order_item(rank=1)、dim_region(rank=2)
  Dense 命中 fact_order_item(rank=1)、fact_order(rank=2)
  RRF 结果:fact_order_item(0.0328)、fact_order(0.0161)、dim_region(0.0161)

SchemaLinker 输出:
  "销售额" → metric:net_sales(evidence=resolved_by_domain_router, confidence=0.95)
  "区域"   → dim_region.region_name(evidence=column_name_match, confidence=0.85)

ValueLinker 输出:
  "华东" → TypedValue(column=dim_region.region_name, value="East China", evidence=alias_dict)

JoinGraph.complete_schema(["fact_order_item", "dim_region"]) 补齐:
  complete_tables = ["fact_order_item", "dim_region", "dim_city", "dim_store", "fact_order"]

案例二:误召回——"营收怎么样"

text
输入:今年营收怎么样

DomainRouter 输出:
  "营收" 是歧义词(_AMBIGUOUS_METRIC_TEXTS 中)
  unresolved = ["营收"]
  requires_clarification = True
  clarification_prompt = '"营收"可能对应多种指标,您希望查询哪个?例如:销售额、毛利率、客户数?'

失败原因:用户没有指定具体指标,系统正确拒绝了静默猜测。
修复方法:用户明确"净销售额"或"收入(财务域)"后,系统可继续处理。

案例三:扇出失败——多事实表直接 JOIN

text
输入(假设):订单金额减去退款金额的净收入

JoinGraph 检测:
  fact_order 和 fact_refund 都以 "fact_" 开头
  check_fan_out() 输出:
    "多事实表 ['fact_order', 'fact_refund'] 直接 JOIN 可能导致金额翻倍,
    需要预聚合或使用 CTE 分别聚合后再关联。"
  requires_clarification = True

失败原因:两张事实表的粒度不同(订单维度 vs 退款申请维度),直接 JOIN 会产生重复计数。
正确做法:各自用 SUM() 聚合后再按 order_id 关联,或使用 CTE。

设计取舍记录

决策选择理由
Join Graph 存储PostgreSQL + 内存图不引入 Neo4j;关系数量(<1000条)不需要图数据库
时间解析规则匹配(不用 LLM)时间表达式需要确定性可重放;规则可测试;V3 可替换
域路由关键词得分(不用 LLM)V2 够用;V3/V4 可升级为向量分类;节省 LLM token
Value Linking 别名字典静态 Python 字典V3 迁移到 business_terms 表;V2 优先跑通链路
BM25+Dense 降级单路超时→另一路降级保证服务可用性;宁可召回率下降,也不要返回错误
Token 预算裁剪保护主外键,裁剪低分普通列JOIN 键缺失会导致 SQL 直接出错;普通列缺失只影响精度
RerankerFakeReranker 离线测试,BGEReranker 生产避免 CI 依赖 GPU/ML 框架

评测指标(实测门槛)

目前测试基于内存 Fake 仓储,无真实 OpenSearch/Milvus 数据,以下指标为待测状态。

指标目标当前状态
Domain Accuracy≥98%待接真实索引后实测
Table Recall@10≥97%待测(列召回按最终上下文 macro 口径)
Column Recall≥95%待测
Value Linking Acc-待测
Join Path Acc-待测

评测命令(接真实数据后运行):

python
from server.evaluation.retrieval import RetrievalEvaluator, compare_v1_v2
evaluator = RetrievalEvaluator(datasource_id=1, k=10)
report = await evaluator.run(questions, retriever, intent_parser, config="hybrid+rerank")
print(report.summary())
print(report.meets_v2_targets())

面试复述骨架

"检索决定候选,链接决定概念对应哪个对象,Join Graph 决定合法关系路径。我们用分层指标定位失败,避免把所有错误归给大模型。"

具体做法:

  1. 域路由先于 Schema 检索:不让全局搜索浪费在无关域的表,同时注入 now 保证时间可重放。

  2. Hybrid Search + RRF + Reranker:BM25 擅长精确编码词(SKU000392),Dense 擅长语义改写(苹果手机),两路融合后 Reranker 精排,主外键强制保留防止 SQL 缺少 JOIN 键。

  3. Value Linking 的层次:精确样例值 > 别名字典 > BM25/fuzzy > 向量回退,找不到证据绝不捏造,而是请求澄清。

  4. Join Graph 内存实现:关系定义存 PostgreSQL,运行时加载为邻接表,BFS 找最短路径补齐桥接表;多事实直接 JOIN 触发扇出警告,不因"外键存在"就自动放行。

  5. 分层评测:召回率、链接精度、JOIN 路径准确率独立计算;最终 SQL 错误可以追溯到具体的失败层(是召回缺失还是链接错误还是 SQL 生成问题)。


延伸阅读

需求 → 代码 → 验证 → 复盘