Full-Text Search with PostgreSQL and Prisma

·16 min read

blog/postgres-full-text-search

Are you struggling with slow and inefficient searches in your database? Do you want to provide your users with lightning-fast, relevant search results? Look no further! In this two-part tutorial, we’ll dive into the world of full-text search using PostgreSQL, and then integrate it with Next.js and Prisma.

In Part 1, we’ll cover:

  • The basics of full-text search and its advantages over traditional LIKE queries
  • How to set up and use PostgreSQL’s native full-text search capabilities
  • Integrating full-text search with Next.js
  • Using Prisma with PostgreSQL full-text search

By the end of this part, you’ll have a solid foundation in implementing powerful search features in your applications.

Table of Contents

Imagine you have a huge library of books, and you want to find all the books that mention “time travel.” You could flip through each book, page by page, but that would take forever! Full-text search is like having a super-smart assistant who can instantly find all the relevant books for you.

In database terms, full-text search allows you to search through large amounts of text quickly and efficiently. It goes beyond simple pattern matching (like the LIKE operator in SQL) and understands the meaning of words, their variations, and even their importance in the text.

Comparison with Traditional LIKE Queries

Traditional LIKE queries in SQL are limited in several ways:

  1. Performance: LIKE queries with wildcards (e.g., WHERE title LIKE '%time travel%') can’t use indexes effectively, leading to full table scans. This becomes increasingly slow as your dataset grows.

  2. Flexibility: LIKE queries don’t handle word variations or synonyms. Searching for “run” won’t find “running” or “ran”.

  3. Relevance: LIKE queries can’t rank results based on relevance. They simply return all matches in no particular order.

Full-text search addresses these limitations:

  1. Performance: Full-text search uses specialized indexes, allowing for fast searches even on large datasets.

  2. Flexibility: It understands word variations and can be configured to understand synonyms.

  3. Relevance: Results can be ranked based on how well they match the search query.

Performance Advantages for Large Datasets

For large datasets, the performance difference between LIKE queries and full-text search can be dramatic:

  • A LIKE query might take seconds or even minutes to search through millions of rows.
  • A properly indexed full-text search can return results in milliseconds, regardless of the dataset size.

This performance advantage becomes crucial for applications with large amounts of text data or those requiring real-time search capabilities.

PostgreSQL, a powerful open-source database, comes with built-in full-text search capabilities. It can:

  • Break down text into words (called tokens)
  • Remove common words that don’t help in searching (like “the” or “and”)
  • Understand different forms of words (e.g., “run,” “running,” and “ran” are treated as the same word)
  • Rank results based on how well they match the search query

Now that we know what full-text search is, let’s see how we can set it up using Prisma, a modern database toolkit.

2. Native PostgreSQL Full-Text Search

Quick Note on PostgreSQL Basics:

  1. To connect to PostgreSQL, open your terminal and type:
    psql postgres
  2. To create a new database, use:
    CREATE DATABASE your_database_name;
  3. To connect to your new database:
    \c your_database_name
    or exit psql and reconnect:
    psql your_database_name

PostgreSQL provides robust full-text search capabilities out of the box. Let’s explore how to use these features directly with PostgreSQL before we integrate them into our application.

2.1 Setting Up Full-Text Search Indexes

First, let’s create a table and add some sample data:

-- Create a table
CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    title TEXT,
    content TEXT
);

-- Insert some sample data
INSERT INTO articles (title, content) VALUES
('PostgreSQL Full-Text Search', 'Learn how to implement powerful full-text search capabilities in PostgreSQL...'),
('Next.js and Database Integration', 'Explore efficient ways to connect Next.js applications with databases...'),
('Prisma ORM Overview', 'Discover the features and benefits of using Prisma ORM for database operations...');

-- Create a GIN index for full-text search
CREATE INDEX articles_fts_idx ON articles USING gin(to_tsvector('english', title || ' ' || content));

Here, we’ve created a GIN (Generalized Inverted Index) on a combination of the title and content fields. The to_tsvector function converts the text into a searchable format.

Note: GIN indexes are best for static data that doesn’t change often. They’re faster for lookups but slower to build and take more space. For frequently changing data, consider using a GiST (Generalized Search Tree) index instead.

2.2 Basic Full-Text Search Queries

Now that we have our index set up, let’s explore various ways to perform full-text searches:

-- Basic search for articles containing both "postgresql" and "search"
SELECT title
FROM articles
WHERE to_tsvector('english', title || ' ' || content) @@ to_tsquery('english', 'postgresql & search');

-- Search for articles containing either "database" or "sql"
SELECT title
FROM articles
WHERE to_tsvector('english', title || ' ' || content) @@ to_tsquery('english', 'database | sql');

