Prechádzať zdrojové kódy

feat(query):四种打法的 query 生成引擎(只到生成 query)

acquisition/query.py 实现四套生成:① 实质×创作阶段×需求(LLM) ② 形式×载体位置(LLM)
③ 搜索词扩展(suggest,xhs) ④ 多轴正交组合(机械拼接:实质×形式×阶段×动作×作用×知识类型)。
轴取自 scope_trees 节点 + 人工定义;prompts/{query_gen,form_query_gen}.txt 为提示词;
scripts/build_query_demo.py 产出 data/queries/demo.json(不落库)。设计见 开发文档/query构造.md。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SamLee 3 týždňov pred
rodič
commit
933f57b8bc

+ 140 - 0
acquisition/query.py

@@ -0,0 +1,140 @@
+"""生成 query 的 demo 引擎(只到「生成 query」为止:不搜索、不解构)。
+
+① 实质×创作阶段×需求点(LLM)  ② 形式×载体位置(LLM)  ③ 搜索词扩展(待接入真 sug)
+④ 多轴正交组合(机械):实质×形式×阶段×动作×作用×知识类型
+取自 scope_trees 节点 + 人工定义轴;设计见 开发文档/query构造.md。
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Optional
+
+from acquisition.suggest import suggest
+from core.config import Settings
+from core.llm import chat_json
+from core.prompts import load_prompt
+
+ROOT = Path(__file__).resolve().parent.parent
+TREES = ROOT / "scope_trees" / "trees_index.json"
+
+# ② 载体位置:载体 × 位置 的交叉(短视频/图片有封面,文章无封面);剧本/小说/长文属于实质,不在此
+CARRIERS = ["短视频", "图片", "文章"]
+POSITIONS = ["开头", "中间", "收尾", "封面"]
+CARRIER_POS = [f"{c}{p}" for c in CARRIERS for p in POSITIONS if not (c == "文章" and p == "封面")]
+# 分组形式(给前端「查看全部」展示 载体 × 位置 的交叉)
+CARRIER_POS_GROUPED = {c: [p for p in POSITIONS if not (c == "文章" and p == "封面")] for c in CARRIERS}
+# ④ 需求点(人工拟定):每个创作阶段下的细分需求,喂给 LLM 从中选 + 前端按钮展示
+DEMAND = {
+    "灵感": ["找方向", "找素材", "拆案例"],
+    "选题": ["选题", "爆款选题", "什么内容火"],
+    "脚本": ["开头钩子", "结构", "标题", "文案"],
+}
+# 制作屏蔽词:④ 过滤 + 前端标记(创作 vs 制作边界)
+BLOCK = ["剪辑", "调色", "参数", "导出", "软件", "生成", "插件", "渲染", "压制"]
+
+# ④ 多轴正交「组合」query(机械拼接)的人工定义轴
+ACTIONS = ["构思", "策划", "组织", "撰写", "改编", "润色"]            # 动作(待修改)
+STAGES = ["灵感", "选题", "脚本"]                                    # 阶段
+KTYPE_SUFFIX = {"what": "有哪些", "why": "为什么", "how": "怎么做"}   # 知识类型→句尾后缀
+MODALITIES = ["图文", "视频"]                                       # 知识模态(搜索筛选维度,不进 query 串)
+
+
+def sample_nodes(source_type: str, depths=(3, 4), limit: int = 20,
+                 under: Optional[str] = None) -> list[str]:
+    """读 trees_index.json,取某棵树的中层节点名(按出现序去重、封顶)。
+    under 给定时只取 path 含该分支的节点(如实质取 实质树/理念、手法取 形式树/架构)。"""
+    idx = json.loads(TREES.read_text("utf-8"))
+    out: list[str] = []
+    seen: set[str] = set()
+    for n in idx:
+        if n.get("source_type") != source_type:
+            continue
+        path = [x for x in (n.get("path") or "").split("/") if x]
+        if under and under not in path:
+            continue
+        if len(path) in depths:
+            name = n.get("name") or (path[-1] if path else "")
+            if name and name not in seen:
+                seen.add(name)
+                out.append(name)
+                if len(out) >= limit:
+                    break
+    return out
+
+
+def tactic2_form_llm(form_nodes: list[str], settings: Settings) -> list[dict]:
+    """② 形式树 × 载体位置 → LLM 正交生成自然 query(LLM 自行把书面形式标签理解成创作手法)。
+    返回 [{形式, 载体位置, query}],供前端表格从左到右展示正交。1 次批量调用。"""
+    user = json.dumps({"形式树": form_nodes, "载体位置": CARRIER_POS, "屏蔽制作词": BLOCK}, ensure_ascii=False)
+    try:
+        res = chat_json(load_prompt("form_query_gen"), user, settings=settings, timeout=120)
+        rows = res.get("rows") or []
+    except Exception:
+        rows = []
+    return [{"形式": r.get("形式", ""), "载体位置": r.get("载体位置", ""), "query": r.get("query", "")}
+            for r in rows if isinstance(r, dict) and r.get("query")]
+
+
+def tactic3_suggest(seeds: list[dict], settings: Settings) -> list[dict]:
+    """③ 搜索词扩展(仅小红书):每个种子 → keyword_v2 → 从相关帖挖候选搜索词。
+    seeds=[{query, 来源}];来源标明种子出处(实质+意图 / 形式+需求词)。"""
+    from acquisition.crawler import RateLimiter
+    rl = RateLimiter(min_interval_seconds=1.0)
+    out = []
+    for s in seeds:
+        q, origin = s["query"], s.get("来源", "")
+        try:
+            cands = suggest(q, settings=settings, rate_limiter=rl, limit=12)
+        except Exception as exc:
+            cands = [f"(失败: {str(exc)[:40]})"]
+        out.append({"seed": q, "来源": origin, "候选": cands})
+    return out
+
+
+def tactic4_llm(topics: list[str], settings: Settings) -> list[dict]:
+    """④ LLM 正交清洗:实质 × 创作阶段 × 需求点(人工拟定 DEMAND) → 自然 query(滤制作)。
+    返回逐条带正交三轴的行:[{实质, 阶段, 需求点, query}],供前端表格展示。1 次批量调用。"""
+    user = json.dumps({"实质": topics, "需求点表": DEMAND, "屏蔽制作词": BLOCK}, ensure_ascii=False)
+    try:
+        res = chat_json(load_prompt("query_gen"), user, settings=settings, timeout=120)
+        rows = res.get("rows") or []
+    except Exception:
+        rows = []
+    return [{"实质": r.get("实质", ""), "阶段": r.get("阶段", ""),
+             "需求点": r.get("需求点", ""), "query": r.get("query", "")}
+            for r in rows if isinstance(r, dict) and r.get("query")]
+
+
+def _nonleaf_d4(source_type: str, limit: int, under: Optional[str] = None) -> list[str]:
+    """取某棵树的【4级非叶子节点】名(实质 79 / 形式 47 / 作用 16…),按序采样封顶。
+    under 给定时只取该分支(如形式限 架构,避开 呈现 里的剪辑/后期等制作节点)。"""
+    idx = json.loads(TREES.read_text("utf-8"))
+    paths = {(n.get("path") or "") for n in idx if n.get("source_type") == source_type
+             and (not under or under in (n.get("path") or "").split("/"))}
+    d4 = [p for p in paths if len([x for x in p.split("/") if x]) == 4]
+    nonleaf = sorted(p for p in d4 if any(o != p and o.startswith(p + "/") for o in paths))
+    return [p.split("/")[-1] for p in nonleaf][:limit]
+
+
+def tactic_multiaxis(n: int = 36) -> list[dict]:
+    """④ 多轴正交组合(机械拼接):实质×形式×阶段×动作×作用×知识类型 → 拼成「组合 query」。
+    实质/形式/作用 取自分类树(4级非叶子),阶段/动作/知识类型 人工定义;模态不进 query。
+    组合空间 ~320 万,这里 round-robin 取不同轴做【采样】,避免爆炸。无 LLM、纯机械。"""
+    sz = _nonleaf_d4("实质", 8)
+    xs = _nonleaf_d4("形式", 6, under="架构")   # 限创作手法(架构),避开呈现里的制作节点
+    zy = _nonleaf_d4("作用", 6)
+    ktypes = list(KTYPE_SUFFIX.items())                       # [(what,有哪些),…]
+    stage_act = [(s, a) for s in STAGES for a in ACTIONS] + [("", "")]  # +「无动作」变体
+    rows = []
+    for i in range(n):
+        s_ = sz[i % len(sz)]
+        f_ = xs[i % len(xs)]
+        st, ac = stage_act[i % len(stage_act)]
+        zy_ = zy[i % len(zy)]
+        kt, suf = ktypes[i % len(ktypes)]
+        seg = (st + ac) if ac else ""                         # 脚本撰写 / 空
+        parts = [s_, f_] + ([seg] if seg else []) + [zy_, suf]
+        rows.append({"实质": s_, "形式": f_, "阶段": st or "/", "动作": ac or "/",
+                     "作用": zy_, "知识类型": kt, "query": " ".join(parts)})
+    return rows

+ 91 - 0
acquisition/suggest.py

@@ -0,0 +1,91 @@
+"""联想扩展:种子词 → 小红书 keyword_v2(返回相关帖)→ 从帖子标题/话题标签挖候选搜索词。
+
+⚠️ 小红书没有纯「搜索联想词」接口,所以这里是退一步:拿种子打 keyword_v2,从返回的
+【相关帖子】的 title + body_text 里的 #标签 + topic_list 挖候选词,不是搜索框下拉补全。
+(抖音只有 keyword 搜索接口、且与搜索共享强限流,已从 demo 移除,只保留小红书。)
+
+parse_suggest 纯函数可离线测;suggest 负责 HTTP(mirror search.py)。
+"""
+from __future__ import annotations
+
+import re
+from typing import Any, Optional
+from urllib.parse import urljoin
+
+import httpx
+
+from acquisition.crawler import RateLimiter
+from core.config import Settings
+
+SUGGEST_PATH = "/crawler/xiao_hong_shu/keyword_v2"
+_TAG = re.compile(r"#([^\s##]{2,20})")
+
+
+class SuggestError(RuntimeError):
+    pass
+
+
+def parse_suggest(response: Any, *, limit: int = 15) -> list[str]:
+    """从 keyword_v2 回包的相关帖挖候选搜索词:#标签/话题优先,标题兜底。去重截断。"""
+    if not isinstance(response, dict):
+        raise SuggestError("bad_response: not a dict")
+    if response.get("code") not in (0, "0"):
+        raise SuggestError(f"business_error: code={response.get('code')} msg={response.get('msg')}")
+    posts = ((response.get("data") or {}).get("data")) or []
+    tags: list[str] = []
+    titles: list[str] = []
+    for p in posts:
+        if not isinstance(p, dict):
+            continue
+        tags += _TAG.findall(f"{p.get('title', '')} {p.get('body_text', '')}")
+        for t in (p.get("topic_list") or []):
+            name = t.get("name") if isinstance(t, dict) else t
+            if name:
+                tags.append(str(name))
+        title = (p.get("title") or "").strip()
+        if title:
+            titles.append(title)
+    out: list[str] = []
+    seen: set[str] = set()
+    for c in tags + titles:          # 标签优先、标题兜底
+        c = c.strip()
+        if c and c not in seen:
+            seen.add(c)
+            out.append(c)
+            if len(out) >= limit:
+                break
+    return out
+
+
+def suggest(
+    keyword: str,
+    *,
+    content_type: str = "图文",
+    settings: Optional[Settings] = None,
+    http_client: Any = None,
+    rate_limiter: Optional[RateLimiter] = None,
+    env_file: str = ".env",
+    limit: int = 15,
+) -> list[str]:
+    """种子词 → 小红书候选搜索词列表(从相关帖挖)。失败抛 SuggestError。"""
+    settings = settings or Settings.from_env(env_file)
+    rate_limiter = rate_limiter or RateLimiter()
+    owns_client = http_client is None
+    client = http_client or httpx.Client()
+    try:
+        rate_limiter.wait("xhs_suggest")
+        url = urljoin(settings.crawler_base_url, SUGGEST_PATH)
+        body = {"keyword": keyword, "content_type": content_type, "sort_type": "综合", "cursor": ""}
+        try:
+            resp = client.post(url, json=body, headers={"Content-Type": "application/json"},
+                               timeout=settings.crawler_timeout)
+            resp.raise_for_status()
+            data = resp.json()
+        except httpx.HTTPError as exc:
+            raise SuggestError(f"http_error: {exc}") from exc
+        except ValueError as exc:
+            raise SuggestError("bad_json") from exc
+        return parse_suggest(data, limit=limit)
+    finally:
+        if owns_client:
+            client.close()

