Module 3: Full-Text Search + pg_trgm — a quality search engine without Elasticsearch

`tsvector` and `tsquery`: the two types that make FTS possible

Capsule description

Before talking about ranking, indexes, or decisions against Elasticsearch, you need to understand the two data types that make all of PostgreSQL's full-text search machinery possible: tsvector and tsquery. Without them, FTS is magic. With them, FTS is a series of predictable operations over normalized text.

This capsule teaches you what tsvector and tsquery are, how they're built from plain text with the five converters PostgreSQL offers (to_tsvector, to_tsquery, plainto_tsquery, phraseto_tsquery, websearch_to_tsquery), and how they're pitted against each other with the @@ operator to produce matches. You're going to see the real output of each converter, you're going to understand why caminando becomes caminand (lexeme), and you're going to know which of the converters to choose depending on the case.

By the end you'll be able to convert any text to a tsvector, write queries with the correct syntax for each converter, and predict what @@ does in a given case without having to run the query. It's the base the whole module is built on.


Mental model: two representations of the same text

FTS doesn't search in raw text. It searches in a processed representation of the text. And for the match to work, both the document's text and the query's text are processed with the same method.

┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  Document: "Los gatos están corriendo en el jardín"          │
│                          │                                   │
│                          ▼                                   │
│             to_tsvector('spanish', ...)                      │
│                          │                                   │
│                          ▼                                   │
│   tsvector: 'corr':4 'gat':2 'jardin':7                      │
│   (normalized tokens, stemmed, no stop words)                │
│                                                              │
│  ─────────────────────────────────────────────────────────   │
│                                                              │
│  User's query: "gato corriendo"                              │
│                          │                                   │
│                          ▼                                   │
│           websearch_to_tsquery('spanish', ...)               │
│                          │                                   │
│                          ▼                                   │
│   tsquery: 'gat' & 'corr'                                    │
│   (same processing: stemming, stop words, lowercase)         │
│                                                              │
│  ─────────────────────────────────────────────────────────   │
│                                                              │
│  tsvector @@ tsquery → true / false                          │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The three ideas to internalize:

  1. tsvector is processed text: a list of lexemes (tokens reduced to their root) with their positions in the original text. Stop words removed. Lowercase. No accents if you configure unaccent. It's what gets indexed.

  2. tsquery is a processed query: a combination of lexemes with boolean operators (&, |, !) and positional ones (<-> for phrases). The user writes natural language; PostgreSQL converts it to this form.

  3. @@ is the match: it returns true if the tsvector satisfies the tsquery. Stop words, uppercase, accents, conjugations — all of that has already been normalized on both sides.

If you remember that box, everything else is decoration.


tsvector: the processed text

Let's start with the star converter: to_tsvector(config, text).

SELECT to_tsvector('spanish', 'Los gatos están corriendo en el jardín');

Output:

        to_tsvector
---------------------------
 'corr':4 'gat':2 'jardin':7

Three things happened:

  1. Stop words removed. "Los", "están", "en", "el" disappeared. The spanish dictionary knows those words show up in any text and don't contribute to a match.
  2. Stemming applied. "gatos" → "gat", "corriendo" → "corr", "jardín" → "jardin". Every form of the word is reduced to its root (lexeme).
  3. Positions recorded. The number after each lexeme ('gat':2) indicates the position in the original text. This is used later for proximity ranking.

