paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

July 05, 2026

Building Fast AI Search: A Practical Guide to Vector Databases and Embeddings

An end-to-end blueprint for implementing high-performance retrieval - from ingestion and embedding to indexing, querying, and scaling

Introduction

Most guidance on vector search covers either the theory - what an embedding is, how approximate nearest neighbor indexing works - or a single query snippet showing how to call a vector database's search method. What's missing more often is the piece in between: how to actually build the full pipeline that gets real, messy source data into a searchable index and keeps it fast and accurate as that data grows and changes. That pipeline - ingestion, embedding, indexing, querying, and ongoing maintenance - is where most of the real engineering effort in a production AI search system actually goes, and it's the part that determines whether a system that worked well in a prototype still works well a year later against ten times the data and a hundred times the query volume.

This article works through that full pipeline as a single, connected system rather than a set of disconnected concerns: how to structure ingestion so embeddings stay consistent and updates are cheap, how to configure an index for the performance characteristics a specific workload actually needs, how to architect the query path so it's both fast and accurate, and the operational practices that keep the whole system healthy as it scales. The goal is a practical blueprint an engineering team can actually build from, not just a conceptual overview of the pieces involved.

Context: What "Fast AI Search" Actually Requires End to End

A vector search system that only exists as a single, one-time script - embed some documents, build an index, run a few test queries - looks nothing like the system that needs to run reliably in production. Production search needs an ingestion pipeline that can process new and updated documents continuously without reprocessing the entire corpus every time, a query path that meets a real latency budget under real concurrent load, and an operational story for what happens when the underlying embedding model changes, when the corpus grows by an order of magnitude, or when a specific category of query starts returning poor results and needs to be diagnosed and fixed. None of these needs are visible in a basic "embed and query" tutorial, and all of them are exactly where a system that looked solid in prototype starts to strain in production.

The performance side of this is genuinely a systems problem, not just an algorithm problem. Embedding generation itself has real cost and latency - calling a hosted embedding API for every document during ingestion, and for every query at search time, adds a network round trip that needs to be accounted for in any latency budget. Index build and update characteristics vary meaningfully by index type and configuration, and a system whose ingestion pipeline naively rebuilds its entire index on every batch of new documents will eventually hit a wall that a system designed for incremental updates from the start will not. None of this is exotic engineering, but all of it needs to be designed deliberately rather than left as an afterthought once the first performance complaint arrives.

The third piece of "fast AI search," and the one most often underestimated, is that raw vector similarity alone rarely produces search results good enough to ship without additional layers - hybrid retrieval to catch exact-match queries semantic search misses, metadata filtering to scope results to what a specific user or tenant should actually see, and often a reranking step to sharpen precision at the very top of the results. Building "just" a vector search system, without these additional layers designed in from the start, tends to produce a system that performs acceptably in an internal demo and disappoints once real users start issuing the full range of queries a production system actually needs to handle well.

Deep Technical Explanation: The Anatomy of a Production Search Pipeline

The ingestion side of a production pipeline has three distinct responsibilities that are easy to conflate but genuinely separate: parsing and chunking source documents into coherent, retrievable units; generating embeddings for those chunks in a way that's efficient and resilient to transient failures; and writing the results to a vector index in a way that supports incremental updates rather than requiring a full rebuild every time. Chunking strategy directly determines retrieval quality, as covered in embeddings fundamentals more broadly, but the ingestion pipeline's engineering concern is different: it needs to track which source document produced which chunks, so that when a source document is updated or deleted, the pipeline can find and update or remove exactly the corresponding vectors rather than treating the entire index as something that only gets rebuilt from scratch.

Embedding generation at ingestion scale introduces its own engineering considerations distinct from the one-off embedding calls shown in introductory examples. Batching multiple chunks into a single embedding API call, rather than issuing one request per chunk, meaningfully reduces the number of network round trips and is supported directly by most embedding APIs' batch input parameters. Rate limiting and retry logic matter more here than in a low-volume prototype, since a bulk ingestion job processing thousands of documents will reliably encounter transient rate-limit responses from a hosted embedding provider, and a pipeline without backoff and retry logic will either fail partway through a large batch or, worse, silently skip chunks that failed to embed without anyone noticing until a search for that content quietly returns nothing.

