Add multi-vector preference entity (per-signal-type preference vectors with event-time decay) feeding ANN candidate generation in the query executor. - entities: multi_preference vectors + event-time-aware preference updates - query/executor: ANN candidate-gen + personalization/pipeline integration - storage/keys, db ops, state_rebuild: persist & rebuild multi-vector prefs - ranking: profile + builtins support for multi-vector scoring - tidal-server/config: expose multi-preference knobs - tests/bench: m12_preference_event_time integration + multi_preference bench - docs: multi-vector-preference research, ROADMAP/ARCHITECTURE refresh, legal/tidaldb-patent-proposal - .codex/agents: codex agent definitions - chore: gitignore tool-regenerated .agents/ mirror (doc-guard rejects it)
469 lines
29 KiB
Markdown
469 lines
29 KiB
Markdown
# Research: Multi-Vector (PinnerSage-Style) User Preference
|
||
|
||
## Question
|
||
|
||
Should tidalDB replace its single per-user EMA preference vector with a multi-vector
|
||
(PinnerSage-style) representation, and if so, which clustering regime —
|
||
**offline-batch medoid** (PinnerSage proper), **online-maintained centroids** (the
|
||
`ARCHITECTURE.md:252-254` variant), or a **hybrid** — fits an embeddable,
|
||
single-node-first, in-process database? This doc settles one design for
|
||
@tidal-engineer to build.
|
||
|
||
## TidalDB Context
|
||
|
||
### What exists today (verified against code, 2026-06)
|
||
|
||
- **Single preference vector per user.** `tidal/src/entities/preference.rs`:
|
||
`DashMap<u64, Vec<f32>>`, L2-normalized, blended via an adaptive-LR EMA
|
||
`alpha = base / (1 + ln_1p(count))` (`update`, lines 109-126). Checkpoint/restore
|
||
under `Tag::Preference` (`= 0x16`, `storage/keys.rs:71`); value layout
|
||
`[count:8 LE][dim:4 LE][f32*dim LE]`, one row per user keyed by `EntityId(0)`
|
||
sentinel + `[user:8 BE]` suffix (lines 263-362).
|
||
- **Update trigger.** On a *positive engagement* signal, `signals.rs:547` calls
|
||
`try_update_preference_vector` (lines 765-816): reads the engaged item's stored
|
||
embedding and blends it via `preference_vectors.update`. **There is no decay,
|
||
velocity, or recency weighting on this blend** — the forward-decay signal model
|
||
does not touch the preference vector at all. The only "recency" is the EMA's
|
||
implicit exponential forgetting via `alpha`.
|
||
- **Consumption — `for_you`.** `query_ops.rs:99-116`: when the profile's
|
||
`CandidateStrategy::Ann` is set and `query.for_user` is present, the db resolves
|
||
**exactly one** query vector (`preference_vectors.get(user)`) and threads it as
|
||
`ann_query_vector`. `candidate_gen.rs::ann_candidates` (lines 104-130) then issues
|
||
**one** `index.search(query_vector, k, ef=64)` (`ANN_DEFAULT_EF_SEARCH`). Stage 3
|
||
re-scores this pool by signals; `compute_preference_boosts`
|
||
(`personalization.rs:91-123`) additionally applies a per-candidate cosine boost
|
||
against the *same single* preference vector.
|
||
- **The `filtered_search` at `pipeline.rs:431`** is the **SEARCH** executor
|
||
(explicit query vector + scope bitmap), **not** the `for_you` preference path. A
|
||
multi-vector preference query would issue `search`/`filtered_search` K times from
|
||
the `ann_candidates`/`query_ops` path, not from `pipeline.rs`.
|
||
|
||
### Why this matters for tidalDB specifically
|
||
|
||
- **Averaging problem is live.** A user who engages with hiking, cooking, and cars
|
||
collapses to one EMA centroid that, per PinnerSage (KDD 2020), can represent *none*
|
||
of them. tidalDB's core promise is "given a user and a context, what content should
|
||
they see" — a multi-modal user is exactly where a single vector fails hardest.
|
||
- **Latency budget.** <50ms p99 end-to-end. The single-query ANN path is measured at
|
||
p99 1.22ms (100k/1536-D, ef=64; `candidate_gen.rs:81-89`). K queries multiply the
|
||
ANN leg, but the ANN leg is a small fraction of the budget — headroom exists (see
|
||
§4).
|
||
- **Embeddable, single-node, in-process.** PinnerSage runs Ward hierarchical
|
||
clustering on a *Spark cluster, daily, offline*. tidalDB has no Spark, no offline
|
||
batch tier, and must cluster **in the same process that serves queries**. This is
|
||
the single most important constraint and it disqualifies PinnerSage's literal
|
||
implementation. The clustering primitive must be O(interactions) incremental work
|
||
on the write path or a bounded periodic in-process pass — never an O(n²) hierarchical
|
||
pass per user.
|
||
|
||
---
|
||
|
||
## Approaches Surveyed
|
||
|
||
### Approach 1: Offline-batch medoid clustering (PinnerSage proper)
|
||
|
||
**How it works:** Periodically (daily at Pinterest), collect each user's last ~N
|
||
interaction embeddings, run **Ward hierarchical agglomerative clustering** to produce
|
||
3-100 clusters, represent each cluster by its **medoid** (the actual interaction
|
||
embedding closest to the cluster's other members — *not* a synthetic centroid), and
|
||
assign each cluster an **importance** score (time-decayed engagement mass). At serve
|
||
time, sample 3 clusters by importance and issue one ANN query per sampled medoid.
|
||
|
||
**Used by:** Pinterest PinnerSage (KDD 2020, "PinnerSage: Multi-Modal User Embedding
|
||
Framework for Recommendations at Pinterest", Pal et al.), 400M+ MAU. The medoid
|
||
choice is load-bearing: Pinterest reports synthetic centroids degrade because the
|
||
centroid drifts into empty embedding-space regions ("energy boosting breakfast"
|
||
example).
|
||
|
||
**Evidence:** KDD 2020 paper; `docs/research/ann_for_tidaldb.md:102-116` already
|
||
endorses "PinnerSage-style multi-query with result merging — no special index
|
||
modifications required."
|
||
|
||
**Strengths for tidalDB:** Highest-quality clusters (Ward is the gold standard for
|
||
this exact problem). Medoid = an actual stored embedding, so it is always a valid,
|
||
in-distribution query vector. Importance sampling composes cleanly with engagement
|
||
mass.
|
||
|
||
**Weaknesses for tidalDB:** **Ward is O(n²) memory / O(n² log n) time** per user. It
|
||
requires holding the user's full interaction history (or a window) in memory and
|
||
re-clustering from scratch. There is **no offline tier in an embeddable DB** — running
|
||
this inline on the write path or on a checkpoint thread for every active user is a
|
||
non-starter at content-platform scale. Medoid maintenance requires storing the raw
|
||
interaction embeddings, not just K centroids — a large storage multiplier
|
||
(N interactions × dim vs K × dim).
|
||
|
||
### Approach 2: Online-maintained centroids (the ARCHITECTURE.md variant)
|
||
|
||
**How it works:** Maintain K running centroids per user directly on the write path.
|
||
On each positive engagement, assign the item embedding to the nearest existing
|
||
centroid (by cosine) and blend it in via an EMA; if no centroid is within a similarity
|
||
threshold τ and K is below the cap, spawn a new centroid seeded by the embedding. This
|
||
is **streaming/online k-means** (specifically *sequential k-means* / MacQueen's
|
||
online update) with a threshold-based split rule (a lightweight **DP-means**, Kulis &
|
||
Jordan, ICML 2012). `ARCHITECTURE.md:252-254` describes this: "3-10 interest cluster
|
||
centroids (PinnerSage-style), maintained by the database as signals arrive."
|
||
|
||
**Used by:** The *pattern* (streaming k-means / DP-means) is textbook and ships in
|
||
many online-learning systems; the *specific* "K running user-interest centroids
|
||
updated on engagement" is what large-scale rec systems approximate when they cannot
|
||
afford offline Ward. No single citable production system matches it exactly under this
|
||
name — this is a tidalDB-original synthesis, which has patent-conception relevance
|
||
(§7).
|
||
|
||
**Strengths for tidalDB:** O(K·dim) work per engagement — trivially cheap, fits the
|
||
write hot path, no batch tier, no Spark. Stores only K × dim floats per user, a near
|
||
zero storage delta over today's single vector. Reuses the existing EMA + adaptive-LR
|
||
machinery per centroid. Naturally incremental — no rebuild.
|
||
|
||
**Weaknesses for tidalDB:** **Re-introduces the averaging problem *within* a cluster.**
|
||
A centroid is by construction a synthetic mean — exactly what PinnerSage rejected. If
|
||
τ is too loose, two genuinely distinct interests merge into one drifting centroid and
|
||
you are back to the single-vector failure mode at finer granularity. **Order
|
||
sensitivity:** online assignment is greedy and non-revisable — an early
|
||
misassignment is permanent (no reclustering pass corrects it). **No medoid
|
||
guarantee** — a centroid can drift to an empty embedding-space region between its
|
||
members (the precise PinnerSage failure). **τ is a per-corpus magic number** that
|
||
governs cluster count and must be tuned/benchmarked.
|
||
|
||
### Approach 3: Hybrid — online assignment + bounded periodic in-process recluster
|
||
|
||
**How it works:** Run Approach 2 online for cheap, always-fresh, write-path
|
||
clustering. **Additionally**, on the existing periodic checkpoint thread (which
|
||
already walks every user for `Tag::Preference` checkpoint, `preference.rs:263`), run a
|
||
bounded **in-process micro-recluster** for users whose interaction count crossed a
|
||
threshold since their last recluster: pull that user's recent interaction-embedding
|
||
window (capped, e.g. last 256), run a *cheap* clustering pass (mini-batch k-means with
|
||
k-means++ seed, or single-linkage with a distance cutoff — both O(N·K·dim), not
|
||
O(N²)), and **snap each cluster's representative to the nearest actual interaction
|
||
embedding (a medoid)**, recovering PinnerSage's medoid property. Online centroids
|
||
serve queries between reclusters; the periodic pass corrects greedy misassignments and
|
||
prevents centroid drift.
|
||
|
||
**Used by:** This is the architectural pattern Qdrant/Tantivy/Lucene use for *segment
|
||
management* (cheap incremental writes + periodic compaction merge) applied to user
|
||
clustering. `ann_for_tidaldb.md:96` already cites this segment pattern as proven. No
|
||
rec system publishes this exact hybrid for *user clustering*, again giving it
|
||
conception novelty (§7).
|
||
|
||
**Strengths for tidalDB:** Gets online's write-path cheapness AND offline's quality
|
||
correction, with **no new background subsystem** — it rides the checkpoint thread that
|
||
already exists. Recovers the medoid property periodically. The recluster is bounded
|
||
(capped window, K small, mini-batch) so it is O(active_users × window × K × dim), not
|
||
O(n²) global.
|
||
|
||
**Weaknesses for tidalDB:** Most implementation surface of the three. Requires storing
|
||
a **bounded recent interaction-embedding window per user** (the recluster input) — a
|
||
storage cost between Approach 1 (full history) and Approach 2 (K centroids only).
|
||
Two code paths (online + periodic) that must agree on the centroid format. The medoid
|
||
snap requires the window to still hold the chosen embedding (it does, by construction).
|
||
|
||
---
|
||
|
||
## Comparison
|
||
|
||
| Criterion | A: Offline medoid (Ward) | B: Online centroids | C: Hybrid (online + periodic medoid recluster) |
|
||
|---|---|---|---|
|
||
| Clustering cost | O(n² log n) per user, batch | O(K·dim) per engagement | O(K·dim)/engagement + bounded periodic O(W·K·dim) |
|
||
| Needs offline/Spark tier | **Yes (disqualifying)** | No | No (rides checkpoint thread) |
|
||
| Avoids averaging *across* interests | Yes | Yes (if τ tuned) | Yes |
|
||
| Avoids averaging *within* a cluster | **Yes (medoid)** | **No (synthetic centroid)** | **Yes (periodic medoid snap)** |
|
||
| Order-sensitivity / corrects misassignment | Corrects (full recluster) | **No (greedy, permanent)** | Corrects periodically |
|
||
| Storage per user | N×dim (full window) | **K×dim (≈ today)** | bounded W×dim window + K×dim |
|
||
| Reuses existing EMA/adaptive-LR | Partially | **Fully** | Fully (online leg) |
|
||
| Write-path latency impact | n/a (offline) | Negligible (K cosine + 1 EMA) | Negligible (online leg) |
|
||
| New magic constants | linkage cutoff | **τ (split threshold), K cap** | τ, K cap, recluster window W, recluster cadence |
|
||
| Implementation surface | High (Ward, but offline) | **Low** | Medium |
|
||
| Patent-conception novelty | None (prior art) | Medium | **High** (no published equivalent) |
|
||
|
||
---
|
||
|
||
## Settling the Sub-Questions
|
||
|
||
### 1. Central fork — recommendation
|
||
|
||
**Build Approach B (online-maintained centroids) now, with the data layout designed
|
||
so Approach C (the periodic medoid recluster) drops in later without a format
|
||
migration.** Concretely: ship B; reserve the persistence fields and the
|
||
interaction-window storage hooks that C needs (§5), but gate the periodic recluster
|
||
behind a follow-up milestone.
|
||
|
||
Rationale:
|
||
|
||
- **A is disqualified by the in-process constraint.** Ward hierarchical clustering has
|
||
no home in an embeddable single-node DB with no batch tier. The prompt's framing is
|
||
correct: PinnerSage's offline-medoid approach assumes a Spark cluster tidalDB does
|
||
not have.
|
||
- **B is the cheapest correct online primitive** and is already 80% built — it is N
|
||
copies of the existing `preference.rs` EMA, one per centroid, plus a nearest-centroid
|
||
assignment and a threshold split. It eliminates the *across-interest* averaging
|
||
problem (the dominant failure) immediately.
|
||
- **Does online clustering reintroduce the averaging problem within a cluster?** Yes —
|
||
honestly, it does. A centroid is a mean. But this is a *second-order* failure
|
||
(averaging within one coherent interest is far less harmful than averaging across
|
||
three unrelated ones), and it is exactly what the deferred Approach-C medoid snap
|
||
fixes. Shipping B first captures the large win; C captures the residual.
|
||
- **Cheapest correct online clustering primitive:** **threshold-based sequential
|
||
k-means with a DP-means-style split** (assign to nearest centroid if cosine ≥ τ,
|
||
else open a new centroid up to K_max). This is strictly cheaper than online
|
||
agglomerative (which needs a merge step) and needs no fixed-K commitment up front
|
||
(unlike classic streaming k-means). Use cosine on unit vectors (consistent with the
|
||
existing L2-normalized invariant).
|
||
|
||
### 2. Cluster count K — recommendation
|
||
|
||
- **Adaptive per user, capped.** K grows by the DP-means split rule up to **K_max = 10**
|
||
(matches `ARCHITECTURE.md`'s "3-10"). Most users will have 1-3 active clusters; only
|
||
multi-modal users reach the cap. A fixed K wastes vectors for single-interest users
|
||
and starves multi-modal ones.
|
||
- **Cold-start rule:** **below N = 5 positive interactions, fall back to the existing
|
||
single adaptive-LR vector** (the current code path, unchanged). This is exactly
|
||
`ann_for_tidaldb.md:112` ("for users with <5 interactions: simple weighted average
|
||
is acceptable"). The single vector *is* the K=1 case; cold-start is therefore not a
|
||
separate code path but the natural K=1 floor — the multi-vector store seeds its
|
||
first centroid from the existing single vector when it crosses N.
|
||
- When over the cap, the split rule must **not** open an 11th cluster; instead assign to
|
||
the nearest existing centroid (the standard DP-means cap behavior). Do not evict —
|
||
eviction loses an interest; merging is the periodic recluster's job (Approach C).
|
||
|
||
### 3. Cluster importance / time-decay — recommendation
|
||
|
||
- **Per-cluster importance = decayed engagement mass.** Each centroid carries a scalar
|
||
`importance` that is incremented on assignment and **forward-decayed using tidalDB's
|
||
existing signal-decay model** rather than wall-clock recomputation. This is the
|
||
composition point with the forward-decay signal system: store
|
||
`(importance_at_anchor, anchor_timestamp)` per cluster and compute
|
||
`current_importance = importance_at_anchor × decay(now − anchor)` on read — the same
|
||
O(1) forward-decay primitive the signal ledger already uses
|
||
(`docs/research/tidaldb_signal_ledger.md`). This is the *correct* reconciliation of
|
||
the ARCHITECTURE.md "maintained by the database as signals arrive with decay" claim,
|
||
which today is unimplemented (the EMA has only implicit forgetting, no decay term).
|
||
- **Query-time cluster selection = importance sampling, deterministic top-M.** Rather
|
||
than PinnerSage's stochastic sample, query the **top-M clusters by current
|
||
importance** where M = min(K_active, 3). Deterministic top-M is reproducible (tidalDB
|
||
values deterministic queries elsewhere, e.g. the exploration shuffle in
|
||
`candidate_gen.rs:174`) and 3 is PinnerSage's serve-time count. Make M a profile
|
||
parameter so a profile can widen exploration.
|
||
- This means a stale interest naturally falls out of the queried set as its importance
|
||
decays below the 3rd-ranked cluster — without ever deleting it (a re-engagement
|
||
re-boosts it).
|
||
|
||
### 4. Query-time mechanics — recommendation and latency budget
|
||
|
||
- **Issue M `search` calls** (one per selected cluster centroid), where M ≤ 3. **As
|
||
shipped these run sequentially on the query thread** (the loop is embarrassingly
|
||
parallel and reserved for parallelization). For the `for_you` path these are
|
||
unfiltered `search` (today's call); when a scope bitmap is present they become
|
||
`filtered_search` — identical to the existing `pipeline.rs:431` shape, just issued
|
||
M times.
|
||
- **Merge + dedup:** union the M result lists, dedup by entity id keeping the **best
|
||
(min) distance** across clusters, then take top-k by that distance. This is a
|
||
k-way merge of sorted lists, O(M·k). Critically, **dedup-by-best-distance, not
|
||
dedup-by-score** — the ANN distance is the cluster-relevance signal; the engagement
|
||
*score* is applied afterward by Stage 3, which must remain the single authority on
|
||
final ordering.
|
||
- **Interaction with Stage 3:** unchanged. Stage 3 re-ranks the merged pool by signals
|
||
exactly as today. The only change `compute_preference_boosts`
|
||
(`personalization.rs:91`) needs: compute the per-candidate cosine boost against the
|
||
**nearest of the user's clusters** (max cosine over **all** the user's centroids, not
|
||
only the M queried — a union candidate is then scored against its true best interest),
|
||
not a single vector — otherwise a candidate retrieved via the "cars" cluster gets a
|
||
near-zero boost measured against a "cooking"-dominated single vector.
|
||
- **Latency cost (quantitative):** the ANN leg at 100k/1536-D, ef=64 is **p99 1.22ms**
|
||
per query (`candidate_gen.rs:81-89`). M=3 issued sequentially (as shipped) is ~3.7ms
|
||
p99 — well inside the 50ms budget; parallelizing would adds only parallel-merge
|
||
overhead, not 3× latency. The merge of 3×k lists (k≈200) is sub-microsecond. **Net:
|
||
the multi-vector path costs <3ms additional p99 in the as-shipped serial case (<1.5ms
|
||
if parallelized).** The dominant budget consumer remains Stage 3
|
||
preference-boost recompute (per MEMORY: `for_you` ANN p99 ~24ms is Stage-3, not the
|
||
ANN leg) — multi-vector does not move that needle except for the nearest-centroid
|
||
cosine change, which is M cosines instead of 1 per candidate (negligible).
|
||
- **Throughput:** at 1K queries/sec × M=3 = 3K ANN searches/sec; USearch sustains this
|
||
comfortably at this corpus (per m12p3 grid-search). No new throughput risk.
|
||
|
||
### 5. Persistence — recommendation
|
||
|
||
Extend the `Tag::Preference` format, do **not** add a new tag (keeps the single
|
||
prefix-scan restore). Today's value is `[count:8 LE][dim:4 LE][f32*dim LE]`. Make it
|
||
**self-describing and backward-compatible**:
|
||
|
||
```
|
||
[version:1][n_clusters:1][dim:4 LE]
|
||
repeated n_clusters times:
|
||
[update_count:8 LE] # per-cluster adaptive-LR count
|
||
[importance_at_anchor:4 LE f32]
|
||
[anchor_ts:8 LE] # for forward-decay importance
|
||
[f32 * dim LE] # the centroid (unit-normalized)
|
||
```
|
||
|
||
- `version` byte distinguishes the new layout from the legacy one. **Restore must
|
||
read legacy rows** (no version byte, starts with an 8-byte count then a 4-byte dim ≤
|
||
some sane max) and load them as a single K=1 cluster — a zero-migration upgrade. A
|
||
legacy detector: if `bytes[0]` is a plausible version (e.g. `2`) AND the implied
|
||
length matches, parse new; else parse legacy. **As built:** the 1-byte version
|
||
sentinel CAN collide with a legacy little-endian count's low byte (e.g. `update_count
|
||
== 2`), so the discriminator is *structural*, not byte-value-based — a row is parsed
|
||
as multi-cluster only if `bytes[0] == FORMAT_VERSION` AND it decodes into ≥1
|
||
dimension-correct cluster; otherwise it falls through to the legacy decoder. This
|
||
rescues a colliding legacy row instead of dropping it. (See `FORMAT_VERSION` in
|
||
`entities/multi_preference.rs`.)
|
||
- **For Approach C (deferred):** a *second* tag `Tag::PreferenceWindow` (discriminant
|
||
`0x1A` — `0x17`–`0x19` are already taken) stores the bounded recent
|
||
interaction-embedding window per user, written ring-buffer style. **This tag is now
|
||
reserved in `keys.rs`** (not yet populated); adding the window when C ships is an
|
||
additive row, never a rewrite of existing `Tag::Preference` rows.
|
||
- The atomic-batch swap (`checkpoint`, lines 270-296) and the load-boundary
|
||
re-normalization + NaN-neutralization (`restore`, lines 333-353) carry over
|
||
per-cluster unchanged — re-normalize every centroid, drop torn clusters
|
||
individually rather than the whole user.
|
||
|
||
### 6. Adaptive learning rate — recommendation
|
||
|
||
**Preserve it per-cluster.** Each centroid keeps its own `update_count` and computes
|
||
`alpha = base / (1 + ln_1p(count))` exactly as today — a cluster stabilizes as it
|
||
accumulates engagements, which is the correct behavior (a well-established interest
|
||
should resist drift). Do **not** replace the per-cluster EMA with re-clustering in
|
||
Approach B — there is no reclustering in B. When Approach C's periodic recluster
|
||
runs, it **resets** the per-cluster counts for any cluster whose membership it
|
||
recomputes (a reclustered centroid is effectively new), which is the only point where
|
||
"re-clustering replaces the LR." So: **B preserves per-cluster adaptive LR; C's
|
||
periodic pass resets the LR of reclustered clusters.** This is internally consistent —
|
||
the LR tracks "how settled is *this* centroid," and a recluster un-settles it.
|
||
|
||
### 7. Reduced-to-practice vs. conception boundary
|
||
|
||
Patent filing distinguishes what the shipped design **actually implements** (reduced
|
||
to practice) from what is **described but not built** (conception only). Be precise —
|
||
claiming reduction to practice for unbuilt aspirational features is a filing defect.
|
||
|
||
| Element | Status in the recommended design | RTP or Conception |
|
||
|---|---|---|
|
||
| Single adaptive-LR EMA preference vector | Shipped today (`preference.rs`) | **Reduced to practice** (prior, in-tree) |
|
||
| Cold-start fallback to K=1 single vector below N interactions | Built in Approach B | **Reduced to practice** (on ship) |
|
||
| Online sequential-k-means + DP-means threshold split, K_max cap | Built in Approach B | **Reduced to practice** (on ship) |
|
||
| Per-cluster adaptive learning rate | Built in Approach B | **Reduced to practice** (on ship) |
|
||
| Forward-decayed per-cluster importance composed with the signal-decay model | Built in Approach B | **Reduced to practice** (on ship) |
|
||
| Deterministic top-M importance-ranked cluster selection at query time | Built in Approach B | **Reduced to practice** (on ship) |
|
||
| M **sequential** ANN queries + dedup-by-best-distance merge feeding Stage 3 (parallelization reserved) | Built in Approach B | **Reduced to practice** (on ship) |
|
||
| Max-cosine-over-all-clusters preference boost in Stage 3 | Built in Approach B | **Reduced to practice** (on ship) |
|
||
| Version-tagged backward-compatible `Tag::Preference` multi-cluster layout | Built in Approach B | **Reduced to practice** (on ship) |
|
||
| Periodic in-process medoid recluster on the checkpoint thread (Approach C) | **Designed, not built** | **Conception only** |
|
||
| Medoid snap recovering an actual-interaction representative | **Designed, not built** | **Conception only** |
|
||
| Bounded per-user interaction-embedding window (`Tag::PreferenceWindow`) | Reserved, not populated | **Conception only** |
|
||
| LR reset on recluster | Designed for C, not built | **Conception only** |
|
||
| Offline Ward hierarchical clustering (Approach A) | Surveyed, rejected | **Neither** (not pursued) |
|
||
|
||
The novel-conception kernel for a filing is the **combination**: online DP-means user
|
||
clustering + forward-decay-composed cluster importance + deterministic top-M multi-ANN
|
||
merge, **with a deferred in-process medoid-recluster correction** — no surveyed
|
||
production system (PinnerSage included, which is offline+medoid) combines online
|
||
maintenance with a periodic in-process medoid correction on an embedded DB's
|
||
checkpoint thread.
|
||
|
||
---
|
||
|
||
## Recommendation (one design, chosen)
|
||
|
||
**Ship Approach B.** Implement multi-vector user preference as **online
|
||
sequential-k-means with a DP-means threshold split**, capped at K_max=10, with:
|
||
|
||
1. **Cold start:** below N=5 positive interactions, use the existing single
|
||
adaptive-LR vector (the K=1 floor; seed the first centroid from it on crossover).
|
||
2. **Assignment:** on each positive engagement, find the nearest centroid by cosine;
|
||
if cosine ≥ τ blend via the existing per-cluster adaptive-LR EMA, else open a new
|
||
centroid (up to K_max; over the cap, assign to nearest — never evict).
|
||
3. **Importance:** per-cluster `(importance_at_anchor, anchor_ts)`, forward-decayed
|
||
with tidalDB's existing signal-decay primitive on read.
|
||
4. **Query (`for_you`):** select top-M (M=min(K_active,3)) clusters by current
|
||
importance; issue M sequential `search`/`filtered_search` calls (parallelizable,
|
||
not yet parallelized); merge with dedup-by-best-distance; feed the unified pool to
|
||
Stage 3 unchanged.
|
||
5. **Stage 3 boost:** `compute_preference_boosts` uses max cosine over **all** the
|
||
user's cluster centroids (a union candidate is scored against its true best interest).
|
||
6. **Persistence:** version-tagged multi-cluster `Tag::Preference` value, backward-
|
||
compatible with legacy K=1 rows (structural discriminator, no count-collision drop);
|
||
`Tag::PreferenceWindow` (`0x1A`) reserved in `keys.rs` for Approach C.
|
||
7. **Design the centroid + window storage so Approach C (periodic in-process medoid
|
||
recluster) drops in with no format migration** — but do not build C in this
|
||
milestone.
|
||
|
||
τ, K_max, N, M, and the decay half-life are **benchmark-derived constants**, not
|
||
guesses (see Open Questions). Default τ around 0.5-0.6 cosine for OpenAI-1536-D
|
||
embeddings as a *starting* point for the grid search, not a shipped value.
|
||
|
||
---
|
||
|
||
## Doc / Roadmap Corrections Required
|
||
|
||
These are factual reconciliations the survey surfaced; @tidal-engineer should land
|
||
them alongside (or before) the implementation:
|
||
|
||
1. **`ARCHITECTURE.md:252-254`** currently asserts, as if shipped, that each user's
|
||
preference "is represented as 3-10 interest cluster centroids (PinnerSage-style),
|
||
maintained by the database as signals arrive." **This is not implemented** — the
|
||
code maintains exactly one EMA vector with no decay term. Correct the doc to either
|
||
(a) describe the *single-vector* reality with a forward-pointer to this research, or
|
||
(b) keep the multi-vector description but explicitly mark it as the planned design
|
||
(this doc) rather than current behavior. Also fix the implicit "with decay" claim:
|
||
today's blend has no decay; the decay composition is the *new* work in §3 here.
|
||
|
||
2. **`ROADMAP.md:587`** claims Phase 1 delivered "the **adaptive filtered search
|
||
planner**." The delivered artifact is the **selectivity-based query planner** (AC at
|
||
line 594: <2% → pre-filter+brute-force; 2-100% → `filtered_search`). That is real
|
||
and shipped. But "adaptive" here risks being read as the adaptive *preference*/
|
||
multi-vector planner, which does **not** exist. Reword to "selectivity-based
|
||
filtered-search planner" to remove the conflation, and do not let this line stand as
|
||
evidence that multi-vector preference is delivered.
|
||
|
||
3. **`ann_for_tidaldb.md:106-116`** is consistent with this doc (PinnerSage multi-query
|
||
merge) but says "pre-compute user interest clusters **offline**" — which contradicts
|
||
the embeddable, no-batch-tier constraint. Add a cross-reference from
|
||
`ann_for_tidaldb.md` to this doc noting that the *offline* framing is superseded by
|
||
the *online* Approach B for tidalDB's in-process model.
|
||
|
||
---
|
||
|
||
## Open Questions (benchmarks tidalDB must run)
|
||
|
||
- **τ (split threshold):** the single most important constant. Grid-search τ ∈
|
||
{0.4..0.7} on a real multi-modal interaction trace; measure cluster count
|
||
distribution and feed-relevance vs. ground truth. Too loose → averaging within
|
||
cluster returns; too tight → K_max saturates and fragments one interest.
|
||
- **Does online-only (B) measurably beat the single vector** on a multi-modal cohort,
|
||
and **by how much does the deferred medoid recluster (C) add** on top? If C's lift
|
||
over B is marginal, C may never need to ship — that decision must be data-driven, not
|
||
assumed.
|
||
- **Order-sensitivity magnitude:** construct adversarial interaction orderings; measure
|
||
how often greedy online assignment produces a materially worse clustering than a
|
||
batch recluster of the same set. This quantifies the value of Approach C.
|
||
- **M (clusters queried) sweep:** M ∈ {2,3,5} — relevance lift vs. the linear ANN-leg
|
||
latency cost. Confirm the <3ms p99 estimate holds at 1M/1536-D (currently k3s-pending,
|
||
per the m12 memory entries).
|
||
- **Dedup-by-best-distance vs. round-robin interleave** for the merge: which yields
|
||
better post-Stage-3 diversity? Interleaving may better surface minority interests.
|
||
- **Importance decay half-life:** how fast should a stale interest fall out of the
|
||
top-M? Tie to the signal-decay half-life or make it independent? Needs a retention/
|
||
freshness A/B.
|
||
- **Storage delta for Approach C's interaction window** at scale (W=256 × 1536 × 4B ≈
|
||
1.5MB/user) — validate this is acceptable for the active-user set before committing to
|
||
C's window.
|
||
|
||
## Sources
|
||
|
||
- Pal et al., "PinnerSage: Multi-Modal User Embedding Framework for Recommendations at
|
||
Pinterest," KDD 2020. (Ward hierarchical clustering, medoid representation,
|
||
importance sampling, averaging-failure example.)
|
||
- Kulis & Jordan, "Revisiting k-means: New Algorithms via Bayesian Nonparametrics,"
|
||
ICML 2012. (DP-means — threshold-based adaptive cluster count, the online split rule.)
|
||
- MacQueen, "Some methods for classification and analysis of multivariate
|
||
observations," 1967. (Sequential/online k-means update.)
|
||
- `docs/research/ann_for_tidaldb.md` §"Multi-vector retrieval needs no special
|
||
indexing" (lines 102-135). (USearch multi-query merge endorsement; segment pattern.)
|
||
- `docs/research/tidaldb_signal_ledger.md`. (Forward-decay O(1) primitive composed with
|
||
cluster importance.)
|
||
- In-tree code verified 2026-06: `tidal/src/entities/preference.rs`,
|
||
`tidal/src/db/signals.rs:547,765-816`, `tidal/src/db/query_ops.rs:99-116`,
|
||
`tidal/src/query/executor/candidate_gen.rs:64-130`,
|
||
`tidal/src/query/executor/personalization.rs:91-123`,
|
||
`tidal/src/query/search/executor/pipeline.rs:429-440`,
|
||
`tidal/src/storage/keys.rs:71`, `ARCHITECTURE.md:252-254`, `ROADMAP.md:587-598`.
|