<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Shekhar maheshwari</title>
    <description>The latest articles on DEV Community by Shekhar maheshwari (@shekhariee).</description>
    <link>https://dev.to/shekhariee</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4100414%2Fdf246913-06d5-4c05-b5c5-ec52758e8b8e.png</url>
      <title>DEV Community: Shekhar maheshwari</title>
      <link>https://dev.to/shekhariee</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shekhariee"/>
    <language>en</language>
    <item>
      <title>From Click Logs to Search Ranking: Building an Offline Popularity Trainer with Python and Typesense</title>
      <dc:creator>Shekhar maheshwari</dc:creator>
      <pubDate>Mon, 31 Aug 2026 03:58:52 +0000</pubDate>
      <link>https://dev.to/shekhariee/from-click-logs-to-search-ranking-building-an-offline-popularity-trainer-with-python-and-typesense-3f1</link>
      <guid>https://dev.to/shekhariee/from-click-logs-to-search-ranking-building-an-offline-popularity-trainer-with-python-and-typesense-3f1</guid>
      <description>&lt;p&gt;Most learning-to-rank examples end when the model has been trained. A production search system still has several harder questions to answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which user events are safe to treat as training examples?&lt;/li&gt;
&lt;li&gt;How should result position affect a click?&lt;/li&gt;
&lt;li&gt;How do we build features across catalogs with different schemas?&lt;/li&gt;
&lt;li&gt;How do we prevent document leakage between training and evaluation?&lt;/li&gt;
&lt;li&gt;What happens when there is too little usable data?&lt;/li&gt;
&lt;li&gt;Does the model need to run inside every search request?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I encountered these questions while building the popularity-training pipeline for an Intelligent Search Platform backed by Python, PostgreSQL, scikit-learn, and Typesense.&lt;/p&gt;

&lt;p&gt;The resulting design trains a small model offline, converts its predictions into bounded popularity scores, and writes those scores back to the search index. The search path reads a number; it never invokes scikit-learn.&lt;/p&gt;

&lt;p&gt;This article explains the architecture, the decisions behind it, and several edge cases that only became obvious after testing the full training path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why replace a fixed popularity formula?
&lt;/h2&gt;

&lt;p&gt;A simple popularity calculation might assign every event a contribution such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;event contribution = position weight × event value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A click could be worth &lt;code&gt;1.0&lt;/code&gt;, an impression could be worth &lt;code&gt;0.1&lt;/code&gt;, and results farther down the list could receive a logarithmic discount.&lt;/p&gt;

&lt;p&gt;This approach is understandable, cheap, and often a reasonable starting point. Its limitation is that every catalog receives the same assumptions. A fixed formula cannot learn that ratings matter in one catalog, recency matters in another, or a particular categorical attribute is associated with user engagement in a third.&lt;/p&gt;

&lt;p&gt;The trainer changes the role of the formula. Rather than directly adding position-discounted events into the final score, it uses position weighting while fitting a model from document features and click labels.&lt;/p&gt;

&lt;p&gt;That distinction matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fixed formula:
events → hand-written aggregation → popularity

Offline trainer:
events + document features → fitted model → predictions → popularity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a claim that a small logistic-regression model automatically improves relevance. It is an implementation that makes the ranking signal learnable and independently testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complete offline architecture
&lt;/h2&gt;

&lt;p&gt;The pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browse impressions and clicks in PostgreSQL
                   │
                   ▼
       Join events to current documents
                   │
                   ▼
       Build schema-independent features
                   │
                   ▼
      DictVectorizer → sparse matrix
                   │
                   ▼
   Position-discounted logistic regression
                   │
                   ▼
        Predict a score for every document
                   │
                   ▼
      Max-normalize scores to 0–1,000,000
                   │
                   ▼
        Partial updates sent to Typesense
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Training is exposed through an administrative endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/training/popularity?index_id=my-catalog&amp;amp;days=30
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint is deliberately on demand. A deployment can invoke it manually or from cron or a Kubernetes &lt;code&gt;CronJob&lt;/code&gt;, but scheduling is not hidden inside the search service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: restrict the first model to browse behavior
&lt;/h2&gt;

&lt;p&gt;The first version uses events where the query is empty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clicked&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;search_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;index_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This models browse popularity rather than query-specific relevance.&lt;/p&gt;

&lt;p&gt;That narrower scope avoids mixing two different questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which documents are generally attractive during browsing?&lt;/li&gt;
&lt;li&gt;Which documents are relevant to a particular query?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Query-conditional ranking requires query features, query-document interactions, and a different evaluation design. Treating all traffic as interchangeable would make the model simpler on paper but less precise about what it actually learns.&lt;/p&gt;

