Semantic Search Architecture

Design hybrid search systems combining vector similarity, BM25 keyword matching, and cross-encoder reranking for production accuracy.

Semantic search architecture enables systems to retrieve information based on meaning, not just keywords. It matters when users expect natural, context-aware retrieval—when a query like *“What’s the best way to reduce latency in microservices?”* should surface documents discussing distributed tracing, circuit breakers, and service mesh tuning, even if those exact terms don’t appear in the original documents. This architecture is essential for documentation platforms, knowledge bases, AI agent backends, and search-driven user interfaces.

Core Components and Their Roles

Document Ingestion Pipeline

Documents arrive in a variety of formats—PDFs, Markdown, HTML, JSON, plain text—and must be transformed into dense, meaning-rich representations. The ingestion process begins with a preprocessing stage that normalizes input:


pdfminer.six -o /tmp/docs/12345.pdf -t -p 1000 -o /tmp/text/12345.txt

# Clean and split text into chunks
python -c '
import re
from pathlib import Path

def chunk_text(text, chunk_size=512):
    words = text.split()
    chunks = []
    current = []
    for word in words:
        if len(" ".join(current + [word])) > chunk_size:
            chunks.append(" ".join(current))
            current = [word]
        else:
            current.append(word)
    if current:
        chunks.append(" ".join(current))
    return chunks

text = (Path("/tmp/text/12345.txt").read_text(encoding="utf-8"))
chunks = chunk_text(text, chunk_size=512)

for i, chunk in enumerate(chunks):
    (Path("/tmp/chunks/12345").mkdir(exist_ok=True) / f"{i:04d}.txt").write_text(chunk)
'

Each chunk is then passed through a text embedding model to generate vector representations. The model is typically a fine-tuned Transformer such as all-MiniLM-L6-v2 or BAAI/bge-small-en.

# Generate embeddings using Hugging Face's `sentence-transformers`
curl -X POST http://embedder:8080/infer \
  -H "Content-Type: application/json" \
  -d '{
    "texts": [
      "Microservices should be independently deployable and loosely coupled.",
      "Circuit breakers prevent cascading failures in distributed systems."
    ],
    "model": "all-MiniLM-L6-v2"
  }' \
  -o /tmp/embeddings.json

The response includes a list of vectors, each of length 384 for all-MiniLM-L6-v2, with each vector stored as a floating-point array.

Vector Database Storage and Querying

The vector embeddings are stored in a vector database with efficient similarity search. The database accepts a INSERT operation with a vector, metadata, and an ID.

-- Insert a chunk into a PostgreSQL table with pgvector extension
INSERT INTO documents (id, text, embedding, metadata, source)
VALUES (
  'doc-12345-chunk-0012',
  'Service mesh enables observability and traffic management across microservices.',
  '{0.12, -0.34, 0.55, ...}',  -- 384-element vector
  '{"source": "docs/microservices.md", "page": 12, "section": "observability"}',
  'docs/microservices.md'
);

To perform a query, the system computes a query vector and retrieves the nearest neighbors:

-- Find the top 10 most similar documents to a query
SELECT id, text, metadata, 1 - (embedding <=> '{0.45, -0.11, 0.82, ...}') AS score
FROM documents
ORDER BY embedding <=> '{0.45, -0.11, 0.82, ...}'
LIMIT 10;

The <= operator performs cosine similarity, and the => operator computes Euclidean distance. The query response includes id, text, metadata, and score fields.

Query Processing Pipeline

When a user submits a query, the system applies query preprocessing:

def preprocess_query(query: str) -> str:
    # Remove extra whitespace and normalize
    query = re.sub(r'\s+', ' ', query.strip())
    # Apply query expansion using synonyms
    synonyms = {
        "latency": ["delay", "response time", "latency"],
        "microservices": ["services", "distributed services", "service-oriented architecture"],
        "reduce": ["minimize", "decrease", "lower"]
    }
    expanded = []
    for word in query.split():
        expanded.append(word.lower())
        if word.lower() in synonyms:
            expanded.extend(synonyms[word.lower()])
    return " ".join(set(expanded))

The preprocessed query is then embedded:

curl -X POST http://embedder:8080/infer \
  -H "Content-Type: application/json" \
  -d '{
    "texts": ["reduce latency in microservices"],
    "model": "all-MiniLM-L6-v2"
  }' \
  -o /tmp/query_vector.json

The resulting vector is used in a similarity search. The system may apply query expansion by re-ranking results with a retrieval-augmented generation (RAG) step:

{
  "query": "How to reduce latency in microservices?",
  "top_k": 5,
  "context": [
    "Microservices should be independently deployable and loosely coupled.",
    "Circuit breakers prevent cascading failures in distributed systems.",
    "Service mesh enables observability and traffic management across microservices."
  ],
  "retrieval_score": 0.87,
  "rank": 1
}

Hybrid Search and Ranking

For better accuracy, systems combine vector search with keyword search. The two are fused using a weighted combination of scores:

