LLM Guides

Make Python Search Smarter with Semantic Search

Aug 14, 2026

A user types: “stuff about refunds.”

Your app searches for the word refunds.

The best document says “billing reversals.”

The app misses it.

Beautiful. Useless.

That is the classic keyword search problem. Keyword search is fast, familiar, and often good enough when users know the exact words. But users are not little SQL robots. They type vague, messy, emotional, half-formed queries like:

  • “the thing where payment failed”
  • “can I get my money back”
  • “docs about user limits”
  • “why upload broken”
  • “client asked about contract ending”
  • “that policy for cancelling subscription”
  • “how do I connect Slack again”

The user knows what they mean. The database does not.

Semantic search helps bridge that gap. Instead of matching only exact words, we represent text as embeddings — numerical vectors that capture meaning — then search for content that is close in meaning to the query. Pinecone’s docs describe dense vectors as vectors that represent the meaning and relationships of data, and semantic search retrieves records with dense vectors most similar to the query.

In this guide, we’ll build a Python semantic search workflow that understands what users actually mean, even when their queries are vague, typo-heavy, or painfully human.

The search problem hiding inside most apps

Most product search starts simple.

The user enters a query.
The app searches text fields.
Results come back.

That works when the query and document use the same words.

User queryDocument wordingKeyword search result
refund policyrefund policyGood
password resetreset passwordGood
invoice exportinvoice exportGood
cancel subscriptionsubscription cancellationUsually fine
money backrefundMaybe bad
billing reversalrefundMaybe bad
account locked outlogin blockedMaybe bad
cannot upload filefile import failedMaybe bad
customer is angry about being charged twiceduplicate billing complaintOften bad

Semantic search helps when meaning matters more than exact phrasing.

It can connect:

User saysDocument says
“money back”refund
“charged twice”duplicate billing
“can’t log in”authentication failure
“cancel my plan”subscription termination
“upload broken”file import error
“team permissions”workspace roles
“old invoices”billing history

This is the search users expected all along.

They just did not know the word “embeddings.”

How semantic search works in one pass

The basic workflow:

documents
→ split into chunks
→ create embeddings
→ store vectors + metadata
→ embed user query
→ compare query vector to document vectors
→ return nearest matches

Each text chunk becomes a vector.

The query also becomes a vector.

Then we compare vectors using similarity search.

Sentence Transformers documentation describes semantic search as embedding the query and corpus into the same vector space, then finding the closest embeddings based on semantic similarity.

A tiny example:

TextMeaning
“Users can cancel subscriptions from billing settings.”Cancellation policy
“Refunds are reviewed within 7 business days.”Refund policy
“CSV uploads fail when files exceed 25 MB.”Upload troubleshooting
“Admins can invite teammates from workspace settings.”Team management

Query:

how do I stop paying for my plan?

A semantic search system can return the cancellation policy even if the exact words “stop paying” never appear.

That is the whole trick.

Why we can write this guide

We’ve spent around 6 years working with AI APIs, embeddings, semantic search, RAG workflows, Python automation, vector databases, structured outputs, and developer tutorials. We also checked current documentation from LLMAPI, Pinecone, Sentence Transformers, FAISS, and OpenAI’s Q&A guidance while preparing this guide.

The current tooling landscape is very practical. Sentence Transformers supports semantic search by computing embeddings for corpus documents and queries, then calculating similarity scores; its docs say small corpora up to about 1 million entries can use a manual implementation before moving to heavier vector search infrastructure. Pinecone supports dense-vector semantic search, sparse-vector lexical search, full-text search, and hybrid search patterns for combining semantic and keyword-style retrieval. FAISS is a library focused on efficient vector similarity search, which makes it useful when local or self-managed similarity search is enough.

The practical lesson: semantic search can start small in Python, then grow into a real retrieval system when your corpus, traffic, or relevance requirements get bigger.

What we’ll build

We’ll build this in layers.

LayerWhat it does
DatasetA small set of searchable documents
ChunkingSplits longer documents into useful pieces
EmbeddingsConverts chunks and queries into vectors
Local searchFinds similar chunks with Python
Vector database optionScales search with Pinecone-style storage
Hybrid searchCombines keyword and semantic signals
RerankingImproves top results
LLMAPI answer layerTurns retrieved results into helpful answers
EvaluationChecks whether search actually improved

