tinyhumansai/openhuman · src/openhuman/memory_store/trees/hotness.rs

一棵树怎么决定什么该浮上来

"会自己长大的记忆树"里,真正决定哪些实体/主题该浮到顶部的,是一张很小的热度计数表。本页基于 hotness.rs 的真实表结构与计数器拆解:热度由哪些信号喂养、跨源为什么被奖励、重算为什么要摊还,以及封桶在内存增长与新鲜度之间怎么取舍。

关于这一页的依据:下面的表结构、列、计数器语义全部来自 hotness.rs 源码(真实可查)。而把这些计数器合成单一热度分的精确算术(系数、对数底、晋升阈值)住在 tree_policy.rs 的重算路径里——本页对公式只描述其形状与设计意图,不杜撰具体常数。

热度计数器:一张侧表,而不是树的一行

源码里有一句关键注释:"hotness 是唯一区分 topic 树与 source/global 树的状态——而且即便如此,它也只是一张侧表,不是树里的行。" 这定下了整个设计的基调:树的结构是稳定的,热度是挂在旁边、随时可重算的派生量。它存在 mem_tree_entity_hotness 表里,每个实体一行:

hotness.rs — HotnessCounters(mem_tree_entity_hotness 的一行)
struct HotnessCounters {
    entity_id:           String,
    mention_count_30d:   u32,          // 近 30 天被提及次数
    distinct_sources:    u32,          // 跨多少个不同的源/树出现
    last_seen_ms:        Option<i64>,   // 最近一次出现(recency)
    query_hits_30d:      u32,          // 近 30 天被检索命中次数
    graph_centrality:    Option<f64>,   // 图中心性
    ingests_since_check: u32,          // 距上次热度重算的 ingest 数
    last_hotness:        Option<f64>,   // 上次算出的热度(缓存)
    last_updated_ms:     i64,
}

get_or_fresh() 对没见过的实体返回一个全零行但不落库——调用方 bump 完计数器后必须显式 upsert();upsert 以 entity_id 为主键 ON CONFLICT DO UPDATE,幂等。也就是说,"没见过"和"热度为 0"被刻意区分开,新实体不会凭空占一行。

七个输入,各自挡住一种"假热"

把这几列读成"热度函数的自变量",能看出每一列都在防一种退化:

  • mention_count_30d:被提及的频率,但开了 30 天窗口——热度天然会随时间退潮,不会因为半年前刷过屏就永久占顶。
  • query_hits_30d:被检索命中的次数。提及多 ≠ 有用;真正被翻出来用过,才是更强的重要性信号。
  • distinct_sources:在多少个不同的源里出现——跨源出现的实体比单一来源刷量的更可信(见下一节)。
  • graph_centrality:在实体图里的中心性,捕捉"很多东西都连到它"的结构重要性。
  • last_seen_ms:最近一次出现的时间戳,是时间衰减项的输入。
  • ingests_since_check:不是热度输入,而是重算的节流器(见"摊还")。
  • last_hotness:上次算出的值,缓存下来,避免每次都从零重算。

奖励跨源:distinct_sources 怎么数

"跨源"在源码里有精确定义——它数的是这个实体在实体索引里关联到多少个不同的 tree_id:

hotness.rs — distinct_sources_for
SELECT COUNT(DISTINCT tree_id)
  FROM mem_tree_entity_index
 WHERE entity_id = ?1 AND tree_id IS NOT NULL

为什么要单独奖励它?因为单源高频很容易造假:一个聊天群里反复出现的梗、一封长邮件里重复的词,mention_count 都会很高,但它们只活在一个上下文里。distinct_sources 让"在邮件、Slack、笔记里都出现的人/项目"获得额外权重——跨源是"这真的重要"的强信号,也是把记忆树和"又一个按词频排序的向量库"区分开的地方。

封桶触发:ingests_since_check 的摊还

