<?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%2F872d32a6-478e-41df-ab36-5275d5f04680.jpeg</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>Your LLM Is Reading Too Much: How RAG Can Lower AI Costs</title>
      <dc:creator>Shekhar maheshwari</dc:creator>
      <pubDate>Wed, 21 Oct 2026 03:07:01 +0000</pubDate>
      <link>https://dev.to/shekhariee/your-llm-is-reading-too-much-how-rag-can-lower-ai-costs-jpe</link>
      <guid>https://dev.to/shekhariee/your-llm-is-reading-too-much-how-rag-can-lower-ai-costs-jpe</guid>
      <description>&lt;p&gt;When teams talk about Retrieval-Augmented Generation, they usually focus on accuracy. RAG helps a model answer with current, relevant information and reduces hallucinations.&lt;/p&gt;

&lt;p&gt;That is true, but it misses another practical benefit: &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;RAG can make an AI application much cheaper to run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reason is simple. Every time you call an LLM, you pay for what the model reads as well as what it writes. If you repeatedly send a long policy manual, a large product catalog, or an entire conversation history, the model processes all of it—even when only a small part is useful.&lt;/p&gt;

&lt;p&gt;RAG gives us a better option. Search first, retrieve only the relevant information, and let the model read that smaller set of evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Short Version&lt;/strong&gt;&lt;br&gt;
LLMs charge for input tokens as well as output tokens.&lt;br&gt;
Sending more context than necessary increases cost and latency.&lt;br&gt;
RAG retrieves a focused evidence set before calling the model.&lt;br&gt;
Deterministic parsing and structured filters can reduce the work even further.&lt;br&gt;
The goal is not to retrieve everything that might be relevant. It is to retrieve enough trustworthy information to answer the question correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most of the Waste Is in the Prompt&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A simplified LLM bill 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;request cost = input tokens × input rate
             + output tokens × output rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The input is not just the user's question. It may include the system prompt, chat history, reference documents, product records, examples, formatting rules, and tool definitions.&lt;/p&gt;

&lt;p&gt;Imagine a support assistant with a 40,000-token policy manual. A customer asks one question about refunds, but the application sends the complete manual on every request. The model may only need two paragraphs, yet you still pay for it to process everything else.&lt;/p&gt;

&lt;p&gt;RAG changes the flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Question
   ↓
Search and filtering
   ↓
A small set of relevant evidence
   ↓
Grounded LLM response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If retrieval cuts the reference material from 40,000 tokens to 2,000, the model avoids reading 38,000 unnecessary tokens each time. That saving becomes significant once the application handles thousands of requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search Should Do Search Work&lt;/strong&gt;&lt;br&gt;
Without retrieval, we often ask the LLM to do several jobs at once: find the relevant information, ignore unrelated content, apply business rules, resolve ambiguity, and write the answer.&lt;/p&gt;

&lt;p&gt;An LLM can do this, but it is an expensive way to search.&lt;/p&gt;

&lt;p&gt;A search engine, vector database, or hybrid retrieval system can narrow the candidate set before generation. Metadata filters can remove products from the wrong category, incompatible devices, expired policies, or records the user is not allowed to access.&lt;/p&gt;

&lt;p&gt;The model then receives a cleaner prompt and has a smaller job: explain the evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Practical Multi-Intent Example&lt;/strong&gt;&lt;br&gt;
Consider this request to a telecom shopping assistant:&lt;/p&gt;

&lt;p&gt;Find a case for my iPhone 15 and a screen protector for my Galaxy S24, both under $50.&lt;br&gt;
This looks like one sentence, but it contains two separate product searches. Each accessory must remain connected to the correct phone, and both searches must respect the budget.&lt;/p&gt;

&lt;p&gt;One approach is to send the sentence and a large product catalog to an LLM and ask it to work everything out. A more efficient 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;User query
   ↓
Deterministic telecom parsing
   ↓
Multi-intent routing
   ↓
Typesense retrieval with compatibility and price filters
   ↓
A small set of matching products
   ↓
LLM writes the final
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser can recognize the devices, accessory categories, and budget using phrase matching and regular expressions. That does not require an LLM call.&lt;/p&gt;

&lt;p&gt;Conceptually, the result looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requests"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"case"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"device"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iPhone 15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maximum_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"screen protector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"device"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Galaxy S24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maximum_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The routing layer handles the two intents separately. Typesense searches the correct categories and applies compatibility and price filters. Only the matching products reach the final prompt.&lt;/p&gt;

