×marble
all posts
Jun 1, 2026·13 min read

Personalizing Typesense Search in 2026: Sort Scoring, Vector Re-ranking, and the State You Have to Manage Yourself

Typesense is fast, typo-tolerant, and increasingly capable — but it's stateless by design. Here's the exact architecture for injecting user signals at query time without rebuilding your index for every user.

Alex Shrestha·Founder, ×marble

Personalizing Typesense Search in 2026: Sort Scoring, Vector Re-ranking, and the State You Have to Manage Yourself

TL;DR.

  • Typesense is stateless: it knows nothing about your users unless you push that knowledge in at query time or index time.
  • The three levers are sort_by with numeric override fields, query-time filter_by preference injection, and embedding user context directly into the query vector.
  • Per-document, per-user score denormalization doesn't scale — you need a side layer that computes affinity signals and injects them per request.
  • Typesense's hybrid search (BM25 + HNSW) lets you pull semantic personalization off the shelf; the alpha parameter is a per-user dial, not a global constant.
  • If you only remember one thing: Typesense gives you the ranking machinery — the user model that drives it has to live somewhere else.

Typesense is the search engine engineers reach for when they're tired of fighting Elasticsearch's YAML. It's fast, self-hostable, and gets typo tolerance right by default. By 2026 it ships vector search and hybrid BM25+dense retrieval, which means it handles semantic queries without a separate ANN index. What it doesn't ship is any concept of a user. There's no session model, no preference store, no implicit feedback loop. It ranks documents — it doesn't know who's asking.

That gap is the whole problem of personalized Typesense search. Engineers hit it after basic search is working: query latency is fine, relevance is decent, users are engaged — and then the product team asks why everyone gets the same results for "coffee maker." The answer is that Typesense ranked by BM25 score and your popularity_score field, both of which are global. There's no user in the loop. This post is about what it takes to put one there.

What Does Typesense Actually Give You for Personalization?

The short answer: the ranking machinery, not the user model. Typesense exposes four primitives that matter for personalization.

sort_by with numeric fields. Any numeric field in your schema can be a sort key, with optional weighted combination: sort_by=affinity_score:desc,popularity:desc. If you can compute a per-document affinity signal and surface it at query time, Typesense will use it. The catch is how you attach it — the naive approach doesn't scale.

filter_by for hard and soft preference constraints. You can filter on array-valued fields (genre:=[thriller,horror]) or numeric ranges. This is the simplest form of personalization: take the user's stated preferences and add them as query-time filter clauses. It excludes non-preferred content entirely rather than down-ranking it — coarse, but fast and debuggable, and a reasonable first pass before you have ML infrastructure.

pinned_hits and hidden_hits. Editorial overrides: force specific document IDs to the top of results, or suppress them entirely. In a personalization context, your ML system can compute the top-k documents for a user offline and pin them, leaving Typesense to fill the rest organically. This is a viable bootstrap strategy before you're ready to personalize every query path.

Vector search and hybrid search. Since Typesense 0.25, you can store dense vectors in a float[] field with HNSW indexing and query with vector_query. Hybrid mode blends BM25 and vector scores with a tunable alpha. This is where personalization gets structurally interesting: if the query vector encodes user context — not just the query string — you get personalized retrieval without any index changes.

None of these features ships with user-awareness built in. They're blank primitives. The personalization logic is yours to write.

The Statelessness Problem: Where Does the User Model Live?

Typesense is stateless in the sense that each search request is independent. It has no session concept, no user preference table, no implicit feedback loop. When a user searches, Typesense sees the query string, the collection schema, and whatever parameters you pass in the API call — nothing more.

This design is correct. Search engines shouldn't be behavioral signal databases. But it creates a clear architectural requirement: you need a user-state layer that computes personalization parameters at request time and injects them into the Typesense query. There are three architectures for this.

1. Query-Time Parameter Injection

The user's preference model lives in a fast read store — Redis, your personalization API, a knowledge graph edge store. When a search request arrives, you fetch the user's signals (preferred categories, recency weights, entity affinities), transform them into Typesense API parameters, and forward the augmented query.

def personalized_search(user_id: str, query: str) -> dict:
    prefs = preference_store.get(user_id)  # Redis, < 5 ms
    return typesense.collections["products"].documents.search({
        "q": query,
        "filter_by": f"category:=[{','.join(prefs.categories)}]",
        "sort_by": "recency_score:desc,_text_match:desc",
    })

This is the right default pattern for soft personalization. It's stateless from Typesense's perspective, adds one round-trip to your latency budget (zero if you pre-warm a session cache), and doesn't require reindexing. The resolution is coarse — you're adjusting filter and sort parameters, not document-level scores — but it's fast to ship and easy to A/B test. Start here.

2. Document-Level Score Denormalization

The idea: pre-compute a per-user affinity score for each document and store it in the schema as a user-specific numeric field. Then sort_by=user_${user_id}_score:desc. This sounds appealing until you count the fields.