The query side of the pipeline is where the layers discussed elsewhere in AI search - hybrid retrieval, metadata filtering, reranking - come together into a single request path with its own latency budget. A well-architected query path treats these as sequential stages with a clear cost and latency profile at each: a fast vector and keyword retrieval stage casting a wide net cheaply, an inexpensive fusion step combining their results, metadata filtering integrated into the retrieval stage rather than applied afterward, and an optional, more expensive reranking stage reserved for queries where the added precision is worth the added cost. Designing this path explicitly, with each stage's expected latency contribution measured and monitored independently, is what keeps a production search system fast under real load rather than accumulating hidden latency across stages nobody is tracking individually.

Implementation: Building the Ingestion and Query Pipelines

The Python example below implements a batch ingestion pipeline that chunks source documents, embeds them in batches with retry logic, and upserts them into a vector index while tracking source-document lineage so a later update or deletion can target exactly the right vectors.

# ingestion_pipeline.py
import time
import hashlib
from dataclasses import dataclass
from openai import OpenAI, RateLimitError
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct

client = OpenAI()
qdrant = QdrantClient(url="http://localhost:6333")
COLLECTION = "knowledge_base"

@dataclass
class Chunk:
    doc_id: str
    chunk_index: int
    text: str
    metadata: dict

def chunk_document(doc_id: str, text: str, metadata: dict, chunk_size: int = 800) -> list[Chunk]:
    words = text.split()
    chunks = []
    for i in range(0, len(words), chunk_size):
        chunk_text = " ".join(words[i:i + chunk_size])
        chunks.append(Chunk(
            doc_id=doc_id,
            chunk_index=i // chunk_size,
            text=chunk_text,
            metadata=metadata,
        ))
    return chunks

def embed_batch(texts: list[str], max_retries: int = 3) -> list[list[float]]:
    # Embedding APIs generally accept a batch of inputs in one call -
    # batching here meaningfully reduces round trips versus one call per chunk.
    for attempt in range(max_retries):
        try:
            response = client.embeddings.create(
                input=texts, model="text-embedding-3-small"
            )
            return [item.embedding for item in response.data]
        except RateLimitError:
            wait = 2 ** attempt
            time.sleep(wait)
    raise RuntimeError(f"Failed to embed batch after {max_retries} retries")

def point_id_for(chunk: Chunk) -> str:
    # Deterministic ID derived from doc_id + chunk_index means re-ingesting
    # an updated document naturally overwrites its old chunks via upsert,
    # rather than creating duplicates alongside stale vectors.
    raw = f"{chunk.doc_id}:{chunk.chunk_index}"
    return hashlib.sha256(raw.encode()).hexdigest()

def ingest_document(doc_id: str, text: str, metadata: dict, batch_size: int = 50):
    chunks = chunk_document(doc_id, text, metadata)

    for i in range(0, len(chunks), batch_size):
        batch = chunks[i:i + batch_size]
        vectors = embed_batch([c.text for c in batch])

        points = [
            PointStruct(
                id=point_id_for(chunk),
                vector=vector,
                payload={
                    "doc_id": chunk.doc_id,
                    "chunk_index": chunk.chunk_index,
                    "text": chunk.text,
                    **chunk.metadata,
                },
            )
            for chunk, vector in zip(batch, vectors)
        ]
        qdrant.upsert(collection_name=COLLECTION, points=points)

def delete_document(doc_id: str):
    # Deleting by a metadata filter rather than reconstructing exact point
    # IDs, since a document's chunk count may have changed since ingestion.
    qdrant.delete(
        collection_name=COLLECTION,
        points_selector={"filter": {"must": [{"key": "doc_id", "match": {"value": doc_id}}]}},
    )

The query side needs its own explicit structure: a fast retrieval stage, a caching layer for repeated queries, and clear separation between the parts of the request that are cheap and the parts that are expensive. The TypeScript example below implements a query API with an embedding cache and a simple latency-budget-aware decision about whether to invoke a reranking stage.

// searchApi.ts
import { QdrantClient } from "@qdrant/js-client-rest";
import OpenAI from "openai";
import { LRUCache } from "lru-cache";

const openai = new OpenAI();
const qdrant = new QdrantClient({ url: "http://localhost:6333" });
const COLLECTION = "knowledge_base";

