"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-transformers01Matching 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
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
- Support and FAQ deflection. People use their vocabulary, your KB uses yours, and nothing overlaps literally.
- Deduplication. Two records saying the same thing in different words — merged contacts, resubmitted tickets, reposted listings.
- "More like this." The query is an item, not a sentence. There's no query text to keyword-match.
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
- Product and catalog search. SKUs and model numbers sitting next to vague descriptions of what the customer wants.
- Docs and code search. "Why does
ECONNRESEThappen on retry?" is half exact token, half concept. - Regulated corpora. Statute numbers, ICD codes, drug names — approximating a drug name isn't acceptable.
- RAG with exact citations. If the answer must quote policy 4.2.1, retrieval has to find 4.2.1, not something nearby.
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
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
- Continuous feeds. News, social, market data — searchable within seconds of landing.
- Logs over a rolling window. Constant new events, old shards dropped off the back, no nightly reindex.
- Corpora too big to embed in one pass. Batching stops being an optimization and becomes the only option.
- Scheduled ETL loads. A slice lands every hour from a warehouse or partner feed; each load is another
add_batch.
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:
- A support platform. Agent-assist matches tickets to canonical answers (vector). The customer-facing help centre gets product names and error codes typed into it (hybrid). Ticket history never stops growing (streaming).
- An e-commerce marketplace. Catalog search mixes SKUs with intent (hybrid). The "similar items" rail queries by item vector with no text at all (vector). Third-party listings arrive in continuous bulk imports (streaming).
- An internal knowledge assistant. RAG over policy docs needs exact clause numbers (hybrid). "Find similar past incidents" queries by embedding (vector). The Drive or SharePoint feed syncs on a schedule (streaming).
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
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 →