Online Serving Model¶
User data flows app -> RudderStack -> {PostHog, ai-engine}. PostHog stays the
analytics/eval sink, it is not queried at request time. Serving is online: events
are normalized and buffered, the user model is rebuilt incrementally,
and recommendations read a materialized model with no rebuild on the hot path.
flowchart TB
app["In-memorial app"]
rs["RudderStack"]
ph["PostHog<br/>(analytics, not on hot path)"]
app -->|track events| rs
rs --> ph
subgraph engine["ai-engine (serving)"]
direction TB
wh["/api/ingest webhook"]
norm["normalize_events<br/>(rudderstack adapter)"]
buf[("EventBuffer<br/>Redis sorted set")]
upd["UserModelUpdater.refresh<br/>(build_user_signals, pure)"]
ums[("UserModelStore<br/>Redis JSON")]
rec["Recommender.recommend"]
end
rs -->|webhook| wh --> norm --> buf
buf --> upd
upd -->|save| ums
ums -->|fast read, no rebuild| rec
rec --> out["Recommendation"]
classDef store fill:#EFEAE0,stroke:#A8895B,color:#423D34;
class buf,ums store;
Why two stores¶
| Store | Type | Role |
|---|---|---|
EventBuffer |
Redis sorted set, score = timestamp | Hot per-user event window (default 30 days, auto-pruned). The raw material. |
UserModelStore |
Redis JSON, TTL ~7 days | Materialized UserSignals: the answer, read directly at serve time. |
Both are ports (EventSource, UserModelStore). Tests swap in FakeEventSource +
InMemoryUserModelStore; production swaps in Redis, without touching ranking.
Two sides, one brain¶
sequenceDiagram
autonumber
participant RS as RudderStack
participant API as api.ingest
participant UP as UserModelUpdater
participant B as EventBuffer (Redis)
participant M as UserModelStore (Redis)
participant R as Recommender
participant C as ContentStore (Qdrant)
Note over RS,M: INGEST side (write)
RS->>API: webhook batch
API->>API: normalize_events()
API->>B: append(InteractionEvent...)
API->>UP: refresh(user_id)
UP->>B: fetch_events(user_id)
UP->>C: get(touched content)
UP->>UP: build_user_signals() (pure)
UP->>M: save_signals(UserSignals)
Note over R,C: SERVE side (read)
R->>M: get_signals(user_id)
R->>R: recommend_for_signals() (pure)
R->>C: search_vector + search_tags
R-->>API: Recommendation
The ingest side rebuilds from the buffer every refresh, so build_user_signals stays
the single source of truth, no incremental drift between "what we stored" and "what a full
recompute would give."
Durable logs and the impression store¶
The two Redis stores above are the ephemeral serving substrate. Alongside them the engine keeps durable and short-lived records that close the training loop. None of these are on the recommendation hot path; the online read above is unchanged.
| Store | Backing | Role |
|---|---|---|
| Event log | Parquet, EVENT_LOG_DIR/date=*/ |
append-only ingested InteractionEvents (the reward record); immutable parts |
| Served log | Parquet, EVENT_LOG_DIR/served/date=*/ |
every recommendation served: user, ranked items with their feature vectors, distractor, and the request_id |
| Impression store | Redis (TTL'd) | request_id then {content_id: features}, so a later reward event joins back to the exact served context for an online bandit update |
The request_id is the join key. /api/recommend returns it; the app echoes it on the
resulting CONTENT_VIEW_ENDED, so served context (features) meets outcome (reward). The
served log makes the join reconstructable offline; the impression store makes it possible
online. See Bandit / online learning and the new
adapters.
Multi-tenancy routing¶
One engine can serve many clients. Every request and event carries an X-Tenant-Id
header; a pure-ASGI middleware sets a contextvar and a TenantProxy resolves each c.<attr>
to that tenant's components, so the endpoints above are unchanged. Each tenant has its own
Qdrant collection and its own Redis key-prefix (user models, events, impressions, config), so
nothing crosses. No header means the default tenant (single-tenant behaviour, unchanged).
Full detail: Multi-tenancy.
Rebuild-from-buffer rationale¶
- Determinism: the model is always a pure function of the buffered events + content, testable with golden fixtures.
- Schema evolution: change the signal math, and the next refresh re-derives every user's model from raw events; no migration of derived state.
- Cheap reads: the recommender never folds events at request time; it reads one JSON blob and runs scoring.
Status¶
Core is implemented and tested. The live FastAPI ingest webhook, the geo scorer, the durable
logs, and the learned ranker (offline trainer plus optional online updates) are all wired. The
composition root (composition.py) falls back to in-memory fakes
when REDIS_URL / QDRANT_API_URL are unset, so the full pipeline runs locally with no infra.