-- Search for articles containing "full" followed by "text" (in that order)
SELECT title
FROM articles
WHERE to_tsvector('english', title || ' ' || content) @@ to_tsquery('english', 'full <-> text');

-- Search for articles containing "database" but not "nosql"
SELECT title
FROM articles
WHERE to_tsvector('english', title || ' ' || content) @@ to_tsquery('english', 'database & !nosql');

Let’s break down these queries and their results:

  1. Basic AND search:

    title
    -----------------------------
    PostgreSQL Full-Text Search
    (1 row)

    This query returns articles containing both “postgresql” and “search”.

  2. OR search:

    title
    ----------------------------------
    Next.js and Database Integration
    Prisma ORM Overview
    (2 rows)

    This query returns articles containing either “database” or “sql”.

  3. Phrase search:

    title
    -----------------------------
    PostgreSQL Full-Text Search
    (1 row)

    This query looks for “full” immediately followed by “text”.

  4. Exclusion search:

    title
    ----------------------------------
    Next.js and Database Integration
    Prisma ORM Overview
    (2 rows)

    This query returns articles containing “database” but not “nosql”.

In these queries:

  • to_tsvector converts our text to a searchable format called a tsvector.
  • @@ is the text search match operator, which returns true if the tsvector matches the tsquery.
  • to_tsquery converts our search term into a tsquery format, which can include operators:
    • & for AND
    • | for OR
    • ! for NOT
    • <-> for phrase search (words must be adjacent and in order)

Use these queries when you need precise control over the search logic. They’re particularly useful for:

  • Implementing advanced search features in applications
  • Searching across multiple fields (like title and content)
  • Creating complex search conditions (e.g., must contain X and Y, but not Z)

2.3 Advanced Query Techniques

PostgreSQL offers several functions for more user-friendly and flexible searches:

  1. plainto_tsquery: Converts plain text to a tsquery, treating the input as a phrase
  2. phraseto_tsquery: Similar to plainto_tsquery, but it also creates phrase searches
  3. websearch_to_tsquery: Supports a syntax similar to what web search engines use

Let’s look at examples and use cases for each:

-- plainto_tsquery: Searches for articles containing all words in any order
SELECT title
FROM articles
WHERE to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', 'postgresql database');

-- phraseto_tsquery: Searches for articles containing the exact phrase
SELECT title
FROM articles
WHERE to_tsvector('english', title || ' ' || content) @@ phraseto_tsquery('english', 'full text search');

-- websearch_to_tsquery: Supports a more intuitive search syntax
SELECT title
FROM articles
WHERE to_tsvector('english', title || ' ' || content) @@ websearch_to_tsquery('english', 'postgresql -prisma "full text search"');

The websearch_to_tsquery function supports the following syntax:

  • Unquoted words are treated as optional but preferred
  • "quoted phrases" must appear exactly as written
  • - excludes words (e.g., -prisma excludes articles containing “prisma”)
  • OR can be used to specify alternatives

Use these functions when:

  • You want to provide a more user-friendly search interface
  • You need to handle user input that might include quotes or special characters
  • You want to mimic the behavior of popular web search engines

2.4 Ranking and Highlighting Results

To improve the usefulness of our search results, we can rank them and highlight the matching terms:

SELECT 
    title,
    ts_rank(to_tsvector('english', title || ' ' || content), query) AS rank,
    ts_headline('english', content, query, 'StartSel = <mark>, StopSel = </mark>, MaxWords=35, MinWords=15') AS excerpt
FROM 
    articles,
    to_tsquery('english', 'postgresql & search') query
WHERE 
    to_tsvector('english', title || ' ' || content) @@ query
ORDER BY 
    rank DESC;

Let’s analyze the results of each query:

  1. plainto_tsquery:

    title
    -------
    (0 rows)

    This query returned no results. It searched for articles containing both “postgresql” and “database” in any order. The lack of results suggests that no single article in our sample data contains both these terms.

  2. phraseto_tsquery:

    title
    -----------------------------
    PostgreSQL Full-Text Search
    (1 row)

    This query successfully found an article with the exact phrase “full text search”. It’s more strict than plainto_tsquery as it requires the words to appear in the specified order.

  3. websearch_to_tsquery:

    title
    -----------------------------
    PostgreSQL Full-Text Search
    (1 row)

    This query demonstrates a more complex search: it looks for articles containing “postgresql” and the phrase “full text search”, but excludes any containing “prisma”. The result shows it successfully found a matching article.

