Learn SQL with PostgreSQL - Full-Text Search (FTS) & Vector Extension (pgvector)
Episode 17 of 21

Learn SQL with PostgreSQL - Full-Text Search (FTS) & Vector Extension (pgvector)

This episode covers native Full-Text Search with to_tsvector and to_tsquery, stemming and stop words, ranking results with ts_rank and GIN index, and an introduction to the pgvector extension for similarity search and AI embeddings with L2 and cosine distance operators.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

Welcome to episode 17 of the Learn SQL with PostgreSQL series! In episode 5 we used ILIKE '%kata%' for text search. It works — but it has limits: it's slow on big tables, can't sort by relevance, and doesn't understand that "berlari" and "berlari cepat" are related concepts. For serious search, PostgreSQL has a built-in and very mature Full-Text Search (FTS) engine.

And there's one more thing making PostgreSQL increasingly relevant in the AI era: the pgvector extension. It turns PostgreSQL into a vector database — a place to store embeddings and perform similarity search for RAG (Retrieval-Augmented Generation) applications, recommendations, and semantic search. You don't need a separate database to build AI applications.

In this episode, we'll cover native FTS with to_tsvector and to_tsquery, the concepts of stemming and stop words, ranking search results with ts_rank, accelerating with a GIN index, then get to know pgvector with the L2 distance and cosine distance operators for similarity search.

Native Full-Text Search (FTS) in PostgreSQL

The Concept: Documents Become Searchable Vectors

The core of FTS is turning raw text into a tsvector — a normalized word representation (lowercase, root words, common words removed). Search then matches a tsquery against that tsvector.

Converting text into a tsvector
SELECT to_tsvector('indonesian',
    'Saya berlari sangat cepat di pagi hari');

The result is a list of lexemes — cleaned-up root word forms. The words "berlari" and "berlari cepat" become the same lexeme. That's the power of FTS that LIKE doesn't have.

to_tsquery: Building the Search Query

to_tsquery converts a query string into a tsquery with the logical operators & (AND), | (OR), and ! (NOT):

Matching a tsquery against a tsvector
SELECT
    to_tsvector('indonesian', 'Saya belajar SQL dan database') @@
    to_tsquery('indonesian', 'sql & database') AS cocok;

The @@ operator returns TRUE if the tsvector matches the tsquery. For keyword search without strict logic, the plainto_tsquery function converts ordinary user input into a tsquery with automatic AND.

plainto_tsquery for user input
SELECT id, title
FROM articles
WHERE to_tsvector('indonesian', title || ' ' || body)
      @@ plainto_tsquery('indonesian', 'cara belajar postgresql');

Stemming and Stop Words

Two concepts that make FTS intelligent:

  • Stemming: reducing words to their root form. "Berlari", "berlari", "lari" are treated as the same concept.
  • Stop words: common words removed because they carry no meaning — "yang", "di", "ke", "dari", and so on.

PostgreSQL has built-in language configurations (english, indonesian, and others). With 'indonesian', Indonesian stop words are automatically dropped and Indonesian stemming is applied. This is why FTS far outperforms character-based LIKE searches.

Tip

Don't forget to specify the language in to_tsvector('indonesian', ...) and to_tsquery('indonesian', ...). If omitted, PostgreSQL uses the default_text_search_config — usually english, so English stemming and stop words are applied and Indonesian search results become suboptimal.

Ranking Search Results with ts_rank

FTS search results are a set — but users need ordering by relevance. The ts_rank function (or ts_rank_cd for coverage density) computes a score for how well a document matches the query:

Ranking search results
SELECT
    title,
    ts_rank(
        to_tsvector('indonesian', title || ' ' || body),
        plainto_tsquery('indonesian', 'belajar postgresql')
    ) AS skor
FROM articles
WHERE to_tsvector('indonesian', title || ' ' || body)
      @@ plainto_tsquery('indonesian', 'belajar postgresql')
ORDER BY skor DESC;

