InterviewKata
System design · Technology guide T07 · Builds on Module 29

Search engines and vector databases

A database answers "give me order 81"; a search engine answers "find products like red running shoes, best first". Keyword search engines have done this for decades with inverted indexes. Vector databases do it by meaning, which is what retrieval for AI assistants needs. Most real systems now combine both. This guide explains how each works, the products, how they differ and which to choose, with a chooser and code you can run.

A 3-minute conversation: Sam asks, Priya explains
0:00 / 2:44

1. Two ways to find things

Keyword searchVector search
IndexAn inverted index: each word lists the documents containing itEach document becomes a vector (an embedding); an approximate nearest-neighbour index (HNSW, IVF, ScaNN) finds close ones
MatchesThe words you typed (with stemming, synonyms you configure)Meaning: "low-cost airfare" finds "cheap flights"
Good atExact terms: product codes, error IDs, names; filters and facetsParaphrases, questions, other languages, images
Weak atSynonyms you didn't configureExact codes and rare words; explaining why something matched

Hybrid search runs both and merges the results (often by reciprocal rank fusion), then a reranker may reorder the top few. That's the default for retrieval-augmented generation (Module 29).

2. How a search engine answers a query

3. The products

Search engine · AGPLv3, SSPL or ELv2

Elasticsearch

Built on Lucene: documents are split into shards, each an inverted index, with replicas for availability. Full-text relevance (BM25), aggregations, and vector fields for kNN and hybrid search. Since August 2024 the free source is also offered under the AGPLv3, an OSI-approved licence, alongside SSPL and Elastic License 2.0.

Best for: product and site search, log analytics, and hybrid search in one engine.

Search engine · Apache 2.0

OpenSearch

The fork of Elasticsearch 7.10 started by AWS in 2021, now governed by the OpenSearch Software Foundation under the Linux Foundation. Same model of shards and Lucene indexes, with k-NN vector search and dashboards. Amazon OpenSearch Service runs it managed, including a serverless option.

Best for: Elasticsearch-style search under a permissive licence, or on AWS.

Hosted and lightweight search

Algolia, Typesense, Meilisearch

Algolia is a hosted search API tuned for instant, typo-tolerant search-as-you-type. Typesense and Meilisearch are simpler open-source engines with the same focus, easy to run for small and medium datasets.

Best for: in-app and e-commerce search without running a cluster.

Vectors in PostgreSQL · open source

pgvector

A PostgreSQL extension adding a vector type and HNSW and IVFFlat indexes, so embeddings live next to the rows they describe, with SQL filters and transactions. Available on Cloud SQL, AlloyDB (which adds a ScaNN index), RDS, Aurora and Azure.

Best for: up to tens of millions of vectors when you already run PostgreSQL.

Vector database · managed

Pinecone

A managed, serverless vector database: namespaces, metadata filters, and hybrid (sparse plus dense) search, with storage separated from compute.

Best for: large vector workloads without running anything.

Vector databases · open source

Milvus, Qdrant, Weaviate

Dedicated vector databases you can run yourself or use as managed clouds (Zilliz for Milvus). They offer several index types, filtering on payloads, quantisation to save memory, and horizontal scaling to billions of vectors.

Best for: very large or self-hosted vector search.

Vectors in object storage · AWS

Amazon S3 Vectors

Vector buckets and indexes inside S3, generally available since December 2025, with up to 2 billion vectors per index. Much cheaper storage than a memory-based index, with query latency suited to RAG and agent memory rather than the fastest interactive search.

Best for: large, cost-sensitive vector collections on AWS.

Managed · Google Cloud and Azure

Vertex AI Vector Search, Azure AI Search

Vertex AI Vector Search uses Google's ScaNN algorithm for low-latency nearest-neighbour search at large scale. Azure AI Search combines keyword, vector and semantic ranking in one managed service.

Best for: managed retrieval on those clouds.

4. Differences at a glance

OptionKeywordVectorScaleLicence or model
ElasticsearchExcellentYes (HNSW)Very large clustersAGPLv3, SSPL or ELv2; Elastic Cloud
OpenSearchExcellentYes (k-NN)Very large clustersApache 2.0; managed on AWS
Algolia / Typesense / MeilisearchExcellent, instantSupportedSmall to largeHosted API / open source
pgvectorPostgreSQL full textHNSW, IVFFlatTens of millionsOpen source extension
PineconeSparse vectorsCoreBillionsManaged only
Milvus / Qdrant / WeaviateHybrid supportedCoreBillionsOpen source and managed
S3 VectorsNoCore2 billion per indexManaged, priced like storage
Vertex AI Vector SearchHybrid supportedScaNNBillionsManaged