+ 15 - 0
prompts/form_query_gen.txt

@@ -0,0 +1,15 @@
+你在为「创作知识」做 query 正交生成。用户消息给一组【形式树】节点(创作的形式/手法分析标签,偏书面,如 条目列举 / 反差错位 / 戏剧张力 / 细节描绘)、一组【载体位置】(载体 × 位置 的交叉,如 短视频开头 / 图片封面 / 文章收尾)、一组【屏蔽制作词】。
+
+对每个 形式,挑 3-5 个**最贴切**的载体位置,各生成一条创作者真会在小红书/抖音搜的短句 query:
+- 先把书面的形式标签理解成创作者口语会说的手法(如 条目列举→列点/清单、反差错位→反差/反转、戏剧张力→冲突/张力、细节描绘→画面感),再结合该载体位置(哪个载体的哪个部分)生成自然口语 query;
+  例:形式『反差错位』×『短视频开头』→「短视频开头怎么用反差抓住人」;『条目列举』×『图片封面』→「图文封面怎么用清单感更吸睛」;『戏剧张力』×『文章收尾』→「文章结尾怎么留张力」;
+- 不是所有形式都适配所有载体位置——只生成讲得通的组合,跳过别扭的;
+- 只要「创作」(怎么想 / 怎么写 / 怎么呈现),**剔除「制作」**:含屏蔽制作词(剪辑/调色/参数/导出/软件…)或本质是「用工具产出成品」的,一律不要。
+
+每条 query 都要保留它的「形式」来源和「载体位置」。
+
+只输出一个 JSON 对象,rows 逐条列出:
+{"rows": [
+  {"形式":"<形式树节点>","载体位置":"<载体位置>","query":"<生成的搜索词>"},
+  ...
+]}

