apps/docs/content/guides/database/full-text-search.mdx
Postgres has built-in functions to handle Full Text Search queries. This is like a "search engine" within Postgres.
For this guide we'll use the following example data:
<Tabs scrollable size="small" type="underlined" defaultActiveId="data" queryGroup="example-view"
<TabPanel id="data" label="Data">
| id | title | author | description |
|---|---|---|---|
| 1 | The Poky Little Puppy | Janette Sebring Lowrey | Puppy is slower than other, bigger animals. |
| 2 | The Tale of Peter Rabbit | Beatrix Potter | Rabbit eats some vegetables. |
| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. |
| 4 | Green Eggs and Ham | Dr. Seuss | Sam has changing food preferences and eats unusually colored food. |
| 5 | Harry Potter and the Goblet of Fire | J.K. Rowling | Fourth year of school starts, big drama ensues. |
create table books (
id serial primary key,
title text,
author text,
description text
);
insert into books
(title, author, description)
values
(
'The Poky Little Puppy',
'Janette Sebring Lowrey',
'Puppy is slower than other, bigger animals.'
),
('The Tale of Peter Rabbit', 'Beatrix Potter', 'Rabbit eats some vegetables.'),
('Tootle', 'Gertrude Crampton', 'Little toy train has big dreams.'),
(
'Green Eggs and Ham',
'Dr. Seuss',
'Sam has changing food preferences and eats unusually colored food.'
),
(
'Harry Potter and the Goblet of Fire',
'J.K. Rowling',
'Fourth year of school starts, big drama ensues.'
);
The functions we'll cover in this guide are:
to_tsvector() [#to-tsvector]Converts your data into searchable tokens. to_tsvector() stands for "to text search vector." For example:
select to_tsvector('green eggs and ham');
-- Returns 'egg':2 'green':1 'ham':4
Collectively these tokens are called a "document" which Postgres can use for comparisons.
to_tsquery() [#to-tsquery]Converts a query string into tokens to match. to_tsquery() stands for "to text search query."
This conversion step is important because we will want to "fuzzy match" on keywords.
For example if a user searches for eggs, and a column has the value egg, we probably still want to return a match.
Postgres provides several functions to create tsquery objects:
to_tsquery() - Requires manual specification of operators (&, |, !)plainto_tsquery() - Converts plain text to an AND query: plainto_tsquery('english', 'fat rats') → 'fat' & 'rat'phraseto_tsquery() - Creates phrase queries: phraseto_tsquery('english', 'fat rats') → 'fat' <-> 'rat'websearch_to_tsquery() - Supports web search syntax with quotes, "or", and negation@@ [#match]The @@ symbol is the "match" symbol for Full Text Search. It returns any matches between a to_tsvector result and a to_tsquery result.
Take the following example:
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select *
from books
where title = 'Harry';
const { data, error } = await supabase.from('books').select().eq('title', 'Harry')
final result = await client
.from('books')
.select()
.eq('title', 'Harry');
let response = try await supabase.from("books")
.select()
.eq("title", value: "Harry")
.execute()
val data = supabase.from("books").select {
filter {
eq("title", "Harry")
}
}
data = supabase.from_('books').select().eq('title', 'Harry').execute()
The equality symbol above (=) is very "strict" on what it matches. In a full text search context, we might want to find all "Harry Potter" books and so we can rewrite the
example above:
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select *
from books
where to_tsvector(title) @@ to_tsquery('Harry');
const { data, error } = await supabase.from('books').select().textSearch('title', `'Harry'`)
final result = await client
.from('books')
.select()
.textSearch('title', "'Harry'");
let response = try await supabase.from("books")
.select()
.textSearch("title", value: "'Harry'")
val data = supabase.from("books").select {
filter {
textSearch("title", "'Harry'", TextSearchType.NONE)
}
}
To find all books where the description contain the word big:
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select
*
from
books
where
to_tsvector(description)
@@ to_tsquery('big');
const { data, error } = await supabase.from('books').select().textSearch('description', `'big'`)
final result = await client
.from('books')
.select()
.textSearch('description', "'big'");
let response = await client.from("books")
.select()
.textSearch("description", value: "'big'")
.execute()
val data = supabase.from("books").select {
filter {
textSearch("description", "'big'", TextSearchType.NONE)
}
}
data = supabase.from_('books').select().text_search('description', "'big'").execute()
| id | title | author | description |
|---|---|---|---|
| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. |
| 5 | Harry Potter and the Goblet of Fire | J.K. Rowling | Fourth year of school starts, big drama ensues. |
Right now there is no direct way to use JavaScript or Dart to search through multiple columns but you can do it by creating computed columns on the database.
To find all books where description or title contain the word little:
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select
*
from
books
where
to_tsvector(description || ' ' || title) -- concat columns, but be sure to include a space to separate them!
@@ to_tsquery('little');
create function title_description(books) returns text as $$
select $1.title || ' ' || $1.description;
$$ language sql immutable;
const { data, error } = await supabase
.from('books')
.select()
.textSearch('title_description', `little`)
create function title_description(books) returns text as $$
select $1.title || ' ' || $1.description;
$$ language sql immutable;
final result = await client
.from('books')
.select()
.textSearch('title_description', "little")
create function title_description(books) returns text as $$
select $1.title || ' ' || $1.description;
$$ language sql immutable;
let response = try await client
.from("books")
.select()
.textSearch("title_description", value: "little")
.execute()
create function title_description(books) returns text as $$
select $1.title || ' ' || $1.description;
$$ language sql immutable;
val data = supabase.from("books").select {
filter {
textSearch("title_description", "title", TextSearchType.NONE)
}
}
create function title_description(books) returns text as $$
select $1.title || ' ' || $1.description;
$$ language sql immutable;
data = supabase.from_('books').select().text_search('title_description', "little").execute()
| id | title | author | description |
|---|---|---|---|
| 1 | The Poky Little Puppy | Janette Sebring Lowrey | Puppy is slower than other, bigger animals. |
| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. |
To find all books where description contains BOTH of the words little and big, we can use the & symbol:
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select
*
from
books
where
to_tsvector(description)
@@ to_tsquery('little & big'); -- use & for AND in the search query
const { data, error } = await supabase
.from('books')
.select()
.textSearch('description', `'little' & 'big'`)
final result = await client
.from('books')
.select()
.textSearch('description', "'little' & 'big'");
let response = try await client
.from("books")
.select()
.textSearch("description", value: "'little' & 'big'");
.execute()
val data = supabase.from("books").select {
filter {
textSearch("description", "'title' & 'big'", TextSearchType.NONE)
}
}
data = supabase.from_('books').select().text_search('description', "'little' & 'big'").execute()
| id | title | author | description |
|---|---|---|---|
| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. |
To find all books where description contain ANY of the words little or big, use the | symbol:
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select
*
from
books
where
to_tsvector(description)
@@ to_tsquery('little | big'); -- use | for OR in the search query
const { data, error } = await supabase
.from('books')
.select()
.textSearch('description', `'little' | 'big'`)
final result = await client
.from('books')
.select()
.textSearch('description', "'little' | 'big'");
let response = try await client
.from("books")
.select()
.textSearch("description", value: "'little' | 'big'")
.execute()
val data = supabase.from("books").select {
filter {
textSearch("description", "'title' | 'big'", TextSearchType.NONE)
}
}
response = client.from_('books').select().text_search('description', "'little' | 'big'").execute()
| id | title | author | description |
|---|---|---|---|
| 1 | The Poky Little Puppy | Janette Sebring Lowrey | Puppy is slower than other, bigger animals. |
| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. |
Notice how searching for big includes results with the word bigger (or biggest, etc).
Partial search is particularly useful when you want to find matches on substrings within your data.
You can use the :* syntax with to_tsquery(). Here's an example that searches for any book titles beginning with "Lit":
select title from books where to_tsvector(title) @@ to_tsquery('Lit:*');
To make the partial search functionality accessible through the API, you can wrap the search logic in a stored procedure.
After creating this function, you can invoke it from your application using the SDK for your platform. Here's an example:
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
create or replace function search_books_by_title_prefix(prefix text)
returns setof books AS $$
begin
return query
select * from books where to_tsvector('english', title) @@ to_tsquery(prefix || ':*');
end;
$$ language plpgsql;
const { data, error } = await supabase.rpc('search_books_by_title_prefix', { prefix: 'Lit' })
final data = await supabase.rpc('search_books_by_title_prefix', params: { 'prefix': 'Lit' });
let response = try await supabase.rpc(
"search_books_by_title_prefix",
params: ["prefix": "Lit"]
)
.execute()
val rpcParams = mapOf("prefix" to "Lit")
val result = supabase.postgrest.rpc("search_books_by_title_prefix", rpcParams)
data = client.rpc('search_books_by_title_prefix', { 'prefix': 'Lit' }).execute()
This function takes a prefix parameter and returns all books where the title contains a word starting with that prefix. The :* operator is used to denote a prefix match in the to_tsquery() function.
When you want the search term to include a phrase or multiple words, you can concatenate words using a + as a placeholder for space:
select * from search_books_by_title_prefix('Little+Puppy');
websearch_to_tsquery() [#websearch-to-tsquery]The websearch_to_tsquery() function provides an intuitive search syntax similar to popular web search engines, making it ideal for user-facing search interfaces.
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select *
from books
where to_tsvector(description) @@ websearch_to_tsquery('english', 'green eggs');
const { data, error } = await supabase
.from('books')
.select()
.textSearch('description', 'green eggs', { type: 'websearch' })
Use quotes to search for exact phrases:
select * from books
where to_tsvector(description || ' ' || title) @@ websearch_to_tsquery('english', '"Green Eggs"');
-- Matches documents containing "Green" immediately followed by "Eggs"
Use "or" (case-insensitive) to search for multiple terms:
select * from books
where to_tsvector(description) @@ websearch_to_tsquery('english', 'puppy or rabbit');
-- Matches documents containing either "puppy" OR "rabbit"
Use a dash (-) to exclude terms:
select * from books
where to_tsvector(description) @@ websearch_to_tsquery('english', 'animal -rabbit');
-- Matches documents containing "animal" but NOT "rabbit"
Combine multiple operators for sophisticated searches:
select * from books
where to_tsvector(description || ' ' || title) @@
websearch_to_tsquery('english', '"Harry Potter" or "Dr. Seuss" -vegetables');
-- Matches books by "Harry Potter" or "Dr. Seuss" but excludes those mentioning vegetables
Now that you have Full Text Search working, create an index. This allows Postgres to "build" the documents preemptively so that they
don't need to be created at the time we execute the query. This will make our queries much faster.
Let's create a new column fts inside the books table to store the searchable index of the title and description columns.
We can use a special feature of Postgres called
Generated Columns
to ensure that the index is updated any time the values in the title and description columns change.
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="example-view"
<TabPanel id="sql" label="SQL">
alter table
books
add column
fts tsvector generated always as (to_tsvector('english', description || ' ' || title)) stored;
create index books_fts on books using gin (fts); -- generate the index
select id, fts
from books;
| id | fts |
| --- | --------------------------------------------------------------------------------------------------------------- |
| 1 | 'anim':7 'bigger':6 'littl':10 'poki':9 'puppi':1,11 'slower':3 |
| 2 | 'eat':2 'peter':8 'rabbit':1,9 'tale':6 'veget':4 |
| 3 | 'big':5 'dream':6 'littl':1 'tootl':7 'toy':2 'train':3 |
| 4 | 'chang':3 'color':9 'eat':7 'egg':12 'food':4,10 'green':11 'ham':14 'prefer':5 'sam':1 'unus':8 |
| 5 | 'big':6 'drama':7 'ensu':8 'fire':15 'fourth':1 'goblet':13 'harri':9 'potter':10 'school':4 'start':5 'year':2 |
Now that we've created and populated our index, we can search it using the same techniques as before:
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select
*
from
books
where
fts @@ to_tsquery('little & big');
const { data, error } = await supabase.from('books').select().textSearch('fts', `'little' & 'big'`)
final result = await client
.from('books')
.select()
.textSearch('fts', "'little' & 'big'");
let response = try await client
.from("books")
.select()
.textSearch("fts", value: "'little' & 'big'")
.execute()
val data = supabase.from("books").select {
filter {
textSearch("fts", "'title' & 'big'", TextSearchType.NONE)
}
}
data = client.from_('books').select().text_search('fts', "'little' & 'big'").execute()
| id | title | author | description | fts |
|---|---|---|---|---|
| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. | 'big':5 'dream':6 'littl':1 'tootl':7 'toy':2 'train':3 |
Visit Postgres: Text Search Functions and Operators
to learn about additional query operators you can use to do more advanced full text queries, such as:
<-> [#proximity]The proximity symbol is useful for searching for terms that are a certain "distance" apart.
For example, to find the phrase big dreams, where the a match for "big" is followed immediately by a match for "dreams":
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select
*
from
books
where
to_tsvector(description) @@ to_tsquery('big <-> dreams');
const { data, error } = await supabase
.from('books')
.select()
.textSearch('description', `'big' <-> 'dreams'`)
final result = await client
.from('books')
.select()
.textSearch('description', "'big' <-> 'dreams'");
let response = try await client
.from("books")
.select()
.textSearch("description", value: "'big' <-> 'dreams'")
.execute()
val data = supabase.from("books").select {
filter {
textSearch("description", "'big' <-> 'dreams'", TextSearchType.NONE)
}
}
data = client.from_('books').select().text_search('description', "'big' <-> 'dreams'").execute()
We can also use the <-> to find words within a certain distance of each other. For example to find year and school within 2 words of each other:
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select
*
from
books
where
to_tsvector(description) @@ to_tsquery('year <2> school');
const { data, error } = await supabase
.from('books')
.select()
.textSearch('description', `'year' <2> 'school'`)
final result = await client
.from('books')
.select()
.textSearch('description', "'year' <2> 'school'");
let response = try await supabase
.from("books")
.select()
.textSearch("description", value: "'year' <2> 'school'")
.execute()
val data = supabase.from("books").select {
filter {
textSearch("description", "'year' <2> 'school'", TextSearchType.NONE)
}
}
data = client.from_('books').select().text_search('description', "'year' <2> 'school'").execute()
! [#negation]The negation symbol can be used to find phrases which don't contain a search term.
For example, to find records that have the word big but not little:
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL">
select
*
from
books
where
to_tsvector(description) @@ to_tsquery('big & !little');
const { data, error } = await supabase
.from('books')
.select()
.textSearch('description', `'big' & !'little'`)
final result = await client
.from('books')
.select()
.textSearch('description', "'big' & !'little'");
let response = try await client
.from("books")
.select()
.textSearch("description", value: "'big' & !'little'")
.execute()
val data = supabase.from("books").select {
filter {
textSearch("description", "'big' & !'little'", TextSearchType.NONE)
}
}
data = client.from_('books').select().text_search('description', "'big' & !'little'").execute()
Postgres provides ranking functions to sort search results by relevance, helping you present the most relevant matches first. Since ranking functions need to be computed server-side, use RPC functions and generated columns.
First, create a Postgres function that handles search and ranking:
create or replace function search_books(search_query text)
returns table(id int, title text, description text, rank real) as $$
begin
return query
select
books.id,
books.title,
books.description,
ts_rank(to_tsvector('english', books.description), to_tsquery(search_query)) as rank
from books
where to_tsvector('english', books.description) @@ to_tsquery(search_query)
order by rank desc;
end;
$$ language plpgsql;
Now you can call this function from your client:
<Tabs scrollable size="small" type="underlined" defaultActiveId="js" queryGroup="language"
<TabPanel id="js" label="JavaScript">
const { data, error } = await supabase.rpc('search_books', { search_query: 'big' })
final result = await client
.rpc('search_books', params: { 'search_query': 'big' });
data = client.rpc('search_books', { 'search_query': 'big' }).execute()
select * from search_books('big');
Postgres allows you to assign different importance levels to different parts of your documents using weight labels. This is especially useful when you want matches in certain fields (like titles) to rank higher than matches in other fields (like descriptions).
Postgres uses four weight labels: A, B, C, and D, where:
First, create a weighted tsvector column that gives titles higher priority than descriptions:
-- Add a weighted fts column
alter table books
add column fts_weighted tsvector
generated always as (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', description), 'B')
) stored;
-- Create index for the weighted column
create index books_fts_weighted on books using gin (fts_weighted);
Now create a search function that uses this weighted column:
create or replace function search_books_weighted(search_query text)
returns table(id int, title text, description text, rank real) as $$
begin
return query
select
books.id,
books.title,
books.description,
ts_rank(books.fts_weighted, to_tsquery(search_query)) as rank
from books
where books.fts_weighted @@ to_tsquery(search_query)
order by rank desc;
end;
$$ language plpgsql;
You can also specify custom weights by providing a weight array to ts_rank():
create or replace function search_books_custom_weights(search_query text)
returns table(id int, title text, description text, rank real) as $$
begin
return query
select
books.id,
books.title,
books.description,
ts_rank(
'{0.0, 0.2, 0.5, 1.0}'::real[], -- Custom weights {D, C, B, A}
books.fts_weighted,
to_tsquery(search_query)
) as rank
from books
where books.fts_weighted @@ to_tsquery(search_query)
order by rank desc;
end;
$$ language plpgsql;
This example uses custom weights where:
<Tabs scrollable size="small" type="underlined" defaultActiveId="js" queryGroup="language"
<TabPanel id="js" label="JavaScript">
// Search with standard weighted ranking
const { data, error } = await supabase.rpc('search_books_weighted', { search_query: 'Harry' })
// Search with custom weights
const { data: customData, error: customError } = await supabase.rpc('search_books_custom_weights', {
search_query: 'Harry',
})
# Search with standard weighted ranking
data = client.rpc('search_books_weighted', { 'search_query': 'Harry' }).execute()
# Search with custom weights
custom_data = client.rpc('search_books_custom_weights', { 'search_query': 'Harry' }).execute()
-- Standard weighted search
select * from search_books_weighted('Harry');
-- Custom weighted search
select * from search_books_custom_weights('Harry');
Say you search for "Harry". With weighted columns:
This ensures that books with "Harry" in the title ranks significantly higher than books that only mention "Harry" in the description, providing more relevant search results for users.
When using the fts column you created earlier, ranking becomes more efficient. Create a function that uses the indexed column:
create or replace function search_books_fts(search_query text)
returns table(id int, title text, description text, rank real) as $$
begin
return query
select
books.id,
books.title,
books.description,
ts_rank(books.fts, to_tsquery(search_query)) as rank
from books
where books.fts @@ to_tsquery(search_query)
order by rank desc;
end;
$$ language plpgsql;
<Tabs scrollable size="small" type="underlined" defaultActiveId="js" queryGroup="language"
<TabPanel id="js" label="JavaScript">
const { data, error } = await supabase.rpc('search_books_fts', { search_query: 'little & big' })
final result = await client
.rpc('search_books_fts', params: { 'search_query': 'little & big' });
data = client.rpc('search_books_fts', { 'search_query': 'little & big' }).execute()
select * from search_books_fts('little & big');
You can also create a function that combines websearch_to_tsquery() with ranking for user-friendly search:
create or replace function websearch_books(search_text text)
returns table(id int, title text, description text, rank real) as $$
begin
return query
select
books.id,
books.title,
books.description,
ts_rank(books.fts, websearch_to_tsquery('english', search_text)) as rank
from books
where books.fts @@ websearch_to_tsquery('english', search_text)
order by rank desc;
end;
$$ language plpgsql;
<Tabs scrollable size="small" type="underlined" defaultActiveId="js" queryGroup="language"
<TabPanel id="js" label="JavaScript">
// Support natural search syntax
const { data, error } = await supabase.rpc('websearch_books', {
search_text: '"little puppy" or train -vegetables',
})
select * from websearch_books('"little puppy" or train -vegetables');