5. When to choose which, and why

  1. Search inside an app or shop, a modest catalogue? A hosted or lightweight engine (Algolia, Typesense, Meilisearch), or PostgreSQL full text if it's small.
  2. Large catalogues, logs, facets and aggregations? Elasticsearch or OpenSearch.
  3. Retrieval for an AI assistant, and you already run PostgreSQL? Start with pgvector; the vectors sit next to the data and permissions you already have.
  4. Hundreds of millions of vectors or more? A dedicated vector database, a managed one, or S3 Vectors when cost matters more than milliseconds.
  5. Users type exact codes and natural questions? Use hybrid search: keyword and vector, fused, then reranked.

Try it: choose for your requirements

6. Keyword, vector and hybrid search, in code

Five short documents, three queries. Keyword search finds "cheap flight" deals but misses "low-cost airfare" (no shared words); vector search finds it by meaning. For "error E1234", keyword search puts the exact error first, while vector search, which has no idea what E1234 means, prefers the general payment-error page. Hybrid search with reciprocal rank fusion gets both right. The embeddings are three hand-made numbers per word, so the effect is easy to see. The program was run in all three languages; each prints the output below it.

import math

DOCS = {
    "d1": "cheap flight deals to paris",
    "d2": "low-cost airfare for summer trips",
    "d3": "error E1234 when booking a flight",
    "d4": "hotel prices in paris",
    "d5": "fix payment error on checkout",
}

# Toy 3-number embeddings: [travel, price, error]. Real models output hundreds of numbers learned from text.
EMBED = {
    "cheap": [0, 1, 0], "low-cost": [0, 1, 0], "deals": [0, .8, 0], "prices": [0, .9, 0],
    "flight": [1, 0, 0], "airfare": [.9, .3, 0], "trips": [.8, 0, 0], "hotel": [.4, .2, 0], "paris": [.3, 0, 0],
    "booking": [.5, 0, 0], "error": [0, 0, 1], "fix": [0, 0, .6], "payment": [0, .4, .3], "checkout": [0, .3, .2],
}


def words(text):
    return text.lower().split()


def keyword_rank(query):
    """An inverted index scores documents that contain the query's words, rarer words counting more (idf)."""
    scores = {}
    for doc, text in DOCS.items():
        w = words(text)
        for term in words(query):
            if term in w:
                df = sum(1 for t in DOCS.values() if term in words(t))
                scores[doc] = scores.get(doc, 0) + math.log(1 + len(DOCS) / df)
    return sorted(scores, key=lambda d: (-scores[d], d))


def vector(text):
    known = [EMBED[w] for w in words(text) if w in EMBED]
    return [sum(v[i] for v in known) / len(known) for i in range(3)] if known else [0, 0, 0]


def cosine(a, b):
    na, nb = math.sqrt(sum(x * x for x in a)), math.sqrt(sum(x * x for x in b))
    return sum(x * y for x, y in zip(a, b)) / (na * nb) if na and nb else 0


def vector_rank(query, k=3):
    """Vector search ranks every document by how close its meaning is, whether or not words match."""
    q = vector(query)
    scores = {doc: cosine(q, vector(text)) for doc, text in DOCS.items()}
    return sorted(scores, key=lambda d: (-scores[d], d))[:k]


def hybrid(query, k=3):
    """Reciprocal rank fusion: add 1 / (60 + rank) from each list, so documents near the top of either list rise."""
    fused = {}
    for ranking in (keyword_rank(query), vector_rank(query, len(DOCS))):
        for rank, doc in enumerate(ranking, start=1):
            fused[doc] = fused.get(doc, 0) + 1 / (60 + rank)
    return sorted(fused, key=lambda d: (-fused[d], d))[:k]


for query in ["cheap flight", "low-cost airfare", "error E1234"]:
    print(f'"{query}"')
    print("  keyword:", ", ".join(keyword_rank(query)) or "(no matches)")
    print("  vector: ", ", ".join(vector_rank(query)))
    print("  hybrid: ", ", ".join(hybrid(query)))
const DOCS: Record<string, string> = {
  d1: "cheap flight deals to paris",
  d2: "low-cost airfare for summer trips",
  d3: "error E1234 when booking a flight",
  d4: "hotel prices in paris",
  d5: "fix payment error on checkout",
};

// Toy 3-number embeddings: [travel, price, error]. Real models output hundreds of numbers learned from text.
const EMBED: Record<string, number[]> = {
  cheap: [0, 1, 0], "low-cost": [0, 1, 0], deals: [0, 0.8, 0], prices: [0, 0.9, 0],
  flight: [1, 0, 0], airfare: [0.9, 0.3, 0], trips: [0.8, 0, 0], hotel: [0.4, 0.2, 0], paris: [0.3, 0, 0],
  booking: [0.5, 0, 0], error: [0, 0, 1], fix: [0, 0, 0.6], payment: [0, 0.4, 0.3], checkout: [0, 0.3, 0.2],
};