// Caching query embeddings avoids re-embedding identical or frequently
// repeated queries - a meaningful latency and cost win for common queries.
const embeddingCache = new LRUCache<string, number[]>({ max: 5000 });

async function embedQuery(query: string): Promise<number[]> {
  const cached = embeddingCache.get(query);
  if (cached) return cached;

  const response = await openai.embeddings.create({
    input: query,
    model: "text-embedding-3-small",
  });
  const vector = response.data[0].embedding;
  embeddingCache.set(query, vector);
  return vector;
}

interface SearchOptions {
  tenantId: string;
  topK?: number;
  allowRerank?: boolean;
}

async function search(query: string, options: SearchOptions) {
  const { tenantId, topK = 10, allowRerank = true } = options;
  const startTime = Date.now();

  const queryVector = await embedQuery(query);

  const results = await qdrant.search(COLLECTION, {
    vector: queryVector,
    limit: allowRerank ? 50 : topK, // wider pool only if reranking follows
    filter: { must: [{ key: "tenant_id", match: { value: tenantId } }] },
  });

  const retrievalLatencyMs = Date.now() - startTime;

  // Only escalate to the more expensive reranking stage if there's enough
  // latency budget remaining and the caller actually wants it - keeps the
  // fast path fast for latency-sensitive callers.
  if (allowRerank && retrievalLatencyMs < 200) {
    return rerankResults(query, results, topK);
  }

  return results.slice(0, topK);
}

async function rerankResults(query: string, candidates: any[], topK: number) {
  // Placeholder for a call to a hosted reranking API (e.g. Cohere Rerank),
  // applied only to the already-narrowed candidate pool from retrieval.
  return candidates.slice(0, topK);
}

Trade-offs and Pitfalls

The most common architectural mistake is designing the ingestion pipeline around full reprocessing rather than incremental updates, which works fine for an initial load and becomes a real operational problem the first time a small fraction of a large corpus needs updating. A pipeline that can only "re-embed and rebuild everything" turns a routine content update into an expensive, slow operation that scales with total corpus size rather than with the size of the actual change - exactly the kind of design decision that looks harmless during prototyping and becomes a genuine bottleneck once a corpus reaches a meaningful size and update frequency.

A second pitfall is under-provisioning for the embedding API's own rate limits and latency during bulk ingestion, treating a hosted embedding provider as an infinitely scalable, instantaneous dependency rather than an external service with its own real constraints. A large ingestion job that fires unbounded concurrent requests at an embedding API without backoff will reliably hit rate limits, and a pipeline that doesn't retry gracefully risks silently dropping content - a much worse failure mode than simply running more slowly, since a silently incomplete index looks identical to a complete one until a specific piece of content someone expects to find turns out to be missing from search results.

A third pitfall is treating the query path's latency budget as a single number rather than a budget allocated deliberately across stages. A system that doesn't explicitly decide how much of its total latency budget goes to embedding the query, how much to retrieval, and how much to an optional reranking stage tends to discover its actual latency profile only under production load, at which point diagnosing which stage is responsible for a slow response requires exactly the kind of trace-level instrumentation that should have been built in from the start rather than retrofitted during an incident.

Best Practices for Building the Full Pipeline

Design for incremental updates from the very first version of the ingestion pipeline, using deterministic identifiers derived from source document and chunk position so that re-ingesting updated content naturally overwrites the correct existing vectors through an upsert operation, rather than requiring a separate reconciliation step. This single design decision - shown in the deterministic point_id_for function above - is what turns "update this document" from an operation that scales with total corpus size into one that scales only with the size of the specific update, and it costs almost nothing to build in from the start relative to the cost of retrofitting it after a corpus has grown large.

Batch and rate-limit embedding calls deliberately during ingestion, treating the embedding provider as a real external dependency with real constraints rather than an unlimited resource. Combining reasonable batch sizes with exponential backoff on rate-limit responses keeps a large ingestion job resilient to the kind of transient failures that are close to guaranteed at scale, and logging exactly which chunks succeeded and which failed - rather than only logging overall job success or failure - makes it possible to detect and re-process specific missing content rather than discovering a silent gap only when a user reports a missing search result.

