pgvector and RAG10 min read

MySQL FULLTEXT vs PostgreSQL tsvector: An Operator's Comparison

InnoDB FULLTEXT and PostgreSQL tsvector both promise search without a search engine. Here is how indexing, ranking, and language support actually compare, and when neither is enough.

"Do we need Elasticsearch, or can the database do it?" is a question I have answered on both MySQL and PostgreSQL, and the honest answer differs by engine more than most comparisons admit. Both databases ship built-in full-text search. Both are good enough for a real class of products. But PostgreSQL's text search is a composable system you can shape, while InnoDB FULLTEXT is a feature you mostly take as-is. If you are evaluating a migration, search is one of the areas where the two engines feel least alike.

Here is what each one actually gives you, where each one runs out of road, and how to decide when a dedicated search engine is justified.

InnoDB FULLTEXT: what you get

InnoDB full-text indexes work on CHAR, VARCHAR, and TEXT columns and are queried through MATCH ... AGAINST. You get three query modes: natural language mode, which ranks by a TF-IDF-style relevance; boolean mode, with operators for required, excluded, and wildcard-suffixed terms; and query expansion, which reruns the search seeded with terms from the top initial results.

CREATE FULLTEXT INDEX ft_articles_body
ON articles (title, body);

SELECT id, title,
       MATCH (title, body) AGAINST ('replication lag' IN NATURAL LANGUAGE MODE) AS score
FROM articles
WHERE MATCH (title, body) AGAINST ('+replication +lag -mysql' IN BOOLEAN MODE)
ORDER BY score DESC
LIMIT 20;

Under the hood, InnoDB maintains inverted-index auxiliary tables per FULLTEXT index, with deletes handled via a deletion table and merged during OPTIMIZE TABLE. Operationally this matters: heavy churn on a FULLTEXT-indexed table grows those auxiliary structures, and periodic optimization becomes part of your maintenance story.

Tokenization is controlled by a handful of server-level knobs: innodb_ft_min_token_size and innodb_ft_max_token_size, stopword tables, and the ngram parser for Chinese, Japanese, and Korean, plus MeCab for Japanese. What you do not get is linguistic stemming: "index" does not match "indexes" or "indexing" in InnoDB FULLTEXT unless you use wildcards or expansion. There is no per-language analyzer selection per column, no synonym support, and relevance tuning options are essentially the boolean-mode weighting operators. It is a fixed-function pipeline: genuinely useful, rarely shapeable.

PostgreSQL tsvector: a small search toolkit

PostgreSQL's model separates the pieces. A tsvector is a normalized document: lowercased, stemmed lexemes with positions and optional weights. A tsquery is a parsed query with AND, OR, NOT, phrase, and prefix operators. A text search configuration, such as english, french, or simple, decides tokenization, stopwords, and stemming via snowball dictionaries, and you can build custom configurations with synonym dictionaries, thesauri, or unaccenting. A GIN index makes the match operator fast.

The modern setup is a stored generated column, which keeps the tsvector in sync without triggers:

ALTER TABLE articles
ADD COLUMN search tsvector
GENERATED ALWAYS AS (
  setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
  setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;

CREATE INDEX idx_articles_search ON articles USING GIN (search);

SELECT id, title,
       ts_rank(search, q) AS score
FROM articles,
     websearch_to_tsquery('english', 'replication lag -mysql') AS q
WHERE search @@ q
ORDER BY score DESC
LIMIT 20;

Three things in that snippet are worth calling out for MySQL readers. The setweight calls give title matches more ranking influence than body matches, a per-document-region weighting InnoDB has no equivalent for. The websearch_to_tsquery parser accepts Google-style syntax from end users safely, so you are not building a query-string sanitizer. And stemming is on by default: "indexes" and "indexing" both reduce to the lexeme "index" under the english configuration.

Ranking is ts_rank or ts_rank_cd, with cover-density ranking in the latter and tunable normalization for document length. It is not BM25, and honest expectations matter here: relevance quality is serviceable, not state of the art. You also get ts_headline for generating highlighted snippets, and the pg_trgm extension covers the adjacent problem tsvector does not: substring and fuzzy matching for typo-tolerant lookups on names and codes.

Ranking and language support, head to head

On ranking, both engines give you a relevance score you mostly cannot retrain. InnoDB's natural-language scoring is a reasonable TF-IDF variant; PostgreSQL's ts_rank adds positional and weight information you control per column. In practice, PostgreSQL wins on shaping results, because weights, custom dictionaries, and the ability to combine the text score with any SQL expression, recency decay, popularity, exact-title boosts, let you build a ranking function in plain SQL. On MySQL you can also blend MATCH scores with other columns in an expression, but the inputs are coarser.

On language support, the difference is wider. PostgreSQL ships snowball stemmers for a couple of dozen languages and lets you pick the configuration per column, per query, or even per row. MySQL's answer for most languages is "tokens and stopwords," with ngram and MeCab as the CJK-specific parsers. If your product is multilingual, PostgreSQL's configuration system does real work for you; on MySQL you will be compensating in application code.

PostgreSQL's costs are real too. GIN indexes are expensive to update on write-heavy tables, and the fastupdate pending-list mechanism trades write latency for periodic merge work that can surprise you during vacuum. A large tsvector column inflates the table and its TOAST relation. None of this is disqualifying, but search-indexing a high-churn table deserves the same write-amplification thinking as any other indexing decision.

When the database is enough

Built-in search on either engine is the right call when search is a feature, not the product: admin lookups, documentation search, filtering a few million rows of tickets or products, internal tools. The wins are enormous and boring: no second system to deploy, no indexing pipeline to build, no consistency gap between the store and the index, transactional updates for free, and one backup story. A GIN-indexed tsvector over a few million documents answers most queries fast enough that users will not ask for more, and the same is true of InnoDB FULLTEXT at similar scale for exact-token search.

I hold the line on the database answer longer on PostgreSQL than on MySQL, specifically because of stemming, per-field weights, websearch parsing, and pg_trgm. Products that would have needed an external engine on MySQL just to get "plurals match" can stay in PostgreSQL for another year or three. Verify that search queries behave under production load, though: search is spiky, and one popular broad query can dominate a top-queries list. That is a slow query monitoring problem like any other, not a reason to panic-adopt new infrastructure.

When to reach for a real search engine

Some requirements genuinely exceed both engines, and pretending otherwise wastes quarters. Reach for Elasticsearch, OpenSearch, Meilisearch, or Typesense when you need: BM25-quality relevance with tunable analyzers and per-field boosting beyond weights; typo tolerance and instant-search UX at scale; faceted navigation over many attributes with counts; search traffic large enough that you want to scale it independently of the transactional database; or index sizes where GIN maintenance visibly competes with your OLTP workload.

The migration-friendly path is incremental: start with tsvector in PostgreSQL, keep the search interface behind one module in your codebase, and if the day comes, feed the external engine from logical replication or CDC rather than dual writes. Teams that skipped the database phase usually built the two-system operational burden before they had users who needed it.

How MonPG helps once you are on PostgreSQL

MonPG monitors PostgreSQL only, and database-backed search is exactly the workload where its evidence pays off. Query-family history shows what search actually costs across a day, not just in one EXPLAIN. Index statistics show whether the GIN index earns its write overhead as the table churns, and table statistics surface the bloat and vacuum pressure a large tsvector column adds. When you are debating "is Postgres search still holding up, or is it time for a dedicated engine," that decision deserves trend data instead of anecdotes. The PostgreSQL monitoring guide covers how query, index, and vacuum signals combine into that picture.