This structure matters because semantic search is rarely one magic function.

Good search is a pipeline.

Step 1: Create a tiny document set

Let’s start with product support content.

Create documents.py:

DOCUMENTS = [
    {
        "id": "billing_refunds",
        "title": "Refund policy",
        "text": """
Customers can request a refund within 14 days of the original payment.
Refunds are reviewed by the billing team and usually processed within 7 business days.
Duplicate charges should be reported with the invoice number and payment date.
"""
    },
    {
        "id": "billing_cancel",
        "title": "Cancel subscription",
        "text": """
Users can cancel a subscription from Billing Settings.
After cancellation, the plan remains active until the end of the current billing period.
Admins can download invoices before closing the workspace.
"""
    },
    {
        "id": "upload_limits",
        "title": "File upload limits",
        "text": """
CSV uploads support files up to 25 MB.
If an upload fails, check the file size, encoding, and required column names.
Large imports should be split into smaller files.
"""
    },
    {
        "id": "workspace_roles",
        "title": "Workspace roles",
        "text": """
Workspace owners can invite teammates and assign roles.
Admins can manage billing, integrations, and user permissions.
Members can access shared projects but cannot change billing settings.
"""
    }
]

This is tiny on purpose.

We want the logic to be easy to see before adding infrastructure.

Step 2: Install packages

Install Sentence Transformers and basic helpers:

pip install sentence-transformers numpy pandas python-dotenv openai pydantic

Sentence Transformers is a good local starting point because it gives us ready-to-use embedding models for semantic similarity and search. Its docs list models trained for semantic search and explain that query and passage embeddings can be compared with cosine similarity, dot product, or other similarity functions depending on the model.

Step 3: Build local semantic search

Create semantic_search_local.py:

from sentence_transformers import SentenceTransformer
from sentence_transformers.util import semantic_search
from documents import DOCUMENTS


model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")


def build_corpus():
    corpus = []

    for doc in DOCUMENTS:
        corpus.append({
            "id": doc["id"],
            "title": doc["title"],
            "text": doc["text"].strip()
        })

    return corpus


def create_corpus_embeddings(corpus):
    texts = [item["text"] for item in corpus]
    return model.encode(texts, convert_to_tensor=True)


def search(query: str, top_k: int = 3):
    corpus = build_corpus()
    corpus_embeddings = create_corpus_embeddings(corpus)

    query_embedding = model.encode(query, convert_to_tensor=True)

    hits = semantic_search(
        query_embedding,
        corpus_embeddings,
        top_k=top_k
    )[0]

    results = []

    for hit in hits:
        item = corpus[hit["corpus_id"]]

        results.append({
            "id": item["id"],
            "title": item["title"],
            "text": item["text"],
            "score": float(hit["score"])
        })

    return results


if __name__ == "__main__":
    query = "how do I get my money back if I was charged twice?"

    for result in search(query):
        print(result["score"], result["title"])

Try these queries:

how do I get my money back?
I was charged twice
upload keeps breaking
who can change payment settings?

The search should find related documents even when the query does not use the exact document wording.

That is already smarter than basic keyword matching.

Step 4: Add chunking for longer documents

For real apps, documents are bigger.

A whole policy page may contain ten different topics. If we embed the entire page as one vector, the result can be too broad.

Chunking fixes that.

Create chunking.py:

def chunk_text(text: str, max_chars: int = 800, overlap: int = 100) -> list[str]:
    text = text.strip()

    if len(text) <= max_chars:
        return [text]

    chunks = []
    start = 0

    while start < len(text):
        end = min(start + max_chars, len(text))
        chunk = text[start:end].strip()

        if chunk:
            chunks.append(chunk)

        if end == len(text):
            break

        start = end - overlap

    return chunks

Now create chunk records:

from documents import DOCUMENTS
from chunking import chunk_text


def build_chunks():
    chunks = []

    for doc in DOCUMENTS:
        doc_chunks = chunk_text(doc["text"], max_chars=500, overlap=80)

        for index, chunk in enumerate(doc_chunks):
            chunks.append({
                "chunk_id": f"{doc['id']}:{index}",
                "document_id": doc["id"],
                "title": doc["title"],
                "text": chunk
            })

    return chunks

