# 大规模 schema 下的 Cypher 生成：元数据怎么裁

配套 `generate_neo4j_cypher_node.py`。当前实现是把 `tcm_metadata.json` 整份塞进
SystemPrompt，本文讨论 schema 变大之后该怎么改。**这是方案文档，不是已实现的功能。**

## 现状与临界点

现在的 schema 规模：

| 项 | 数量 |
|---|---|
| labels | 6（Disease / Effect / Formula / Herb / Source / Symptom）|
| relationships | 6 |
| triples（标签-关系-标签） | 14 |
| 属性 | Herb 13 个、Formula 6 个，其余四个标签只有 `name` |

`tcm_metadata.json` 约 14.8 KB，全量塞进 prompt 每次调用多花大概 5–8k token（中文 JSON，
粗估）。这个量级下全量喂是**正确选择**：召回率 100%，没有裁剪逻辑要维护，也没有
"该给的没给"这类难查的 bug。别过早优化。

会出问题的信号，按出现顺序：

1. **成本**：schema 到几十 KB 时，每次生成都在为 99% 用不上的 schema 付钱。
2. **准确率下降**（比成本更早咬人）。这是关键——不是"塞不进上下文窗口"，而是
   **候选变多之后模型选错**。200 个标签里有 `Herb` / `HerbalMedicine` / `Medicine`
   三个近义标签时，模型挑错的概率显著上升。裁剪的首要目的是**提准**，省钱是副产品。
3. **上下文超限**：几百个标签、上千个属性时才会真正撞墙。

## 核心思路

`matched_*` 已经告诉你这次查询涉及哪些标签了 —— 这是**免费的、确定性的**过滤信号，
不需要再调一次大模型去猜。裁剪的主线就是：从 `matched_*` 命中的标签出发，
在 schema 图上做有限步扩展，只把这部分喂给模型。

下面五层按"该先上哪个"排序，不是全都要上。

---

## 第 1 层：从 matched_* 反查子图（首选，先做这个）

**做法**：`matched_*` 非空的键对应哪些标签是已知的（`MATCH_FIELDS` 里就有映射），
以这些标签为种子，在 triples 上做 1 跳（必要时 2 跳）BFS，只保留触达的
labels / relationships / triples。

举例，"人参有什么功效？"命中 `matched_herbs`，种子 `{Herb}`：

- 1 跳拿到 `Herb HAS_EFFECT Effect`、`Herb ALLEVIATES_SYMPTOM Symptom`、
  `Herb TREATS_DISEASE Disease`、`Herb FROM_SOURCE Source`、`Herb HAS_INGREDIENT Herb`、
  `Herb TREATS_DISEASE Symptom`
- 涉及标签收敛到 `{Herb, Effect, Symptom, Disease, Source}`，`Formula` 及其 6 个属性整块不用给

**跳数怎么定**：1 跳覆盖"这个实体的直接属性和邻居"，绝大多数提问是这类。需要 2 跳的
是"桥接型"提问——"治感冒的方剂里都有哪些药材"要走
`Disease <- Formula -> Herb`，种子是 `{Disease, Herb}`，1 跳各自扩展后并集里已经有
`Formula` 了，所以这种情况**种子有两个标签时 1 跳往往就够**。真正需要 2 跳的是种子只有
一个标签、但答案在两跳之外的提问。建议：**默认 1 跳；种子只有 1 个标签时放宽到 2 跳**。

**为什么这条排第一**：纯确定性、零额外 LLM 调用、零额外延迟，而且复用了已有的
`matched_*`。在当前 6 标签的图上就能立刻见效（Herb 类提问能省掉 Formula 那 6 个属性）。

**风险**：`matched_*` 全空时没有种子。此时现有代码已经直接返回 `[]` 不调模型，所以
这个洞不存在。但如果以后放宽成"无实体也生成"，要记得回退到全量 schema。

---

## 第 2 层：属性按需给（省的量最大）

属性是 schema 体积的主要来源——真实业务库里"20 个标签 × 每个 30 个属性"很常见，
属性占的字符数会远超 labels 和 triples 之和。当前库里 Herb 一个标签就占了 13 个属性。

