Home / Blog / Guide

Three Search Problems, One Library: sim_LAR by Example

A support knowledge base, a product catalog, and a 120,000-document streaming archive — three different shapes of search problem, one pip install. Here's what each one actually took.

"Add search to this" can mean three completely different things. Sometimes only meaning matters. Sometimes the literal words matter just as much — brand names, SKUs, error codes that an embedding model will cheerfully talk itself out of. And sometimes the query isn't the hard part at all: the corpus never stops arriving and stopped fitting in memory a while ago.

Three shapes, usually three pieces of infrastructure. Here's each one as a standalone case, from one pip install, in a notebook, on a single laptop — then what happens when all three turn up in the same product. Every timing and result list below is copied straight out of a notebook run.

pip install simlar simlar-engine
pip install datasets sentence-transformers

01Matching Tickets by Meaning

26,872 resolved support conversations — the Bitext dataset, where each row is a customer's question plus the answer an agent gave. A new ticket arrives; find the answer that already exists.

This one is pure meaning — customers never phrase things the way your KB does — so SimlarEngine, which works on embedding vectors alone. Bring whatever embedding model you already use:

import numpy as np
from datasets import load_dataset
from sentence_transformers import SentenceTransformer

ds = load_dataset("bitext/Bitext-customer-support-llm-chatbot-training-dataset", split="train")

QUESTIONS = [str(q) for q in ds["instruction"]]   # what we match against
ANSWERS   = [str(a) for a in ds["response"]]      # the KB answer to surface
INTENTS   = [str(i) for i in ds["intent"]]
IDS       = [f"kb_{i}" for i in range(len(QUESTIONS))]

model   = SentenceTransformer("all-MiniLM-L6-v2")
vectors = model.encode(QUESTIONS, normalize_embeddings=True).astype(np.float32)

Seed the index with the first 400 entries, as if you'd only started curating last week:

from simlar import SimlarEngine

kb = SimlarEngine()
kb.add(ids=IDS[:400], vectors=vectors[:400])
print(f"KB size: {kb.size}  trained: {kb.is_trained}")
KB size: 400  trained: True

trained: True right after the first add — no separate fit step, no minimum corpus size, no "building…" bar. You add vectors, you search.

def match(ticket, k=3):
    qv = model.encode([ticket], normalize_embeddings=True).astype(np.float32)
    hits = kb.search(qv, k=k)
    print(f"Ticket: {ticket!r}\n")
    for r in hits:
        print(f"  rank={r.rank}  score={r.score:.2f}  intent={id_to_intent[r.id]}")
        print(f"    matched Q: {id_to_q[r.id][:70]}")
    return hits

hits = match("how do I get my money back for an order I cancelled?")
print("\nSuggested answer:\n", id_to_a[hits[0].id][:200])
Ticket: 'how do I get my money back for an order I cancelled?'

  rank=0  score=0.28  intent=cancel_order
    matched Q: I purchased some item, help canceling one of the orders
  rank=1  score=0.24  intent=cancel_order
    matched Q: I have got to cancel purchase {{Order Number}}, help me
  rank=2  score=0.24  intent=cancel_order
    matched Q: I have got to cancel purchase {{Order Number}}, how do I do it?

Suggested answer:
 I understand your request for assistance with canceling one of the orders for the item you purchased.
 I apologize for any inconvenience, and I'm here to guide you through the process.…

The customer said "get my money back." No stored question contains that phrase, and all three hits still land on cancel_order. Results come back sorted best-first by rank — that's the field to trust.

A support KB isn't a fixed corpus, though. Articles get written, reworded and retired every week, and sim_LAR treats that as Tuesday rather than a rebuild event:

# The rest of the KB gets curated and imported.
kb.add(ids=IDS[400:], vectors=vectors[400:])
print(f"KB size after incremental add: {kb.size}")