Chunking helps because semantic search retrieves the exact section that answers the query, not only the document that vaguely contains the answer.

Step 5: Search chunks instead of full documents

Update the local search:

from sentence_transformers import SentenceTransformer
from sentence_transformers.util import semantic_search

from build_chunks import build_chunks


model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")


class LocalSemanticIndex:
    def __init__(self):
        self.chunks = build_chunks()
        self.embeddings = model.encode(
            [chunk["text"] for chunk in self.chunks],
            convert_to_tensor=True
        )

    def search(self, query: str, top_k: int = 5):
        query_embedding = model.encode(query, convert_to_tensor=True)

        hits = semantic_search(
            query_embedding,
            self.embeddings,
            top_k=top_k
        )[0]

        results = []

        for hit in hits:
            chunk = self.chunks[hit["corpus_id"]]

            results.append({
                **chunk,
                "score": float(hit["score"])
            })

        return results


if __name__ == "__main__":
    index = LocalSemanticIndex()

    results = index.search(
        "I need to stop my subscription but keep access until the end of the month"
    )

    for result in results:
        print(result["score"], result["title"], result["chunk_id"])

For small datasets, this is enough.

For larger datasets, use a vector database or FAISS.

Step 6: Add FAISS for local vector search

FAISS is useful when the corpus grows and manual similarity search gets slow. The FAISS library is dedicated to vector similarity search and is widely used for nearest-neighbor retrieval.

Install:

pip install faiss-cpu

Create semantic_search_faiss.py:

import faiss
import numpy as np
from sentence_transformers import SentenceTransformer

from build_chunks import build_chunks


model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")


class FaissSemanticIndex:
    def __init__(self):
        self.chunks = build_chunks()

        embeddings = model.encode(
            [chunk["text"] for chunk in self.chunks],
            convert_to_numpy=True,
            normalize_embeddings=True
        )

        self.embeddings = embeddings.astype("float32")

        dimension = self.embeddings.shape[1]
        self.index = faiss.IndexFlatIP(dimension)
        self.index.add(self.embeddings)

    def search(self, query: str, top_k: int = 5):
        query_embedding = model.encode(
            [query],
            convert_to_numpy=True,
            normalize_embeddings=True
        ).astype("float32")

        scores, indexes = self.index.search(query_embedding, top_k)

        results = []

        for score, item_index in zip(scores[0], indexes[0]):
            chunk = self.chunks[item_index]

            results.append({
                **chunk,
                "score": float(score)
            })

        return results

Why IndexFlatIP?

Because we normalized the embeddings, inner product behaves like cosine similarity. Sentence Transformers notes that some models produce normalized vectors where dot product, cosine similarity, and Euclidean distance can be used depending on setup.

This is a good local option for prototypes, internal tools, and smaller production systems.

Step 7: Use Pinecone when search needs to scale

A local FAISS index is great until you need managed storage, metadata filtering, multi-user data isolation, updates, scaling, and production operations.

Pinecone is one common vector database option. Its docs describe semantic search with dense vectors and show indexes that can use integrated embedding models or external embeddings. Pinecone also supports metadata, dense vector search, sparse vectors, full-text search, and hybrid search patterns.

Install:

pip install pinecone

Example shape:

from pinecone import Pinecone
from sentence_transformers import SentenceTransformer

from build_chunks import build_chunks


pc = Pinecone(api_key="YOUR_PINECONE_API_KEY")
index = pc.Index("support-search")

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")


def upsert_chunks():
    chunks = build_chunks()
    texts = [chunk["text"] for chunk in chunks]

    embeddings = model.encode(
        texts,
        convert_to_numpy=True,
        normalize_embeddings=True
    )

    records = []

    for chunk, embedding in zip(chunks, embeddings):
        records.append({
            "id": chunk["chunk_id"],
            "values": embedding.tolist(),
            "metadata": {
                "document_id": chunk["document_id"],
                "title": chunk["title"],
                "text": chunk["text"]
            }
        })

    index.upsert(vectors=records)


def search_pinecone(query: str, top_k: int = 5):
    query_embedding = model.encode(
        query,
        convert_to_numpy=True,
        normalize_embeddings=True
    )

    response = index.query(
        vector=query_embedding.tolist(),
        top_k=top_k,
        include_metadata=True
    )

    return response["matches"]

