On this article, you’ll discover ways to construct a context-aware semantic search engine in Python that mixes embedding-based similarity with structured metadata filtering.
Matters we’ll cowl embrace:
- How sentence embeddings and cosine similarity work collectively to seek out semantically related paperwork.
- The best way to construct a metadata-aware search index that filters by crew, standing, precedence, and date earlier than scoring candidates.
- The best way to persist the index to disk so embeddings are computed solely as soon as and reloaded effectively on subsequent runs.
Constructing Context-Conscious Search in Python with LLM Embeddings + Metadata
Introduction
Key phrase search breaks the second a person sorts one thing a doc doesn’t actually say. A help engineer looking for “login retains failing” received’t discover a ticket titled “OAuth2 token refresh race situation”, although that’s precisely what they want. That is the core downside that context-aware semantic search goals to unravel.
Semantic search solves this by changing textual content into dense vector representations known as embeddings, the place that means determines proximity reasonably than actual phrase overlap. Layer structured metadata filters on high — by date, standing, crew, precedence — and also you get a system that understands what somebody is asking whereas respecting contextual constraints on the similar time.
This text walks by means of constructing that system end-to-end: embeddings from an area pretrained mannequin, a metadata-aware index, cosine similarity rating, and an index that persists throughout restarts with out requiring re-encoding.
You will get the code on GitHub.
What You Will Construct
A easy context-aware search engine over a corpus of engineering help tickets. By the tip you’ll have:
- 384-dimensional embeddings generated domestically from a pretrained mannequin, no API key required
- A search index that filters by crew, standing, precedence, and date earlier than scoring
- Cosine similarity rating over the filtered candidate pool
- A persevered index that reloads with out re-encoding
Conditions: Python 3.8+, primary familiarity with NumPy and dealing with lists of dictionaries.
Set up dependencies:
|
pip set up sentence–transformers numpy |
Understanding How Semantic Search Works
A sentence embedding mannequin takes a string and returns a fixed-length vector of floating-point numbers. The mannequin is skilled in order that sentences with comparable meanings produce vectors pointing in comparable instructions in high-dimensional area.
Cosine similarity measures the angle between two vectors:
[
text(A, B) =
fracB
]
When vectors are unit-normalized — that means their size equals 1.0 — this simplifies to the dot product: A · B. Scores vary from -1 (reverse) to 1 (an identical). In apply, unrelated paperwork rating round 0.1–0.25, and powerful matches rating above 0.6.
So why does metadata filtering matter? Embedding fashions encode semantic content material. They do not encode who wrote a doc, what crew owns it, or when it was created. These attributes reside outdoors the textual content and have to be dealt with individually. Combining each indicators — semantic rating and metadata constraints — is what makes search helpful in actual techniques.
Setting Up the Dataset
We’ll work with 20 engineering help tickets throughout three groups — infrastructure, backend, and frontend — with 4 precedence ranges, two statuses, and a two-month date window.
Every ticket is a plain dictionary. The textual content area is what will get embedded; the whole lot else is metadata for filtering.
To maintain issues concise, a truncated listing is proven right here as an alternative of the complete code block. The entire set of tickets is offered on this GitHub gist.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
from datetime import date
tickets = [ {“id”: “T-101”, “team”: “infrastructure”, “status”: “open”, “priority”: “high”, “created”: date(2025, 11, 3), “text”: “Kubernetes pod keeps crashing with OOMKilled — memory limits on the ML inference container are set too low for the model it loads at runtime.”},
{“id”: “T-102”, “team”: “infrastructure”, “status”: “open”, “priority”: “high”, “created”: date(2025, 11, 8), “text”: “Nginx ingress returning 502 after rotating TLS certificate. Chain is valid per openssl verify but the backend handshake fails immediately.”},
{“id”: “T-103”, “team”: “infrastructure”, “status”: “resolved”, “priority”: “medium”, “created”: date(2025, 10, 14), “text”: “Terraform state file locked in S3 — a team member force-applied a plan without releasing the DynamoDB lock first.”},
...
{“id”: “T-401”, “team”: “infrastructure”, “status”: “open”, “priority”: “medium”, “created”: date(2025, 11, 11), “text”: “CI pipeline fails on ARM64 runners — base Docker image has no ARM variant, exec format error at build stage.”},
{“id”: “T-402”, “team”: “infrastructure”, “status”: “resolved”, “priority”: “high”, “created”: date(2025, 10, 9), “text”: “VPN gateway latency spikes at peak hours — BGP route flapping between two peers causing intermittent packet loss across the private subnet.”}, ] |
A fast test on the form of the corpus earlier than transferring on:
|
open_ct = sum(1 for t in tickets if t[“status”] == “open”) resolved_ct = sum(1 for t in tickets if t[“status”] == “resolved”) print(f“{len(tickets)} tickets | {open_ct} open | {resolved_ct} resolved”) |
Output:
|
20 tickets | 14 open | 6 resolved |
Working the snippet confirms the distribution: 20 tickets complete, 14 open and 6 resolved, unfold throughout the three groups.
Step 1: Producing Embeddings
all-MiniLM-L6-v2 maps any sentence to a 384-dimensional vector. It runs completely on CPU, downloads as soon as from Hugging Face (~22 MB), is cached domestically after that, and requires no API key.
|
from sentence_transformers import SentenceTransformer import numpy as np
mannequin = SentenceTransformer(“all-MiniLM-L6-v2”)
texts = [t[“text”] for t in tickets] embeddings = mannequin.encode(texts, normalize_embeddings=True, show_progress_bar=True)
print(f“Form: {embeddings.form} | norm[0]: {np.linalg.norm(embeddings[0]):.4f}”) |
We go normalize_embeddings=True so every output vector comes out with L2 norm precisely 1.0. As soon as vectors sit on the unit hypersphere, cosine similarity between any two of them is simply their dot product, so no division is required at question time. Meaning scoring the complete candidate pool reduces to a single matrix multiplication.
Output:
Sentence Embeddings for 20 Tickets
We get again a (20, 384) float32 matrix — one row per ticket. The norm of 1.0 confirms the normalization labored.
Step 2: Constructing the Index
The index shops the embedding matrix alongside the related metadata and exposes a search technique that accepts non-obligatory key phrase arguments for each metadata area.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
class ContextAwareIndex: def __init__(self, embeddings: np.ndarray, paperwork: listing): self.embeddings = embeddings # (N, D), L2-normalized self.paperwork = paperwork
def search( self, question: str, top_k: int = 5, crew: str = None, standing: str = None, precedence: str = None, after: “date” = None, earlier than: “date” = None, min_score: float = 0.0, ) -> listing[dict]:
# Embed the question into the identical vector area because the paperwork q_vec = mannequin.encode([query], normalize_embeddings=True)[0]
# Construct a boolean masks — False for any doc that fails a filter situation masks = np.ones(len(self.paperwork), dtype=bool) for i, doc in enumerate(self.paperwork): if crew and doc[“team”] != crew: masks[i] = False if standing and doc[“status”] != standing: masks[i] = False if precedence and doc[“priority”] != precedence: masks[i] = False if after and doc[“created”] < after: masks[i] = False if earlier than and doc[“created”] > earlier than: masks[i] = False
candidate_idx = np.the place(masks)[0] if len(candidate_idx) == 0: return []
# Rating solely the candidates that handed the filter scores = self.embeddings[candidate_idx] @ q_vec
# Drop something under the minimal rating threshold, type, return top-k legitimate = np.the place(scores >= min_score)[0] if len(legitimate) == 0: return []
top_local = np.argsort(scores[valid])[::–1][:top_k] top_global = candidate_idx[valid[top_local]]
return [ {**self.documents[i], “rating”: float(scores[valid[top_local[j]]])} for j, i in enumerate(top_global) ]
index = ContextAwareIndex(embeddings, tickets) |
The important thing design determination right here is filtering earlier than scoring, not after. Submit-hoc filtering wastes dot-product compute on paperwork you’d discard anyway. Filtering first additionally ensures min_score can drop irrelevant outcomes as an alternative of returning noisy low-confidence matches.
Step 3: Working Queries
We’ll run three queries to indicate totally different features of the system: semantic search alone, the identical question with metadata filters, and a cross-team question scoped by precedence.
First, a small helper that codecs outcomes constantly throughout all three examples.
Question 1: Looking With out Filters
To determine a baseline, we search with none metadata constraints, letting the embedding mannequin rank the complete corpus on semantic similarity alone.
|
outcomes = index.search(“authentication token expiry and session administration”, top_k=4) present(“‘authentication token expiry and session administration’ (no filters)”, outcomes) |
Working this towards the complete 20-ticket corpus returns the next 4 backend tickets:
|
Question: ‘authentication token expiry and session administration’ (no filters) [0.6133] T–207 backend open excessive 2025–11–03 Session cookie persists after logout — token blacklist test is lacking from the midd... [0.4958] T–201 backend open excessive 2025–11–05 OAuth2 token refresh fails intermittently — race situation in the token cache the place t... [0.3459] T–203 backend open medium 2025–11–01 JWT signature verification fails intermittently — clock skew of 4 seconds between the... [0.1714] T–206 backend open excessive 2025–11–13 Price limiting not scoping per person — middleware makes use of a shared Redis key derived from ... |
Question 2: Filtering by Standing and Date
The question textual content is an identical to the earlier one. What adjustments is the candidate pool: this time we prohibit to open tickets created earlier than November tenth, 2025, simulating a workflow the place a crew needs solely unresolved points inside a sure window.
|
outcomes = index.search( “authentication token expiry and session administration”, top_k=4, standing=“open”, earlier than=date(2025, 11, 10), ) present(“similar question [status=open, before=2025-11-10]”, outcomes) |
Output:
|
Question: similar question [status=open, before=2025–11–10] [0.6133] T–207 backend open excessive 2025–11–03 Session cookie persists after logout — token blacklist test is lacking from the midd... [0.4958] T–201 backend open excessive 2025–11–05 OAuth2 token refresh fails intermittently — race situation in the token cache the place t... [0.3459] T–203 backend open medium 2025–11–01 JWT signature verification fails intermittently — clock skew of 4 seconds between the... [0.1419] T–202 backend open excessive 2025–11–09 Database connection pool exhausted beneath load — pool capped at 20 connections however the... |
Question 3: Looking Throughout Groups with a Precedence Filter
Useful resource exhaustion seems in each infrastructure and backend tickets; they share semantic territory no matter crew possession. This question assessments whether or not the mannequin teams them appropriately throughout that boundary.
|
outcomes = index.search( “useful resource exhaustion and reminiscence stress beneath load”, top_k=2, standing=“open”, precedence=“excessive”, ) present(“‘useful resource exhaustion and reminiscence stress’ [status=open, priority=high]”, outcomes) |
This outputs:
|
Question: ‘useful resource exhaustion and reminiscence stress’ [status=open, priority=high] [0.3877] T–202 backend open excessive 2025–11–09 Database connection pool exhausted beneath load — pool capped at 20 connections however the... [0.2908] T–101 infrastructure open excessive 2025–11–03 Kubernetes pod retains crashing with OOMKilled — reminiscence limits on the ML inference cont... |
Step 4: Persisting the Index
Re-encoding the corpus on each startup defeats the aim of constructing an index. The fitting sample is to encode as soon as, save the embedding matrix and metadata to disk, and reload them on subsequent runs.
|
import json
# Write the embedding matrix and ticket metadata to disk np.save(“ticket_embeddings.npy”, embeddings)
with open(“ticket_metadata.json”, “w”) as f: json.dump( [{**t, “created”: t[“created”].isoformat()} for t in tickets], f, indent=2, ) |
The embedding matrix saves as a binary .npy file. Metadata saves as JSON, however Python’s date objects have to be transformed to ISO strings first. When beginning a brand new session, the loading course of works in two phases:
Mannequin loading (from cache): The SentenceTransformer mannequin first checks your native cache (e.g. .cache/huggingface/hub/). If the mannequin is already out there there, it hundreds instantly. In any other case, it downloads the mannequin as soon as from Hugging Face and shops it domestically to keep away from repeated downloads sooner or later.
Index reloading (from saved information): The saved ticket embeddings (ticket_embeddings.npy) and metadata (ticket_metadata.json) are loaded from disk. This permits the ContextAwareIndex to be rebuilt immediately with out recomputing embeddings, saving each time and compute.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
from datetime import date import json import numpy as np from sentence_transformers import SentenceTransformer
# Restore the embedding matrix, deserialize the metadata, rebuild the index embeddings_loaded = np.load(“ticket_embeddings.npy”)
with open(“ticket_metadata.json”) as f: tickets_loaded = json.load(f) for t in tickets_loaded: t[“created”] = date.fromisoformat(t[“created”])
mannequin = SentenceTransformer(“all-MiniLM-L6-v2”) index = ContextAwareIndex(embeddings_loaded, tickets_loaded)
print(f“Reloaded: {embeddings_loaded.form[0]} docs, {embeddings_loaded.form[1]}D.”) |
The encoding step runs as soon as. Each subsequent startup is 2 file reads and one mannequin load from cache.
Abstract
Context-aware semantic search combines an embedding mannequin to transform textual content into vectors, normalization to align cosine similarity with dot merchandise, a metadata masks to limit candidates earlier than scoring, and a rating step that orders outcomes by similarity.
Right here’s what you are able to do subsequent:
- Add new paperwork: Encode with
mannequin.encode, stack withnp.vstack, append metadata — no re-indexing wanted. - Multi-value metadata filters: Retailer groups as a listing of strings and test
doc["team"]towards the listing. - Scale past 100k paperwork: Change brute-force scoring with an approximate nearest neighbor index like FAISS and maintain the metadata pre-filter unchanged.
- Hybrid scoring: Mix semantic and key phrase indicators with a weighted combine.
Completely satisfied constructing!