def hybrid_search(query_vector: list, keyword_query: str, k: int = 10) -> list[dict]:
    # Get vector results
    vector_results = vector_db.search(query_vector, k=k)
    
    # Get keyword results using full-text search
    keyword_results = postgresql.query(f"""
        SELECT id, text, metadata, 
               ts_rank_cd(to_tsvector('english', text), to_tsquery('english', ?)) AS score
        FROM documents
        WHERE to_tsvector('english', text) @@ to_tsquery('english', ?)
        ORDER BY score DESC
        LIMIT {k}
    """, (keyword_query, keyword_query))

    # Combine results with weighted scores
    merged = {}
    for result in vector_results:
        merged[result["id"]] = {
            "id": result["id"],
            "text": result["text"],
            "metadata": result["metadata"],
            "score": result["score"] * 0.7,  # vector weight
            "type": "vector"
        }
    
    for result in keyword_results:
        id = result["id"]
        if id in merged:
            merged[id]["score"] += result["score"] * 0.3
        else:
            merged[id] = {
                "id": id,
                "text": result["text"],
                "metadata": result["metadata"],
                "score": result["score"] * 0.3,
                "type": "keyword"
            }

    return sorted(merged.values(), key=lambda x: x["score"], reverse=True)[:k]

Query-Time Optimization

To reduce latency, systems use caching at multiple levels. A Redis instance stores recent query embeddings and retrieval results:

# Cache query embedding
redis-cli SETEX query-embedding-latency-in-microservices 300 \
  '[0.45, -0.11, 0.82, ...]'

# Retrieve cached results
redis-cli HGETALL cached-retrieval-latency-in-microservices

The cache key follows the pattern cached-retrieval-{query_hash} where the query is hashed using SHA-256. If a query is not in cache, the system computes it and stores it for future use.

Indexing and Update Strategy

New documents are ingested asynchronously. The system maintains an indexing queue using RabbitMQ or Kafka:

{
  "event": "document_added",
  "document_id": "doc-12345",
  "source_path": "/data/docs/microservices.md",
  "timestamp": "2024-04-15T10:30:00Z"
}

Workers consume the queue and perform the following steps:

  1. Extract text
  2. Split into chunks
  3. Compute embeddings
  4. Insert into vector database
  5. Update full-text index

The system handles incremental indexing—it tracks the last processed document and resumes from the last offset, even across restarts. When an error occurs during embedding, the system logs the failure in a error log table:

CREATE TABLE ingestion_errors (
  id SERIAL PRIMARY KEY,
  document_id VARCHAR(255),
  error_type VARCHAR(100),
  error_message TEXT,
  retry_count INTEGER DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  processed_at TIMESTAMPTZ
);

A background job scans this table and retries failed embeddings every 5 minutes.

Failure Modes and Sharp Edges

Silent Failures in Embedding Model Inference

The most common silent failure occurs when the embedding model returns a vector of incorrect dimensionality. A all-MiniLM-L6-v2 model expects 384-dimensional vectors, but the inference service returns 384.1 or 383. The vector database assumes the correct size, but the query returns incorrect results:

{
  "error": "Vector dimension mismatch",
  "expected": 384,
  "actual": 383,
  "query_vector": [0.1, -0.3, 0.5, ...],
  "document_id": "doc-12345",
  "timestamp": "2024-04-15T11:00:00Z"
}

This issue is subtle: results look plausible but are consistently inaccurate. The system must validate vector dimensions on insert and query.

Query Expansion and Synonym Confusion

Query expansion using synonyms can introduce noise. A query for *“optimize microservices”* expands to *“optimize microservices, services, distributed services”*, but the system fails to normalize case and stemming. The term *“services”* is not matched to *“service”* in the vector database. The system must apply standardization:

def normalize_term(term: str) -> str:
    return stemmer.stem(lemmatizer.lemmatize(term.lower()))

Without this, a query for *“service”* fails to retrieve documents with *“services”*.

Cache Invalidation and Staleness

When a new document is added, the cache for related queries becomes stale. A user searches for *“microservices latency”*, and the cache returns results based on old data. The system must track cache dependencies:

{
  "cache_key": "cached-retrieval-microservices-latency",
  "dependencies": [
    "doc-12345",
    "doc-12346",
    "doc-12347"
  ],
  "last_updated": "2024-04-15T11:00:00Z"
}

When any of the dependent documents are updated, the cache is marked as invalid and refreshed asynchronously.

Vector Database Scaling and Indexing Trade-offs

As the dataset grows, the choice of indexing algorithm becomes critical. The system may use IVF-PQ (Inverted File with Product Quantization) for scalability, but the parameters are sensitive:

A poorly tuned index leads to slow queries and high memory usage. The system logs the query latency and memory usage per index type and adjusts parameters dynamically.

Metadata Filtering and Query Expansion

Metadata fields like source, section, and page enable filtering. A query for *“tracing in microservices”* can filter to documents in the observability section:

SELECT id, text, metadata, 1 - (embedding <=> ?) AS score
FROM documents
WHERE metadata->>'section' = 'observability'
ORDER BY embedding <=> ?
LIMIT 10;

But the system often applies filtering too late—after retrieving all vectors—leading to unnecessary computation. The optimal approach is to filter early in the retrieval pipeline.

Summary

Semantic search architecture is not a single service but a coordinated pipeline across preprocessing, embedding, storage, querying, and optimization. It succeeds when queries return relevant results even when key terms are absent. It fails silently when vector dimensions mismatch, caches go stale, or metadata filtering is applied too late. The system must be vigilant in its validation, error reporting, and configuration of the vector database, embedding models, and query pipelines.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.