const words = (text: string) => text.toLowerCase().split(" ");
const byScore = (scores: Map<string, number>) =>
  [...scores.keys()].sort((a, b) => scores.get(b)! - scores.get(a)! || (a < b ? -1 : 1));

// An inverted index scores documents that contain the query's words, rarer words counting more (idf).
function keywordRank(query: string): string[] {
  const scores = new Map<string, number>();
  for (const [doc, text] of Object.entries(DOCS)) {
    const w = words(text);
    for (const term of words(query)) {
      if (!w.includes(term)) continue;
      const df = Object.values(DOCS).filter((t) => words(t).includes(term)).length;
      scores.set(doc, (scores.get(doc) ?? 0) + Math.log(1 + Object.keys(DOCS).length / df));
    }
  }
  return byScore(scores);
}

function vector(text: string): number[] {
  const known = words(text).filter((w) => w in EMBED).map((w) => EMBED[w]);
  return known.length ? [0, 1, 2].map((i) => known.reduce((s, v) => s + v[i], 0) / known.length) : [0, 0, 0];
}

function cosine(a: number[], b: number[]): number {
  const na = Math.sqrt(a.reduce((s, x) => s + x * x, 0)), nb = Math.sqrt(b.reduce((s, x) => s + x * x, 0));
  return na && nb ? a.reduce((s, x, i) => s + x * b[i], 0) / (na * nb) : 0;
}

// Vector search ranks every document by how close its meaning is, whether or not words match.
function vectorRank(query: string, k = 3): string[] {
  const q = vector(query);
  return byScore(new Map(Object.entries(DOCS).map(([doc, text]) => [doc, cosine(q, vector(text))]))).slice(0, k);
}

// Reciprocal rank fusion: add 1 / (60 + rank) from each list, so documents near the top of either list rise.
function hybrid(query: string, k = 3): string[] {
  const fused = new Map<string, number>();
  for (const ranking of [keywordRank(query), vectorRank(query, Object.keys(DOCS).length)]) {
    ranking.forEach((doc, i) => fused.set(doc, (fused.get(doc) ?? 0) + 1 / (60 + i + 1)));
  }
  return byScore(fused).slice(0, k);
}

for (const query of ["cheap flight", "low-cost airfare", "error E1234"]) {
  console.log(`"${query}"`);
  console.log("  keyword:", keywordRank(query).join(", ") || "(no matches)");
  console.log("  vector: ", vectorRank(query).join(", "));
  console.log("  hybrid: ", hybrid(query).join(", "));
}
import java.util.*;
import java.util.stream.*;

public class Main {
    static final Map<String, String> DOCS = new LinkedHashMap<>();
    static final Map<String, double[]> EMBED = new HashMap<>();

    static {
        DOCS.put("d1", "cheap flight deals to paris");
        DOCS.put("d2", "low-cost airfare for summer trips");
        DOCS.put("d3", "error E1234 when booking a flight");
        DOCS.put("d4", "hotel prices in paris");
        DOCS.put("d5", "fix payment error on checkout");
        // Toy 3-number embeddings: [travel, price, error]. Real models output hundreds of numbers learned from text.
        Object[][] e = {{"cheap", 0, 1, 0}, {"low-cost", 0, 1, 0}, {"deals", 0, .8, 0}, {"prices", 0, .9, 0},
            {"flight", 1, 0, 0}, {"airfare", .9, .3, 0}, {"trips", .8, 0, 0}, {"hotel", .4, .2, 0}, {"paris", .3, 0, 0},
            {"booking", .5, 0, 0}, {"error", 0, 0, 1}, {"fix", 0, 0, .6}, {"payment", 0, .4, .3}, {"checkout", 0, .3, .2}};
        for (Object[] r : e) EMBED.put((String) r[0], new double[] {((Number) r[1]).doubleValue(), ((Number) r[2]).doubleValue(), ((Number) r[3]).doubleValue()});
    }

    static List<String> words(String text) { return Arrays.asList(text.toLowerCase().split(" ")); }

    static List<String> byScore(Map<String, Double> scores) {
        return scores.keySet().stream().sorted(Comparator.comparing((String d) -> -scores.get(d)).thenComparing(d -> d)).collect(Collectors.toList());
    }

    // An inverted index scores documents that contain the query's words, rarer words counting more (idf).
    static List<String> keywordRank(String query) {
        Map<String, Double> scores = new HashMap<>();
        DOCS.forEach((doc, text) -> {
            for (String term : words(query)) {
                if (!words(text).contains(term)) continue;
                long df = DOCS.values().stream().filter(t -> words(t).contains(term)).count();
                scores.merge(doc, Math.log(1 + (double) DOCS.size() / df), Double::sum);
            }
        });
        return byScore(scores);
    }