A collection with 1 million documents and 100,000 users would need 100 billion field values. Typesense isn't designed for this — the schema would be unbounded and every index update catastrophically expensive. The only workable variant is pre-computing scores for your highest-value users and falling back to global scores for everyone else, which introduces a two-tier system that's messy to maintain. Skip this pattern entirely.

3. Personalized Query Embeddings

This is the most technically interesting approach and the one that compounds best with hybrid search. Instead of embedding just the query string, embed the query plus a user context summary — a dense representation of the user's preference state. Retrieve against document embeddings. The nearest neighbors are semantically relevant and user-adapted.

The standard architecture for this is a two-tower model: one encoder for queries (including user context), one encoder for items, both trained to align in a shared embedding space (Yi et al., RecSys 2019). At inference time, the query tower produces a personalized vector; you run vector_query against Typesense's HNSW index.

The user context summary can be as simple as a bag of preferred entity labels concatenated to the query before encoding: "coffee maker user:specialty-coffee pour-over single-origin". More sophisticated approaches use learned user embeddings from your behavioral history. Either way, the result is the same: Typesense retrieves documents that are close to a vector that already knows who's asking.

The tradeoff: your embedding model re-encodes on every search, which adds latency. In practice, a small distilled bi-encoder on a co-located GPU endpoint (quantized MiniLM-L6 or similar) runs in 5–10 ms. But it's a new infrastructure dependency. Don't introduce it until the simpler injection patterns have been tuned and benchmarked.

How to Wire Sort Scoring Without Per-User Schema Fields

The practical answer to personalized ranking — without the denormalization problem — is a small set of global numeric fields that capture orthogonal quality signals, combined at query time with user-specific filter_by and vector weights.

A concrete field set that works:

  • freshness_score: time-decayed publish or update timestamp, recomputed daily. score = exp(-λ * days_since_publish) with λ tuned to your content half-life.
  • popularity_score: click-normalized CTR over a trailing 7-day window, smoothed against document count.
  • quality_score: editorial rating or ML-derived content quality estimate, updated infrequently.

At query time, combine them: sort_by=quality_score:desc,popularity_score:desc,freshness_score:desc,_text_match:desc. Adjust the sort profile per user cohort — new users get freshness-weighted, power users get quality-weighted — without any per-user fields.

This gives you progressive personalization depth: filter_by handles category-level adaptation, global sort fields handle content quality, and the vector component (when enabled) handles fine-grained semantic personalization. You're tuning three dials rather than maintaining 100,000 custom score columns.

The Alpha Parameter as a Per-User Personalization Dial

Typesense's hybrid search blends BM25 text score with vector cosine similarity using alpha (0 = pure vector, 1 = pure BM25). Most implementations set alpha globally and never revisit it. That's a missed opportunity.

Alpha should be per-user, derived from signal density. A user with 200 interactions has a rich preference embedding; set alpha low (0.2–0.3) and let the personalized vector dominate. A new user with three events has a noisy, unreliable embedding — set alpha high (0.7–0.8) and let lexical matching carry the weight. A session with no user identity at all gets pure BM25 (alpha = 1.0).

Compute a signal_density score in your personalization layer — a log-scaled count of user events in the trailing 30-day window — and map it to alpha at query time:

def personalization_alpha(event_count: int) -> float:
    if event_count < 10:
        return 0.8   # new user: mostly BM25
    elif event_count < 50:
        return 0.5   # developing signal: balanced
    else:
        return 0.2   # established user: vector dominates

This is a lightweight version of what the dense retrieval literature calls adaptive retrieval weighting — varying the contribution of learned representations based on the evidence available for each query context (Karpukhin et al., 2020). The key difference from static hybrid search is that you're not treating alpha as a tuning hyperparameter you set once; you're treating it as a runtime signal that reflects how much you know about this particular user.

The practical impact: users with rich history get results that feel tuned; users without history get results that are at least lexically correct. Both groups get an experience that's better than either pure BM25 or pure-vector retrieval would provide alone.

Cold-Start Personalization in Typesense

Cold start is the case where you have no user signal. Typesense's default ranking — BM25 combined with your global sort fields — is already a reasonable fallback. The question is how to introduce personalization before behavioral signals accumulate, which is what determines whether session one feels generic.

Popularity and freshness baseline. Your global popularity_score and freshness_score fields do useful work here. This isn't personalization, but it surfaces high-quality recent content rather than arbitrary results. Treat it as the floor your personalization has to beat.

Onboarding signal collection. Asking three to five preference questions at registration produces immediate filter_by parameters. This is stated preference, not inferred — it's explicit but subject to social desirability bias and rapid drift. Treat it as a prior, update it with behavioral signal as fast as possible, and weight it less as observed signal accumulates.

Context signals without identity. Device type, locale, time-of-day, referral source, and query patterns within the current session are available on session zero without account history. A rule-based system that maps these to initial sort and filter profiles is fast to build and meaningfully better than nothing. "Mobile, food newsletter referral, 7 AM" is a usable prior for breakfast recipe ranking even with no user identity.