If you run it with the simple dictionary (the default if you don't specify one), the output is very different:

SELECT to_tsvector('simple', 'Los gatos están corriendo en el jardín');
                            to_tsvector
-------------------------------------------------------------------
 'corriendo':4 'el':6 'en':5 'están':3 'gatos':2 'jardín':7 'los':1

No stop words removed, no stemming. simple only does lowercasing and tokenization. For English, Spanish, or any language with inflection, you always want the language's dictionary, not simple.

Lexemes: the heart of stemming

The "lexeme" is the root that the stemming algorithm considers invariant across all inflections of the word. In Spanish, the spanish dictionary uses the Snowball algorithm and produces results like:

SELECT to_tsvector('spanish', 'corro corres corre corremos corrieron correrá correr corriendo');
       to_tsvector
--------------------------
 'corr':1,2,3,4,5,6,7,8

A single word: corr. With all the positions where it appears. Any query that asks for one conjugation of correr will match all the others.

This is what makes FTS useful for real users. The user searches "como correr" and matches posts that say "corrió ayer", "correrá mañana", "está corriendo ahora". Without stemming, every conjugation would be a different keyword.

tsvector also accepts concatenation and weights

You can combine several tsvectors with || (it isn't SQL's boolean OR — here it's the tsvector concatenation operator):

SELECT
  to_tsvector('spanish', 'Python para principiantes')
  || to_tsvector('spanish', 'Aprende a programar paso a paso');

And you can assign weights (A, B, C, D — A is the most important) with setweight. This is what you're going to use to make a match in the title weigh more than a match in the body:

SELECT
  setweight(to_tsvector('spanish', 'Python para principiantes'), 'A')
  || setweight(to_tsvector('spanish', 'Aprende a programar paso a paso'), 'B');

We'll go deeper into weights and ranking in capsule 05. For now it's enough that you know they exist and that they're enriched tsvectors.


tsquery: the processed query

The user doesn't write tsquery directly. Neither do you. There are five converters in PostgreSQL for building a tsquery from plain text. Each one accepts a different kind of input and produces a different kind of output.

1. to_tsquery(config, text) — explicit syntax

Accepts boolean operators (&, |, !) and proximity ones (<->). It's the strictest one and the one you probably will NOT use in production.

SELECT to_tsquery('spanish', 'gato & corriendo');
   to_tsquery
----------------
 'gat' & 'corr'

If you pass text without operators, it fails:

SELECT to_tsquery('spanish', 'gato corriendo');  -- ERROR
ERROR:  syntax error in tsquery: "gato corriendo"

Useful when you build the query from code and you control the boolean logic exactly. Useless for user input.

2. plainto_tsquery(config, text) — plain text with an implicit AND

Accepts text without operators. It connects all the words with & (AND).

SELECT plainto_tsquery('spanish', 'gato corriendo');
  plainto_tsquery
------------------
 'gat' & 'corr'

If the user writes "gato OR perro", it treats it as three words: "gato", "OR", "perro". OR isn't an operator, it's a word. And since "OR" probably isn't in the dictionary's stemming, it stays as is:

SELECT plainto_tsquery('spanish', 'gato OR perro');
       plainto_tsquery
-----------------------------
 'gat' & 'or' & 'perr'

Useful for very basic inputs where you don't need the user to be able to combine terms with booleans.

3. phraseto_tsquery(config, text) — exact phrase

Connects the words with <-> (the proximity operator: the next word must come immediately after).

SELECT phraseto_tsquery('spanish', 'gato corriendo');
  phraseto_tsquery
-------------------
 'gat' <-> 'corr'

It only matches if "gato" and "corriendo" appear in the document in exactly that order, with nothing in between. For "exact phrase in quotes"-style searches.

4. websearch_to_tsquery(config, text) — Google syntax (PG 11+)

This is the one you're going to use 95% of the time. It accepts:

  • Words separated by spaces → AND.
  • OR (uppercase) → the OR operator.
  • -word → exclusion (NOT).
  • "exact phrase" → a quoted phrase.

It's exactly the format the user expects from a modern search engine.

SELECT websearch_to_tsquery('spanish', 'gato corriendo');
  websearch_to_tsquery
-----------------------
 'gat' & 'corr'
SELECT websearch_to_tsquery('spanish', 'gato OR perro');
  websearch_to_tsquery
-----------------------
 'gat' | 'perr'
SELECT websearch_to_tsquery('spanish', 'gato -negro');
  websearch_to_tsquery
-----------------------
 'gat' & !'negr'
SELECT websearch_to_tsquery('spanish', '"gato negro"');
  websearch_to_tsquery
-----------------------
 'gat' <-> 'negr'

Unlike to_tsquery, it doesn't fail on invalid syntax. If the user writes something strange, websearch_to_tsquery cleans it up and returns the best possible query:

SELECT websearch_to_tsquery('spanish', 'gato @#$ corriendo');
  websearch_to_tsquery
-----------------------
 'gat' & 'corr'

That's why it's the safe default: it never breaks on arbitrary input and it produces queries that feel natural for the user.

5. Other variants (rare)

There are variants for migration or conversion cases (building a tsquery by hand with to_tsquery over already-normalized text, or manipulating it with querytree), but you're not going to need them for application FTS. Mentioned so you know they exist if you see them in the docs.

Quick comparison: which converter to use

ConverterUse it when...
to_tsqueryYou build the query from code with specific boolean logic
plainto_tsqueryBasic word input, no operators or phrases
phraseto_tsqueryYou need a forced exact phrase (rare)
websearch_to_tsqueryThe default for user input. Accepts OR, exclusions, phrases
(other variants)Very specific cases, rare in application backends

Practical rule: always start with websearch_to_tsquery. Only switch to to_tsquery if you need absolute programmatic control over the query.


The @@ operator: the match

Once you have a tsvector and a tsquery, you pit them against each other with @@:

SELECT to_tsvector('spanish', 'Los gatos están corriendo en el jardín')
       @@ websearch_to_tsquery('spanish', 'gato corriendo');
 ?column?
----------
 t

It returns true. The document contains the lexemes gat and corr, both required by the query (implicit AND).

If the query isn't satisfied:

SELECT to_tsvector('spanish', 'Los gatos están corriendo en el jardín')
       @@ websearch_to_tsquery('spanish', 'perro');
 ?column?
----------
 f

The @@ operator is what you're going to put in your WHERE clause:

SELECT id, title
FROM posts
WHERE to_tsvector('spanish', body) @@ websearch_to_tsquery('spanish', 'python');

Without a GIN index, this does a sequential scan: PostgreSQL calls to_tsvector for every row. For 100 rows you don't notice. For 100k rows it becomes impractical. Capsule 04 covers how to add the GIN index to speed this up. For now it's enough to know that @@ is the match primitive.


Worked example: minimal setup + first end-to-end match

We're going to set up a test table, insert Spanish data, and run the whole FTS flow from Python with async SQLAlchemy 2.0. It's the setup you'll reuse in the following capsules.

1. The table and the seed data

# fts_demo.py — table setup and seed
import asyncio

from sqlalchemy import BigInteger, String, Text, select, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    title: Mapped[str] = mapped_column(String(200))
    body: Mapped[str] = mapped_column(Text)


SEED = [
    ("Python para principiantes", "Aprende Python desde cero. Variables, funciones y estructuras de control."),
    ("Cómo correr más rápido", "Técnicas de entrenamiento para quienes corren maratones. Velocidad, resistencia y descanso."),
    ("El gato que perdimos", "Una historia sobre nuestro gato perdido en el jardín de la abuela."),
    ("FastAPI vs Flask", "Comparativa de frameworks web en Python para construir APIs modernas."),
    ("Recetas de pasta", "Tres recetas italianas tradicionales para preparar pasta en casa."),
    ("Jardinería para todos", "Consejos para iniciar tu propio jardín, hasta en balcones pequeños."),
    ("PostgreSQL avanzado", "Full-text search, JSONB y particionamiento en PostgreSQL 16."),
    ("Mi gata Luna", "El día que adopté a mi gata Luna y cambió mi vida cotidiana."),
]


async def main() -> None:
    engine = create_async_engine(
        "postgresql+asyncpg://postgres:postgres@localhost:5432/fts_demo",
        echo=False,
    )

    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
        await conn.run_sync(Base.metadata.create_all)

    Session = async_sessionmaker(engine, expire_on_commit=False)

    async with Session() as session:
        for title, body in SEED:
            session.add(Post(title=title, body=body))
        await session.commit()

    print(f"Inserted {len(SEED)} posts.")
    await engine.dispose()


if __name__ == "__main__":
    asyncio.run(main())

To run it:

pip install "sqlalchemy[asyncio]>=2.0" asyncpg
# Assumes a local PostgreSQL with user/pass postgres and the 'fts_demo' database created
python fts_demo.py

Output:

Inserted 8 posts.

2. Doing the first match with raw SQL from SQLAlchemy

# fts_query.py
import asyncio

from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine


async def search(term: str) -> None:
    engine = create_async_engine(
        "postgresql+asyncpg://postgres:postgres@localhost:5432/fts_demo"
    )
    Session = async_sessionmaker(engine, expire_on_commit=False)

    async with Session() as session:
        result = await session.execute(
            text(
                """
                SELECT id, title
                FROM posts
                WHERE to_tsvector('spanish', body)
                      @@ websearch_to_tsquery('spanish', :q)
                ORDER BY id
                """
            ),
            {"q": term},
        )
        rows = result.all()
        print(f"\nResults for '{term}': {len(rows)}")
        for row in rows:
            print(f"  [{row.id}] {row.title}")

    await engine.dispose()


async def main() -> None:
    await search("python")
    await search("gato")
    await search("correr")
    await search("jardin")


if __name__ == "__main__":
    asyncio.run(main())

Expected output:

Results for 'python': 2
  [1] Python para principiantes
  [4] FastAPI vs Flask

Results for 'gato': 2
  [3] El gato que perdimos
  [8] Mi gata Luna

Results for 'correr': 1
  [2] Cómo correr más rápido

Results for 'jardin': 2
  [3] El gato que perdimos
  [6] Jardinería para todos

Three important details that prove Spanish FTS works:

  1. gato matches both "gato" and "gata" (posts 3 and 8). Stemming reduces both to gat.
  2. correr matches "corren" (post 2). Stemming reduces both conjugations to corr. Careful: not everything that "sounds similar" shares a lexeme. "corredores" does not match correr, because the stemmer reduces it to corredor (it's a noun, not a conjugation of the verb). Stemming follows morphological rules, not intuition.
  3. jardin matches "jardín" and "jardinería" (posts 3 and 6). Stemming reduces both to jardin (without the accent, because the stemmer doesn't use accents).

Accents only "work" because the stemmer normalizes them. If you search python it will match "Python" (lowercase). But if you search cancion and the document says canción with an accent, it will NOT match — and that's what unaccent solves in capsule 03. For now, take away that the end-to-end flow works.

3. The same thing with the SQLAlchemy expression API (no raw SQL)

For real apps, you prefer the expression API over text(). SQLAlchemy 2.0 supports func for calling PostgreSQL functions:

from sqlalchemy import func, select

stmt = (
    select(Post.id, Post.title)
    .where(
        func.to_tsvector("spanish", Post.body).bool_op("@@")(
            func.websearch_to_tsquery("spanish", term)
        )
    )
    .order_by(Post.id)
)

result = await session.execute(stmt)

func.to_tsvector("spanish", Post.body).bool_op("@@")(...) is the pattern for calling the @@ operator from the ORM. It's verbose but typesafe. SQLAlchemy also offers Post.body.match(...) as a shortcut for @@, though with less control:

stmt = select(Post.id, Post.title).where(Post.body.match(term))

.match() internally uses plainto_tsquery (not websearch_to_tsquery). For real user input you prefer an explicit func.websearch_to_tsquery. This gets tied together more in capsule 04 when we add the generated column and the GIN index.


Why does this matter in real work?

1. It's the base vocabulary of every search decision in PostgreSQL. Every time tsvector, tsquery, @@, to_tsvector, websearch_to_tsquery show up in a PR, you know how to read it. You don't have to pause and consult the docs.

2. It lets you tell a search engine "that passes the tests" from one that serves the user. A teammate implements search with Post.body.ilike(f"%{q}%") and says "it works." You look and answer: "what happens when the user searches 'corriendo'? Does it match 'corrió'?". With FTS, yes. With ILIKE, no. That observation changes the team's technical decision.

3. Without understanding tsvector, the following concepts (ranking, headlines, GIN indexes) are magic recipes. Knowing that the tsvector already has the positions of each lexeme explains why ts_rank_cd can measure proximity. Knowing that it's processed text explains why a GIN index is the right one and not a B-tree.


Traps and common mistakes

Mistake 1 (conceptual): thinking that tsvector is just lowercase

Symptom: you assume to_tsvector('spanish', text) is equivalent to lower(text) and you get confused by results that don't match "obviously similar" words.

Why it happens: the docs mention "normalization" and many people read that as "lowercase." It's much more: stemming + stop words + tokenization + positions.

How to tell: run to_tsvector('spanish', 'corriendo') and look at the output. It's 'corr':1, not 'corriendo':1. That's stemming, not lowercase. Capsule 03 goes deeper into how the spanish dictionary does this work.

Mistake 2 (practical): using to_tsquery with user input

Symptom: the search endpoint crashes when the user types something with spaces and no operators.

await session.execute(text("SELECT id FROM posts WHERE to_tsvector('spanish', body) @@ to_tsquery('spanish', :q)"), {"q": "gato corriendo"})
# psycopg2.errors.SyntaxError: syntax error in tsquery: "gato corriendo"

Why it happens: to_tsquery requires syntax with explicit operators (gato & corriendo). The user never writes that syntax. If you pass the input directly, it fails.

Fix: use websearch_to_tsquery for user input, always. It handles spaces, accepts OR and exclusions, and doesn't fail on arbitrary input.

await session.execute(text("SELECT id FROM posts WHERE to_tsvector('spanish', body) @@ websearch_to_tsquery('spanish', :q)"), {"q": "gato corriendo"})
# OK

Mistake 3 (conceptual): assuming that tsvector searches in the structure of the text

Symptom: you expect FTS to distinguish "first sentence" from "last sentence," or to consider punctuation, or to tell words in the title apart from words in the body.

Why it happens: tsvector is a bag of lexemes with positions. Positions are used for proximity and ranking, not for distinguishing sections. To differentiate title and body, you build two separate tsvectors with setweight (capsule 05).

How to work with this: if you want matches in the title to weigh more than matches in the body, use weights:

SELECT
  setweight(to_tsvector('spanish', title), 'A')
  || setweight(to_tsvector('spanish', body), 'B') AS tsv
FROM posts;

And then you rank with ts_rank, which takes the weights into account. We'll see it in capsule 05.

Mistake 4 (practical): forgetting the second argument (config) and using the default

Symptom: you call to_tsvector(body) without the first argument. PostgreSQL uses default_text_search_config (which is almost always english). Your Spanish FTS doesn't work because it's stemming with English rules.

Why it happens: the to_tsvector(text) signature is valid and compiles. You only notice the bug when "caminando" doesn't match "camina".

Fix: always pass the config explicitly. to_tsvector('spanish', body), not to_tsvector(body). To be safe globally, configure:

ALTER DATABASE your_database SET default_text_search_config = 'pg_catalog.spanish';

But even so, write the config explicitly in the code. It's more readable and it avoids bugs when someone changes the default.

Mistake 5 (conceptual): thinking that @@ is the only match operator

Symptom: you see @@@, @@@@, @> in tutorials and you get confused.

Why it's confusing: PostgreSQL has several operators with @. The relevant ones for FTS are:

  • @@tsvector @@ tsquery (the basic match).
  • @@@ — a deprecated alias of @@ (don't use it, it's legacy).
  • For pg_trgm you see % (similarity), <%, <<% — those are something else, we'll see them in capsule 06.
  • @> is a JSONB operator, not FTS.

Rule: for FTS, always @@. If you see something else in old code, suspect legacy or confusion with another extension.


Exercises

Exercise 1: predict the output of to_tsvector

Without running the SQL, predict what each one returns:

SELECT to_tsvector('spanish', 'Los perros caminan en el parque');
SELECT to_tsvector('spanish', 'caminé caminaste caminó caminaron');
SELECT to_tsvector('simple', 'caminé caminaste caminó caminaron');
SELECT to_tsvector('english', 'caminé caminaste caminó caminaron');
See solution
-- Case 1
to_tsvector
---------------------
 'camin':3 'parqu':6 'perr':2

-- Stop words: "Los", "en", "el" removed.
-- Stemming: "perros" → "perr", "caminan" → "camin", "parque" → "parqu".
-- Case 2
to_tsvector
-----------------
 'camin':1,2,3,4

-- The four conjugations reduce to the same lexeme "camin".
-- Positions 1,2,3,4 correspond to the four words of the input.
-- Case 3
to_tsvector
------------------------------------------------
 'caminaron':4 'caminaste':2 'caminé':1 'caminó':3

-- No stemming. Each word is a different token.
-- 'simple' only does lowercasing and tokenization. Accents preserved.
-- Case 4
to_tsvector
-----------------------------------------------
 'caminaron':4 'caminast':2 'caminé':1 'caminó':3

-- 'english' doesn't know Spanish. It doesn't recognize the words as conjugations
-- of the same verb, so it doesn't unify them into a single lexeme.
-- Worse still: it applies English rules to words that aren't English.
-- "caminaste" → "caminast" (it strips the final 'e', as it would with "make" → "make").
-- In other words, it doesn't just fail to help: it also mangles the words unpredictably.

Lesson: the config matters. spanish gives useful stemming; simple does no stemming and english applies the wrong rules. For Spanish text, neither of the last two is any use.

Exercise 2: choose the right converter

For each user input, indicate which query converter you would use and why.

a) The user types in a simple input: python fastapi b) The user types in a Google-style search box: "machine learning" -tutorial c) Your code builds a query with specific boolean logic: match posts that contain ("python" OR "javascript") AND "tutorial". d) The user searches for an exact phrase: gato negro

