目录

text-to-SQL 的 RAG 实践:检索路由 + 权威源精读

为什么不做经典 RAG,以及 BM25 + 向量混合检索的工程落地

数分团队做 text-to-SQL,最常见的失败模式不是「SQL 语法写错」,而是看起来很像真的——表名对了一半、过滤条件漏了、业务编码用错了版本,跑出来的数字和口径完全对不上。

这篇文章分享一套我在实际项目中落地的方案:用本地混合检索做路由,用标准 SQL 全文做生成依据,而不是把文档 chunk 丢给 LLM 自由发挥。全文数据已脱敏,业务域、表名、编码 ID 均为虚构占位。

1. 背景与痛点

1.1 指标口径散落

典型数分团队的现状:

痛点表现
口径散落指标定义在个人目录、聊天记录、口头约定里
新人上手难不知道「订单量 / 指标X / 转化率」到底怎么算
LLM 胡编通用大模型能写出「语法正确」的 SQL,但表名、过滤、业务编码经常全错
同名多套算法同一指标名对应多个版本,没有明确的 active / deprecated 标记

1.2 我们要什么

目标不是「让 LLM 会写 SQL」,而是:

  1. 可复制:生成的查询能直接贴到大数据平台执行
  2. 可核对:每条输出都能回查到标准源文件
  3. 口径零偏差:业务过滤、聚合公式、JOIN 逻辑不允许被模型改写

这意味着:知识必须结构化沉淀在 Git 仓库里,检索层只负责「找到对的文件」,生成层只负责「改日期等运行参数」。


2. 核心选型:Retrieve-then-Read,而非经典 RAG

2.1 两种范式

flowchart LR subgraph RAG["典型 RAG"] Q1[用户问题] --> R1[检索 chunks] R1 --> P1[chunks 注入 prompt] P1 --> G1[LLM 自由生成答案] end subgraph MS["Retrieve-then-Read"] Q2[用户问题] --> R2[混合检索路由] R2 --> F2[精读标准 SQL 全文] F2 --> S2[Skill 规则约束] S2 --> G2[参数化 SQL 输出] end
维度典型 RAG本方案
检索目的供 LLM 综合生成定位该读哪个文件
生成依据检索 chunk(切片)标准 SQL 全文 + 规则
索引内容文档正文切块search_text(元数据 + SQL 头部),非正文
无匹配时易凑「看起来像」的答案reject: true 强制拒答
可追溯常仅「根据文档…」强制标注 参考文件 / metric_id
上下文开销chunk 拼进 prompt,随召回量上涨Top 1~3 候选 + 精读 1 个文件,token 基本恒定

2.2 为什么数分场景不适合经典 RAG 生成 SQL

风险点RAG 常见问题Retrieve-then-Read
表名 / JOIN 错误chunk 不全,模型补全已评审 SQL 全文,禁止改表名
编码 ID / 过滤遗漏切片切断 WHERE / CASE WHEN精读全文 + catalog 元数据
公式被简化根据片段重写白名单:只改 dt、日期、LIMIT
仓库无收录仍可能生成「类似」SQLreject: true → 明确拒答

结论:我们复用 RAG 的 「R」(BM25 + 向量混合检索),但不做 chunk → LLM 自由生成。规模假设是 ≤1000 条 catalog、≤200 个 SQL——小而精的标准资产库,不是开放域大语料。

2.3 顺带的收益:省 token

检索路由这个模式还有一个容易被忽略的好处——显著降低上下文 token 开销,而且和准确性是同向收益。

不做路由时,Agent 要判断「有哪些指标、该用哪条」,往往得把整个 catalog/<域>.yaml 全文读进上下文。随着指标增多,这个文件涨到数千行,每次问数都要吞掉大量 token,又慢又贵。有了检索路由后:

环节无路由有检索路由
找指标全文读 catalog(数千 token,随指标线性增长)Top 1~3 候选(约数百 token,基本恒定)
读 SQL可能多轮 grep、读多个文件渐进式披露,只精读命中的 1 个 path

省下来的 token 直接换来两个好处:一是成本与延迟更低;二是上下文越聚焦,模型「自由发挥」的空间越小,幻觉概率越低——这正是后文第 8 章「渐进式披露」的核心。