+ 15 - 0
prompts/query_gen.txt

@@ -0,0 +1,15 @@
+你在为「创作知识」做 query 正交生成。用户消息给【实质】、【需求点表】(按创作阶段分好的需求点,人工拟定)、【屏蔽制作词】。
+
+对每个实质,沿「创作阶段 × 需求点」正交,生成口语化的搜索 query:
+- 阶段和需求点**只能用「需求点表」里给的**,不要自己另造;
+- 每个实质尽量覆盖多个 (阶段 × 需求点) 组合;
+- 像真人搜的短句(如「历史视频选题怎么找」「健身脚本开头怎么写」「美食爆款选题有哪些」),带上该实质词;
+- 只要「创作」(怎么想 / 怎么选 / 怎么写),**剔除「制作」**:含屏蔽制作词(剪辑/调色/参数/导出/软件…)或本质是「用工具产出成品」的,一律不要。
+
+为每条 query 标注它的【阶段】和【需求点】(正交两轴,取自需求点表)。
+
+只输出一个 JSON 对象,rows 逐条列出(每条含正交三轴 + 生成的 query):
+{"rows": [
+  {"实质":"<实质>","阶段":"<需求点表里的阶段>","需求点":"<该阶段下的需求点>","query":"<生成的搜索词>"},
+  ...
+]}