Use a vector database when you need:

  • Large-scale search
  • Fast retrieval
  • Metadata filters
  • Multi-tenant data
  • Frequent updates
  • Production monitoring
  • Managed indexes
  • Hybrid retrieval
  • High availability

Start local. Move managed when the product needs it.

Step 8: Add metadata filters

Semantic similarity alone is not always enough.

Sometimes users want:

  • Only docs from one workspace
  • Only active policies
  • Only support articles
  • Only product docs
  • Only English content
  • Only documents updated this year
  • Only invoices from one customer

Metadata filters solve this.

Example chunk metadata:

{
  "chunk_id": "billing_refunds:0",
  "document_id": "billing_refunds",
  "title": "Refund policy",
  "workspace_id": "workspace_123",
  "doc_type": "support_article",
  "language": "en",
  "updated_at": "2026-08-01"
}

Search with filters:

response = index.query(
    vector=query_embedding.tolist(),
    top_k=5,
    include_metadata=True,
    filter={
        "workspace_id": {"$eq": "workspace_123"},
        "doc_type": {"$eq": "support_article"}
    }
)

Metadata filtering makes semantic search safer and more useful.

A query should only search the content the user is allowed to see.

Step 9: Add hybrid search

Semantic search understands meaning.

Keyword search is still useful.

Why?

Because exact terms matter.

Examples:

  • Error codes
  • Invoice numbers
  • Product IDs
  • Function names
  • Legal clause numbers
  • SKU codes
  • Acronyms
  • Names
  • Dates
  • Version numbers

A user searching ERR_AUTH_402 probably does not want “general login issues.” They want that exact error.

Hybrid search combines semantic and lexical signals.

Pinecone’s search overview describes hybrid search as combining dense and sparse vectors, and its docs also discuss mixing dense vectors, sparse vectors, and full-text search fields in an index.

A simple hybrid strategy in Python:

  1. Run semantic search.
  2. Run keyword search.
  3. Merge results.
  4. Rerank combined candidates.

Very simple keyword search:

def keyword_search(query: str, chunks: list[dict], top_k: int = 5):
    query_terms = set(query.lower().split())
    results = []

    for chunk in chunks:
        text = chunk["text"].lower()
        score = sum(1 for term in query_terms if term in text)

        if score > 0:
            results.append({
                **chunk,
                "keyword_score": score
            })

    return sorted(
        results,
        key=lambda item: item["keyword_score"],
        reverse=True
    )[:top_k]

Merge candidates:

def merge_results(semantic_results, keyword_results):
    merged = {}

    for item in semantic_results:
        merged[item["chunk_id"]] = {
            **item,
            "semantic_score": item.get("score", 0),
            "keyword_score": 0
        }

    for item in keyword_results:
        existing = merged.get(item["chunk_id"], item)
        existing["keyword_score"] = item.get("keyword_score", 0)
        merged[item["chunk_id"]] = existing

    return list(merged.values())

This is basic, but it shows the idea.

Semantic search helps with meaning. Keyword search protects exact matches.

Together, they usually behave better than either one alone.

Step 10: Add reranking

Semantic search retrieves candidates.

Reranking reorders the best candidates more carefully.

A common flow:

query
→ retrieve top 20 chunks
→ rerank top 20
→ return top 5

Why rerank?

Because vector similarity is fast, but the top result is not always the best answer. Reranking can compare the query and each candidate more directly.

Use reranking when:

  • Search quality matters a lot
  • Results are close together
  • Documents are similar
  • The corpus has repeated content
  • Users complain about “almost right” results
  • You are building RAG
  • You need higher precision in top 3 results

A simple LLMAPI reranker can score candidates:

import json
from llmapi_client import client


def rerank_with_llmapi(query: str, candidates: list[dict], top_k: int = 5):
    compact_candidates = [
        {
            "chunk_id": item["chunk_id"],
            "title": item["title"],
            "text": item["text"]
        }
        for item in candidates
    ]

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": """
You rerank search results for relevance.

Return only valid JSON:
{
  "results": [
    {
      "chunk_id": "string",
      "relevance_score": 0,
      "reason": "short reason"
    }
  ]
}