# An article was reworded: re-embed just that one entry and update in place.
reworded = "I want to request a refund for a purchase I already cancelled"
new_vec = model.encode([reworded], normalize_embeddings=True).astype(np.float32)
kb.update(ids=[IDS[0]], vectors=new_vec)
print(f"After update: size {kb.size} (unchanged — update is in place)")

# Two articles were retired: delete them so they never surface again.
kb.delete(ids=[IDS[5], IDS[6]])
print(f"After delete: size {kb.size}")
KB size after incremental add: 26872
After update: size 26872 (unchanged — update is in place)
After delete: size 26870
400 entries to 26,872, one reworded article, two retired policies — and not a single index rebuild in between.

Four calls: add, search, update, delete. A retired article stops surfacing the moment you delete it. Full notebook here.

Where meaning is the only signal

02Catalog Search With Both Signals

Different problem: 44,072 items from ashraq/fashion-product-images-small, with names like "Turtle Check Men Navy Blue Shirt."

Catalogs are packed with literal tokens, and an embedding model will happily rank a conceptually similar item above the one containing the exact word your customer typed. HelixIndex runs keyword and vector search side by side and fuses them into one list:

%%time
from simlar import HelixIndex

index = HelixIndex(top_k=20, text_k=100, vector_k=100)
index.add(ids=IDS, texts=CORPUS, vectors=vectors)
print(f"Indexed {index.size} products  (index_type={index.index_type})")
Indexed 44072 products  (index_type=helix)
CPU times: user 971 ms, sys: 58.3 ms, total: 1.03 s
Wall time: 807 ms

807 milliseconds — and that's both indexes, keyword and vector, from one add call. Switching a query between semantic-only and hybrid then costs exactly one argument:

%%time
QUERY = "comfortable shoes for working out"
q_vec = model.encode([QUERY], normalize_embeddings=True).astype(np.float32)

show("Semantic-only:", index.search(query_vector=q_vec, k=5))
show("Hybrid:",        index.search(query_text=QUERY, query_vector=q_vec, k=5))
Query: 'comfortable shoes for working out'

Semantic-only:
  rank=0  score=0.3333  |  Nike Men Sports Shoes  [White Sports Shoes]
  rank=1  score=0.2500  |  ADIDAS Women Trainer White Sports Shoes  [White Sports Shoes]
  rank=2  score=0.2000  |  Nike Men Elite Brown Sports Shoes  [Brown Sports Shoes]
  rank=3  score=0.1667  |  ADIDAS Neo Men Ez Desert Boot Olive Shoes  [Olive Casual Shoes]
  rank=4  score=0.1429  |  Nike Women Steady VIII White Sports Shoes  [White Sports Shoes]

Hybrid:
  rank=0  score=0.3333  |  Nike Men Sports Shoes  [White Sports Shoes]
  rank=1  score=0.3333  |  ADIDAS Men's White Comfort Shoe  [White Sports Shoes]
  rank=2  score=0.2500  |  ADIDAS Women Trainer White Sports Shoes  [White Sports Shoes]
  rank=3  score=0.2500  |  Jockey COMFORT PLUS Men Comfort Plus Olive Trunks 8015  [Olive Trunk]
  rank=4  score=0.2000  |  Nike Men Elite Brown Sports Shoes  [Brown Sports Shoes]

CPU times: user 145 ms, sys: 0 ns, total: 145 ms
Wall time: 12.5 ms

Rank 1 in the hybrid list is ADIDAS Men's White Comfort Shoe, which isn't in the semantic-only top five at all. The customer typed "comfortable," the product is literally named "Comfort Shoe," and the keyword half caught what the embedding half shrugged at.

Rank 3 is Jockey COMFORT PLUS Men Comfort Plus Olive Trunks, and we're not going to pretend it isn't there. It's underwear, on the list for the same reason the shoe is: it contains "Comfort," twice. Keyword signal is literal, and literal cuts both ways — which is what alpha_text and fusion weights are for. It ships with a default, not a verdict.

