💻 源码
# 大规模 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,模型就会把箭头写反,语法合法但恒返回空。
- **裁剪逻辑要能被观测**。至少打日志记录"这次给了哪些标签",否则线上出现"某类提问
  总查不到"时,没法区分是裁错了还是模型生成错了。