Rules:
- Score from 0 to 100.
- Prefer results that directly answer the query.
- Penalize results that are only loosely related.
- Do not invent content outside the candidate text.
"""
            },
            {
                "role": "user",
                "content": json.dumps({
                    "query": query,
                    "candidates": compact_candidates
                })
            }
        ],
        temperature=0
    )

    ranking = json.loads(response.choices[0].message.content)
    scores = {
        item["chunk_id"]: item
        for item in ranking["results"]
    }

    reranked = sorted(
        candidates,
        key=lambda item: scores.get(
            item["chunk_id"],
            {}
        ).get("relevance_score", 0),
        reverse=True
    )

    return reranked[:top_k]

For high-volume search, use a dedicated reranker model instead of calling an LLM for every query.

For an MVP or internal tool, this can be enough to test whether reranking helps.

Step 11: Generate answers with LLMAPI

Semantic search returns documents.

Sometimes users want an answer.

This becomes a basic RAG workflow:

query
→ semantic search
→ top chunks
→ LLMAPI answer grounded in chunks
→ response with sources

Create llmapi_client.py:

import os
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

client = OpenAI(
    api_key=os.environ["LLMAPI_API_KEY"],
    base_url=os.environ.get("LLMAPI_BASE_URL", "https://api.llmapi.ai/v1")
)

LLMAPI’s quick-start docs show an OpenAI-compatible chat completions flow through /v1/chat/completions, which makes it straightforward to add an answer-generation layer after retrieval.

Create answer_with_sources.py:

import json
from llmapi_client import client


def answer_from_search_results(query: str, search_results: list[dict]) -> dict:
    sources = [
        {
            "chunk_id": result["chunk_id"],
            "title": result["title"],
            "text": result["text"]
        }
        for result in search_results
    ]

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": """
Answer the user's question using only the provided sources.

Return only valid JSON:
{
  "answer": "string",
  "source_ids": ["string"],
  "missing_information": ["string"]
}

Rules:
- Use only the source text.
- If the answer is not in the sources, say that the available documents do not contain enough information.
- Do not invent policies, numbers, dates, or steps.
- Keep the answer clear and practical.
"""
            },
            {
                "role": "user",
                "content": json.dumps({
                    "query": query,
                    "sources": sources
                })
            }
        ],
        temperature=0
    )

    return json.loads(response.choices[0].message.content)

Example:

index = LocalSemanticIndex()

query = "Can I cancel and still use the app until the month ends?"
results = index.search(query, top_k=3)

answer = answer_from_search_results(query, results)

print(answer)

Possible output:

{
  "answer": "Yes. After cancellation, the plan remains active until the end of the current billing period.",
  "source_ids": ["billing_cancel:0"],
  "missing_information": []
}

Now search becomes more than a results page.

It becomes an assistant grounded in your own content.

Step 12: Build a FastAPI search endpoint

Create app.py:

from fastapi import FastAPI, Query
from semantic_search_local import LocalSemanticIndex
from answer_with_sources import answer_from_search_results

app = FastAPI(
    title="Semantic Search API",
    description="Search documents by meaning with Python and LLMAPI.",
    version="1.0.0"
)

index = LocalSemanticIndex()


@app.get("/health")
def health_check():
    return {
        "status": "ok"
    }


@app.get("/search")
def search(
    q: str = Query(..., min_length=2),
    top_k: int = Query(5, ge=1, le=20)
):
    results = index.search(q, top_k=top_k)

    return {
        "query": q,
        "results": results
    }


@app.get("/answer")
def answer(
    q: str = Query(..., min_length=2),
    top_k: int = Query(5, ge=1, le=10)
):
    results = index.search(q, top_k=top_k)
    answer_payload = answer_from_search_results(q, results)

    return {
        "query": q,
        "answer": answer_payload,
        "search_results": results
    }

Run it:

uvicorn app:app --reload

Try:

curl "http://127.0.0.1:8000/search?q=I%20was%20charged%20twice"

And:

curl "http://127.0.0.1:8000/answer?q=How%20do%20I%20stop%20paying%20but%20keep%20access"

Now you have both semantic search and a grounded answer endpoint.

Step 13: Add query cleanup

Users type weird things.

That is normal.

Before embedding, lightly clean queries.

Create query_cleaning.py:

import re


def clean_query(query: str) -> str:
    query = query.strip()
    query = re.sub(r"\s+", " ", query)
    return query

Do not over-clean.

You usually want to preserve the user’s wording because embeddings can handle natural language. Removing too much can make queries worse.

Good cleanup:

  • Trim whitespace
  • Normalize repeated spaces
  • Remove empty input
  • Detect extremely long queries
  • Remove control characters

Risky cleanup:

  • Removing “not”
  • Removing all punctuation
  • Aggressive stemming
  • Dropping short words blindly
  • Rewriting user intent without review

Semantic search works partly because users can be natural.

Do not turn the query back into 2006 search syntax.

Step 14: Add query expansion with LLMAPI

Sometimes users type very short queries.

Example:

billing issue

That could mean refunds, duplicate charges, invoices, payment failures, cancellation, plan upgrades, or receipts.

LLMAPI can expand vague queries into search-friendly alternatives.

Create query_expansion.py:

import json
from llmapi_client import client


def expand_query(query: str) -> list[str]:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": """
Create search query variants.