One caveat on that score column: those are Reciprocal Rank Fusion scores, derived from each document's position in the two input lists. They rank correctly within a query and mean nothing across queries, so don't build a threshold on them. Both searches together: 12.5 ms over 44,072 products, query embedding included. Full notebook here.

Where exact tokens decide the ranking

03Indexing a Never-Ending Archive

120,000 documents — AG News stands in here — still arriving, and too big to hold in memory or embed in one pass. StreamingHybridIndex is the same hybrid idea with the corpus showing up in pieces: each batch becomes a shard, and queries hit every shard and merge globally.

import time
from simlar import StreamingHybridIndex

index = StreamingHybridIndex(top_k=10)

BATCH = 20000
for start in range(0, len(CORPUS), BATCH):
    batch_texts = CORPUS[start:start + BATCH]
    batch_vecs = model.encode(batch_texts, normalize_embeddings=True).astype(np.float32)
    start_time = time.time()
    index.add_batch(batch_texts, batch_vecs)
    end_time = time.time()
    print(f"  ingested batch {start // BATCH + 1}: rows {start}-{start + len(batch_texts) - 1}")
    print(f"    took {end_time - start_time:.2f} seconds")
  ingested batch 1: rows 0-19999
    took 0.52 seconds
  ingested batch 2: rows 20000-39999
    took 0.45 seconds
  ingested batch 3: rows 40000-59999
    took 0.60 seconds
  ingested batch 4: rows 60000-79999
    took 0.46 seconds
  ingested batch 5: rows 80000-99999
    took 0.45 seconds
  ingested batch 6: rows 100000-119999
    took 0.44 seconds
Bar chart of six streaming ingest batches taking 0.52, 0.45, 0.60, 0.46, 0.45 and 0.44 seconds, averaging 0.49 seconds, showing per-batch cost staying flat as the corpus grows to 120,000 documents.

Six batches of 20,000 AG News articles. The last batch goes into an index already holding 100,000 documents and costs the same as the first.

The interesting number isn't the total, it's the shape of the line. Batch six goes into an index already holding 100,000 documents across five shards and costs the same as batch one going into an empty index — ingest is flat in corpus size, which is what decides whether this is still pleasant to operate at four times the size. (These timings bracket add_batch only; embedding sits outside the timer, because that cost belongs to your model.)

Searching is where the streaming index goes its own way, and you'd rather hear it here than at runtime: it returns (ids, scores) NumPy arrays instead of SearchResult objects, each id is an integer position into the corpus list you maintain, and it wants a 1-D query vector rather than the (1, dim) shape the other indexes take.

def search_new(query_text):
    query_vec = model.encode([query_text], normalize_embeddings=True).astype(np.float32)[0]
    ids, scores = index.search(query_text, query_vec, k=5)

    print(f"\nQuery: {query_text!r}\n")
    for pos, score in zip(ids, scores):
        pos = int(pos)
        print(f"  pos={pos:5d}  score={score:.4f}  [{CATEGORY[pos]}]  {CORPUS[pos]}")

search_new("stock market rally and corporate earnings")
Query: 'stock market rally and corporate earnings'

  pos=88452  score=0.3410  [Business]  Stock markets rally on Fed statement, lower oil prices By George…
  pos= 9786  score=0.2504  [World]  Stocks Rally on Lower Oil Prices NEW YORK - Stocks rallied in quiet…
  pos=59557  score=0.2007  [World]  Stocks Mixed on Strong Earnings Reports NEW YORK - Falling oil prices…
  pos=75096  score=0.1676  [Business]  Stocks Rally on Oil, Economic News With oil prices dropping, stocks…
  pos=24461  score=0.1456  [Business]  Earning Reports Keep Investors on Edge  NEW YORK (Reuters) - Investors…

CPU times: user 456 ms, sys: 12.2 ms, total: 468 ms
Wall time: 60.6 ms