See solution

a) websearch_to_tsquery. It's the safe default: it accepts spaces as an implicit AND and handles any arbitrary input without failing. plainto_tsquery would work too, but websearch_to_tsquery lets the user grow later (when they add OR or exclusions).

b) websearch_to_tsquery. It's exactly for this: it accepts "phrase", OR, -exclusion. Expected output:

'machin' <-> 'learning' & !'tutorial'

(Notice that "learning" isn't reduced to "learn": the spanish dictionary doesn't know English stemming. It's the same problem as exercise 1, seen from the other side.)

c) to_tsquery. You build the query from code, you control the logic:

to_tsquery('spanish', '(python | javascript) & tutorial')

Here to_tsquery makes sense because you control the syntax and you want precision.

d) It depends. If you want a "forced exact phrase," phraseto_tsquery('spanish', 'gato negro') produces 'gat' <-> 'negr'. If you want "the user wrote 'gato negro' but any order is fine," websearch_to_tsquery('spanish', 'gato negro') produces 'gat' & 'negr' (AND, no proximity). For a modern search engine, websearch_to_tsquery is almost always better: the user who wants an exact phrase uses quotes ("gato negro") and websearch_to_tsquery already handles them.

Rule: websearch_to_tsquery by default. to_tsquery only when you build the query from code.