Let’s break this query down:

  1. ts_rank calculates a relevance score based on how well the document matches the query. It considers factors like:

    • How many times the search terms appear
    • How close the search terms are to each other
    • The importance of the field where the match occurred (e.g., title vs. content)
  2. ts_headline generates an excerpt of the content with matching terms highlighted. We can customize its behavior:

    • StartSel and StopSel define the HTML tags used for highlighting
    • MaxWords and MinWords control the length of the excerpt
    • Other options include ShortWord (minimum word length to highlight) and HighlightAll (highlight all words)
  3. We join the articles table with the query to avoid repeating the to_tsquery call

  4. The results are ordered by rank, showing the most relevant results first

Use this approach when you want to:

  • Present search results in order of relevance
  • Show users why a particular result matched their search
  • Provide context around the matching terms in the search results

For even more control over ranking, you can use ts_rank_cd, which considers the proximity of matching lexemes:

SELECT 
    title,
    ts_rank_cd(to_tsvector('english', title || ' ' || content), query) AS rank,
    ts_headline('english', content, query, 'StartSel = <mark>, StopSel = </mark>') AS excerpt
FROM 
    articles,
    to_tsquery('english', 'postgresql & search') query
WHERE 
    to_tsvector('english', title || ' ' || content) @@ query
ORDER BY 
    rank DESC;

Let’s break down the results:

Title: PostgreSQL Full-Text Search
Rank: 0.058333334
Excerpt: Learn how to implement powerful full-text <mark>search</mark> capabilities in <mark>PostgreSQL</mark>...

(1 row)

This query demonstrates several powerful features:

  1. Matching: It finds articles that contain both “postgresql” and “search”.
  2. Ranking: The ts_rank_cd function assigns a relevance score to each result. Higher scores indicate more relevant matches.
  3. Highlighting: The ts_headline function creates an excerpt of the content with the matching terms highlighted (in this case, wrapped in <mark> tags).
  4. Ordering: The results are ordered by rank, showing the most relevant results first.

Use this approach when you want to:

  • Present search results in order of relevance
  • Show users why a particular result matched their search
  • Provide context around the matching terms in the search results

ts_rank_cd is particularly useful when you want to give higher scores to documents where the matching terms appear closer together.

By understanding and utilizing these PostgreSQL full-text search features, you can create powerful and flexible search functionality in your applications, providing users with fast, relevant, and highlighted search results.

3. Integrating PostgreSQL Full-Text Search with Next.js

Now that we understand how to use PostgreSQL’s full-text search capabilities, let’s integrate them into a Next.js application.

3.1 Setting Up PostgreSQL and Creating Credentials

First, let’s set up PostgreSQL using psql:

  1. Open a terminal and start psql:

    psql postgres
  2. I’m going to use the db I created earlier full_text_search_db:

    CREATE USER youruser WITH ENCRYPTED PASSWORD 'yourpassword';
    GRANT ALL PRIVILEGES ON DATABASE full_text_search_db TO youruser;
  3. Connect to your new database:

    \c full_text_search_db
  4. Exit psql:

    \q

Now that we have our database credentials set up, let’s create a connection string:

postgresql://youruser:yourpassword@localhost:5432/full_text_search_db

You can use this connection string in your Next.js application. For example, in your lib/db.js:

const { Pool } = require('pg');

const pool = new Pool({
  connectionString: 'postgresql://youruser:yourpassword@localhost:5432/yourdatabasename',
});

module.exports = {
  query: (text, params) => pool.query(text, params),
};

If you prefer to use environment variables (recommended for production), you can set DATABASE_URL in your .env file:

DATABASE_URL=postgresql://youruser:yourpassword@localhost:5432/full_text_search_db

Then use it in your code:

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

Remember to replace youruser, yourpassword, and full_text_search_db with your actual PostgreSQL credentials.

3.2 Setting Up a Next.js Project with PostgreSQL

First, create a new Next.js project and install the necessary dependencies:

npx create-next-app@latest my-search-app
cd my-search-app
npm install pg

Create a database connection utility in lib/db.js:

const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

module.exports = {
  query: (text, params) => pool.query(text, params),
};

3.3 Creating API Routes for Full-Text Search

Create an API route for search in pages/api/search.js:

import db from '../../lib/db';

export default async function handler(req, res) {
  const { q } = req.query;
  
  if (!q) {
    return res.status(400).json({ error: 'Search query is required' });
  }

  try {
    const result = await db.query(
      `SELECT title,
              ts_rank(to_tsvector('english', title || ' ' || content), query) AS rank,
              ts_headline('english', content, query, 'StartSel = <mark>, StopSel = </mark>')
       FROM articles,
            plainto_tsquery('english', $1) query
       WHERE to_tsvector('english', title || ' ' || content) @@ query
       ORDER BY rank DESC`,
      [q]
    );

    res.status(200).json(result.rows);
  } catch (error) {
    console.error('Search error:', error);
    res.status(500).json({ error: 'An error occurred while searching' });
  }
}