**做法**，从省得多到省得少：

- **只给第 1 层筛出来的标签的属性**（和第 1 层天然合并，无额外成本）
- **属性名做向量召回**：把提问和属性名（带 description）一起 embed，只给 top-N。
  "人参是什么性味"只需要 `property_flavor`，不需要 `processing` / `place` / `traits`。
  这一步能用上现成的 `common/embedding_model.py`。
- **建一个属性别名表**：`性味 -> property_flavor`、`用法用量 -> usage`、`归经 -> meridian`。
  中文提问到英文属性名的映射是固定的，查表比向量召回更准更快，值得手工维护。
  `tcm_metadata.json` 里每个属性都有空的 `description` 字段，这个表就该填在那儿。

**注意**：`name` 属性永远要给——所有 WHERE 条件都靠它。

---

## 第 3 层：Few-shot 示例检索

schema 大了之后，光给结构还不够，模型需要看到"这类问题在这个图上长什么样"。

**做法**：攒一批 `(提问, 正确 cypher)` 对，embed 提问侧存进 faiss，生成时召回最相似的
3–5 条塞进 prompt。

这一层的收益常常**超过**裁 schema 本身：一条示例把标签、关系方向、属性名、LIMIT 习惯
全都示范了一遍，比一堆结构描述更有效。当前节点里那句写死的输出格式示例，其实就是
一条退化的 few-shot。

**冷启动**：从人工写 20–30 条常见提问开始。之后把线上验证通过的 cypher（第 5 层的
`is_all_validate_cypher` 为真、且实际有返回行的）自动回灌进这个库。

---

## 第 4 层：分层 schema / 领域切分

标签上百之后，schema 本身要有结构。

**做法**：给标签打领域标签（药材域 / 方剂域 / 诊断域 / 文献域），先用一次小模型调用
或关键词规则判定提问属于哪个域，再只加载该域的 schema。也可以做成两级 prompt：
第一级给"域清单 + 每域一句话说明"让模型选域，第二级给选中域的完整 schema。

**代价**：多一次 LLM 往返，且选错域会直接查不到。所以这层要等到第 1–3 层都压不住时再上。

---

## 第 5 层：结果反馈（兜底，不是裁剪）

裁剪必然带来"该给的没给"，需要一个自愈回路。

**做法**：`AgentState` 里已经预留了 `is_all_validate_cypher`。执行 cypher 后如果全部
返回空行，说明可能裁错了 —— 这时候放宽到"上一轮跳数 +1"或全量 schema，重生成一次。
最多重试一次，避免死循环。

配合 `EXPLAIN`（不是 `PROFILE`，`EXPLAIN` 不真正执行）可以在打库之前就验证语法和
标签/属性是否存在，把"用了不存在的属性"这类错误在执行前拦掉。

---

## 推荐落地顺序

| 阶段 | 触发条件 | 做什么 |
|---|---|---|
| 现在 | schema < 20 KB | **什么都不做**，全量喂 |
| 第一步 | 生成开始偶尔选错标签，或 schema > 30 KB | 第 1 层 + 第 2 层的"只给相关标签属性" |
| 第二步 | 准确率仍不够 | 第 3 层 few-shot |
| 第三步 | 标签上百 | 第 2 层的属性向量召回 + 第 4 层领域切分 |
| 全程 | —— | 第 5 层空结果回退，配 `EXPLAIN` 预校验 |

## 两个容易踩的坑

- **别裁掉关系方向信息**。triples 的价值就在方向。当前节点已经验证过这点：
  "《伤寒论》里有哪些方剂"必须生成 `(s:Source)<-[:FROM_SOURCE]-(f:Formula)`，
  因为 schema 里只有 `Formula FROM_SOURCE Source` 这一个方向。裁剪时保留了 label 却
  丢了对应 triple，模型就会把箭头写反，语法合法但恒返回空。
- **裁剪逻辑要能被观测**。至少打日志记录"这次给了哪些标签"，否则线上出现"某类提问
  总查不到"时，没法区分是裁错了还是模型生成错了。