Exercise 3: predict whether @@ matches

For each document + query pair, predict whether @@ returns true or false. Assume to_tsvector('spanish', doc) and websearch_to_tsquery('spanish', q).

a) Doc: "Los perros corren en el parque". Query: corriendo. b) Doc: "Python es un lenguaje de programación". Query: lenguajes programar. c) Doc: "FastAPI es un framework moderno". Query: flask. d) Doc: "Recetas de pasta italiana". Query: recetas -italiana.

See solution

a) true. "corren" → corr. "corriendo" → corr. Same lexeme. Match.

b) true. "lenguaje" → lenguaj. "lenguajes" → lenguaj. "programación" → program. "programar" → program. Same lexemes. Implicit AND (websearch_to_tsquery joins with AND): lenguaj & program. Both are in the doc. Match.

c) false. "FastAPI" → fastapi. "framework" → framework. The query flaskflask. It doesn't appear. No match.

d) false. "recetas" → recet. "italiana" → italian. The query: recet & !italian. The doc has italian. The !italian clause fails. Result false.

Lesson: the implicit AND + the NOT with - are normal operators. Any required term that doesn't appear, or any excluded term that does appear, makes @@ return false.

Exercise 4: implement a basic search engine from SQLAlchemy

Implement a Python function async def search(session, q: str) -> list[Post] that:

  1. Uses websearch_to_tsquery with the spanish dictionary.
  2. Matches against to_tsvector('spanish', Post.body).
  3. Returns the complete Posts ordered by id.
  4. Uses the SQLAlchemy expression API, not raw SQL with text().