Synthetic clones. If your platform has behavioral segments — cohorts defined by engagement patterns, content affinity clusters, or demographic proxies — you can assign new users to the nearest segment and inherit that segment's preference embedding as a starting point. The clone degrades gracefully: as the user accumulates real signal, their actual embedding displaces the inherited segment average. The rate of displacement is a hyperparameter you tune per product vertical based on how fast individual preferences diverge from cohort behavior.

Cold-start in Typesense is entirely an upstream problem. Typesense just needs to receive valid parameters. The hard part is computing a signal for users who haven't given you one yet.

What a Knowledge Graph Layer Adds

The filter_by injection pattern scales well for flat preference structures — a set of preferred categories or tags drawn from a fixed taxonomy. It starts to strain when preferences are relational: "user likes director X; director X collaborated with cinematographer Y; user probably likes Y's solo work" requires graph traversal, not a category lookup.

A knowledge graph over user-item interactions handles this. It encodes preferences as typed edges (watched, liked, purchased, skipped, searched) between user nodes and entity nodes (films, artists, brands, topics, creators). Traversal over this graph produces second-order affinities — entities the user hasn't directly signaled but that are structurally close to entities they have. Those traversal outputs — an affinity vector over entities — become the parameters you inject into Typesense.

The deeper payoff is explainability. A collaborative filter tells you user A is similar to users B and C who liked item X. A knowledge graph tells you why user A would like X: the path through the graph makes the relationship legible. That path is also the explanation surface for UI copy ("Because you watched [Y], which was directed by [Z] who also made this"). Inference-driven explanation is becoming a product requirement in 2026, not a nice-to-have (Wang et al., KGAT 2019).

At ×marble, the knowledge graph computes affinity vectors over entities at request time. Those vectors map directly to Typesense filter_by category preferences and user context strings for query embedding. The graph traversal happens in the personalization API layer — Typesense sees only the resolved parameters, never the graph itself. The search engine stays simple; the intelligence lives upstream.

The Latency Budget

Personalized search adds steps before the Typesense call. In a production system, the per-request cost looks roughly like:

| Step | Target p50 | Notes | |---|---|---| | User preference fetch | < 5 ms | Redis or in-process session cache | | Parameter computation | < 3 ms | CPU-bound, often amortizable across session | | Query embedding (if used) | < 10 ms | Quantized bi-encoder, co-located GPU | | Typesense search | < 15 ms | p50 typically 5–12 ms on well-tuned collections | | Total | < 33 ms | Comfortably under 50 ms UX threshold |

The embedding step is the most variable. A quantized MiniLM-L6 model on a co-located T4 GPU runs in 5–10 ms. An external embedding API with no cache adds 30–80 ms and blows the budget. Cache embeddings for common query strings; the long tail of rare queries benefits least from personalized retrieval anyway. If a query string appears more than 10 times in a trailing hour window, its embedding is worth caching.

Typesense itself is not your bottleneck. A well-tuned deployment with under 1M documents and appropriate RAM allocation consistently runs under 10 ms p50. Don't let personalization overhead dwarf the search latency you worked to optimize.

One architectural note: if your preference fetch and parameter computation are on the hot path, colocate the preference store in the same region as your Typesense cluster. Cross-region Redis latency (30–100 ms) erases any benefit from Typesense's speed.

Where to Start

If you have a working Typesense integration and want to add personalization without a full ML infrastructure investment, the sequence matters.

Week one: stated preference injection. Pull user onboarding preferences and add them as filter_by parameters. Zero ML required. Measure CTR, dwell time, and next-session return rate relative to unpersonalized results. This is your baseline — everything else has to beat it or it doesn't ship.

Week two: global sort field tuning. Add freshness_score and popularity_score to your document schema. Update them on a scheduled job. Combine them in sort_by alongside _text_match. This isn't user personalization — it's collection hygiene — but it raises the floor that personalization has to clear.

Month two: personalized query embeddings. Deploy a quantized bi-encoder endpoint. Build the user context string from behavioral history (or onboarding data for new users). Add vector_query to your search calls alongside text search. Implement per-user alpha weighting as described above. A/B test against the week-one baseline.

Month three: preference graph and cold-start. Build the graph over user-entity interactions. Use traversal output to populate filter_by parameters and user context strings. Implement synthetic clone cold-start using behavioral segments. At this point you have a full personalization stack with explainability surface, adaptive retrieval, and graceful cold-start — all feeding a Typesense core that hasn't changed since week one.

The temptation is to skip to month three. Don't. Week one will tell you whether your users respond to personalization signals at all or whether the problem is somewhere else in the funnel — content quality, onboarding, something unrelated to ranking. The sequence is load-bearing because each stage validates the premise of the next.

Typesense is one of the better foundations for a personalized search stack in 2026: fast, operationally simple, and increasingly capable on semantic retrieval. The work is in everything upstream — the preference store, the embedding pipeline, the user model, the graph. That layer is what determines whether your results feel curated or random. Typesense just runs the query you give it. Make sure it's a good one.

the product behind these notes

×marble is the personalization graph.

One API. A living knowledge graph per user. Day-zero ready, explainable by construction. We built it so you don't have to.

See ×marble