Return only valid JSON:
{
  "queries": ["string"]
}

Rules:
- Keep the original user intent.
- Add up to 4 useful variants.
- Do not add unrelated topics.
- Prefer natural language queries.
"""
            },
            {
                "role": "user",
                "content": query
            }
        ],
        temperature=0.2
    )

    payload = json.loads(response.choices[0].message.content)
    return [query] + payload["queries"]

Then search each variant and merge results.

This helps when user queries are too vague for a single embedding.

Use carefully. Query expansion can also widen the search too much.

Step 15: Add result explanations

Search results are better when users can see why something matched.

Use LLMAPI to explain a top result.

import json
from llmapi_client import client


def explain_search_match(query: str, result: dict) -> str:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": """
Explain why this search result is relevant to the user's query.

Rules:
- Use only the result text.
- Keep it to one sentence.
- Do not overstate relevance.
"""
            },
            {
                "role": "user",
                "content": json.dumps({
                    "query": query,
                    "result_title": result["title"],
                    "result_text": result["text"]
                })
            }
        ],
        temperature=0
    )

    return response.choices[0].message.content.strip()

Example:

This result is relevant because it explains that duplicate charges should be reported with invoice details.

This makes search feel less random.

It is especially useful for internal knowledge bases, legal/admin search, support docs, and research tools.

Step 16: Evaluate search quality

Do not judge search quality by clicking around for three minutes and saying “seems fine.”

Create evaluation queries.

Example:

[
  {
    "query": "I was charged twice",
    "expected_document_id": "billing_refunds"
  },
  {
    "query": "how do I stop my plan",
    "expected_document_id": "billing_cancel"
  },
  {
    "query": "file import keeps failing",
    "expected_document_id": "upload_limits"
  },
  {
    "query": "who can invite users",
    "expected_document_id": "workspace_roles"
  }
]

Simple evaluator:

def evaluate_search(index, eval_cases: list[dict], top_k: int = 3):
    hits_at_1 = 0
    hits_at_k = 0

    for case in eval_cases:
        results = index.search(case["query"], top_k=top_k)
        document_ids = [result["document_id"] for result in results]

        if document_ids and document_ids[0] == case["expected_document_id"]:
            hits_at_1 += 1

        if case["expected_document_id"] in document_ids:
            hits_at_k += 1

    total = len(eval_cases)

    return {
        "total": total,
        "hit_rate_at_1": hits_at_1 / total if total else 0,
        f"hit_rate_at_{top_k}": hits_at_k / total if total else 0
    }

Track:

MetricMeaning
Hit rate@1Correct result is first
Hit rate@3Correct result is in top 3
MRRCorrect result appears higher
Recall@kRelevant results are retrieved
Precision@kTop results are actually useful
No-answer accuracySystem refuses when content is missing
User click-throughReal user behavior
Search-to-resolutionDid search solve the task?

Semantic search should be measured like a product feature, not admired like a magic trick.

Step 17: Handle missing answers

A search system should know when it does not have enough information.

Example user query:

Do you support wire transfers in Brazil?

If your docs never mention Brazil or wire transfers, the system should not make something up.

For answer generation, add a strict rule:

If the answer is not clearly supported by the retrieved sources, say the available documents do not contain enough information.

Also use score thresholds.

Example:

def should_answer(results: list[dict], min_score: float = 0.35) -> bool:
    if not results:
        return False

    return results[0]["score"] >= min_score

Then:

results = index.search(query, top_k=5)

if not should_answer(results):
    return {
        "answer": "I could not find enough relevant information in the available documents.",
        "source_ids": [],
        "missing_information": [query]
    }

Thresholds need testing.

Do not copy a number from a tutorial and treat it like gravity.

Step 18: Update the index when documents change

Semantic search has two data layers:

  1. Original document text.
  2. Embeddings generated from that text.

When documents change, embeddings must be updated too.

Track:

EventAction
New documentChunk and embed
Edited documentRe-chunk and re-embed
Deleted documentRemove chunks and vectors
Permission changeUpdate metadata/filtering
Language changeRe-embed if needed
Chunking strategy changeRebuild index
Embedding model changeRebuild index

Store model metadata:

{
  "chunk_id": "billing_refunds:0",
  "embedding_model": "sentence-transformers/all-MiniLM-L6-v2",
  "chunking_version": "v1",
  "document_version": "2026-08-24"
}

This helps when search results change and someone asks why.

Someone will always ask why.

Step 19: Add permissions and tenant isolation

Semantic search can accidentally leak information if metadata filters are missing.

For multi-user apps, always filter by access.

Examples:

AppRequired filter
Team workspaceworkspace_id
Enterprise docsorganization_id
Private user notesuser_id
Role-based docsrole or permission group
Region-specific policiesregion
Draft/public docsstatus
Customer recordscustomer_id

Search should happen inside the user’s allowed content scope.

A good query flow:

user request
→ auth check
→ build allowed metadata filter
→ semantic search inside allowed scope
→ answer with allowed sources only

Do not rely on the LLM to “not mention private data.”

Filter retrieval before the model sees anything.

Step 20: Common mistakes

MistakeBetter approach
Embedding huge documents as one vectorChunk documents
Using semantic search for exact IDs onlyAdd keyword or hybrid search
No metadata filtersFilter by workspace/user/permissions
No eval queriesMeasure search quality
No missing-answer behaviorAdd thresholds and refusal rules
Sending too many chunks to LLMAPIRetrieve and rerank first
No document versioningTrack embedding and chunk versions
Ignoring deleted docsRemove stale vectors
No source IDsReturn citations/source chunks
Over-cleaning queriesKeep natural language
No rerankingRerank when top results are weak
Treating similarity score as truthTest thresholds with real data

The most annoying semantic search bug is a result that feels close but answers the wrong question.

Reranking, filters, and evaluation are how we fight that.

Where LLMAPI helps

LLMAPI is useful around the semantic search pipeline.

Use it for:

NeedLLMAPI role
Answer generationTurn retrieved chunks into grounded answers
Query expansionCreate better variants of vague queries
Result explanationsExplain why a result matched
SummariesSummarize retrieved documents
RerankingScore candidate results for relevance
ClassificationRoute query by topic before search
Missing-info handlingExplain what the docs do not answer
Search UX copyMake results easier to understand
RAG workflowsCombine search + answer generation

A good search product flow:

user query
→ clean query
→ embed query
→ semantic/hybrid search
→ rerank
→ LLMAPI grounded answer
→ source-backed response

LLMAPI’s role is to make retrieved content useful. The search layer still needs to retrieve the right content first.

Closing notes for builders

Semantic search makes Python search feel less brittle because it listens for meaning, not only exact wording.

That matters when users type like humans instead of documentation authors. They say “money back” when the docs say “refund.” They say “upload broken” when the support article says “CSV import failed.” They ask vague, emotional, half-complete questions because they are trying to solve a problem, not impress the search bar.

Start with a small local embedding index. Add chunking. Measure whether the right documents appear in the top results. Move to FAISS or a vector database when scale demands it. Add metadata filters before you touch private or multi-tenant content. Use hybrid search when exact terms matter. Use reranking when the top results are close. Use LLMAPI when you want grounded answers, summaries, query expansion, or result explanations.

That gives you a search system that feels much closer to how users actually think — messy queries included.

Deploy in minutes