&lt;p&gt;There is an important mismatch in the current implementation: this browse-only boundary is enforced during training, but not during serving. The resulting &lt;code&gt;popularity&lt;/code&gt; field is also added to explicit-query results. The later section, How the learned score enters ranking today, quantifies why that unscoped use is not yet a relevance-safe deployment policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: build features without catalog-specific branches
&lt;/h2&gt;

&lt;p&gt;The platform can index unrelated domains. Hardcoding fields such as &lt;code&gt;brand&lt;/code&gt;, &lt;code&gt;plant_type&lt;/code&gt;, or &lt;code&gt;screen_size&lt;/code&gt; into the trainer would make every new catalog an engineering task.&lt;/p&gt;

&lt;p&gt;Instead, the trainer consumes affinity dimensions already discovered for each index and converts the current document into a flat feature dictionary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;document_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;affinity_dimensions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;affinity_dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;field&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;derived_from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dimension&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;derived_from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;derived_from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;raw_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;derived_from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;raw_value&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num__&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;derived_from&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cat__&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;features&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current implementation also includes these optional signals when available:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;is_featured&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;a rating signal: &lt;code&gt;averageStarRating × log(1 + ratingsCount)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;bounded recency: &lt;code&gt;1 / (1 + days_since_indexing)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the raw numeric source behind discovered tiers such as &lt;code&gt;price_tier&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;is_featured&lt;/code&gt; deserves special caution. Featured placement can itself cause additional exposure and clicks, and ISP applies a separate serving-time featured boost as well. Including it in the learned model can encode an editorial intervention into popularity and then apply that intervention again during serving. I retained it in this description because it is present in the current implementation, but I would exclude it from a subsequent version unless an experiment explicitly estimates its effect.&lt;/p&gt;

&lt;p&gt;Missing fields are omitted. There is no requirement for every catalog to expose an identical schema.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DictVectorizer&lt;/code&gt; is a good fit for this representation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.feature_extraction&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DictVectorizer&lt;/span&gt;

&lt;span class="n"&gt;vectorizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DictVectorizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sparse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;X&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vectorizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit_transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feature_dicts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It one-hot encodes categorical strings, retains numeric values, and produces a sparse matrix without building a DataFrame or maintaining a manual vocabulary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: account for result position—carefully
&lt;/h2&gt;

&lt;p&gt;Clicks are not direct relevance labels. A result near the top is more likely to be examined than the same result near the bottom.&lt;/p&gt;

