增强 E · 语义缓存(仅 category_insight)—— 开发文档(面试向)#
这篇讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话复述。 对应
docs/BACKEND_ENHANCEMENT.md的 E 块(第二波 P1,单机定稿部分)。
一句话概括#
给 category_insight(品类知识查询)加一层进程内缓存:相同 / 近义品类词第二次查,不再走一整条 Hybrid 召回 + cross-encoder 精排的外呼,直接命中缓存返回。两级——精确层(key 完全相同,O(1),免编码免检索)+ 语义层(“luggage” ↔ “行李箱” 这种别名表没收的近义词,靠向量余弦命中)。
打个比方:常被问的几十个品类,第一次查老老实实算,之后就背下来了;连「问法换了但意思一样」也认得出。
1. 背景:为什么只给 category_insight 加,不给比价 / 运费加#
缓存的第一原则是只缓存弱时效数据。给哪些工具加缓存,是个选型判断,不是越多越好:
category_insight(加)——品类的爆款 / 典型属性是慢变的知识,今天查和明天查结论几乎一样;而它的代价实打实:一次 OpenSearch Hybrid(KNN + BM25)召回 + 一遍 cross-encoder 精排,是链路里最贵的外呼之一。弱时效 + 高代价 = 缓存的理想对象。price_compare/shipping_calc(不加)——价格、库存、到手价是强时效数据。「语义近似命中」在这里是硬伤:返回另一个 query 的旧价格,购物场景直接误导用户。宁可每次实算。fx/duty(不加)——汇率 / 关税本来就是进程内静态表查表,本身就是 O(1),再包一层缓存是过度设计,零收益。
所以 E 块精准地只落在 category_insight 一个工具上——这正是「识别真实成本来源、不无脑铺缓存」的体现。
2. 方案:精确 + 语义两级,且精确层总是先写#
两级缓存:
- 精确层(
TTLCache):key 完全相同直接命中,免编码、免检索,最快路径。 - 语义层:精确未命中时,把品类词编码成向量,和缓存里的向量算余弦,近义词也能命中。向量直接复用召回层的
TowerClient.encode_query(已 L2 归一,余弦=点积),不引额外模型。
这里最值得讲的一个设计决定是:精确层总是写,语义层只在拿到向量时才写。
为什么这样分?因为编码本身(towers)可能挂。如果把「写缓存」和「拿到向量」绑死,那 towers 一挂,连「相同 query 第二次」这种最基本的精确缓存都失效了——缓存在最需要扛住的时候反而罢工。拆开之后:即便编码完全不可用,相同 query 仍走精确快路径;语义命中只是「锦上添花」的增量能力,缺了它缓存照样有用。降级是优雅的,不是全有全无。
3. 为什么是进程内,而不是再起一个 Qdrant collection(对方案的主动更正)#
方案原稿(§二·五 C)倾向用 Qdrant collection 做这层缓存。我主动改成进程内 numpy 点积,判据是「单机下进程内原语严格更优」:
- category 的取值空间很小(几十个品类词),进程内几百条向量就足够覆盖;
- 单机下进程内点积没有网络往返,严格快于「再起一个 Qdrant collection」(多一跳 + 一套 collection 生命周期管理);
- Qdrant 路线留作大规模 / 多副本毕业线——那时缓存要跨进程共享才轮到它上场。
这和 D 块「单机用进程内、多副本才上外部组件」是同一套判据,前后一致。
4. 几个把细节做对的地方(含 review 抓到的)#
- 维度守卫,别让混维打挂工具(review 抓到的 must-fix)。towers 远程正常时返回高维向量,降级到本地回退时维度可能不同。如果缓存里存的是远程的高维向量、这次查询拿到的是本地的低维向量,
np.dot会抛 ValueError——而这个调用没被 try 包住,会直接把整个category_insight工具打挂,比没缓存还糟。修法:余弦比对前先比 shape,维度不一致直接跳过当不命中。一行守卫,把「缓存帮倒忙」堵死。 - 返回值按不可变约定使用(review 抓到的)。
get_*返回的是缓存内对象的同一引用(不深拷贝、省开销)。调用方若就地改它,会污染缓存、被后续命中读到。当前 category_insight 的输出只读不改,满足约定;在 docstring 里明确写下这个契约,将来若有消费方要改,先model_copy。 - 维度隔离(group):quick / deep 不互相命中。category_insight 有 quick / deep 两种深度,结论不同。缓存按 depth 分 group,同向量不同 group 不命中——避免「浅查的结论串到深查」。
- 容量与 TTL 双约束,不无界增长。精确层和语义层都受
max_entries上限(超量淘汰最旧)和ttl(过期跳过)约束。语义层顺手在每次查询时清掉过期项。 - 命中 / 未命中进 A 块监控。每次查询按
exact_hit/semantic_hit/miss打点(复用 A 块的 Prometheus counter),/metrics上能直接算缓存命中率——缓存有没有用、调没调对阈值,看数据说话,不靠拍脑袋。
5. 范围与诚实边界#
- 进程内,不跨进程共享。多副本部署下各实例各有一份缓存,命中率打折——这是单机定稿的务实取舍,跨进程共享是毕业线(那时才轮到 Qdrant / Redis)。
- 语义命中有阈值,宁可漏命中不可错命中。余弦阈值默认 0.92,偏保守(更像精确匹配)。错命中(返回不相关品类的结论)的代价远高于漏命中(多算一次),所以阈值往高了调。
- 只缓存 category_insight。这是刻意的克制,不是没做完——见第 1 节,强时效工具绝不加。
6. 一句话面试总结#
E 块体现的是**「缓存是选型、不是堆料」**:先识别出链路里真正贵又弱时效的那一个工具(category_insight 的 Hybrid + 精排),只给它加;两级缓存里把「精确层总是写」和「语义层增量」拆开,让 towers 挂了缓存也不全废;进程内 numpy 点积在单机下严格优于再起一个 Qdrant collection;最后用一行维度守卫堵住「缓存帮倒忙」、用 Prometheus 命中率让缓存效果可观测。