See solution
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession


async def search(session: AsyncSession, q: str) -> list[Post]:
    """
    Search posts whose body matches the query with Spanish FTS.
    Uses websearch_to_tsquery to support Google-style input.
    """
    tsv = func.to_tsvector("spanish", Post.body)
    tsq = func.websearch_to_tsquery("spanish", q)

    stmt = (
        select(Post)
        .where(tsv.bool_op("@@")(tsq))
        .order_by(Post.id)
    )
    result = await session.execute(stmt)
    return list(result.scalars().all())


# Use it in an endpoint or a script
async with SessionLocal() as session:
    results = await search(session, "gato corriendo")
    for post in results:
        print(f"[{post.id}] {post.title}")

Why bool_op("@@")(...):

SQLAlchemy doesn't expose @@ as a native attribute of Column (because it's a PostgreSQL-specific operator). bool_op("@@") lets you build any custom boolean operator. The first parenthesis creates the operator, the second passes it the right-hand operand.

Alternative with Column.op:

stmt = (
    select(Post)
    .where(
        func.to_tsvector("spanish", Post.body).op("@@")(
            func.websearch_to_tsquery("spanish", q)
        )
    )
    .order_by(Post.id)
)

op("@@") is similar but generic (not specifically boolean). Both produce the same SQL.