需要澄清的是:真正省 token 的是「路由 + 只精读命中项」这个模式,BM25 单路也能省;embedding 向量本身不为省 token 服务,它解决的是口语化问法的语义命中率。混合检索是在省 token 的基础上,把命中率也一并提上去。


3. 整体架构:分层知识 + 派生索引

3.1 三层知识 + 一层工具

flowchart TB subgraph user [用户] U1["分析师:查口径 / 复制 SQL"] U2["Agent 对话:问答 / 生成查询"] end subgraph layers [知识仓库] L1["① catalog 索引层<br/>有哪些、在哪、关键词"] L2["② SQL 知识层<br/>怎么算、用什么表、过滤什么"] L3["③ Table Registry<br/>表结构 / 枚举 / 分区"] L4["④ Agent Skills<br/>编排读取顺序与输出约束"] end U1 --> L1 U1 --> L2 U2 --> L4 L4 --> L1 L4 --> L2 L4 --> L3 L1 --> L2 L2 --> L3

各层职责:

层级路径回答的问题
catalog 索引catalog/*.yaml有哪些指标?在哪?别名/关键词?编码 ID?
检索索引(派生)catalog/search/Agent 语义/BM25 路由 Top-K
术语表catalog/glossary.yaml缩写定义、易混对比
SQL 知识<域>/metrics/detail/怎么算?过滤什么?粒度?
表元数据catalog/tables/有哪些字段?枚举?分区?
Agent Skills.agents/skills/先读什么、能改什么、禁止什么

设计原则:Agent 不凭训练数据猜口径,而是按 Skill 规定的顺序,从仓库内结构化资产中 检索 → 精读 → 生成

3.2 离线构建与在线查询

flowchart TB subgraph sources [Git 权威源] CatYAML["catalog/*.yaml"] Glossary["glossary.yaml"] T3["tables/_index.yaml"] SQLHeader["SQL 头部说明块"] end subgraph build [离线构建] Extract["extractors 抽取"] Chunks["chunks.jsonl"] BM25["bm25.pkl"] Vec["vectors.faiss"] Manifest["manifest.yaml"] end subgraph runtime [运行时] CLI["python search.py"] Agent["Agent Skill"] SQL["精读 1 个 path 的 SQL"] end CatYAML --> Extract Glossary --> Extract T3 --> Extract SQLHeader --> Extract Extract --> Chunks Chunks --> BM25 Chunks --> Vec BM25 --> Manifest Vec --> Manifest Agent --> CLI CLI --> SQL

离线构建:catalog + glossary + tables + SQL 头部 → 每条 active 条目生成 1 个 Chunk → chunks.jsonl(提交 Git)→ BM25 索引 + embedding 向量(本地二进制,gitignore)→ manifest.yaml

在线查询:自然语言 query → 过滤 domain / doc_type → BM25 + 向量 → RRF 融合 + 硬加分 → JSON 输出 → Agent 读取 candidates[0].path 对应 SQL 全文。

检索层只负责路由,不改变指标口径;命中后 Agent 仍须读取标准 SQL 全文并按 Skill 白名单改参。


4. 索引里到底放了什么

4.1 不是 SQL 正文,而是「指标说明书」

每条 catalog 条目的索引文本叫 search_text,由以下部分组成:

组成部分示例(脱敏)
条目类型与名称[metric] 业务域A 指标X(月度)
标识与域id: domain_a_metric_x_monthlydomain: 业务域A
catalog 元数据aliaseskeywordsgraincode_idssource_tables
SQL 头部(若有 path)用途过滤条件指标口径数据来源

为什么不索引 SQL 正文?

  • 正文动辄数百行,含大量 JOIN / CTE / 子查询,切块后极易丢失上下文
  • 路由阶段只需要「这条指标是干什么的」,不需要可执行逻辑
  • 生成阶段会精读完整 SQL 全文,索引层不承担生成职责

4.2 Chunk 抽取逻辑

构建时,extractor 从 catalog YAML 和 SQL 头部组装 search_text

def _build_search_text(
    doc_type: str,
    entry: dict,
    domain: str,
    entry_id: str,
    name: str,
    repo_root: Path,
    code_ids: list[str],
) -> str:
    parts = [
        f"[{doc_type}] {name}",
        f"id: {entry_id}",
        f"domain: {domain}",
    ]

    for key in ("aliases", "keywords", "grain", "source_tables"):
        value = entry.get(key)
        if value:
            parts.append(f"{key}: {', '.join(str(v) for v in value)}")

    if code_ids:
        parts.append(f"code_ids: {', '.join(code_ids)}")

    sql_path = entry.get("path")
    if sql_path:
        parts.append(f"path: {sql_path}")
        header = extract_sql_header(repo_root, sql_path)
        if header:
            parts.append(header)

    return "\n".join(parts)

编码 ID 的归并也值得注意:除了 catalog 顶层的 code_ids,还会从 code_id_rules(分时段编码版本)里自动抽取并去重,避免维护者手工复制遗漏:

def _collect_code_ids(entry: dict) -> list[str]:
    ids = [str(q) for q in (entry.get("code_ids") or [])]
    for rule in entry.get("code_id_rules") or []:
        ids.extend(str(q) for q in (rule.get("ids") or []))
    return list(dict.fromkeys(ids))

4.3 Chunk 类型

doc_type来源主键字段
metriccatalog/<域>.yamlmetrics[]metric_id
detaildetails[]id
dimdim[]id
glossarycatalog/glossary.yamlid
tablecatalog/tables/_index.yamlfqn
annotationcatalog/annotations/_index.yamlevent_id

不索引status != activetemp_scripts(临时探索脚本)。


5. 混合检索算法实现

这是整套方案的技术核心。实现分四路:分词 → BM25 → 向量 → RRF 融合 + 硬加分

5.1 查询全流程

flowchart TD UserQuery["用户自然语言 query"] FilterScope["过滤候选集<br/>--domain --doc-types"] ParallelSearch["并行两路检索"] ParallelSearch --> BM25Path["BM25 路径<br/>search_text 分词匹配"] ParallelSearch --> VecPath["has_vectors?"] VecPath -->|是| EmbedQuery["query 向量化<br/>bge-small-zh-v1.5"] EmbedQuery --> FaissSearch["FAISS 相似度 Top-K"] VecPath -->|否| SkipVec["跳过向量路"] BM25Path --> RRF["RRF 融合排名"] FaissSearch --> RRF SkipVec --> RRF RRF --> HardBoost["硬加分<br/>编码ID / metric_id / domain"] HardBoost --> TopK["取 Top 1~3 candidates"] TopK --> Decision{"判定结果"} Decision -->|分数过低| Reject["reject: true<br/>仓库未收录"] Decision -->|Top1与Top2接近| Ambiguous["ambiguous: true<br/>列出候选"] Decision -->|命中唯一 Top1| Hit["返回 path + metric_id"]

5.2 中英混排分词器

数分场景大量中英混排(指标X编码 1001dwd_domain_detail_di),通用英文分词器搞不定中文,纯字符级又太碎。我们的分词策略:

  • 拉丁字母 / 数字 / 下划线:按词切分(metric_id → 一个词)
  • CJK 连续段:同时发射单字(保证 1 字查询的召回)和重叠 bigram(保证「订单」作为整体匹配)
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
_LATIN_NUM_RE = re.compile(r"[a-z0-9_]+|\d+", re.IGNORECASE)

def tokenize(text: str) -> list[str]:
    text = text.lower()
    tokens: list[str] = []
    i, n = 0, len(text)
    while i < n:
        if _CJK_RE.match(text[i]):
            j = i
            while j < n and _CJK_RE.match(text[j]):
                j += 1
            run = text[i:j]
            tokens.extend(run)                    # 单字
            for k in range(len(run) - 1):
                tokens.append(run[k : k + 2])     # bigram
            i = j
            continue
        match = _LATIN_NUM_RE.match(text, i)
        if match:
            tokens.append(match.group().lower())
            i = match.end()
            continue
        i += 1
    return tokens

索引构建和查询共用同一分词函数,保证词表对齐。

5.3 BM25 路径

对每条 chunk 的 search_text 分词后,用 rank_bm25.BM25Okapi 建索引。查询时取 Top max(top_k * 5, 20) 构成排名列表。

BM25 的优势在数分场景非常明显:

  • 编码 1001、表名 dwd_domain_a_fact_detail_di 这类精确 token 不会被语义向量「平滑掉」
  • metric_id、精确 name 子串匹配稳定

5.4 向量路径

默认值
模型BAAI/bge-small-zh-v1.5(约 100MB,支持本地目录离线加载)
向量库FAISS IndexFlatIP(内积,向量 L2 归一化后等价余弦相似度)
规模≤1000 条暴力检索即可,无需 ANN

构建时:

model = SentenceTransformer(model_name)
texts = [c.search_text for c in chunks]
embeddings = model.encode(texts, normalize_embeddings=True)
index = faiss.IndexFlatIP(embeddings.shape[1])
index.add(embeddings)

查询时同样 encode 用户问句,与 vectors.faiss 做内积搜索。

vectors.faiss 或模型加载失败时,自动退化为 仅 BM25——索引仍可用,只是口语化命中率下降。

5.5 RRF 融合

对 BM25 排名和向量排名做 Reciprocal Rank Fusion(倒数排名融合):

\[ \text{RRF\_score}(d) = \sum_{i} \frac{1}{k + \text{rank}_i(d)} \]

其中 \(k = 60\)(可在配置中调整)。\(k\) 越大,不同检索通道的名次差异被压得越平。

def _rrf_scores(rank_lists: list[list[int]], k: int = 60) -> dict[int, float]:
    scores: dict[int, float] = {}
    for ranks in rank_lists:
        for rank, idx in enumerate(ranks):
            scores[idx] = scores.get(idx, 0.0) + 1.0 / (k + rank + 1)
    return scores

RRF 的好处是不需要两个通道的分数在同一量纲——BM25 的绝对分和余弦相似度直接加没有意义,但排名可以融合。

5.6 硬加分(融合后叠加)

RRF 解决「两路检索怎么合并」,硬加分解决「数分场景的精确匹配需求」:

规则加分说明
metric / detail / dim 基础+0.12 / +0.08 / +0.06问数场景优先指标条目
指定 --domain 且一致+0.1缩小业务域
query 含 metric_id / name 子串+0.5精确标识符命中
query 含编码 ID 且条目命中+0.3如「编码 1001」
methodology 关键词命中+0.2统计方法论检索
def _hard_boost(chunk, query: str, domain: str | None) -> float:
    boost = {"metric": 0.12, "detail": 0.08, "dim": 0.06}.get(chunk.doc_type, 0.0)
    q_lower = query.lower()

    if domain and chunk.domain == domain:
        boost += 0.1

    for key in (chunk.metric_id, chunk.entry_id, chunk.name):
        if key and str(key).lower() in q_lower:
            boost += 0.5
            break

    qids = re.findall(r"\b\d{3,5}\b", query)
    if qids and any(qid in chunk.code_ids for qid in qids):
        boost += 0.3

    return boost

5.7 拒答与歧义判定

融合 + 硬加分后,还有两个安全阀:

条件字段行为
Top1 分数 < 0.15reject: true回复「仓库未收录」,禁止编造 SQL
Top1 与 Top2 分差 < 0.05ambiguous: true列出候选差异,让用户选择
top_score = candidates[0]["score"] if candidates else 0.0
second_score = candidates[1]["score"] if len(candidates) > 1 else 0.0
reject = top_score < reject_threshold          # 默认 0.15
ambiguous = not reject and len(candidates) > 1 and (top_score - second_score) < ambiguity_gap  # 默认 0.05

这两个阈值是防幻觉的第一道结构化护栏:没有匹配就拒答,有多个接近匹配就交给人裁决,而不是让 Agent 擅自选一个或合并口径。


6. 构建与查询两条链路

6.1 索引构建

# 安装依赖(Python 3.11+)
pip install -r scripts/search/requirements.txt

# 完整构建(BM25 + 向量;首次下载 embedding 模型约 100MB)
python scripts/search/build_index.py

# 离线环境:仅 BM25
python scripts/search/build_index.py --skip-embedding

构建流程:

  1. collect_chunks():从 catalog / glossary / tables / annotations / methodology 抽取全部 active 条目
  2. 写入 chunks.jsonl提交 Git,MR 可 diff 检索文本变化)
  3. 构建 bm25.pkl本地生成,gitignore
  4. 尝试 embedding → vectors.faiss本地生成,gitignore
  5. 写入 manifest.yaml

成功输出示例:

Built index: 71 chunks -> catalog/search
  manifest: chunk_count=71, has_vectors=True, mode=hybrid

manifest.yaml 结构:

version: 1
built_at: '2026-06-23T02:51:24Z'
git_sha: 9df7a418...
embedding_model: BAAI/bge-small-zh-v1.5
hf_endpoint: https://hf-mirror.com
chunk_count: 71
has_vectors: true
has_bm25: true
search_mode: hybrid
domains: [业务域A, 业务域B, 业务域C]

6.2 Embedding 多源回退与降级

模型下载不是一帆风顺的。构建时的容错策略:

  1. 主 Hub 失败 → 自动切换备用镜像(如 hf-mirror.com ↔ huggingface.co)
  2. 全部失败 → 降级为 BM25-only,删除残留的 vectors.faissmanifest.has_vectors = false
  3. 查询时模型加载失败 → 同样降级为 BM25-only,不阻断检索

这意味着:索引永远可用,只是语义匹配能力有梯度——有向量时是 hybrid,没有时是 bm25_only。

6.3 在线查询

python scripts/search/search.py "业务域A 指标X" \
  --domain 业务域A \
  --doc-types metric,detail \
  --top 3

返回 JSON 示例(脱敏):

{
  "query": "业务域A 指标X",
  "reject": false,
  "ambiguous": false,
  "candidates": [
    {
      "rank": 1,
      "score": 0.82,
      "doc_type": "metric",
      "metric_id": "domain_a_metric_x_monthly",
      "entry_id": "domain_a_metric_x_monthly",
      "name": "业务域A 指标X(月度)",
      "path": "domain-a/metrics/domain_a_metric_x_monthly.sql",
      "domain": "业务域A",
      "match_reason": "指标X"
    }
  ],
  "search_mode": "hybrid",
  "indexed_at": "2026-06-23T02:51:24Z",
  "manifest_git_sha": "9df7a418..."
}

Agent 拿到 path 后,精读该 SQL 全文,进入生成阶段——检索层的职责到此结束。

6.4 索引与权威源的一致性

提交 Git本地生成原因
chunks.jsonlmanifest.yamlbm25.pklvectors.faiss权威源是 catalog;二进制可一键重建

维护规则:catalog / SQL 头部变更后必须重跑 build_index.py,并提交变更后的 chunks.jsonl + manifest.yaml。其他人 git pull 后本地执行一次构建即可。


7. 常驻检索 Daemon:把冷启动从 ~20s 降到亚秒级

7.1 问题

sentence-transformers 模型加载 + 首次 encode 在单次 CLI 调用中约需 20 秒。Agent 工作流里可能连续调用检索十几次,每次都冷启动不可接受。

7.2 方案

serve.py127.0.0.1:8765 启动一个轻量 HTTP daemon:

  • 进程内模型缓存SentenceTransformer 只加载一次,后续 query encode 复用
  • 索引热重载:监听 manifest.yaml 变更,自动 reload,重建索引后无需重启
  • 空闲自退:默认 7200 秒无请求后自动退出,释放内存
  • 透明加速search.py 默认探测 daemon,可用则走 HTTP,不可用则回退进程内检索——输出完全一致
# 进程级模型缓存
_MODEL_CACHE: dict[str, Any] = {}

def _get_model(model_name: str, hf_endpoint: str | None):
    model = _MODEL_CACHE.get(model_name)
    if model is None:
        model = SentenceTransformer(model_name)
        _MODEL_CACHE[model_name] = model
    return model

Daemon 启动时还会发一条 warmup 查询,确保第一个真实请求已经是热的。

7.3 配置

统一在仓库根 project.yaml 管理(个人可建 project.local.yaml 覆盖):

embedding:
  model: BAAI/bge-small-zh-v1.5
  hf_endpoint: https://hf-mirror.com

daemon:
  port: 8765
  idle_ttl: 7200

search:
  rrf_k: 60
  reject_threshold: 0.15
  ambiguity_gap: 0.05

重要embedding.model 改了必须重跑 build_index.py——查询期实际加载的模型以 manifest.yamlembedding_model(= 构建时写入值)为准,保证查询向量与文档向量出自同一模型。


8. 防幻觉设计:约束从哪里来

检索层解决「找对文件」,防幻觉解决「不许乱改」。核心策略是 知识边界 + 读取顺序 + 改动白名单

8.1 渐进式披露(Progressive Disclosure)

LLM 上下文有限,读得越多越容易「自由发挥」。按问题类型只加载必要文件:

问题类型第一步必读通常不读禁止
术语释义glossary.yamlSQL 全文用训练数据补全定义
指标/口径catalog/<域>.yamlSQL 正文(头部够用时)跳过 catalog 直接猜
表结构/枚举tables/_index.yaml → 单表详情其他表全量 glob 加载
生成查询 SQLsearch.py 路由 → SQL 全文表详情(仅改参时)从零重写「类似逻辑」

8.2 单一事实来源

flowchart TB G["glossary.yaml<br/>术语唯一来源"] C["catalog/*.yaml<br/>指标索引唯一来源"] H["SQL 头部<br/>口径消费契约"] T["tables/*.yaml<br/>物理 schema"] S["SQL 正文<br/>可执行逻辑"] G --> C C --> H H --> S H --> T T -.->|"冲突时以 H 为准"| H
问题权威来源
「指标X 是什么」glossary.yaml
「有哪些业务域A 指标」catalog/业务域A.yaml
「指标X 怎么过滤明细」SQL 头部 + catalog code_id_rules
「day_type 有哪些枚举」表元数据 columns.enum
「这条查询 SQL 怎么写」catalog 命中 → 标准 SQL 全文参数化

8.3 生成 SQL 的改动白名单

✅ 允许修改(运行参数)❌ 禁止修改(业务逻辑)
dt / 日期范围聚合公式、CASE WHEN 口径
LIMIT(预览抽样)is_valid、编码 ID 分段逻辑
生成说明注释块表名、JOIN 键、业务 WHERE
从零重写「类似逻辑」的 SQL

8.4 七条硬性规则(写入 Agent Skill)

  1. 术语必须引用 glossary.yaml;无收录 → 明确说「术语表未定义」
  2. 引用仓库内 pathmetric_id禁止臆造表名
  3. 复述 SQL 头部「指标口径」与「过滤条件」
  4. catalog 无匹配 → 明确说「仓库未收录」,禁止编造口径
  5. 生成 SQL 时只改参数区不改业务过滤与公式
  6. 知识类问题必须先读 catalog,不得凭记忆作答
  7. 表结构问题必须先读 tables/_index.yaml,未登记时不得臆造列名

8.5 生成物可追溯

输出的 SQL 必须在顶部附加生成说明块:

-- =============================================================================
-- 生成说明(请复制到大数据平台执行)
-- 生成工具: Agent(sql-metric-query Skill)
-- 参考指标: domain_a_metric_x_monthly
-- 参考文件: domain-a/metrics/domain_a_metric_x_monthly.sql
-- 时间范围: 2026-03-01 ~ 2026-05-31(最近 3 个完整自然月)
-- 生成时间: 2026-06-22
-- 注意: 本脚本由标准 SQL 参数化生成,未改业务口径;请自行核对分区后执行
-- =============================================================================

用户据此可回查标准文件、核对口径,发现偏差时提 MR 修正源 SQL,而非信任 Agent 记忆。


9. 工程取舍与适用边界

9.1 这套方案适合什么

维度假设
规模≤1000 条 catalog、≤200 个 SQL
知识形态结构化 YAML + 标准 SQL,非开放域 Wiki
准确性要求极高——错一个编码 ID 的代价远大于回答不够流畅
部署约束本地运行,无外部检索服务;embedding 模型下载后可离线
团队工作流Git MR 评审 = 知识门禁

9.2 这套方案不适合什么

场景原因替代
开放探索 / 大段 Wiki 问答没有标准 SQL 可精读局部 RAG 作 lookup 补充
仓库没收录也要给思路牺牲准确性明确拒答,引导沉淀标准 SQL
成百上千非结构化文档chunk 策略持续调参成本高经典 RAG + rerank
需要自动改公式/增维度改动白名单不允许改源 SQL 并走 MR

9.3 维护者如何提升命中率

幻觉往往源于知识缺口,而非模型能力不足:

  1. catalog 的 aliases / keywords 宁多勿少,覆盖团队口语
  2. 含编码 ID 的 SQL 必须写 code_ids 与分时段 code_id_rules
  3. SQL 头部字段完整(用途、过滤条件、指标口径、数据来源)
  4. catalog 变更后及时 build_index.py,保持 chunks.jsonl 与权威源一致
  5. 新缩写必须先登记 glossary.yaml

9.4 演进方向

阶段动作是否驱动 SQL 生成
短期完善 keywords / aliases / 头部注释否,提升路由命中率
中期annotations(数据回溯事件)纳入检索 chunk否,仅增强 lookup 提示
中期Wiki / 文档单独索引否,仅 lookup 补充
不建议RAG chunk 直接驱动 SQL 生成
不建议SQL 正文切块作主索引易丢 JOIN / 分段逻辑
不建议取消 reject 以「无匹配也能答」重新引入编造 SQL 风险

10. 关于上线部署的一点思考

目前这套方案还停留在「每人本地跑 CLI + 可选 daemon」的阶段,尚未做成线上服务。这里只谈一下后续如果要上线的思路,不展开工程细节。

好在整个架构对上线是友好的:检索服务本质上无状态、只读——它只吃索引产物(chunks.jsonl / bm25.pkl / vectors.faiss)、返回 pathmetric_id,既不执行 SQL 也不碰业务库。真要上线,大致就是把第 7 章那个本机 daemon 换成一个容器化的常驻服务(比如 FastAPI + uvicorn,embedding 模型打进镜像避免启动时联网),索引产物交给 CI 跑 build_index.py 构建、按 catalog 的 git_sha 版本化后分发,服务启动时拉取即可;catalog 更新走 MR 合并触发重建,沿用 daemon 已有的「监听 manifest 变更热重载」能力就能平滑发版。数据安全上也不必担心:线上索引的仍然只是脱敏后的 search_text(元数据 + SQL 头部口径),不是 SQL 正文,向量也在内网 encode。

先把本地这套跑顺、让 catalog 资产沉淀到位,是不是上线、什么时候上线,是水到渠成的工程选择题。 在团队规模和调用量真正上来之前,本地方案已经够用了。


11. 小结

回到开头的问题:text-to-SQL 的 RAG 应该怎么做?

我们的答案是:

  1. 知识先行:标准 SQL + catalog 索引 + 术语表 + 表元数据,全部结构化沉淀在 Git 里,MR 评审是知识门禁
  2. 检索做路由,不做生成:BM25 + 向量混合检索 + RRF 融合 + 硬加分,快速定位 Top 1~3 个 path;索引的是「指标说明书」,不是 SQL 正文
  3. 精读权威源:命中后读标准 SQL 全文,只改日期等运行参数
  4. 结构化安全阀reject 拒答 + ambiguous 列候选 + 改动白名单 + 输出可追溯
  5. 本地可运维:无外部服务依赖;chunks.jsonl 可 review;二进制一键重建;daemon 加速冷启动

一句话概括分工:

检索层负责快速找到对的 path;选型决策负责找到之后不许自由发挥。

如果你也在做数分场景的 text-to-SQL,希望这篇拆解对你有参考价值。核心不是选多强的模型,而是把知识边界画清楚,让模型在围栏里工作


参考技术栈

组件选型
关键词检索rank-bm25(BM25Okapi)
语义向量sentence-transformers + BAAI/bge-small-zh-v1.5
向量索引faiss-cpu(IndexFlatIP)
配置YAML(project.yaml
运行时Python 3.11+,本地 CLI + 可选 HTTP daemon(线上:容器化 FastAPI 服务 + 对象存储分发索引)