首页
/ last30days-skill 的 X 搜索 Retrieve-Judge-Retry:从 Rome 事故看如何用语料裁判修复离题洪流

last30days-skill 的 X 搜索 Retrieve-Judge-Retry:从 Rome 事故看如何用语料裁判修复离题洪流

2026-09-05 13:49:33作者:滕妙奇

本篇技术指南围绕 Retrieve-Judge-Retry 计划文档 展开,讲解 last30days-skill 如何修复 X(Twitter)搜索中"多词查询被短语引号包裹后返回大量离题内容"的问题:包括查询编译(Query Compilation)、扇出查询(Fanout Queries)、语料裁判(Corpus Judging)、一次性重试(Retry)与分裂式 FROM 提升(Split FROM Promotion)五个环节的完整设计。读完后,你将掌握该功能从 raw_topic 查询编译、x_judge 语料评分、重试触发条件,到 from:handle 通道门控的完整调用链,以及各项阈值常量的取值依据。

背景:一次真实事故暴露的级联失效

计划文档记录的 2026-08-14 "Rome 事故"是这项工作的直接动因。其失效链条有五个环节,每一环都在放大前一环的错误:

  1. Planner 生成了短语引号包裹的 search_query: "Rome Italy"
  2. 短语引号搜索只能命中极薄的结果,且夹杂着蹭流量的"美景城市"与地缘政治账号(PrettyCitiesX、visegrad24 等);
  3. entity_extract出现频率给这些离题账号排名,把离题账号排到了前面;
  4. pipeline.py 把这些高频率账号提升进 FROM 通道("该账号发的帖子");
  5. FROM 通道用离题时间线填满了 X 源的 40 个预算槽位。

关键教训是:频率不等于相关性。一个在"每个话题下都发帖"的蹭流账号会在任何检索结果中高频出现,按频率排名等于让垃圾账号购买免疫权。修复思路因此是"先检索、再裁判、必要时重试"(retrieve-judge-retry),而不是在检索之前猜对查询。

方案总览:七个环节各司其职

计划文档把解法拆成 R1–R7 七项,它们共同构成一条纵深防御链:

环节 内容 对应实现
R2 查询编译 X 与 Reddit/YouTube 一样使用 raw_topic,不再使用 planner 的 search_query pipeline.py_retrieve_stream_impl 的 X 分支
R3 扇出查询 多词主题用不带引号的 AND 作为第一变体,仅专有名词才用短语引号 grok_x.py_fanout_queries / _is_proper_name
R7 语料裁判 新增 x_judge.py 模块,检索后评估语料的离题比例 x_judge.py
R1 重试 检测到离题洪流(比例 < 0.4)时,在 X 流内部用简化关键词查询只重试一次(不走 _retry_thin_sources pipeline X 分支内的 retry 逻辑
R4 分裂 FROM 提升 显式 handle 永远走 FROM 且不 AND 主题;抽取 handle 需满足"≥2 条在题命中且 ≥50% 比例"才提升,且必须 AND 主题 x_judge.promotable_handles + grok_x.search_handles(and_topic=...)
R5 第一方豁免 Floor 免疫保持保守,只给显式 handle pipeline.pyfirst_party_for_normalize
R6 状态报告 离题洪流产生 artifact 警告,而不是 record_failure(PARTIAL) pipeline X 分支的 artifact["_warnings"]

查询编译:X 源改用 raw_topic

修复的第一刀在查询来源。_retrieve_stream_impl 的 X 分支现在这样编译查询:

if source == "x":
    # Compile X query from raw_topic (like Reddit/YouTube), not planner's
    # search_query which may contain operator strings like "Rome Italy".
    x_query = raw_topic or topic or subquery.search_query
    ranking_query = subquery.ranking_query

pipeline.py。这与 Reddit、YouTube、TikTok、Instagram 各分支的处理完全一致(均以 raw_topic or subquery.search_query 编译),意味着 X 源不再继承 planner 可能携带的算符字符串或短语引号变体。_fetch_x_backend 的 docstring 也明确记录了这一契约:query 参数是编译后的查询(通常是 raw_topic or topic),而不是 planner 的 search_query,见 pipeline.py

另一个关键细节是 ranking_query:它是 planner 给出的、用于相关性打分的查询,裁判时若提供则优先于原始 topic 使用,这给了裁判一个比原始主题更精准的评分基准。

扇出查询:短语引号只留给专有名词

X 的 Grok 后端每次调用最多返回 10 条帖子(_MAX_LIMIT_PER_CALL = 10),深度靠扇出多个查询变体达成。_fanout_queries 生成"最宽信号在前"的变体序列:

def _fanout_queries(topic: str, from_date: str, to_date: str, calls: int) -> List[str]:
    window = f"since:{from_date} until:{to_date}"
    # First variant: unquoted AND (multi-word topics naturally AND their terms)
    variants = [
        f"{topic} {window}",
        f"{topic} {window} min_faves:5",
    ]
    # Third variant: phrase-quote only for proper names, else filter:links
    if " " in topic and _is_proper_name(topic):
        variants.append(f'"{topic}" {window}')
    else:
        variants.append(f"{topic} {window} filter:links")
    variants.append(f"{topic} {window} -filter:replies")
    return variants[:calls]

grok_x.py。规则是:

  • 第一个变体永远是不带引号的 AND——多词主题词之间天然 AND,覆盖面最宽;
  • 第三个变体只有在主题"看起来是专有名词"时才短语引号,否则改用 filter:links 过滤出带链接的帖子;
  • 最后追加 -filter:replies 变体排除回复帖。

_is_proper_name 的判定逻辑是:主题至少两个词、每词首字母大写其余小写,并且所有词不是地名/消歧词(内置了 italyromeparisnew yorklos angeles 等地名词表)也不是全大写缩写。因此 "Peter Steinberger" → True(人名,短语引号),"Rome Italy" → False(地名消歧串,不引号)。见 grok_x.py。这正是成功标准中两条用例的出处:_fanout_queries("Rome Italy") 不得含 "Rome Italy" 变体,而 search_name("Peter Steinberger") 仍须短语引号。

语料裁判:x_judge 模块的三个阈值

新增的 x_judge.py 是纯计算模块(docstring 明确"No I/O: judges items already retrieved"),复用 relevance.py 的 tokenize,围绕三个阈值常量工作:

CORPUS_ON_TOPIC_FLOOR = 0.4   # 语料整体在题比例下限(Rome 实测约 0.2,8/40)
HANDLE_ON_TOPIC_FLOOR = 0.5   # 单个 handle 帖子在题比例下限,低于则不给 FROM 提升
MIN_ON_TOPIC_HITS = 2          # handle 至少要有 2 条在题命中才可提升

judge_x_corpus:四条函数中的核心

judge_x_corpus(items, topic, *, ranking_query="") 返回五个字段:on_topic_ratiois_off_topic_floodon_topic_itemsoff_topic_itemshandle_stats。其判定逻辑值得注意的两点:

  1. 逐条打分:每条帖子用 _compute_relevance 计算查询词覆盖度,达到 relevance.RELEVANCE_FLOOR(0.1)记为在题。空语料直接返回比例 1.0、非洪流,避免空窗误判。
  2. 双条件洪流判定is_off_topic_flood 为真的条件是——整体在题比例低于 0.4,或者"频率前三的账号在题比例全部低于 0.5 且在题条目不足 2 条"。这个复合条件专门捕捉那种"比例刚过线但头部账号全是离题账号"的隐蔽洪水。

_compute_relevance 还处理了一类经典歧义:_CASE_SENSITIVE_ACRONYMS = frozenset({'us'})。当查询 token 是 us 而文本里只有小写代词 "us"(无大写 "US")时,该 token 不计入匹配,防止 "Tell us what you think" 被误判为关于美国的帖子。这一行为由测试 test_us_pronoun_does_not_match_us_topic / test_us_acronym_matches_us_topic 精确锁定,见 test_x_judge.py。同理,"AI" 不会子串匹配到 "said"、"Rome" 不会匹配到 "promoter"——词元化是整词匹配而非子串匹配,test_x_judge.py 中有对应回归用例。

promotable_handles:分裂 FROM 提升的门控

promotable_handles(items, topic, extracted_handles, *, explicit_handles=None, ranking_query="") 返回二元组 (explicit_promotable, extracted_promotable)

  • 显式 handle--x-handle/--x-related):无条件进入 explicit_promotable,走 FROM 且 AND 主题——因为人发的帖子通常不会带上自己的名字,AND 主题会把这条通道直接清空(这在 search_handles 的 docstring 中被标注为"prior defect");
  • 抽取 handleentity_extract 的产出):必须同时满足 on_topic ≥ MIN_ON_TOPIC_HITS (2)on_topic/total ≥ HANDLE_ON_TOPIC_FLOOR (0.5),才进入 extracted_promotable,且这些拉取必须 AND 主题(from:handle Rome),以此证明账号确实产出在题内容。