Generated SQL:

SELECT posts.id, posts.title, posts.body
FROM posts
WHERE to_tsvector('spanish', posts.body) @@ websearch_to_tsquery('spanish', :param_1)
ORDER BY posts.id;

Summary and next step

In this capsule you learned the two types that make FTS possible:

  • tsvector: processed text. A list of lexemes (roots) with positions, without stop words, normalized by the configured dictionary. You build it with to_tsvector(config, text).
  • tsquery: a processed query with the same normalization. You build it with one of five converters; websearch_to_tsquery is the default for user input because it accepts Google syntax (spaces = AND, OR, -exclusion, "phrase").
  • @@: the match operator. tsvector @@ tsquery returns true/false. It's what you put in your WHERE.
  • The config matters: spanish for stemming in Spanish, simple only if you specifically don't want stemming, english never for Spanish text.

Before moving on you should be able to:

  • Predict the output of to_tsvector('spanish', 'any phrase') without running it.
  • Choose among the five query converters depending on the case (default: websearch_to_tsquery).
  • Write a basic search endpoint from SQLAlchemy with func.to_tsvector + func.websearch_to_tsquery + bool_op("@@").
  • Reproduce the table + seed + match setup in your local database.

Next capsule — Multilingual FTS: the spanish dictionary and unaccent. In this capsule you saw that the dictionary matters, but you used 'spanish' as a black box. Capsule 03 opens the box: what exactly the spanish dictionary does, why to_tsvector('english', 'caminando') is useless, how to enable unaccent so that "cancion" matches "canción", and why for a Spanish-speaking audience this setup isn't optional. It's the capsule that sets this guide apart from any FTS tutorial written in English.


Resources

  1. PostgreSQL 16 — Tables and Indexes for Full Text Search (12.2) — the reference for to_tsvector, to_tsquery, and the @@ operator.
  2. PostgreSQL 16 — Parsing Queries (12.3.2) — the five query converters with their exact syntax.
  3. Hubert "depesz" Lubaczewski — "Waiting for PostgreSQL 11: websearch_to_tsquery" — the story of why websearch_to_tsquery was added and its use cases.
  4. SQLAlchemy 2.0 — PostgreSQL dialect: Full Text Search — how to express tsvector, tsquery, and @@ from the ORM.
  5. Crunchy Data — "Postgres Full-Text Search: A Search Engine in a Database" — a narrative review of FTS with a production focus.

Module 3 — Advanced PostgreSQL for Backend Guide

Next capsule: Multilingual FTS with the spanish dictionary and unaccent — the setup that separates a useful search engine from a frustrating one for Spanish speakers.