&lt;p&gt;This matters for both cost and quality. The LLM does not have to inspect a broad catalog, guess which accessory belongs to which device, or remove incompatible products. It receives focused evidence and turns it into a useful answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sometimes You Do Not Need Generation&lt;/strong&gt;&lt;br&gt;
Not every message in a chat interface needs a generated response.&lt;/p&gt;

&lt;p&gt;Requests such as these are often structured searches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Show me cases compatible with an iPhone 15.&lt;/li&gt;
&lt;li&gt;Find plans under $80.&lt;/li&gt;
&lt;li&gt;Look up order 12345.
If the application can answer with a product list, account record, or database result, it may be able to skip generation completely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives us three useful execution paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exact lookup          → search or database response
Grounded explanation → retrieval + smaller LLM
Complex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cheapest model call is still the one you do not make.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG Can Also Become Expensive&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Adding a vector database does not automatically reduce cost. A careless RAG implementation can make the system more expensive.&lt;/p&gt;

&lt;p&gt;One common mistake is retrieving too much. Increasing &lt;code&gt;top_k&lt;/code&gt; may improve recall, but every extra result adds tokens to the prompt. Ten similar passages are not necessarily better than three useful and diverse ones.&lt;/p&gt;

&lt;p&gt;Another mistake is putting an LLM at every stage: one model rewrites the query, another classifies it, another reranks the results, and a final model writes the answer. That may be justified for difficult use cases, but it can also turn one model call into four.&lt;/p&gt;

&lt;p&gt;Conversation history is another source of hidden cost. Even if document retrieval is efficient, an unlimited chat history can make the prompt grow on every turn. Keep the history relevant to the active task and store durable state in a structured form where possible.&lt;/p&gt;

&lt;p&gt;Finally, do not ignore output length. A product lookup usually does not need an essay. Short, task-appropriate answers reduce cost and are often better for users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a Cost-Aware Pipeline Looks Like&lt;/strong&gt;&lt;br&gt;
In practice, I would design the flow around a few questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can the request be normalized or parsed without an LLM?&lt;/li&gt;
&lt;li&gt;Are there explicit filters such as category, compatibility, price, date, or permissions?&lt;/li&gt;
&lt;li&gt;Can search answer the request directly?&lt;/li&gt;
&lt;li&gt;If generation is required, what is the smallest useful evidence set?&lt;/li&gt;
&lt;li&gt;Does the task need a large model, or can a smaller model handle the grounded answer?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The resulting architecture might normalize the request, apply structured filters, run lexical and semantic retrieval, deduplicate the results, and pass only a bounded evidence set to the appropriate model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure Cost per Successful Task&lt;/strong&gt;&lt;br&gt;
Token usage is important, but it should not be measured in isolation. An answer that is cheap and wrong simply creates another request or sends the customer to support.&lt;/p&gt;

&lt;p&gt;Useful metrics include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Input and output tokens per request&lt;/li&gt;
&lt;li&gt;Number of retrieved tokens that are actually used&lt;/li&gt;
&lt;li&gt;Retrieval and generation latency&lt;/li&gt;
&lt;li&gt;Requests answered without an LLM&lt;/li&gt;
&lt;li&gt;Small-model and large-model routing rates&lt;/li&gt;
&lt;li&gt;Retry rate&lt;/li&gt;
&lt;li&gt;Cache-hit rate&lt;/li&gt;
&lt;li&gt;Cost per successfully completed task&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These metrics reveal whether the pipeline is genuinely becoming more efficient or merely moving the cost somewhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Principle I Keep Coming Back To&lt;/strong&gt;&lt;br&gt;
The purpose of RAG is not to send the model as much potentially useful information as possible.&lt;/p&gt;

&lt;p&gt;It is to find the &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;smallest trustworthy set of evidence that can answer the question.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That principle leads to better engineering decisions. Parse predictable inputs without an LLM. Apply business rules before retrieval. Use search for search problems. Keep the context focused. Call a larger model only when the task truly needs one.&lt;/p&gt;

&lt;p&gt;RAG reduces cost when it changes what the model is responsible for. Search retrieves. Filters remove irrelevant options. Caches reuse stable work. The LLM focuses on the part it does best: turning the right evidence into a clear answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your model does not need to read everything you know. It only needs what helps it answer the question in front of it.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>rag</category>
    </item>
    <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>