如果每写入一条记忆就把所有受影响实体的热度全量重算一遍,记忆树会被自己拖垮。ingests_since_check 这一列正是为此存在——它是一个每次 ingest 都自增、重算后归零的计数器。重算("封桶")被摊还成:平时只 bump 计数器(便宜),等某个实体累计的 ingest 数越过阈值,才真正重算它的热度分并把 last_hotness 落库。

读这一列的方式:ingests_since_check 把"实时精确"换成了"最终一致"。一个实体越活跃(ingest 越频繁),就越快撞到阈值、越频繁被重算;冷门实体则可能很久才算一次——重算频率自适应地跟随活跃度,把算力花在真正在变的实体上。

热度公式的形状:对数压头部

把上面的输入合成单一热度分,重算路径采用的形状(设计意图层面)是——对频次类信号取对数压住头部、对跨源与命中给正向权重、再乘一个随 last_seen_ms 衰减的时间因子。对数是关键:如果热度对 mention_count 线性,少数超高频实体会把分数尺度撑爆、永久霸榜;取对数后,"被提到 1000 次"和"被提到 100 次"的差距被压缩,让中段实体仍有机会靠跨源、命中、中心性浮上来。

概念上可以读成:hotness ≈ (w₁·log(1+mention) + w₂·log(1+query_hits) + w₃·distinct_sources + w₄·centrality) × decay(now − last_seen)。精确的系数、对数底与晋升阈值在 tree_policy.rs 里;本页只刻画它"对数压头 + 跨源加权 + 时间衰减"的骨架,不替源码杜撰数字。

为什么只有 topic 树需要热度

hotness.rs 的开篇注释点明了树的分工:hotness 是唯一区分 topic 树与 source/global 树的状态。换句话说——

  • source / global 树是结构性的:一条消息来自哪个源、全局时间线长什么样,由 ingest 的来源与时间天然决定,不需要"热度"来排序。
  • topic 树是涌现的:哪些主题/实体值得成为一个被维护的"桶"、桶内谁该在顶部,没有天然答案——这正是热度分要回答的问题。所以热度计数表只为 topic 维度存在,作为一张侧表挂在树存储旁边,而不污染树本身的行。

这也解释了存储布局:注释说把 hotness 折叠进 trees/ 目录,是为了让"memory_store 对树到底知道些什么"成为单目录可答的问题——结构的归结构,派生的热度归侧表。

内存增长 ↔ 重算频率的权衡

把这几个设计决定串起来,能看到一条清晰的权衡线:

  • 不为"没见过"的实体占行(get_or_fresh 返回不落库的全零行)——把表的增长绑定到"真正出现过的实体",而非"理论上可能的实体"。
  • 30 天窗口的计数器让久未出现的实体热度自然趋零,配合时间衰减,老桶会沉下去而不必显式删除。
  • ingests_since_check 的摊还把重算成本与活跃度挂钩——表越大、写入越多,也不会变成"每次写入 O(实体数) 重算",而是每个实体各自按自己的活跃节奏重算。
  • last_hotness 缓存让两次重算之间的检索/展示可以直接读上次的分,不必为了排序而即时重算。

三者合力,回答了"一棵会自己长大的树为什么不会自己撑爆":增长被绑定到真实出现、旧热度靠窗口与衰减自然退潮、重算成本被摊还到活跃度上。

意义:把"重要性"做成可重算的派生量

很多记忆系统把"重要性"写死进数据本身(打个 importance 字段、塞进向量),结果是改一次评分逻辑就要重写历史。OpenHuman 的做法相反:树的结构是稳定的事实,热度是一张随时可重算、可丢弃的侧表。它用一组朴素但互补的计数器(频次、命中、跨源、中心性、recency)喂养一个"对数压头 + 跨源加权 + 时间衰减"的热度分,再用 ingests_since_check 把重算摊还到活跃度上。这让"什么该浮上来"成为一个可以随策略演进而重算的问题,而不是一次性烙进数据的判断——也正是它敢叫"会自己长大"的底气。