Adapters¶
recsys/adapters/ is the only place IO lives. Each adapter implements a
port Protocol, so the pure core never imports a driver.
flowchart LR
subgraph ports["Port (Protocol)"]
p1["EventSource"]
p2["ContentStore"]
p3["UserModelStore"]
p4["EmbeddingModel"]
end
subgraph impl["adapters/"]
a1["RedisEventBuffer"]
a2["QdrantContentStore"]
a3["RedisUserModelStore"]
a4["FastEmbedModel"]
a5["rudderstack.normalize_*"]
end
p1 -.-> a1
p2 -.-> a2
p3 -.-> a3
p4 -.-> a4
a5 -->|produces| ev["InteractionEvent"]
rudderstack.py, pure normalizer (no infra)¶
The boundary translator: raw RudderStack payload -> canonical InteractionEvent.
| Function | Does |
|---|---|
normalize_content_id(raw_id) |
'content_1234' -> '1234'; bridges string IDs to Qdrant int points. |
normalize_event(raw) |
One track payload -> InteractionEvent (or None if unusable). |
normalize_events(raws) |
Map many, drop None, sort by timestamp. |
This is pure and infra-free on purpose, it is the shared contract every event source
must satisfy (one contract test asserts identical InteractionEvent from equivalent raw
across all source adapters).
qdrant_store.py, ContentStore¶
QdrantContentStore(client, collection_name):
| Method | Does |
|---|---|
get(ids) |
Payloads -> dict[id, Content]. |
get_vectors(ids) |
Embeddings -> dict[id, Vector] (handles named vectors). |
search_vector(vector, *, limit) |
Top-k similar -> Candidates with cosine scores. |
search_tags(tag_keys, *, limit) |
Match any facet:label via Filter(should=...) over the tag_labels KEYWORD index -> Candidates. |
Tag recall is a filter, not a ranker, graded tag ranking happens in pure
score_tag. _pid converts numeric string IDs to ints for Qdrant.
redis_store.py, hot path stores¶
flowchart LR
subgraph buffer["RedisEventBuffer (sorted set, score=ts)"]
ap["append(event)<br/>auto-prune > window_days"]
fe["fetch_events(user_id)"]
end
subgraph model["RedisUserModelStore (JSON + TTL)"]
gs["get_signals(user_id)"]
sv["save_signals(signals)"]
end
RedisEventBuffer: per-user sorted set keyed by timestamp,window_days=30default, auto-prunes old events on append. ImplementsEventSourceplus.append().RedisUserModelStore: materializedUserSignalsas JSON,ttl_secondsā7d. The fast read on the serve path. Also exposesiter_signals()for cohort-wide stats.RedisImpressionStore:request_idthen{content_id: feature_vector}(JSON, TTL'd). The short-lived join store for online bandit updates: a reward event echoing therequest_idlooks up the served context here, thenconsume(request_id, content_id)drops it so a redelivered reward cannot double-count (idempotent). Implements theImpressionStoreport; test fake isInMemoryImpressionStore.
All three Redis stores take a key_prefix so the composition root can isolate tenants
({tenant}:umodel / evt / imp); see Multi-tenancy.
Event log, impressions, and config¶
Three adapters added alongside the hot-path stores.
event_log.py, durable Parquet log¶
The permanent training/eval record (Redis is ephemeral). Two append-only datasets under
EVENT_LOG_DIR:
| Dataset | Holds |
|---|---|
date=YYYY-MM-DD/part-*.parquet |
ingested InteractionEvents (the reward record), including request_id |
served/date=YYYY-MM-DD/part-*.parquet |
recommendations served: user, ranked items with feature vectors, distractor, request_id |
NullEventLog: no-op whenEVENT_LOG_DIRis unset.ParquetEventLog(base_dir):append(events)partitions by day and writes immutable parts;log_served(record)appends one served-impression row (theitemslist is JSON-stringified so the row stays flat). Joining the two datasets on(request_id, content_id)yields(context, action, reward)tuples for the bandit.pyarrowis imported lazily.
redis_store.py -> RedisImpressionStore (ImpressionStore)¶
Covered above: the request_id then features store that feeds online bandit updates,
TTL'd and consume-on-use.
config_store.py, runtime RecConfig override¶
Holds an optional runtime override applied on top of the env/default RecConfig, so recsys
params change live (via PUT /config and the settings page) without a redeploy.
RedisConfigStore(client, key=...):get/set/cleara JSON override under a per-tenant key ({tenant}:recsys:config).deep_mergelayers a partial patch onto the baseline. Redis is the live override layer; the env/configmap stays the source of truth, so if Redis is flushed the config reverts to the baseline.InMemoryConfigStore: dev/test fallback (no Redis).
fastembed_model.py, EmbeddingModel¶
FastEmbedModel(model_name="sentence-transformers/all-MiniLM-L6-v2") -> dim property +
encode(text) -> Vector. Used where the system must embed text at runtime (e.g. cold-start
query text). Test fake is InMemoryEmbeddingModel (deterministic hash buckets).
Infra guarding¶
Adapters import their drivers lazily / behind guards; the composition
root only instantiates a real adapter when its env var
(REDIS_URL, QDRANT_API_URL) is set, else falls back to a fake. Result: the whole
pipeline runs offline with zero infra.