Documents that contain the words more often and earlier get a higher score. Storing the tsvector as a generated column (episode 12) makes this query much faster and cleaner:

Storing tsvector as a generated column
ALTER TABLE articles
ADD COLUMN search_vector TSVECTOR
GENERATED ALWAYS AS (
    to_tsvector('indonesian', title || ' ' || body)
) STORED;

Accelerating FTS with a GIN Index

Without an index, every search scans all rows. With a GIN index on the tsvector column, FTS search becomes an Index Scan (the episode 14 concept):

GIN index for Full-Text Search
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);

Now the query WHERE search_vector @@ plainto_tsquery(...) uses the GIN index and runs in milliseconds even on a table with millions of articles.

Warning

Pay attention to the difference in expressions between the index and the query: the GIN index is built on the search_vector column — so queries must filter on search_vector (or an identical expression), not rewrite to_tsvector(...) in a different format. An expression that isn't exactly the same results in a full scan again.

Introduction to the pgvector Extension

Now the most exciting part in the AI era: pgvector. This extension adds the vector data type to PostgreSQL along with operators for similarity search — turning a relational database into a vector database for AI embeddings.

Enabling and Storing Embeddings

Enable pgvector
CREATE EXTENSION IF NOT EXISTS vector;
Table for storing embeddings
CREATE TABLE documents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    content TEXT NOT NULL,
    embedding VECTOR(1536) NOT NULL
);

VECTOR(1536) denotes the embedding dimension — matching the embedding model used (e.g. OpenAI text-embedding-3-large produces 3072 dimensions, other common models produce 768 or 1536). This column is filled by the application from the embedding model's output.

Similarity Search with Distance Operators

pgvector provides three main distance operators:

OperatorMethodMeaning
<->L2 distance (Euclidean)Geometric distance
<=>Cosine distanceDirectional vector similarity (most common for text)
<#>Inner productDot product
Find the most similar documents (cosine)
SELECT id, content, embedding <=> $1 AS jarak
FROM documents
ORDER BY embedding <=> $1
LIMIT 5;

$1 is the placeholder for the query embedding from the application. The result: the 5 documents with the smallest distance — that is, the most semantically similar to the user's question.

How RAG Works with pgvector

The complete pipeline of a RAG (Retrieval-Augmented Generation) application uses pgvector as follows:

  1. Documents are split into chunks and converted into embeddings by an AI model.
  2. The embeddings are stored in the VECTOR(1536) column.
  3. When a user asks, the question is also converted into an embedding.
  4. PostgreSQL finds the most similar chunks using cosine distance.
  5. The relevant chunks are handed to the LLM as context to generate the answer.

All of these retrieval steps happen inside PostgreSQL — without separate vector database infrastructure.

Note

For large-scale vector search, create an HNSW or IVFFlat index with CREATE INDEX ... USING HNSW (embedding vector_cosine_ops);. Both are approximate nearest neighbor indexes that speed up search from O(N) to sub-linear — a tunable trade-off between speed and accuracy.

When FTS and When pgvector?

NeedBest tool
Exact word search, language stemming, relevance rankingFTS
Semantic search, conceptually similar documents, AI/RAGpgvector
Combination of keyword + semanticBoth (combine with a blended score)

Many modern applications combine both: FTS for precise keyword search, pgvector for "what's similar" — then blend the scores with ts_rank and cosine distance.

Closing

Key takeaways:

  • FTS turns text into normalized lexemes — far smarter than LIKE.
  • Always specify the language in FTS functions ('indonesian').
  • Store the tsvector as a generated column + GIN index for lightning-fast search.
  • pgvector turns PostgreSQL into a vector database for AI embeddings.
  • <-> (L2) and <=> (cosine) are the main similarity operators; cosine is best suited for text.

In the next episode, episode 18, we handle giant data: Table Partitioning for Large-Scale Data — from when partitioning is needed, declarative partitioning with Range, List, and Hash, to partition pruning that makes queries touch only the relevant partitions.