&lt;p&gt;The current trainer uses a logarithmic weight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;position_weight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each event becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X = features of the displayed document
y = 1 for click, 0 for impression without click
w = 1 / log2(rank + 2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model receives &lt;code&gt;w&lt;/code&gt; through &lt;code&gt;sample_weight&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.linear_model&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LogisticRegression&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LogisticRegression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_iter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sample_weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This logarithmic weight is an exposure-confidence discount inherited from the formula-based pipeline. It reduces the influence of events at deeper positions, including deep clicks. It therefore does &lt;strong&gt;not&lt;/strong&gt; correct position bias and may suppress informative clicks on poorly ranked documents.&lt;/p&gt;

&lt;p&gt;Proper inverse-propensity scoring requires estimated examination probabilities, usually obtained through randomized exposure or a validated click model. Click contributions are then weighted by inverse propensity, often with clipping to control variance. Non-clicks also require careful treatment because absence of a click does not establish irrelevance. The counterfactual basis for this approach is described in &lt;a href="https://arxiv.org/abs/1608.04468" rel="noopener noreferrer"&gt;Unbiased Learning-to-Rank with Biased Feedback&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Calling this version “position-discounted” is accurate. Calling it “position-debiased” would not be. Propensity estimation, IPS or SNIPS objectives, randomized exposure data, and counterfactual evaluation remain separate follow-up work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: join against the current index
&lt;/h2&gt;

&lt;p&gt;Interaction logs can outlive documents. Products may be removed between an impression and a later training run.&lt;/p&gt;

&lt;p&gt;The trainer therefore fetches the current documents from Typesense and joins each event by &lt;code&gt;doc_id&lt;/code&gt;. Events for documents that no longer exist are skipped.&lt;/p&gt;

&lt;p&gt;This creates an easily missed edge case. Suppose the database returns six events, clearing a five-event minimum, but four events reference deleted documents. Only two usable examples remain.&lt;/p&gt;

&lt;p&gt;The minimum must be checked twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MIN_EVENTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;empty_result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;usable_examples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join_events_to_current_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usable_examples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MIN_EVENTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;empty_result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking only the SQL result count can send an unexpectedly tiny matrix into model fitting.&lt;/p&gt;

&lt;p&gt;The five-event threshold is only a mechanical guard against invalid model fitting. It is not evidence that five events are statistically sufficient for a useful ranking model. A production promotion policy should require a separately validated traffic threshold and ranking-oriented evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: handle data that cannot train a classifier
&lt;/h2&gt;

&lt;p&gt;Production data is often incomplete in uninteresting ways. The trainer treats these situations as valid empty runs instead of server failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fewer than the minimum number of browse events&lt;/li&gt;
&lt;li&gt;Typesense unavailable or the collection missing&lt;/li&gt;
&lt;li&gt;too few events remaining after the document join&lt;/li&gt;
&lt;li&gt;all labels belonging to one class&lt;/li&gt;
&lt;li&gt;every document producing an empty feature dictionary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last case exposed a real failure mode. &lt;code&gt;DictVectorizer&lt;/code&gt; can produce a matrix with rows but zero columns when every feature dictionary is empty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;X&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vectorizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit_transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feature_dicts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;empty_result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the guard, the downstream estimator receives no features and raises instead of reporting that there is nothing to learn.&lt;/p&gt;

&lt;p&gt;An empty training run is still recorded in the &lt;code&gt;training_runs&lt;/code&gt; table. “No model was produced” is operational information, not an event that should disappear from history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: prevent document leakage during evaluation
&lt;/h2&gt;

&lt;p&gt;The same document can generate many events, and every one of those events shares the same document feature vector.&lt;/p&gt;

&lt;p&gt;A random per-event split can therefore place impressions for one document in training and clicks for the same document in testing. The model has effectively already seen the test document's features, producing an overly optimistic metric.&lt;/p&gt;

&lt;p&gt;The implementation groups by document ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.model_selection&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GroupShuffleSplit&lt;/span&gt;

&lt;span class="n"&gt;splitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GroupShuffleSplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;n_splits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;test_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;random_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;train_indices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;test_indices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;splitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;example_document_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All events for a document remain on one side of the split.&lt;/p&gt;

&lt;p&gt;Small grouped datasets introduce another edge case: one side may contain only clicks or only non-clicks. In that situation, evaluation AUC is unavailable. The trainer returns &lt;code&gt;auc: null&lt;/code&gt; but still fits the production model on the complete usable dataset.&lt;/p&gt;

&lt;p&gt;Evaluation failure and training failure are not necessarily the same event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: score the entire catalog
&lt;/h2&gt;

&lt;p&gt;The model is trained from documents associated with observed events, but it scores every current document using its features:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;all_document_matrix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vectorizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all_document_features&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;probabilities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict_proba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all_document_matrix&lt;/span&gt;&lt;span class="p"&gt;)[:,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is useful for documents that have features but little or no direct interaction history. It does not solve cold start completely—the model still depends on relationships learned from other documents—but it avoids limiting scores only to documents that already received clicks.&lt;/p&gt;

&lt;p&gt;The predictions are max-normalized into the existing popularity range. This is a serving-scale transformation, not statistical probability calibration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;maximum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;probabilities&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;maximum&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;popularity_scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;maximum&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;probability&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;probabilities&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;popularity_scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trainer then performs a partial update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;typesense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_document_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;popularity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;popularity&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Structurally, the existing search and merchandising paths can continue reading the same field, so replacing the score generator does not require model inference inside every query. That is an operational compatibility property—not a claim that the current serving-time blend is relevance-safe. The next section describes the unresolved ranking risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the learned score enters ranking today
&lt;/h2&gt;

&lt;p&gt;The training and serving boundaries are decoupled, but the scale used to combine their outputs still matters.&lt;/p&gt;

&lt;p&gt;ISP currently retrieves lexical and semantic candidates separately and merges their ranks using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rrf_score =
    0.7 / (60 + lexical_rank + 1)
  + 0.3 / (60 + semantic_rank + 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then adds the stored popularity value to every candidate's score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;popularity_boost = min(popularity, 1_000_000) / 2_000_000
final_score = rrf_score + popularity_boost + other_configured_boosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A document ranked first in both retrieval channels receives an RRF score of approximately &lt;code&gt;0.0164&lt;/code&gt;, while the largest popularity boost is &lt;code&gt;0.5&lt;/code&gt;. Popularity can therefore dominate the relative order of the retrieved candidates rather than act as a small tie-breaker.&lt;/p&gt;

&lt;p&gt;That behavior requires particular caution because this model is trained only on empty-query browse events, while the stored field is currently consumed during both browsing and explicit search. The safer target design is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;during browsing, allow popularity to be a primary signal;&lt;/li&gt;
&lt;li&gt;during explicit search, disable it, bound it relative to relevance, or use it only within relevance groups;&lt;/li&gt;
&lt;li&gt;measure how often popularity displaces a more relevant result before promoting a new blending policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Typesense documents a related relevance-bucketing approach in which a custom popularity score reorders results only within text-relevance groups. That is not identical to ISP's application-side RRF, but it illustrates the serving constraint. See &lt;a href="https://typesense.org/docs/guide/ranking-and-relevance.html#ranking-based-on-relevance-and-popularity" rel="noopener noreferrer"&gt;Ranking Based on Relevance and Popularity&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This serving refinement is not implemented yet. The present article documents the current pipeline rather than claiming that its blending coefficient is already validated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why keep inference outside the request path?
&lt;/h2&gt;

&lt;p&gt;Offline scoring has several practical advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no scikit-learn model needs to be loaded by every API worker&lt;/li&gt;
&lt;li&gt;no feature vector is constructed for every candidate during search&lt;/li&gt;
&lt;li&gt;the search latency budget remains controlled by the search engine&lt;/li&gt;
&lt;li&gt;rollback can restore or recompute one numeric field&lt;/li&gt;
&lt;li&gt;the existing formula-based pipeline remains available as an independent fallback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off is freshness. Scores change only when the training job runs. That is acceptable for a browse-popularity signal whose update interval can be measured in hours or days, but it would be inappropriate for a feature requiring immediate adaptation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the path that matters
&lt;/h2&gt;

&lt;p&gt;Router tests can confirm authentication, index resolution, and graceful API responses while still missing the core ML path.&lt;/p&gt;

&lt;p&gt;The domain-level suite therefore drives the complete trainer with a real test database and mocked Typesense reads and writes. Its regression cases include:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Zero-feature matrix:&lt;/strong&gt; mixed click labels but no usable document features must return zero scored documents rather than crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post-join minimum:&lt;/strong&gt; raw events may clear the threshold while usable events do not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grouped evaluation:&lt;/strong&gt; events for the same document must never cross the evaluation boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-class grouped split:&lt;/strong&gt; AUC can become unavailable without preventing the final full-data model from scoring documents.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These tests caught assumptions that ordinary endpoint tests could not exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this system does not prove
&lt;/h2&gt;

&lt;p&gt;An operational model pipeline and an improved ranking policy are not the same achievement.&lt;/p&gt;

&lt;p&gt;This implementation does not yet provide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unbiased propensity correction&lt;/li&gt;
&lt;li&gt;query-specific learning-to-rank&lt;/li&gt;
&lt;li&gt;counterfactual policy evaluation&lt;/li&gt;
&lt;li&gt;nDCG or MRR validation against relevance judgments&lt;/li&gt;
&lt;li&gt;automated retraining schedules&lt;/li&gt;
&lt;li&gt;pooled learning across low-traffic indexes&lt;/li&gt;
&lt;li&gt;evidence of conversion or revenue improvement&lt;/li&gt;
&lt;li&gt;a validated guarantee that popularity cannot overpower explicit-query relevance&lt;/li&gt;
&lt;li&gt;freedom from editorial-exposure confounding or double-counting through &lt;code&gt;is_featured&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;statistically calibrated probabilities across separate training runs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A held-out AUC is a sanity check for the classifier, not proof that users receive better rankings. Demonstrating ranking improvement requires a stronger offline evaluation protocol with temporal splits and ranking metrics such as nDCG or MRR and, when appropriate and authorized, a controlled online experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;p&gt;The model was the shortest part of the feature. The important engineering decisions were around it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Define exactly which behavior the model represents.&lt;/li&gt;
&lt;li&gt;Reuse schema-derived features instead of hardcoding a vertical.&lt;/li&gt;
&lt;li&gt;Describe position discounting accurately; do not present it as debiasing.&lt;/li&gt;
&lt;li&gt;Recheck data sufficiency after every destructive transformation.&lt;/li&gt;
&lt;li&gt;Group repeated entities when splitting interaction data.&lt;/li&gt;
&lt;li&gt;Record empty and failed-to-evaluate runs explicitly.&lt;/li&gt;
&lt;li&gt;Keep offline learning separate from query-time serving when the signal allows it.&lt;/li&gt;
&lt;li&gt;Test vectorization, fitting, scoring, normalization, and write-back—not just the endpoint.&lt;/li&gt;
&lt;li&gt;Keep learned browse signals from silently overpowering explicit-query relevance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is intentionally modest: a small, auditable model that learns a browse-popularity signal and hands the search engine a number it already knows how to use.&lt;/p&gt;

&lt;p&gt;That modest boundary is also what makes the system practical.&lt;/p&gt;

</description>
      <category>python</category>
      <category>machinelearning</category>
      <category>search</category>
      <category>typesense</category>
    </item>
  </channel>
</rss>
