40 条候选里 38 条来自同一个源,主卡整屏重样

0 阅读4分钟

我自己手搓的一个副业点子搜索有关的日报系统里有三个地方要展示候选:主卡、六维雷达、开源方案匹配。原来三处各自写了一句 items[:40]。

跑了一阵子发现一个尴尬的 bug:同一天的日报,主卡讲的是 Reddit 上的 SaaS 吐槽,雷达却全是 HN 的条目。三个模块各切各的,拿到根本不是同一批东西。

于是把取窗收敛成一个模块。但真正麻烦的是下一个问题:切出来的 40 条里,38 条来自同一个源。

分数不能直接比

最直觉的做法是按 signal_score 排序取前 40。问题是这个分数在不同源上不是一把尺子:

  • 降级后的 RSS 源,分数是位次估计(1~30),282 条里绝大多数并列在 30
  • Lobsters 是真实票数(0~54)

跨源比大小,等于拿两把尺子量。有人会想到"除以源内峰值"做归一——我试过,更糟:250 条并列 1.0 的条目会直接把窗口淹掉,把本来没有梯度的数据硬编出一条假梯度。

最后用的是序数归一:不看分数绝对值,只看源内名次。每个源先出第 1 名、再出第 2 名,按权重决定每轮各源取几条。

def rank_norm_weights(cfg):
    rn = ((cfg or {}).get("signals") or {}).get("pool_rank_norm") or {}
    if not isinstance(rn, dict) or not rn.get("enable"):
        return None          # 默认关:不配这个键时与直接切片逐字节等价
    out = {}
    for name, w in (rn.get("weights") or {}).items():
        try:
            out[name] = max(1, int(w))
        except (TypeError, ValueError):
            continue
    return out

一个我刻意留着的默认值

注意 return None。这套轮转默认是关闭的。

原因不是偷懒:这个模块在数据采集主链上,任何改动都可能让已经在跑的日报变形。所以红线是——不配 pool_rank_norm 这个键时,取窗结果必须与原来的 items[:40] 逐字节一致。开了才生效,关了等于没改过。

同样的护栏还有一处:影子源(新接入、只写池不进窗的源)用 window_enabled: false 标出来,否则新源一接进来就改主卡,"影子"就影不住了。

def shadow_sources(cfg):
    out = set()
    for name, sc in ((cfg or {}).get("pool_sources") or {}).items():
        if isinstance(sc, dict) and sc.get("window_enabled") is False:
            out.add(name)
    return out

这里有个容易踩的坑:pool_sources 和 sources 是两套不相干的东西。后者是热点源(arxiv / techmeme 之类),写混了会把风口数据当池源过滤掉。我在 docstring 里专门写了这一句。

最花时间的是"哪条算真需求"

单源霸榜解决之后,还有个更隐蔽的问题:按分数取窗时,需求向的帖子几乎进不来。展示帖("我做了个 X")的赞数天然比提问帖高,10 张卡里只剩 2 张是真需求。

第一版用宽口径正则找需求,10-27 实测 32 条"需求"里 23 条是 Looking for beta testers / honest feedback——那是展示帖,不是需求。

拿 LLM 判过的结果当真值对了一遍,原正则 precision 3/4,但 recall 只有 3/11。漏掉的全是这类真痛点:

How do people fix this without hiring a designer? Why is it so expensive to send an email? So tired of these AI crawlers

同时它还会把 I built X. Is there a market? 这种"展示帖 + 附一句提问"误判成需求——因为负例只杀了 testers / feedback,没杀展示帖标记。

改法是收窄成意图词 + 硬负例两条正则,并且每条新增模式都要有实测支撑(命中数、展示帖数、LLM 真值),没证据的一律不加。how can i / looking for a / best way 这些都量过并否决了。

最后给需求子队列留名额时还有个细节:留了名额也要走源内轮转,否则名额会被 Reddit 一家吃光——刚止住的霸屏,换个入口又放回来了。

最后

这套东西做完,我最大的感受不是"归一算法怎么选",而是:多源数据混在一起,最先坏掉的从来不是排序,是"谁有资格出现在候选里"。

排序再漂亮,只要一个源占满窗口,剩下的数据等于不存在。而这类问题不会报错,日志里一切正常,只是你每天看到的日报越来越像同一个网站。