3.4 Implementing Search in Next.js Components

Create a search component in components/Search.js:

import { useState } from 'react';

export default function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleSearch = async (e) => {
    e.preventDefault();
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await res.json();
    setResults(data);
  };

  return (
    <div>
      <form onSubmit={handleSearch}>
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search articles..."
        />
        <button type="submit">Search</button>
      </form>
      <ul>
        {results.map((article) => (
          <li key={article.id}>
            <h3>{article.title}</h3>
            <p dangerouslySetInnerHTML={{ __html: article.ts_headline }}></p>
          </li>
        ))}
      </ul>
    </div>
  );
}

Now you can use this Search component in any of your pages.

4. Using Prisma with PostgreSQL Full-Text Search

While Prisma doesn’t directly support PostgreSQL’s full-text search features, we can still use them together with raw queries.

4.1 Setting Up Prisma with PostgreSQL

First, install Prisma:

npm install @prisma/client
npx prisma init

Update your schema.prisma file:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Article {
  id      Int    @id @default(autoincrement())
  title   String
  content String
}

4.2 Creating Full-Text Search Indexes using Prisma Migrations

Create a new migration:

npx prisma migrate dev --name add_full_text_search

In the generated migration file, add the following SQL:

-- CreateIndex
CREATE INDEX articles_fts_idx ON "Article" USING gin(to_tsvector('english', title || ' ' || content));

4.3 Performing Full-Text Searches through Prisma

Update your API route to use Prisma:

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export default async function handler(req, res) {
  const { q } = req.query;
  
  if (!q) {
    return res.status(400).json({ error: 'Search query is required' });
  }

  try {
    const results = await prisma.$queryRaw`
      SELECT id, title,
             ts_rank(to_tsvector('english', title || ' ' || content), query) AS rank,
             ts_headline('english', content, query, 'StartSel = <mark>, StopSel = </mark>') as highlight
      FROM "Article",
           plainto_tsquery('english', ${q}) query
      WHERE to_tsvector('english', title || ' ' || content) @@ query
      ORDER BY rank DESC
    `;

    res.status(200).json(results);
  } catch (error) {
    console.error('Search error:', error);
    res.status(500).json({ error: 'An error occurred while searching' });
  }
}

4.4 Conclusion: Integrating Full-Text Search with Next.js and Prisma

By combining PostgreSQL’s powerful full-text search capabilities with Next.js and Prisma, we’ve created a robust and efficient search solution. While Prisma doesn’t natively support full-text search, using raw SQL queries allows us to leverage the best of both worlds: Prisma’s excellent ORM features for most database operations, and PostgreSQL’s specialized full-text search for advanced querying.

This approach provides several benefits:

  1. Type-safe and intuitive database operations with Prisma for most of your application.
  2. Powerful and flexible full-text search capabilities using PostgreSQL’s built-in features.
  3. Seamless integration with Next.js, allowing for server-side search processing and efficient client-side rendering.

As we move forward, we’ll explore even more advanced search techniques using pgvector, which can provide semantic search capabilities beyond traditional full-text search.

5. Conclusion and Next Steps

Congratulations! You’ve now mastered the fundamentals of implementing full-text search with PostgreSQL and Prisma. Let’s recap what we’ve learned:

  1. We explored the concept of full-text search and its advantages over traditional LIKE queries.
  2. We set up full-text search indexes in PostgreSQL and learned how to perform basic and advanced search queries.
  3. We integrated PostgreSQL’s full-text search capabilities with a Next.js application.
  4. We used Prisma alongside PostgreSQL’s full-text search features, combining the best of both worlds.

This knowledge equips you to create fast, efficient, and relevant search functionality in your applications. But the journey doesn’t end here! In Part 2, we’ll dive into even more advanced search techniques using pgvector, which will allow us to perform semantic searches and work with high-dimensional data.

As you continue to develop your search functionality, consider exploring these topics:

  1. Multilingual support for non-English content
  2. Fuzzy searching for typo tolerance
  3. Faceted search to enhance user experience
  4. Real-time search suggestions

Remember, while PostgreSQL’s full-text search is powerful, very large-scale applications might benefit from dedicated search engines like Elasticsearch or Algolia. However, for most applications, the techniques we’ve covered here will provide excellent performance and flexibility.

In Part 2, we’ll take your search capabilities to the next level with pgvector!

Enjoyed this article? Subscribe for more!

Stay Updated

Get my new content delivered straight to your inbox. No spam, ever.

Related PostsPostgres, Development, Databases, Prisma, Nextjs

Pedro Alonso

I'm a software developer and consultant. I help companies build great products. Contact me by email.

Get the latest articles delivered straight to your inbox.