+ 51 - 0
scripts/build_query_demo.py

@@ -0,0 +1,51 @@
+"""四种打法生成 query 的 demo:只到「生成 query」,写 data/queries/demo.json(不落库)。
+
+用法:PYTHONPATH=. python scripts/build_query_demo.py
+③ 联想 + ④ LLM 走真实接口,须在能访问 crawler.aiddit.com / OpenRouter 的环境跑。
+"""
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+
+from acquisition.query import (
+    ACTIONS, BLOCK, CARRIER_POS_GROUPED, DEMAND, KTYPE_SUFFIX, MODALITIES, STAGES,
+    sample_nodes, tactic2_form_llm, tactic4_llm, tactic_multiaxis,
+)
+from core.config import Settings
+
+ROOT = Path(__file__).resolve().parent.parent
+OUT = ROOT / "data" / "queries" / "demo.json"
+
+
+def main() -> None:
+    settings = Settings.from_env()
+    topics = sample_nodes("实质", depths=(3,), limit=12, under="理念")   # 实质:理念分支(事件/知识/情感…)
+    forms = sample_nodes("形式", depths=(4,), limit=20, under="架构")    # 形式:架构分支(叙事/修辞/创意…)
+    print(f"实质 {len(topics)}: {topics}")
+    print(f"形式 {len(forms)}: {forms}")
+
+    t2 = tactic2_form_llm(forms, settings)
+    # ③ 暂留空:小红书/抖音无真·搜索联想接口(keyword_v2 返回的是帖子不是联想词),待接入真 sug 再补
+    print(f"① LLM 实质: {topics[:5]}")
+    t4 = tactic4_llm(topics[:5], settings)
+    t5 = tactic_multiaxis(n=36)   # ④ 机械多轴组合(无 LLM)
+
+    data = {
+        "generated_at": int(time.time()),
+        "block": BLOCK,
+        "tactic1": {"name": "实质 × 创作阶段 × 需求", "需求点": DEMAND, "items": t4},
+        "tactic2": {"name": "形式 + 载体位置", "载体位置": CARRIER_POS_GROUPED, "items": t2},
+        "tactic3": {"name": "搜索词sug扩展", "items": [],
+                    "待接入": "小红书/抖音都没有真正的「输入框搜索联想」接口;keyword_v2 名字像联想、实际返回的是帖子。此页暂空,待接入真 sug 接口后再补。"},
+        "tactic4": {"name": "多轴正交组合", "items": t5,
+                    "人工轴": {"阶段": STAGES, "动作": ACTIONS, "知识类型": KTYPE_SUFFIX, "模态": MODALITIES}},
+    }
+    OUT.parent.mkdir(parents=True, exist_ok=True)
+    OUT.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
+    print(f"\n① {len(t4)} 条  ② {len(t2)} 条  ③ 待接入  ④ {len(t5)} 条(机械组合) → {OUT}")
+
+
+if __name__ == "__main__":
+    main()