Allocate and monitor latency budget explicitly across each stage of the query path - query embedding, retrieval, fusion, and any reranking - rather than only tracking end-to-end response time. This is what makes the selective-reranking pattern shown above possible in the first place: a system that knows how much latency budget retrieval actually consumed can make an informed decision about whether there's room left for an expensive reranking pass, rather than either always skipping it (leaving accuracy on the table) or always running it (risking blown latency budgets under load).

Analogies and Mental Models

Building a production search pipeline is well captured by the difference between a home cook's kitchen and a restaurant's. A home cook (a prototype script) can prepare a full meal from scratch each time, buying ingredients as needed and not worrying much about what happens if a recipe needs to feed twice as many people next week. A restaurant kitchen (a production ingestion and query pipeline) needs prepped ingredients ready in advance, a system for tracking what's in stock and what needs restocking, and a workflow that scales predictably whether it's serving ten covers or two hundred - the same fundamental cooking skills apply, but the operational discipline around them is what separates a kitchen that can actually run a business from one that can only cook a single meal well once.

The relationship between deterministic point IDs and incremental updates is well captured by a library's card catalog using a fixed, predictable numbering scheme tied to each book's actual shelf location, rather than assigning a fresh random number to every card printed. If a library reprints a card for a book that's been updated with a new edition, a fixed, predictable numbering scheme means the new card naturally replaces the old one in the same catalog slot. A numbering scheme that assigns a fresh random number every time would instead leave the old, outdated card sitting in the catalog alongside the new one, confusing anyone searching the catalog later - exactly the failure mode a deterministic ID scheme in a vector index is designed to prevent.

The 80/20 of Building Fast AI Search

A small number of architectural decisions account for most of a production search system's real-world reliability and performance. Designing the ingestion pipeline for incremental updates from the start - deterministic IDs, chunk-to-source tracking, targeted deletes - is the single highest-leverage decision, because it's what determines whether the system remains maintainable as a corpus grows and changes, versus requiring increasingly expensive full rebuilds over time. Building resilience into the embedding step of ingestion - batching, rate-limit-aware retries, and explicit success/failure tracking per chunk - is the second highest-leverage decision, since silent ingestion failures are one of the most common and hardest-to-detect causes of a production search system quietly missing content it should be able to find.

The third disproportionately valuable practice is architecting the query path as explicit, separately-measured stages with an allocated latency budget, rather than treating query latency as a single opaque number. Everything beyond these three - exotic index configurations, elaborate multi-tenant sharding strategies, advanced caching topologies - adds genuine value at real scale, but is refinement layered on top of a foundation these three decisions already establish. Teams building their first production AI search system get disproportionately more value from getting incremental ingestion, resilient embedding, and staged query latency right than from any more advanced infrastructure investment.

Key Takeaways

Conclusion

Fast, high-performance AI search is less about any single clever technique and more about treating ingestion, indexing, and querying as one connected pipeline, each stage engineered deliberately for the operational realities it will actually face in production: documents that get updated, embedding APIs with real rate limits, and query latency that needs to stay predictable under real concurrent load. The individual components - chunking, embedding, vector indexing, hybrid retrieval, reranking - are each well-understood on their own, but a system that only gets the components right in isolation, without connecting them into a coherent, incrementally-updatable, latency-budgeted pipeline, will still struggle once it leaves the prototype stage.

Building this pipeline well from the start costs a modest amount of additional engineering discipline relative to a quick prototype, and it pays that cost back many times over the first time a corpus needs to grow, a document needs to change, or a production incident needs to be diagnosed quickly. Teams that treat the full pipeline - not just the search query itself - as the actual engineering problem end up with AI search systems that stay fast and maintainable well beyond the point where a simpler, ad hoc implementation would have started to strain.

References

  1. Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs - Malkov & Yashunin, 2018: https://arxiv.org/abs/1603.09320
  2. OpenAI - Embeddings API documentation: https://platform.openai.com/docs/guides/embeddings
  3. Qdrant - official documentation: https://qdrant.tech/documentation/
  4. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods - Cormack, Clarke & Büttcher, SIGIR 2009: https://dl.acm.org/doi/10.1145/1571941.1572114
  5. Cohere - Rerank API documentation: https://docs.cohere.com/docs/rerank
  6. pgvector - PostgreSQL vector extension documentation: https://github.com/pgvector/pgvector
  7. Massive Text Embedding Benchmark (MTEB) - Muennighoff et al., 2023: https://arxiv.org/abs/2210.07316