Skip to main content
The examples on this page use two tables. posts has TIN indexes on title and body and btree indexes on author_id and created_at. authors has a TIN index on bio.

Ranked results

ORDER BY tin.score(ctid) DESC LIMIT k is the shape TIN is built around. It returns the k best rows in score order and stops as soon as it has them. Passing the query as a parameter keeps user input out of the SQL text.
tin.max_score(ctid) is the best score among the matches and has the same value on every row, so dividing by it gives a relative score between 0 and 1. That is the number to show in a relevance bar, or to compare against a threshold when you want to hide weak matches.

Filters and counts

Without tin.score, ==> is an ordinary boolean filter. A count(*) over a ==> predicate is answered from the index.

Several TIN-indexed columns

Each TIN index covers one column, so a query over several columns uses one ==> per column. tin.score(ctid) adds up the relevance from every column that matched. The boost on the title query makes a title hit count twice as much as a body hit, which is the usual way to say that one field matters more than another.
With OR, a row qualifies when either column matches, and its score sums whatever matched. This is the shape for a single search box that should look in several fields.

Filtering on an unindexed column

ups has no index. TIN finds the rows that match the text and the ups condition is checked on each of them, so it costs one comparison per candidate and needs no index of its own. When the filter rejects most of the text matches, the search has to look further down the ranking to fill the limit, so this shape suits filters that keep a reasonable share of the matches.

Combining with btree indexes

When the other condition has a btree index, TIN can use both indexes. A selective point or range condition narrows the text search rather than filtering its results afterwards, which matters when the text query matches many rows and the other condition matches few. A recency window over a large corpus is the common case.
With OR, each index contributes its own rows and the result is the union without duplicates. An OR costs about what its more expensive side costs, so a cheap phrase plus a primary-key lookup stays cheap.

Joins with a score on each side

tin.score takes the ctid of the relation it scores, so a join can carry one score per side. Each side needs its own ==> predicate. A side with no text predicate has nothing to score, and the query is refused. The two scores are independent BM25 values from two different indexes, so combine them deliberately: add them, weight one, or order by one and display the other.
The right-hand side of ==> can be a column from the outer query, so here the search is bound once per author using that author’s topics as the TINQL query. A LATERAL subquery with ORDER BY tin.score(ctid) DESC LIMIT k returns the top k for each outer row. This is the shape behind “recommended for you” lists and any page that shows the best matches per user, per category, or per saved search.

Need help?

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