一棵树怎么决定什么该浮上来
"会自己长大的记忆树"里,真正决定哪些实体/主题该浮到顶部的,是一张很小的热度计数表。本页基于 hotness.rs 的真实表结构与计数器拆解:热度由哪些信号喂养、跨源为什么被奖励、重算为什么要摊还,以及封桶在内存增长与新鲜度之间怎么取舍。
hotness.rs 源码(真实可查)。而把这些计数器合成单一热度分的精确算术(系数、对数底、晋升阈值)住在 tree_policy.rs 的重算路径里——本页对公式只描述其形状与设计意图,不杜撰具体常数。热度计数器:一张侧表,而不是树的一行
源码里有一句关键注释:"hotness 是唯一区分 topic 树与 source/global 树的状态——而且即便如此,它也只是一张侧表,不是树里的行。" 这定下了整个设计的基调:树的结构是稳定的,热度是挂在旁边、随时可重算的派生量。它存在 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:
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 把重算摊还到活跃度上。这让"什么该浮上来"成为一个可以随策略演进而重算的问题,而不是一次性烙进数据的判断——也正是它敢叫"会自己长大"的底气。