注意它内部是按在题比例而非频率做门控——这正是对 Rome 事故第 3 环(频率排名)的直接反制。测试用例覆盖了所有门:mamboitaliano__(3 条在题)可提升、visegrad24(4 条中仅 1 条擦边)不可提升、只有 1 条在题的 handle 被 MIN_ON_TOPIC_HITS 卡掉,见 test_x_judge.py

should_retry_x_search 与 prune_off_topic_items

  • should_retry_x_search(items, topic, *, ranking_query="", depth="default")depth == "quick" 或空语料直接返回 False(quick 深度不重试,与 Phase 2 的跳过策略一致),否则返回裁判的 is_off_topic_flood
  • prune_off_topic_items(items, topic, *, ranking_query=""):只返回在题条目。docstring 解释了设计取舍:"8 条在题 + 32 条被剪 → 带着 8 条正常交付;0 条在题 → 报无结果,而不是带着 40 条垃圾交付"。

重试:X 流内部的一次性自救

_retrieve_stream_impl 的 X 分支在拿到后端结果后执行"裁判→重试→修剪"三步(见 pipeline.py):

  1. 触发items 非空、depth != "quick" 且非 mock 时,调用 x_judge.should_retry_x_search(...)
  2. 重试查询:触发后用 query.pyextract_core_subject(x_query) 提取核心词(剥掉 "best/top/latest/news" 等 NOISE_WORDS 噪声词),得到 retry_query,然后经 _fetch_x_backend(used_backend, retry_query, ...) 再查一次。注释强调"strip noise words but preserve all significant terms"——避免丢掉 "react server components" 这类消歧词。整个重试只走一次、复用原后端;
  3. 择优:分别裁判重试语料与原语料,只有重试的 on_topic_ratio 严格大于原语料才采用重试结果,并打印 retry improved on-topic ratio: N% -> M% 供诊断;
  4. 修剪:无论是否重试,进入融合池之前都执行修剪——只保留在题条目(无文本的条目放行,无法裁判),并写入 artifact 警告 X: pruned N off-topic items; M on-topic remain

这里有两个与 R6 直接对应的工程细节:

  • 修剪警告写入 artifact["_warnings"],而不是 bundle.record_failure(x_slug, PARTIAL, ...)。后者会把 X 源标记为 PARTIAL 状态,处于严格退出状态的白名单之外,会让使用 LAST30DAYS_STRICT_EXIT 的包装脚本在一份其实很健康的 X 覆盖上以退出码 3 失败。同文件中 partial coverage 的处理注释(pipeline.py)明确说明了这一权衡:"A warning, not a source outcome"。
  • 该重试刻意放在 X 流内部,而不是复用 _retry_thin_sources——后者针对"结果太薄",而这里针对"结果太多但离题",是两种不同的失败模式。

分裂 FROM 提升在 pipeline 中的落点

Phase 2 的补充搜索 _run_supplemental_searchespromotable_handles 的调用方(见 pipeline.py):

# Split FROM promotion: determine which handles get FROM lane and how.
primary_explicit = [x_handle] if x_handle else []
explicit_promotable, extracted_promotable = x_judge.promotable_handles(
    x_dicts,  # Phase 1 X items for judging
    topic, handles,  # entity_extract handles
    explicit_handles=primary_explicit,
    ranking_query=ranking_query,
)

其后两条 FROM 通道分别执行,且 AND 策略不同:

# 显式 handle:FROM,不 AND 主题
explicit_items, ... = _from_lane(explicit_promotable, FROM_LANE_COUNT_PER, and_topic=False)
# 抽取 handle:FROM,AND 主题(from:handle Rome)
extracted_items, ... = _from_lane(extracted_promotable, FROM_LANE_COUNT_PER, and_topic=True)

and_topic 参数最终落到 grok_x.pysearch_handles