+ 53 - 0
tests/test_query.py

@@ -0,0 +1,53 @@
+"""query demo 离线测:①②纯逻辑 + 树采样 + parse_suggest(不碰网络/LLM)。"""
+from __future__ import annotations
+
+import pytest
+
+from acquisition.query import (
+    CARRIER_POS, CARRIER_POS_GROUPED, KTYPE_SUFFIX, _nonleaf_d4, sample_nodes,
+    tactic_multiaxis,
+)
+from acquisition.suggest import SuggestError, parse_suggest
+
+
+def test_sample_nodes():
+    topics = sample_nodes("实质", limit=8)
+    forms = sample_nodes("形式", limit=8)
+    assert 0 < len(topics) <= 8 and 0 < len(forms) <= 8
+    assert all(isinstance(x, str) and x for x in topics + forms)
+
+
+def test_carrier_pos_and_form_sampling():
+    # ② 的载体位置 = 载体×位置交叉;文章无封面;形式树(架构分支)可采样
+    assert "短视频开头" in CARRIER_POS and "图片封面" in CARRIER_POS
+    assert "文章封面" not in CARRIER_POS            # 文章无封面
+    assert CARRIER_POS_GROUPED["文章"] == ["开头", "中间", "收尾"]
+    forms = sample_nodes("形式", under="架构", depths=(4,), limit=8)
+    assert 0 < len(forms) <= 8 and all(isinstance(f, str) and f for f in forms)
+
+
+def test_parse_suggest_mines_tags_and_titles():
+    resp = {"code": 0, "data": {"data": [
+        {"title": "健身脚本怎么写 #健身 #跟练脚本", "body_text": "教程 #减脂",
+         "topic_list": [{"name": "健身博主"}]},
+    ]}}
+    out = parse_suggest(resp, limit=10)
+    assert "健身" in out and "跟练脚本" in out and "减脂" in out and "健身博主" in out
+
+
+def test_multiaxis_assembly():
+    rows = tactic_multiaxis(n=12)
+    assert len(rows) == 12
+    r = rows[0]
+    assert {"实质", "形式", "阶段", "动作", "作用", "知识类型", "query"} <= set(r)
+    # 形式限架构:不应混进 剪辑/后期 等制作节点
+    forms = _nonleaf_d4("形式", 6, under="架构")
+    assert "剪辑组接" not in forms and "后期处理" not in forms
+    # 组合 query = 各轴机械拼接,句尾是知识类型后缀
+    assert r["query"].startswith(r["实质"] + " " + r["形式"])
+    assert r["query"].endswith(KTYPE_SUFFIX[r["知识类型"]])
+
+
+def test_parse_suggest_business_error():
+    with pytest.raises(SuggestError):
+        parse_suggest({"code": 10000, "msg": "x"})