    static double[] vector(String text) {
        List<double[]> known = words(text).stream().filter(EMBED::containsKey).map(EMBED::get).collect(Collectors.toList());
        double[] v = new double[3];
        if (known.isEmpty()) return v;
        for (int i = 0; i < 3; i++) { double s = 0; for (double[] k : known) s += k[i]; v[i] = s / known.size(); }
        return v;
    }

    static double cosine(double[] a, double[] b) {
        double na = 0, nb = 0, dot = 0;
        for (double x : a) na += x * x;
        for (double x : b) nb += x * x;
        for (int i = 0; i < 3; i++) dot += a[i] * b[i];
        na = Math.sqrt(na); nb = Math.sqrt(nb);
        return na != 0 && nb != 0 ? dot / (na * nb) : 0;
    }

    // Vector search ranks every document by how close its meaning is, whether or not words match.
    static List<String> vectorRank(String query, int k) {
        double[] q = vector(query);
        Map<String, Double> scores = new HashMap<>();
        DOCS.forEach((doc, text) -> scores.put(doc, cosine(q, vector(text))));
        return byScore(scores).subList(0, k);
    }

    // Reciprocal rank fusion: add 1 / (60 + rank) from each list, so documents near the top of either list rise.
    static List<String> hybrid(String query, int k) {
        Map<String, Double> fused = new HashMap<>();
        for (List<String> ranking : List.of(keywordRank(query), vectorRank(query, DOCS.size()))) {
            for (int i = 0; i < ranking.size(); i++) fused.merge(ranking.get(i), 1.0 / (60 + i + 1), Double::sum);
        }
        return byScore(fused).subList(0, k);
    }

    public static void main(String[] args) {
        for (String query : List.of("cheap flight", "low-cost airfare", "error E1234")) {
            System.out.println("\"" + query + "\"");
            List<String> kw = keywordRank(query);
            System.out.println("  keyword: " + (kw.isEmpty() ? "(no matches)" : String.join(", ", kw)));
            System.out.println("  vector:  " + String.join(", ", vectorRank(query, 3)));
            System.out.println("  hybrid:  " + String.join(", ", hybrid(query, 3)));
        }
    }
}
"cheap flight"
  keyword: d1, d3
  vector:  d2, d1, d4
  hybrid:  d1, d3, d2
"low-cost airfare"
  keyword: d2
  vector:  d1, d4, d2
  hybrid:  d2, d1, d4
"error E1234"
  keyword: d3, d5
  vector:  d5, d3, d1
  hybrid:  d3, d5, d1

7. Say it in the interview

Two-sentence answers
  • "How does search work here?" Writes go to the database, and a change stream (CDC) feeds a search index such as Elasticsearch or OpenSearch, so search is eventually consistent by a second or two. Queries hit the index; the database stays the source of truth.
  • "Where do the embeddings live?" In pgvector next to the documents while we're under tens of millions, which keeps filters and permissions in SQL; a dedicated vector database or S3 Vectors beyond that.
  • "Why hybrid?" Vectors catch paraphrases, keywords catch exact codes and names; fusing the two lists and reranking the top results beats either alone.

8. Common mistakes

The search index as the source of truthIndexes are rebuilt, re-sharded and lag behind. Keep the data in a database and reindex from it.
Vectors onlyPure vector search fumbles product codes, names and rare terms. Add keyword search.
A dedicated vector database for 50,000 documentspgvector or the search engine you already run will do; one fewer system to secure and sync.
Forgetting permissionsFilter results by what the user may see before they reach the model or the screen.
Mixing embedding modelsVectors from different models (or versions) aren't comparable; re-embed everything when you change model.

9. Check yourself

A user searches for error code E1234. Which method most reliably ranks the exact match first?

Embeddings capture meaning, not rare exact tokens; an inverted index matches the code itself.

Your app has 2 million documents in PostgreSQL and needs RAG retrieval. A sensible start?

At this size, vectors next to the rows keep filters and permissions in SQL.

How should a search index be kept up to date with the database?

The database stays the source of truth; the index follows within seconds.

What does reciprocal rank fusion do?

Documents near the top of either list rise in the merged list.

10. Sources and further reading

Product facts checked on 3 November 2026. Licences and limits change; check before relying on them.

SourceUsed for
Elastic announces open source licence for Elasticsearch and KibanaAGPLv3 option alongside SSPL and ELv2
Amazon S3 Vectors now generally availableGeneral availability and scale
S3 Vectors limitations2 billion vectors per index
pgvectorIndex types and usage
Module 29: AI systemsRetrieval, embeddings and RAG