Skip to main content
TIN (Text INdex) brings blazing-fast full-text search to PlanetScale Postgres. Enabling the tin extension adds an inverted index type built for search, BM25 ranking, and the TINQL query language.

A search engine inside Postgres

A TIN index is a normal Postgres index, so searches see a consistent snapshot while writes continue. You also get ranking, highlighting, exact counts, and a query language that can express phrases, proximity, and spans. TIN offers richer retrieval features and drastically better performance than typical Postgres search extensions and external services. TINQL is a broader retrieval language than tsquery or pg_textsearch, and TIN is substantially faster than pg_textsearch and ParadeDB pg_search on the count and top-k workloads those engines can run. An external engine such as Elasticsearch has a similar feature set, but requires a second cluster and a sync path with Postgres.

Key features

  • The ==> operator. WHERE body ==> '…' searches a TIN-indexed column with a TINQL string. ==> is boolean: the row matches if the text satisfies the query.
  • TIN index. Create a TIN index with CREATE INDEX ... USING tin on a text column.
  • BM25 ranking with top-k retrieval. tin.score(ctid) returns the BM25 relevance of each matching row, and ORDER BY tin.score(ctid) DESC LIMIT 10 returns the 10 best documents in score order. The same row gets the same score however the query is executed. tin.max_score(ctid) gives a denominator for normalized relevance, and k1 and b can be tuned per index or per query.
  • The TINQL query language. One query string expresses phrases with word gaps, ordered and unordered proximity (THEN/5, NEAR/5), span relations (ENCLOSES, OVERLAPPING, BEFORE, AFTER), positional filters (IN FIRST 100 WORDS), wildcards, fuzzy terms, regular expressions, term ranges, AT LEAST 2 OF, and boosts. Every expression produces spans, so the operators compose freely.
  • Exact counts. count(*) with WHERE body ==> '…' is answered from the index and stays exact under concurrent writes, VACUUM, and on read replicas.
  • Highlighting. tin.highlight adds configurable markers around the text that produced the match. When highlighted, a match on pineapple returns '<b>pineapple</b>'. tin.highlight_ansi does the same for terminals.
  • Cross-column scoring. Search several TIN-indexed columns in one query, and tin.score(ctid) combines relevance across them.
  • Configurable tokenization. The default tokenizer runs on indexed columns and queries, splitting text on Unicode word boundaries, folding case and accents, and indexes emoji as terms.
  • Parallel build and parallel query. Index builds use parallel workers, and based on cost, queries and count(*) can run in parallel too.

TIN example

Create a table, insert some rows, index the text column, and query it:
The query matches rows 1 and 2. Each contains the phrase fuji apple, has citrus within five words of melon, and excludes peel. Row 3 has all three terms but is excluded by AND NOT peel, and rows 4 and 5 lack the phrase. tin.score(ctid) ranks the matches by BM25 relevance, and tin.highlight(body) adds <b>/</b> tags around the text that produced the match.

Hybrid search with pgvector

Hybrid search requires a keyword (lexical) search and a meaning (semantic) search over the same documents, then combining the two ranked lists. TIN does the lexical half. Semantic search needs an embedding model outside Postgres and a vector index on the same table. The model embeds each document when you write it, and embeds the user’s query at search time. pgvector stores those vectors and returns the nearest rows.
You would then combine these results in your application, keeping every id from either query, and treating an id that appears in both as a stronger match.

Next steps

Need help?

Get help from the PlanetScale Support team, or join our Discord community to see how others are using PlanetScale.