if and_topic and topic:
    query = f"from:{clean} {topic} since:{from_date} until:{to_date}"
else:
    query = f"from:{clean} since:{from_date} until:{to_date}"

search_handles(["steipete"], "topic") 默认 AND 主题,而 search_handles(["visegrad24"], "Rome", and_topic=True) 会生成 from:visegrad24 Rome ...——两条都是成功标准中的验收用例。此外 --x-related 的 related handle 以 0.3 权重走独立的 supplemental-related 子查询标签,同样 and_topic=False,见 pipeline.py

第一方豁免保持保守(R5)

promotable_handles 只决定"谁进 FROM 通道",不决定"谁享受第一方豁免"。归一化时的 first-party 集合只包含显式 handle:

# First-party handles: only primary explicit handle, not promoted commentators
# (first-party exempts from relevance floor; granting to commentators
# would let junk become un-prunable)
first_party_for_normalize = list(set(
    h.lower().lstrip("@") for h in primary_explicit if h
))

pipeline.py。第一方状态会豁免相关性 floor、抬高单作者上限,属于强保护;如果按频率或按抽取授予,等于允许蹭流账号让垃圾内容变得不可修剪。这与 resolved_handles 构建处的注释("Require the handle to look like the topic's subject, or to have been named explicitly by the user")是同一套保守原则。

测试与验收标准

tests/test_x_judge.py 是该模块的专属测试文件,共五组:

  • TestJudgeXCorpus:空语料、正常语料、Rome 洪水语料(8 在题 + 32 离题,实测比例 < 0.4 且触发洪流判定)、子串碰撞防护(AI/said、Rome/promoter)、US 大小写歧义、handle 统计;
  • TestPromotableHandles:显式 handle 恒提升、抽取 handle 的在题门控、MIN_ON_TOPIC_HITS 门槛;
  • TestShouldRetryXSearch:quick 深度跳过重试、洪流触发重试、好语料不重试;
  • TestPruneOffTopicItems:修剪保留在题条目、全剪后返回空列表;
  • TestRomeScenario:以 Rome 事故为原型构造 40 条语料(含 visegrad24 地缘帖、PrettyCitiesX 他城帖、AS Roma 足球帖、Rome Odunze NFL 帖——后两类是"Rome"一词的实体碰撞),断言整语料被识别为离题洪流、mamboitaliano__ 可提升、visegrad24/PrettyCitiesX 不可提升、显式指定的 handle 恒提升。

计划文档列出的九条成功标准(fanout 无引号变体、专有名词仍引号、search_handles 默认不 AND、and_topic=True 时 AND、显式 handle 恒进 FROM、离题 handle 不提升、在题 handle 提升、状态为 artifact 警告而非 PARTIAL、全测试通过)与上述测试一一对应;tests/test_grok_x.py 补充了 fanout 与 and_topic 用例,tests/test_pipeline_v3.py 则更新了 fixture 以提供可提升内容。

明确的范围外事项

计划文档的 Out of Scope 部分同样值得保留,它界定了这项修复的边界:

  • 测试中不发起真实 Grok 调用(x_judge 本身无 I/O,测试全部用构造语料);
  • 不触碰 auth/doctor 路径(Grok 鉴权探测由 stored_auth_status 等独立函数负责,见 grok_x.py);
  • 不建碰撞词典——"Rome" 撞上 AS Roma(足球)和 Rome Odunze(NFL 球员)的情况依然存在,靠的是"裁判 + ranking_query 打分"把它们压下去,而不是维护同义词/异义词表;
  • bird_x 保留引号的 build_topic_query 列为后续跟进项(当前 pipeline 中 bird/xquik 后端的 search_handles 尚不支持 and_topic,见 pipeline.py 中的占位实现)。

小结

这套 retrieve-judge-retry 机制的核心思想可以概括为:检索侧放宽(不引号、多变体扇出)换取召回,裁判侧收紧(0.4 语体底线、0.5/2 次 handle 门控)换取精度,中间用一次带择优的重试和池前修剪兜底。它没有试图在检索前猜对每一次查询,而是承认 LLM 驱动的后端"可以自信地返回错东西"(grok_x.py 模块 docstring 原话),把质量保障移到检索之后、融合之前——这正是 Rome 事故五个环节中被逐一点掉的防御链。

登录后查看全文
热门项目推荐
相关项目推荐