A detailed backend reference
From a simple ILIKE query that suffocates under millions of rows, to relevance-ranked millisecond search, this chapter explains why tools like Elasticsearch exist, how the inverted index works, what BM25 does, and when to reach for Postgres FTS versus Elasticsearch in a real backend.
01
The Origin Story: Why Search Got Hard
Imagine it’s 2005. You’re a backend engineer at a fast-growing e-commerce startup. The product catalog has ~5,000 items. You write a search endpoint using a classic SQL pattern:
-- Works great at 5 000 rows
SELECT * FROM products
WHERE name ILIKE '%laptop%'
OR description ILIKE '%laptop%';
This returns results in roughly 50 ms. Users are happy, managers are happy, life is simple.
Fast-forward two years. The catalog explodes to millions of products. The same query now takes 30 seconds. The company is losing sales. On top of the speed issue, new requirements arrive:
- Relevance: searching “laptop” should show a MacBook Pro before a laptop bag.
- Typo tolerance: users type lapto during a sale rush; results must still appear.
- Speed: search must return in milliseconds, not seconds.
02
The Librarian Analogy
Think of your relational database (Postgres, MySQL, etc.) as a librarian in a giant library. The librarian knows exactly where every book lives on the shelf.
But it has one fatal flaw: to find books about a topic, it must physically walk to every single shelf, pull out every book, and read it page by page looking for your keyword.
The two problems summarised:
- Speed: A table with 50 million rows means 50 million row comparisons. On spinning disk this was catastrophic even in the SSD era it wastes enormous I/O.
- No relevance: A book whose title is “Introduction to Machine Learning” ranks identically to a book that merely mentions “machine” once in the appendix. The database has zero concept of importance.
03
Why ILIKE '%term%' Cannot Use Indexes
Postgres B-tree indexes work by sorting values. A B-tree can locate name LIKE 'lapt%' (prefix match) efficiently because sorted strings share prefixes. But ILIKE '%laptop%' has a leading wildcard, there is no useful prefix to sort on. The engine must fall back to a sequential scan.
What the query plan looks like
EXPLAIN ANALYZE
SELECT id, name
FROM products
WHERE name ILIKE '%laptop%';
-- Output (simplified)
-- Seq Scan on products (cost=0.00..18450.00 rows=5 width=36)
-- Filter: ((name)::text ~~* '%laptop%'::text)
-- Rows Removed by Filter: 4 999 995
-- Planning Time: 0.2 ms
-- Execution Time: 28 940.7 ms <- 29 seconds!
Notice Seq Scan: every row examined, 4,999,995 rows thrown away, execution time ~29 s.
04
The Inverted Index: The Core Invention
“Instead of going through every document to find the term, maintain a map from every term to the documents that contain it.”
This insight, inverting the search: is what the name captures. A normal index maps document -> words. An inverted index maps word -> documents.
How It Is Built
When a document is first stored (or updated), the search engine runs it through an analysis pipeline:
- Tokenisation: break text into individual tokens (words). “Introduction to Machine Learning” ->
["introduction", "to", "machine", "learning"] - Normalisation / Lowercasing: convert to lowercase so “Machine” and “machine” are the same token.
- Stop-word removal: drop common words with no discriminative value (“to”, “the”, “a”). (Optional, configurable.)
- Stemming / Lemmatisation: reduce words to their root: “running” -> “run”, “searches” -> “search”. So a query for “searching” also matches “searched”.
- Index entry creation: for each resulting term, record the document ID and position.
05
Elasticsearch: What It Is & How It Works
Elasticsearch is a distributed, JSON-document search engine built on top of Apache Lucene: the battle-tested Java library that implements the inverted index and BM25 scoring. Lucene has been around since 1999; Elasticsearch (2010) wraps it in a REST API and adds distributed clustering, replication, and a rich query DSL.
Key characteristics:
- Schema-less JSON documents: like MongoDB, you store JSON. Fields are auto-detected (or explicitly mapped).
- Near real-time: newly indexed documents are searchable within ~1 second (configurable).
- Horizontal scaling: data is split into shards (sub-indexes), each shard is a complete Lucene index. More nodes = more throughput.
- Powerful query DSL: JSON-based. Supports full-text, fuzzy, range, geo-distance, aggregations, etc.
Creating an Index with Field Mapping
HTTP / Elasticsearch DSLPUT /products
{
"mappings": {
"properties": {
"name": {
"type": "text", // analysed, tokenised, stemmed
"boost": 3 // title matches are 3x more relevant
},
"description": {
"type": "text",
"boost": 1.5
},
"category": {
"type": "keyword" // not analysed, exact match only
},
"price": { "type": "float" }
}
}
}
The difference between text and keyword is critical:
| Mapping Type | Analysed? | Use For |
|---|---|---|
text | Yes, tokenised, stemmed | Product names, descriptions, reviews |
keyword | No, stored as-is | Status codes, tags, category IDs |
A Full-Text Search Query
HTTP / Elasticsearch DSLGET /products/_search
{
"query": {
"multi_match": {
"query": "laptop",
"fields": ["name^3", "description^1.5", "category"],
"fuzziness": "AUTO" // typo tolerance
}
},
"size": 10
}
^3 is field boosting: matches in name contribute 3x as much to the relevance score as matches in the default field weight.
06
BM25: How Relevance Scoring Works
BM25 (Best Match 25) is the default ranking function in Elasticsearch (and also in Postgres FTS). It produces a floating-point score per document; higher = more relevant. The full formula has several components, but conceptually four factors drive the score:
| Factor | What It Measures | Effect on Score |
|---|---|---|
| Term Frequency | Count of query term in document | Higher = more relevant (with diminishing returns) |
| Inverse Doc Frequency | Rarity of term across index | Rare terms carry more signal than common ones |
| Document Length | Length vs. avg document length | Short docs with the term rank higher than long docs |
| Field Boost | Which field the term appears in | Title match > body match (configurable multiplier) |
Why IDF Matters
Consider the word “the”, it appears in virtually every English document, so it has very high document frequency and a very low IDF weight. It contributes almost nothing to scores. A rare word like “photovoltaic” or “elasticsearch” appearing in a document is a strong signal of topical relevance.
07
Typo Tolerance: Fuzzy Search
Elasticsearch uses Levenshtein edit distance under the hood for fuzzy matching. Edit distance is the minimum number of single-character operations (insert, delete, substitute) needed to transform one word into another.
lapto->laptop: 1 insertion -> edit distance 1treading->trending: 1 substitution -> edit distance 1mcbook->macbook: 1 insertion -> edit distance 1
Elasticsearch DSL / fuzzy query{
"query": {
"fuzzy": {
"name": {
"value": "lapto",
"fuzziness": "AUTO", // 0 for 1-2 chars, 1 for 3-5, 2 for 6+
"prefix_length": 1 // first N chars must match exactly
}
}
}
}
"fuzziness": "AUTO" is the recommended setting. It automatically adjusts the allowed edit distance based on word length. prefix_length: 1 prevents matching completely unrelated words that happen to be 1-2 edits away.
08
The ELK Stack: Logs & Observability
Elasticsearch isn’t only for product search. It’s the “E” in the famous ELK Stack used for centralised log management and observability.
If your company already runs ELK for logging, adding product/content search on top of the same Elasticsearch cluster is a natural, cost-effective choice, you’re already paying for the infrastructure and your ops team already knows it.
09
Postgres Full-Text Search
Postgres has had native full-text search since version 8.3 via the tsvector / tsquery types. It uses its own inverted index (the GIN index) and a BM25-variant scorer called ts_rank. It’s not as feature-rich as Elasticsearch but perfectly adequate for moderate search needs on data already in Postgres.
DDL: Add a GIN Index
-- Add a generated tsvector column and index it
ALTER TABLE products
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B')
) STORED;
CREATE INDEX idx_products_fts ON products USING GIN(search_vec);
setweight('A') means title matches outrank description matches, the Postgres equivalent of field boosting.
The Full-Text Search Handler
@@ against the GIN index, ranked by ts_rank, parameterised throughout
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
)
type Product struct {
ID int
Name string
Description string
Rank float64
}
func searchHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "missing query param q", http.StatusBadRequest)
return
}
// plainto_tsquery converts plain text safely (no special chars needed)
// websearch_to_tsquery also supports AND/OR/-term syntax
sql := `
SELECT id, name, description,
ts_rank(search_vec, plainto_tsquery('english', $1)) AS rank
FROM products
WHERE search_vec @@ plainto_tsquery('english', $1)
ORDER BY rank DESC
LIMIT 20
`
rows, err := pool.Query(context.Background(), sql, query)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var results []Product
for rows.Next() {
var p Product
rows.Scan(&p.ID, &p.Name, &p.Description, &p.Rank)
results = append(results, p)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, "found %d results\n", len(results))
}
}
func main() {
pool, _ := pgxpool.New(context.Background(), "postgres://...")
http.HandleFunc("/search", searchHandler(pool))
log.Fatal(http.ListenAndServe(":8080", nil))
}from fastapi import FastAPI, HTTPException
app = FastAPI()
SQL = """
SELECT id, name, description,
ts_rank(search_vec, plainto_tsquery('english', %s)) AS rank
FROM products
WHERE search_vec @@ plainto_tsquery('english', %s)
ORDER BY rank DESC
LIMIT 20
"""
@app.get("/search")
async def search(q: str = ""):
if not q:
raise HTTPException(400, "missing query param q")
# plainto_tsquery converts plain text safely (no special chars needed);
# websearch_to_tsquery also supports AND/OR/-term syntax.
# The query text is a BOUND PARAMETER, never interpolated (chapter 08).
async with pool.connection() as conn:
cur = await conn.execute(SQL, (q, q))
rows = await cur.fetchall()
return [
{"id": r[0], "name": r[1], "description": r[2], "rank": r[3]}
for r in rows
]const SQL = `
SELECT id, name, description,
ts_rank(search_vec, plainto_tsquery('english', $1)) AS rank
FROM products
WHERE search_vec @@ plainto_tsquery('english', $1)
ORDER BY rank DESC
LIMIT 20
`
app.get('/search', async (req, res) => {
const q = req.query.q
if (!q) {
return res.status(400).json({ error: 'missing query param q' })
}
try {
// plainto_tsquery converts plain text safely (no special chars needed);
// websearch_to_tsquery also supports AND/OR/-term syntax.
const { rows } = await pool.query(SQL, [q])
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})import type { Pool } from 'pg'
import type { Request, Response } from 'express'
interface SearchResult {
id: number
name: string
description: string
rank: number
}
const SQL = `
SELECT id, name, description,
ts_rank(search_vec, plainto_tsquery('english', $1)) AS rank
FROM products
WHERE search_vec @@ plainto_tsquery('english', $1)
ORDER BY rank DESC
LIMIT 20
`
export async function search(req: Request, res: Response): Promise<void> {
// `req.query.q` is not a string: Express parses `?q=a&q=b` into an
// ARRAY, so a naive cast would hand an array straight to the driver.
// Narrowing it here is the difference between a 400 and a 500.
const q = typeof req.query.q === 'string' ? req.query.q : ''
if (q === '') {
res.status(400).json({ error: 'missing query param q' })
return
}
const { rows } = await pool.query<SearchResult>(SQL, [q])
res.json(rows)
}public record SearchResult(int id, String name, String description, double rank) {}
@RestController
class SearchController {
private static final String SQL = """
SELECT id, name, description,
ts_rank(search_vec, plainto_tsquery('english', ?)) AS rank
FROM products
WHERE search_vec @@ plainto_tsquery('english', ?)
ORDER BY rank DESC
LIMIT 20
""";
private final JdbcClient db;
SearchController(JdbcClient db) {
this.db = db;
}
@GetMapping("/search")
ResponseEntity<?> search(@RequestParam(defaultValue = "") String q) {
if (q.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "missing query param q"));
}
// plainto_tsquery converts plain text safely (no special chars needed);
// websearch_to_tsquery also supports AND/OR/-term syntax.
// Both placeholders are bound, never concatenated (chapter 08).
List<SearchResult> results = db.sql(SQL)
.params(q, q)
.query(SearchResult.class)
.list();
return ResponseEntity.ok(results);
}
}10
Elasticsearch (Official SDKs)
Elastic ships a first-party client for every language here: elasticsearch-py, go-elasticsearch, @elastic/elasticsearch for Node and TypeScript, and the Elasticsearch Java API Client. They are all thin wrappers over the same JSON REST API, which is worth remembering when you read the tabs: the query DSL is identical everywhere, only the way each language spells a nested object changes. Install:
pip install elasticsearch # Python
npm install @elastic/elasticsearch # Node / TypeScript
go get github.com/elastic/go-elasticsearch/v8 # Go
# Java: implementation 'co.elastic.clients:elasticsearch-java:8.13.0'
Index Setup & Bulk Insert
an explicit mapping (text vs keyword), then a bulk load
// The mapping is the one decision that cannot be undone later: changing
// a field's type means reindexing everything. "text" is analysed and
// searchable by word; "keyword" is stored whole, for exact filters.
mapping := `{
"mappings": {
"properties": {
"review": {"type": "text"},
"sentiment": {"type": "keyword"}
}
}
}`
es, _ := elasticsearch.NewClient(elasticsearch.Config{
CloudID: "YOUR_CLOUD_ID",
APIKey: "YOUR_API_KEY",
})
const index = "reviews"
es.Indices.Delete([]string{index}) // ignore error if absent
es.Indices.Create(index, es.Indices.Create.WithBody(strings.NewReader(mapping)))
// Bulk insert via the BulkIndexer, which batches and retries for you.
bi, _ := esutil.NewBulkIndexer(esutil.BulkIndexerConfig{
Index: index,
Client: es,
NumWorkers: 4,
FlushBytes: 5e6, // flush every 5 MB
FlushInterval: 30 * time.Second,
})
f, _ := os.Open("reviews.csv")
defer f.Close()
r := csv.NewReader(f)
_, _ = r.Read() // skip the header row
for {
row, err := r.Read()
if err == io.EOF {
break
}
if row[0] == "" || row[1] == "" {
continue
}
doc, _ := json.Marshal(map[string]string{"review": row[0], "sentiment": row[1]})
bi.Add(context.Background(), esutil.BulkIndexerItem{
Action: "index",
Body: bytes.NewReader(doc),
})
}
bi.Close(context.Background()) // flushes whatever is still buffered
stats := bi.Stats()
fmt.Printf("Inserted %d documents, %d errors\n", stats.NumFlushed, stats.NumFailed)from elasticsearch import Elasticsearch, helpers
import csv
es = Elasticsearch(
cloud_id="YOUR_CLOUD_ID",
api_key="YOUR_API_KEY"
)
INDEX = "reviews"
# 1. Create index with explicit mapping
if es.indices.exists(index=INDEX):
es.indices.delete(index=INDEX)
es.indices.create(index=INDEX, body={
"mappings": {
"properties": {
"review": {"type": "text"}, # analysed full-text
"sentiment": {"type": "keyword"} # exact: "positive" / "negative"
}
}
})
# 2. Bulk insert from CSV
def generate_docs(path: str):
with open(path) as f:
reader = csv.DictReader(f)
for row in reader:
if row.get("review") and row.get("sentiment"):
yield {
"_index": INDEX,
"_source": {
"review": row["review"],
"sentiment": row["sentiment"]
}
}
success, errors = helpers.bulk(es, generate_docs("reviews.csv"))
print(f"Inserted {success} documents, {len(errors)} errors")import { Client } from '@elastic/elasticsearch'
import { createReadStream } from 'node:fs'
import { parse } from 'csv-parse'
const es = new Client({
cloud: { id: 'YOUR_CLOUD_ID' },
auth: { apiKey: 'YOUR_API_KEY' },
})
const INDEX = 'reviews'
// 1. Create index with explicit mapping
if (await es.indices.exists({ index: INDEX })) {
await es.indices.delete({ index: INDEX })
}
await es.indices.create({
index: INDEX,
mappings: {
properties: {
review: { type: 'text' }, // analysed full-text
sentiment: { type: 'keyword' }, // exact: "positive" / "negative"
},
},
})
// 2. Bulk insert from CSV. `helpers.bulk` takes an async iterable, so the
// file is streamed rather than read into memory, which matters the first
// time somebody points this at a 2 GB export.
async function* generateDocs(path) {
const parser = createReadStream(path).pipe(parse({ columns: true }))
for await (const row of parser) {
if (row.review && row.sentiment) {
yield { review: row.review, sentiment: row.sentiment }
}
}
}
const result = await es.helpers.bulk({
datasource: generateDocs('reviews.csv'),
onDocument: () => ({ index: { _index: INDEX } }),
})
console.log(`Inserted ${result.successful} documents, ${result.failed} errors`)import { Client } from '@elastic/elasticsearch'
// The document type is worth declaring even though Elasticsearch is
// schemaless-ish: it is the only thing keeping the indexer and the
// search code agreeing on field names, and a field name typo here is
// not an error at all in Elasticsearch, it just indexes a new field
// that nothing will ever search.
interface Review {
review: string
sentiment: 'positive' | 'negative'
}
const es = new Client({
cloud: { id: process.env.ELASTIC_CLOUD_ID! },
auth: { apiKey: process.env.ELASTIC_API_KEY! },
})
const INDEX = 'reviews'
// 1. Create index with explicit mapping
if (await es.indices.exists({ index: INDEX })) {
await es.indices.delete({ index: INDEX })
}
await es.indices.create({
index: INDEX,
mappings: {
properties: {
review: { type: 'text' }, // analysed full-text
sentiment: { type: 'keyword' }, // exact: "positive" / "negative"
},
},
})
// 2. Bulk insert, streamed
const result = await es.helpers.bulk<Review>({
datasource: generateDocs('reviews.csv'),
onDocument: () => ({ index: { _index: INDEX } }),
onDrop: (doc) => console.error('dropped', doc.document, doc.error),
})
console.log(`Inserted ${result.successful} documents, ${result.failed} errors`)public record Review(String review, String sentiment) {}
ElasticsearchClient es = new ElasticsearchClient(
new RestClientTransport(
RestClient.builder(HttpHost.create("https://...")).build(),
new JacksonJsonpMapper()));
final String INDEX = "reviews";
// 1. Create index with explicit mapping.
// The Java client's builder lambdas mirror the JSON DSL one level per
// nested object, which is verbose but means a misspelled DSL key is a
// compile error rather than an index that silently ignores it.
if (es.indices().exists(e -> e.index(INDEX)).value()) {
es.indices().delete(d -> d.index(INDEX));
}
es.indices().create(c -> c
.index(INDEX)
.mappings(m -> m
.properties("review", p -> p.text(t -> t)) // analysed full-text
.properties("sentiment", p -> p.keyword(k -> k)) // exact match
));
// 2. Bulk insert from CSV, in batches rather than one request per row.
BulkRequest.Builder br = new BulkRequest.Builder();
try (CSVParser rows = CSVFormat.DEFAULT.withFirstRecordAsHeader()
.parse(new FileReader("reviews.csv"))) {
for (CSVRecord row : rows) {
String review = row.get("review");
String sentiment = row.get("sentiment");
if (review.isBlank() || sentiment.isBlank()) continue;
br.operations(op -> op.index(idx -> idx
.index(INDEX)
.document(new Review(review, sentiment))));
}
}
BulkResponse result = es.bulk(br.build());
long failed = result.items().stream().filter(i -> i.error() != null).count();
System.out.printf("Inserted %d documents, %d errors%n",
result.items().size() - failed, failed);Search with Fuzzy + Field Boost
must scores, filter does not: the difference is relevance vs a yes/no gate
// The DSL below is the SAME JSON as in every other tab. What differs is
// only how the language builds a nested object, so read one tab for the
// query and the others for the ergonomics.
func searchReviews(es *elasticsearch.Client, query, sentiment string) ([]Hit, error) {
must := []map[string]any{{
"multi_match": map[string]any{
"query": strings.ToLower(query),
"fields": []string{"review"},
"fuzziness": "AUTO", // edit distance scaled to term length
"operator": "and", // all terms must appear
},
}}
filter := []map[string]any{}
if sentiment != "" {
// `filter`, not `must`: a filter is a yes/no gate that does not
// affect the score, and Elasticsearch caches it. Putting an exact
// term in `must` pollutes the ranking with a constant.
filter = append(filter, map[string]any{
"term": map[string]any{"sentiment": sentiment},
})
}
body, _ := json.Marshal(map[string]any{
"query": map[string]any{"bool": map[string]any{"must": must, "filter": filter}},
"size": 20,
})
res, err := es.Search(
es.Search.WithIndex("reviews"),
es.Search.WithBody(bytes.NewReader(body)),
)
if err != nil {
return nil, err
}
defer res.Body.Close()
var parsed struct {
Hits struct {
Hits []struct {
Score float64 `json:"_score"`
Source json.RawMessage `json:"_source"`
} `json:"hits"`
} `json:"hits"`
}
json.NewDecoder(res.Body).Decode(&parsed)
// "gret" (typo for "great") matches via fuzziness=AUTO (edit distance 1)
return toHits(parsed.Hits.Hits), nil
}def search_reviews(query: str, sentiment_filter: str = None) -> list:
must_clauses = [{
"multi_match": {
"query": query.lower(),
"fields": ["review"],
"fuzziness": "AUTO",
"operator": "and" # all terms must appear
}
}]
filter_clauses = []
if sentiment_filter:
filter_clauses.append({
"term": {"sentiment": sentiment_filter}
})
body = {
"query": {"bool": {"must": must_clauses, "filter": filter_clauses}},
"size": 20
}
resp = es.search(index=INDEX, body=body)
return [
{"score": hit["_score"], **hit["_source"]}
for hit in resp["hits"]["hits"]
]
# Usage
results = search_reviews("gret product", sentiment_filter="positive")
# "gret" (typo for "great") matched via fuzziness=AUTO (edit distance 1)async function searchReviews(query, sentimentFilter) {
const must = [{
multi_match: {
query: query.toLowerCase(),
fields: ['review'],
fuzziness: 'AUTO', // edit distance scaled to term length
operator: 'and', // all terms must appear
},
}]
// `filter`, not `must`. A filter is a yes/no gate: it does not
// contribute to the score and Elasticsearch caches the bitset, so it
// is both more correct for exact matches and faster.
const filter = sentimentFilter
? [{ term: { sentiment: sentimentFilter } }]
: []
const resp = await es.search({
index: INDEX,
query: { bool: { must, filter } },
size: 20,
})
return resp.hits.hits.map((hit) => ({ score: hit._score, ...hit._source }))
}
// Usage
const results = await searchReviews('gret product', 'positive')
// "gret" (typo for "great") matched via fuzziness=AUTO (edit distance 1)import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'
// The client's own DSL types are the reason to use TypeScript here: the
// query DSL is a deeply nested untyped blob in every other language, and
// a misspelled key like `fuzzines` is not an error to Elasticsearch, it
// is a silently ignored clause that quietly turns typo tolerance off.
async function searchReviews(
query: string,
sentimentFilter?: 'positive' | 'negative',
): Promise<Array<Review & { score: number }>> {
const must: QueryDslQueryContainer[] = [{
multi_match: {
query: query.toLowerCase(),
fields: ['review'],
fuzziness: 'AUTO', // edit distance scaled to term length
operator: 'and', // all terms must appear
},
}]
const filter: QueryDslQueryContainer[] = sentimentFilter
? [{ term: { sentiment: sentimentFilter } }] // exact gate, unscored
: []
const resp = await es.search<Review>({
index: INDEX,
query: { bool: { must, filter } },
size: 20,
})
return resp.hits.hits.map((hit) => ({
score: hit._score ?? 0, // _score is null on a pure filter query
...(hit._source as Review),
}))
}List<Hit<Review>> searchReviews(String query, String sentimentFilter) throws IOException {
Query multiMatch = MultiMatchQuery.of(m -> m
.query(query.toLowerCase())
.fields("review")
.fuzziness("AUTO") // edit distance scaled to term length
.operator(Operator.And) // all terms must appear
)._toQuery();
// `filter`, not `must`: an exact term is a yes/no gate that should
// not influence the score, and Elasticsearch caches it.
List<Query> filters = (sentimentFilter == null) ? List.of() : List.of(
TermQuery.of(t -> t.field("sentiment").value(sentimentFilter))._toQuery());
SearchResponse<Review> resp = es.search(s -> s
.index("reviews")
.size(20)
.query(q -> q.bool(b -> b.must(multiMatch).filter(filters))),
Review.class);
// "gret" (typo for "great") matches via fuzziness=AUTO (edit distance 1)
return resp.hits().hits();
}Streaming Results from Two Sources
The demo in the lecture used a Next.js API route that streams results from Postgres and Elasticsearch simultaneously so neither waits for the other. The shape is the same everywhere: start both queries before awaiting either, then send each result the moment it lands rather than holding the fast one hostage to the slow one.
fire both queries concurrently, stream each result as it arrives
// Go's version of "don't await sequentially" is a goroutine per source
// writing into one channel, and a flush after every write so the bytes
// actually leave the process instead of sitting in the buffer.
func streamSearch(w http.ResponseWriter, r *http.Request) {
term := r.URL.Query().Get("q")
w.Header().Set("Content-Type", "text/event-stream")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
type result struct {
Source string `json:"source"`
Data any `json:"data"`
Err string `json:"error,omitempty"`
}
ch := make(chan result, 2)
var wg sync.WaitGroup
wg.Add(2)
// Fire BOTH queries concurrently, don't await sequentially
go func() {
defer wg.Done()
rows, err := pgSearch(r.Context(), term)
ch <- result{Source: "postgres", Data: rows, Err: errString(err)}
}()
go func() {
defer wg.Done()
hits, err := esSearch(r.Context(), term)
ch <- result{Source: "elasticsearch", Data: hits, Err: errString(err)}
}()
go func() { wg.Wait(); close(ch) }()
enc := json.NewEncoder(w)
for res := range ch { // whichever finishes first is sent first
enc.Encode(res)
flusher.Flush()
}
}import asyncio
import json
from fastapi import Request
from fastapi.responses import StreamingResponse
@app.get("/search/stream")
async def stream_search(request: Request):
term = request.query_params.get("q", "")
async def generate():
# Fire BOTH queries concurrently, don't await sequentially.
# create_task starts the coroutine now; awaiting them in turn
# would run the second only after the first had finished.
pending = {
asyncio.create_task(pg_search(term)): "postgres",
asyncio.create_task(es_search(term)): "elasticsearch",
}
while pending:
# Wake on whichever task finishes FIRST, so the fast source
# is never held hostage by the slow one.
done, _ = await asyncio.wait(
pending, return_when=asyncio.FIRST_COMPLETED)
for task in done:
source = pending.pop(task)
try:
yield json.dumps({"source": source, "data": task.result()}) + "\n"
except Exception as e:
# One source failing degrades the page, it does not
# blank it: the other source's results still ship.
yield json.dumps({"source": source, "error": str(e)}) + "\n"
return StreamingResponse(generate(), media_type="text/event-stream")import { Client } from '@elastic/elasticsearch'
import { neon } from '@neondatabase/serverless'
export async function GET(req) {
const { searchParams } = new URL(req.url)
const term = searchParams.get('q')
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// Fire BOTH queries concurrently, don't await sequentially
const [pgResult, esResult] = await Promise.allSettled([
pgSearch(term),
esSearch(term),
])
controller.enqueue(encoder.encode(JSON.stringify({
source: 'postgres',
...pgResult
})))
controller.enqueue(encoder.encode(JSON.stringify({
source: 'elasticsearch',
...esResult
})))
controller.close()
}
})
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } })
}interface StreamChunk<T> {
source: 'postgres' | 'elasticsearch'
status: 'fulfilled' | 'rejected'
data?: T
error?: string
}
// allSettled, never all: `Promise.all` rejects the whole response the
// moment ONE source fails, which is precisely the wrong behaviour here.
// Elasticsearch being down should degrade the page to Postgres results,
// not blank it. The union type above is what forces the consumer to
// handle the rejected case rather than assuming `data` is there.
export async function GET(req: Request): Promise<Response> {
const term = new URL(req.url).searchParams.get('q') ?? ''
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const send = <T>(chunk: StreamChunk<T>): void => {
controller.enqueue(encoder.encode(JSON.stringify(chunk) + '\n'))
}
// Fire BOTH queries concurrently, and send each as it settles
await Promise.all([
pgSearch(term).then(
(data) => send({ source: 'postgres', status: 'fulfilled', data }),
(err: Error) => send({ source: 'postgres', status: 'rejected', error: err.message }),
),
esSearch(term).then(
(data) => send({ source: 'elasticsearch', status: 'fulfilled', data }),
(err: Error) => send({ source: 'elasticsearch', status: 'rejected', error: err.message }),
),
])
controller.close()
},
})
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } })
}@RestController
class StreamController {
// SseEmitter is Spring's streaming response: the request thread is
// released immediately and events are pushed as they are produced.
@GetMapping(value = "/search/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
SseEmitter streamSearch(@RequestParam(defaultValue = "") String q) {
SseEmitter emitter = new SseEmitter(30_000L); // always set a timeout
// Fire BOTH queries concurrently, don't await sequentially.
// supplyAsync starts the work now; the thenAccept fires per source.
CompletableFuture<Void> pg = CompletableFuture
.supplyAsync(() -> pgSearch(q))
.handle((data, err) -> send(emitter, "postgres", data, err))
.thenAccept(x -> { });
CompletableFuture<Void> es = CompletableFuture
.supplyAsync(() -> esSearch(q))
.handle((data, err) -> send(emitter, "elasticsearch", data, err))
.thenAccept(x -> { });
// Complete only once BOTH have reported, success or failure.
CompletableFuture.allOf(pg, es)
.whenComplete((v, err) -> emitter.complete());
return emitter;
}
private Object send(SseEmitter emitter, String source, Object data, Throwable err) {
try {
emitter.send(Map.of(
"source", source,
err == null ? "data" : "error",
err == null ? data : err.getMessage()));
} catch (IOException io) {
emitter.completeWithError(io); // client hung up mid-stream
}
return null;
}
}11
The Benchmark: ILIKE vs Elasticsearch on 50k Rows
The demo in the lecture populated 50,000 review documents into both Neon (cloud Postgres) and Elastic Cloud, both hosted in us-west to keep network latency equal. Results:
| Query | Elasticsearch | Postgres ILIKE | Speedup |
|---|---|---|---|
| “laptop” | ~500 ms | ~3 s | 6x |
| “only” (broad) | ~500 ms | ~7.5 s | 15x |
The broader the search term (more matching rows), the worse ILIKE gets, because Postgres must retrieve and return all those rows from disk. Elasticsearch computes relevance scores in-memory on the shard and only returns the top-N, so latency stays low.
12
When to Use What
Postgres Full-Text Search
- Data already in Postgres
- Search is a secondary feature, not the core product
- Team small / no dedicated infra for ES
- Moderate data volume (< a few million rows)
- Don’t need fuzzy/typo search on day one
Elasticsearch
- Search is a first-class feature
- Millions+ of documents
- Need typo tolerance / autocomplete
- Complex relevance tuning needed
- Already running ELK for logs anyway
- Need aggregations / analytics on search results
Decision Flow
13
References & Further Reading
Elasticsearch Official Docs Postgres Full-Text Search Elasticsearch Query DSL BM25 Similarity Config Apache Lucene Analysis & Tokenisation ELK Stack Overview pgx Go Driver Docs Python ES Client
Backend Field Manual / Full-Text Search Chapter / Notes compiled from video lecture
Backend from First Principles / Chapter 11 / Search. Code targets Postgres 14+, Elasticsearch 8+, Go 1.22+, Python 3.11+, Node.js 20+, TypeScript 5+ and Java 17+ (Spring Boot).