Read the positions rather than the headlines: 88452, 9786, 59557, 75096, 24461 live in five different shards, ingested in five different batches. The merge is global, so a query never sees the seams. 60.6 ms for the lot.

And new documents have to be findable immediately, not after tonight's reindex:

extra_texts = [
    "testing frontier",
    "Central bank raises interest rates amid inflation concerns",
    "Tech startup unveils new AI chip for data centers",
]
extra_vecs = model.encode(extra_texts)

# NOTE: positions are relative to the corpus list you maintain — extend it in lockstep.
CORPUS.extend(extra_texts)
CATEGORY.extend(["testing", "Business", "Sci/Tech"])
index.add_batch(extra_texts, extra_vecs)

search_new("Central bank raises.")
search_new("Tech startup unveils new AI chip for data centers")
Query: 'Central bank raises.'

  pos=120001  score=0.3334  [Business]  Central bank raises interest rates amid inflation concerns
  pos=116104  score=0.2583  [Business]  US Central Bank Boosts Interest Rates Again The American central…
  pos=116103  score=0.2059  [Business]  Fed Panel Lifts Rates and Says More Increases Are Probable As…
  pos=22417  score=0.1708  [Business]  Central bank bumps rate The Bank of Canada rate will hit four per…
  pos=22403  score=0.1441  [Business]  Central bank sends cost of debt higher Canadians are paying more to…

Query: 'Tech startup unveils new AI chip for data centers'

  pos=120002  score=0.3333  [Sci/Tech]  Tech startup unveils new AI chip for data centers
  pos=68661  score=0.2502  [Sci/Tech]  Intel abandons digital TV chip project NEW YORK, October 22…
  pos=80215  score=0.2024  [Sci/Tech]  Intel unveils new chip platform Hoping to annihilate its rivals in…
  pos=23449  score=0.1669  [Sci/Tech]  Intel shows chip roadmap Intel has disclosed new technologies that…
  pos=34047  score=0.1429  [Sci/Tech]  Intel invests in five 'digital home' companies SAN FRANCISCO - Intel…

CPU times: user 737 ms, sys: 8.56 ms, total: 746 ms
Wall time: 89.9 ms

Documents added seconds earlier land at rank 0, competing against 120,000 existing articles from a shard of three, with no rebuild and no warm-up — and four genuine archive matches sit underneath each one. A fresh shard doesn't crowd out the corpus, it competes inside it. Two queries, 89.9 ms. Full notebook here.

Where the corpus is the constraint

04Running All Three at Once

The cases aren't mutually exclusive. In a system of any size they show up together, usually in different surfaces of the same application:

Each surface has genuinely different requirements, so forcing one index across all of them means at least one gets a worse answer than it should. This is where running three separate systems hurts most — and where not having to is worth the most.

05The Numbers, Side by Side

807 mshybrid index, 44,072 products
12.5 mstwo searches over that catalog
0.44–0.60 sper 20,000-doc stream batch
0rebuilds to add, update, or delete

The honest framing: every figure came off a single machine running a notebook with all-MiniLM-L6-v2, and none of it is a controlled benchmark — for that, see the retrieval benchmark. Search timings include embedding the query, so they make sim_LAR look slower than it is; ingest timings don't, because that cost is your model's.


A knowledge base, a catalog and a streaming archive turned out to be the same handful of lines with a different class in the middle — three problems that normally cost three procurement cycles, handled with one dependency and an afternoon. Whether you need one of them or all three, that's the difference between a roadmap item and a Tuesday.

All three notebooks run end to end on public datasets: customer_support.ipynb, ecommerce_hybrid_search.ipynb, search_at_scale_streaming.ipynb. Starting from zero? The practical guide is the shorter way in.

Not sure which of these your problem is?

If you're weighing another piece of retrieval infrastructure against the one you already run, we'd like to hear about your workload.

Talk to the TekDatum team