+ 184 - 0
开发文档/query构造.md

@@ -0,0 +1,184 @@
+# 批量 Query 构造 · 设计脑暴
+
+> 目标:批量造出一大堆「好 query」,喂给小红书/抖音搜索,把全网**教创作的帖子**捞回来解构。
+> 配套:本目录《产品文档.md》《技术文档.md》《创作知识拆解框架.md》。本文是策略脑暴,非最终实现。
+
+---
+
+## 0. 先说清楚在干嘛
+
+搜索引擎不认识「创作知识」这种抽象概念,它只认**关键词(query)**——就是你在小红书搜索框敲的那几个字。
+所以问题变成:**怎么批量造出一大堆好 query。**
+
+**好 query 两条标准:**
+1. **真有人这么搜**(否则搜索返回空);
+2. **搜回来的帖是教创作的**(灵感/选题/脚本),不是教制作的(剪辑/调色/参数/器材)。
+
+> 一句话边界:我们要**创作**(怎么想 / 怎么判断 / 怎么搭框架),不要**制作**(怎么把成品做出来)。
+
+---
+
+## 1. 我们手里的素材:两棵正交分类树
+
+本地 `scope_trees/` 里 5 棵作用域树,其中**实质**和**形式**正是造 query 的两根主轴(和那个「制作 query 工厂」dashboard 的前两列同源):
+
+| 树 | 节点数 | 管什么 | 一级/二级骨架 |
+|---|---|---|---|
+| **实质** | 972 | 讲什么(**题材/对象**) | 理念(事件/情感/方法/现象/知识/观念) + 表象(声音/视觉) |
+| **形式** | 624 | 可迁移的**怎么呈现(手法/结构)** | 架构(修辞/创意/叙事/策略/逻辑) + 呈现(听觉/视觉) |
+
+- **实质 = 题材轴**:历史、商业、健身、美食、职场、情感、自然科学…
+- **形式 = 手法轴**:叙事视角、反转、悬念、对比反差、三幕结构、模拟扮演…
+
+> 那个 dashboard 是 7 轴正交(实质×形式×动作×作用×类型×模态×工具),但后 5 轴(生成/加工/成品/素材…)是一条**「把东西生产出来」的制作流水线**,不适用创作。我们**只留实质、形式两轴**,把后 5 轴整体换成一条**「创作阶段意图轴」**(灵感/选题/脚本)。
+
+---
+
+## 2. 关键判断:实质 × 形式 不能硬正交
+
+972 × 624 = **60 万组合**,绝大多数是语义噪声("军事事件 × 配乐"、"自然科学 × 修辞意象" 没人搜、也搜不到帖)。
+
+**两棵树在 query 里扮演的角色根本不同:**
+
+| 轴 | 在 query 里怎么用 | 例子 |
+|---|---|---|
+| **实质(题材)** | 当**主语**,× 创作意图 | "美食 选题怎么找"、"历史 脚本"、"健身 文案开头" |
+| **形式(手法)** | 当**独立知识点**,基本不配题材 | "叙事视角怎么选"、"反转结尾"、"三幕结构" |
+
+形式树本身就是创作知识的骨架——"悬念怎么设置" 要的就是可迁移手法,跟题材无关;硬塞题材("美食 悬念设置")反而打窄召回、多半噪声。
+
+**所以不是一个大正交,是两套生成逻辑:题材轴做「题材×意图」矩阵,形式轴做「手法单点」清单。**
+
+---
+
+## 3. 四种生成打法(逐个讲透 + 真实例子)
+
+### 打法① 题材 × 创作意图矩阵(主力,铺垂类)
+
+**大白话**:做填空题——左边一个**题材**,右边一个**创作意图词**,拼成 query。
+
+- 题材:从【实质树】叶子拿(剔掉太抽象的高层 理念/观念)。
+- 意图词:把 `灵感/选题/脚本` 翻译成创作者真会搜的词:
+
+| 阶段 | 真实搜索词 |
+|---|---|
+| 灵感 | 找素材、内容方向、案例拆解、灵感 |
+| 选题 | 选题、爆款选题、选题方向、什么内容火 |
+| 脚本 | 脚本、文案、开头、钩子、结构、标题、结尾 |
+
+**真实例子**——题材「健身」× 意图词:
+```
+健身 选题怎么找     健身 爆款选题     健身 视频脚本
+健身 文案开头       健身 标题怎么写   健身 内容方向
+```
+规模:几百题材 × ~10 意图 = **几千条**,条条像活人搜。负责"把各垂类铺开"。
+
+### 打法② 形式手法单点清单(最纯的创作金矿)
+
+**大白话**:【形式树】每个叶子本身就是一个"创作技巧名",直接当 query(+教学后缀),**不配题材**。
+
+**真实例子**——直接拿形式树叶子:
+```
+叙事视角怎么选     反转结尾怎么写     悬念怎么设置
+对比反差 文案      三幕结构讲故事     模拟扮演 玩法
+```
+**为什么最值钱**:召回的帖几乎一定是**纯方法论**(可迁移到任何题材),正中 form 知识,且基本不混进制作。→ **建议这批先跑。**
+
+### 打法③ 平台联想词 / 热搜 反向扩展(接地气,防瞎编)
+
+**大白话**:别闷头造词。在搜索框敲"健身 脚本"会跳出一排**下拉提示词**——那是平台告诉你"真实用户还在搜这些"。爬虫接口里**已有此能力**(小红书`联想关键词`/`热搜`/`垂类推荐`;抖音/知乎`搜索推荐词`)。
+
+**玩法**:把 ①② 的种子词丢进联想词接口,收回长尾真词。
+
+**真实例子**——种子「健身 脚本」→ 联想词可能返回:
+```
+健身博主脚本模板   健身vlog脚本怎么写   健身房探店脚本   减脂科普脚本
+```
+平台担保"有人搜、有帖",且是我们想不到的长尾。业界造 query 标准做法(种子 → 自动补全扩展)。
+
+### 打法④ LLM 当"正交器 + 清洗工"(去噪/口语化/滤制作)
+
+**大白话**:代码硬拼笛卡尔积会拼出一堆狗屁组合。改让大模型拼——喂"题材 + 三阶段定义 + 几个好 query 范例",让它像懂行运营,吐出**创作者真会打的、口语化的、不带制作味的** query。
+
+**真实例子**——输入 题材「历史」+ 说明"要灵感/选题/脚本,别要剪辑调色",LLM 输出:
+```
+✅ 历史类视频选题怎么找    ✅ 历史故事怎么讲得抓人
+✅ 历史科普开头怎么写      ✅ 冷门历史选题方向
+✗ 历史视频怎么剪辑   ✗ 历史视频调色参数   ← 自动不输出
+```
+一步搞定:**正交 + 去重 + 滤制作**。
+
+---
+
+## 4. 闭环:用「覆盖度」自动决定下一批搜什么
+
+前四种是"怎么造一批"。不该一次灌完,而要**会自我调整的循环**——盯着**树上哪些节点还空白**,专门补白处(即 SEO 圈的 content-gap analysis,也是 dashboard「维度覆盖度」的逻辑)。
+
+**一圈怎么转**(每步我们现在都有能力做):
+```
+① 造一批 query
+   ↓
+② 跑搜索 → 召回帖 → 解构出"颗"
+   ↓
+③ 每颗解构时本就会打作用域 → 命中【实质/形式树】具体节点
+   (现成能力:embedding 最近邻定位 scope_link)
+   ↓
+④ 统计每个节点被多少颗知识覆盖 → 画"覆盖度地图"(哪些满、哪些白)
+   ↓
+⑤ 挑"白的/稀的"节点 → 专为这些节点造下一批 query(回到①)
+   ↓
+   再转一圈,地图填得更满……直到满意
+```
+
+**比方**:像填涂色书——先随手铺底色,再看哪块白,**专门涂白处**,不重复涂已满处。
+
+**真实例子**:第一圈跑完——
+- 实质树「情感」分支:知识覆盖 60%(够了)
+- 实质树「自然科学」分支:知识覆盖 5%(大片空白)
+
+→ 下一批 query 重点补自然科学:"天文 选题怎么找"、"地理冷知识 脚本"、"科普 开头钩子",**不再生成情感类**。预算永远花在缺口,不重复爬热门。
+
+---
+
+## 5. 两个现实约束(来自实测)
+
+1. **单 query 召回有限**:小红书翻 3 页 58 条、去重只剩 35;抖音每页 ~10 但无重复。
+   → **广撒网、每个浅挖**(每 query limit ~10–20,别深翻),所以 query 要多、要散。
+2. **创作 vs 制作过滤前移**:query 层就用创作意图词(选题/脚本/叙事)偏置、屏蔽制作词(生成/参数/导出/软件/调色);解构链 `creation_gate` 再兜一道。两层一起,省爬取 + 解构预算。
+
+---
+
+## 6. 推荐的 Pilot:四种打法全用,只跑一小圈
+
+不追规模,目的是**验证流程跑通 + 产出确实是创作不是制作**。把四种打法串成流水线,再转**闭环第一圈**:
+
+```
+②形式单点 + ①题材×意图   →  ③联想词扩展  →  ④LLM清洗  →  跑解构  →  出覆盖度报表
+   (造种子)                (接地气放大)    (去噪滤制作)  (小批量)  (闭环第1圈)
+```
+
+**具体数字走一遍:**
+
+| 步 | 动作 | 量 | 例子 |
+|---|---|---|---|
+| 1 造种子(②+①) | 形式单点 ~30 + 题材×意图 ~120 | ~150 条 | `叙事视角怎么选`、`反转结尾`;`历史 选题`、`健身 脚本`、`美食 开头` |
+| 2 ③联想词放大 | 150 种子各打一次联想词 | ~500 条长尾 | `健身博主脚本模板`、`减脂科普脚本`… |
+| 3 ④LLM清洗 | 去重/去制作味/口语化 | ~300 条干净 | 留创作味,剔 `…怎么剪辑/调色` |
+| 4 跑解构 | 300 条 × 每条 ~10 浅挖 | ~3000 帖→去重~1500 | 喂现成解构链(创作闸+拆颗+作用域) |
+| 5 出覆盖度报表 | 闭环第 1 圈 | 1 张地图 | 看打中实质/形式树多少节点、哪空 → 定下一圈 |
+
+**Pilot 验证两件事:**
+1. 这 300 条 query 召回解构出的颗,**确实落在 灵感/选题/脚本**(创作),而非制作;
+2. **覆盖度报表跑得出来**——闭环能转。
+
+一圈跑通,再用覆盖度驱动放量到几千上万条 query。
+
+---
+
+## 7. 待定(往下落实现前要拍的)
+
+1. **种子主力**:②形式单点先行(最纯)还是 ①题材×意图先铺量?倾向 **②先行**。
+2. **意图词表 + 制作屏蔽词表**:决定 query 的"创作味"和"防制作"边界,需拟一版。
+3. **闭环何时接**:pilot 第一圈就做覆盖度报表,还是先把前 4 步跑通再加。
+
+> 参考:[Query Expansion in LLMs](https://arxiv.org/pdf/2509.07794) · [AmazonQAC 自动补全](https://arxiv.org/html/2411.04129v1) · [Content Gap Analysis & Topic Clusters](https://thatware.co/content-gap-analysis-with-topic-cluster-optimization/)