#RAG#Next.js

Hands-On: Add Semantic Search to a Content Site

Keyword search cannot handle vague questions or synonyms; semantic search fills the gap. This guide uses Next.js and pgvector to add complete semantic search to a content site, covering indexing, queries, hybrid retrieval, frontend highlighting, and incremental updates in a production-ready design.

Key Points

  • Topic Focus: RAG、Next.js
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Your blog, documentation site, or community probably still relies on keyword matching in its search box. That has a longstanding problem: a user searches for "how to deploy Next.js," but the title says "Taking an Application Live." If the keywords do not match, the page cannot be found. Semantic search solves this by understanding that "deployment ≈ going live" and "Next.js application ≈ Next.js."

This article uses Next.js + pgvector to add complete semantic search to a content site with a production-ready solution.

1. Why Choose pgvector

If your site already uses PostgreSQL, pgvector is the first choice: there is no need to run a separate vector service, and one database can store both application data and vectors. It supports JOINs and conditional filtering ("search only published content or a specific category"), and it is more than sufficient for small and medium-scale workloads of up to millions of records.

sql
CREATE EXTENSION IF NOT EXISTS vector;

ALTER TABLE articles ADD COLUMN embedding vector(1536);
CREATE INDEX ON articles USING hnsw (embedding vector_cosine_ops);

1536 is the dimensionality used by OpenAI's text-embedding-3-small; change the dimension when you change models. The HNSW index makes vector retrieval fast, with millisecond latency at the million-vector scale.

2. Indexing: Vectorize the Content

When content is stored (when an article is created or updated), vectorize the body and save it in the embedding column.

ts
// app/admin/actions.ts or a backend script
import OpenAI from "openai";
const openai = new OpenAI();

async function embed(text: string): Promise<number[]> {
  const res = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: text.slice(0, 8000),  // Truncate to avoid exceeding the limit
  });
  return res.data[0].embedding;
}

// When saving an article
const embedding = await embed(`${article.title}\n${article.summary}\n${article.bodyMd}`);
await db.article.update({ where: { id }, data: { embedding } });

A few important points:

  • The text you vectorize matters. The title + summary + body (or a summary of the body) works better than the body alone because the title carries a strong signal with less noise.
  • Truncation. Truncate long articles to the model limit (8192 tokens for OpenAI). For anything beyond that, either split it into chunks or summarize it first.
  • Cost. Embeddings are inexpensive, costing two orders of magnitude less than generation, so a one-time full index is affordable.

When a user submits a query, vectorize it and perform a top-k cosine similarity search in the database. Pure vector search is weak at exact keywords such as slugs and error codes, so use a hybrid approach:

ts
async function semanticSearch(query: string, limit = 10) {
  const [qEmbedding] = await Promise.all([
    embed(query),
    // Use native SQL through Prisma for hybrid retrieval
  ]);

  // pgvector semantic retrieval + PostgreSQL full-text search, fused with RRF
  const results = await db.$queryRaw`
    WITH semantic AS (
      SELECT id, row_number() OVER (ORDER BY embedding <=> ${qEmbedding}::vector) AS rank
      FROM articles WHERE status = 'PUBLISHED'
    ),
    keyword AS (
      SELECT id, row_number() OVER (
        ORDER BY ts_rank(search_vector, websearch_to_tsquery('simple', ${query})) DESC
      ) AS rank
      FROM articles WHERE status = 'PUBLISHED'
        AND search_vector @@ websearch_to_tsquery('simple', ${query})
    )
    SELECT a.id, a.title, a.summary, a.slug,
      COALESCE(1.0/(s.rank+60), 0) + COALESCE(1.0/(k.rank+60), 0) AS score
    FROM articles a
    LEFT JOIN semantic s ON s.id = a.id
    LEFT JOIN keyword k ON k.id = a.id
    WHERE s.id IS NOT NULL OR k.id IS NOT NULL
    ORDER BY score DESC
    LIMIT ${limit};
  `;
  return results;
}

<=> is pgvector's cosine distance operator. RRF (1/(rank+60)) combines the semantic and keyword rankings. This is the standard recipe for production-grade semantic search: accurate, filterable, and handled entirely by one database.

4. Reranking (Optional, for Better Quality)

For the top 10 results returned by hybrid retrieval, add a rerank step using a cross-encoder if you need greater accuracy. Reranking is accurate but slow, so run it only on the small set of retrieved candidates.

ts
import { CohereClient } from "cohere-ai";
const cohere = new CohereClient({ token: process.env.COHERE_KEY });

const reranked = await cohere.rerank({
  model: "rerank-multilingual-v3.0",
  query, documents: results.map(r => `${r.title} ${r.summary}`),
  topN: 5,
});

Whether to add reranking depends on your accuracy requirements. Hybrid retrieval is usually sufficient for community or blog search; for an enterprise knowledge base, it is recommended.

5. Frontend: Streaming Highlighting

The frontend consists of a search box and a results list, connected to the backend through a Server Action or route handler:

tsx
// app/search/page.tsx
"use client";
import { useState } from "react";

export default function SearchPage() {
  const [q, setQ] = useState("");
  const [results, setResults] = useState<Hit[]>([]);

  async function onSearch(e: React.FormEvent) {
    e.preventDefault();
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`).then(r => r.json());
    setResults(res.hits);
  }

  return (
    <form onSubmit={onSearch}>
      <input value={q} onChange={e => setQ(e.target.value)} placeholder="Semantic search…" />
      {results.map(h => (
        <a key={h.id} href={`/en/articles/${h.slug}`}>
          <h3>{h.title}</h3>
          <p>{h.summary}</p>
        </a>
      ))}
    </form>
  );
}

User experience considerations:

  • Debounce requests by 300 ms instead of sending one after every keystroke.
  • Highlight matching keywords. Results from the keyword branch can be highlighted; the semantic branch has no exact matching term, so highlighting is optional there.
  • Provide guidance for empty states and no results, such as "Try a broader term."

6. Incremental Updates

When a new article is published, generate its embedding synchronously, or it will not appear in search. Call embed + update from the hook or action that saves the article. For historical content, write a one-time script to backfill everything.

Note: Do not switch embedding models midway. If the dimension changes, every vector must be recalculated. Pin one embedding model version and use it over the long term.

7. Cost and Performance

  • Low embedding cost: text-embedding-3-small costs about $0.02 per million tokens, so fully indexing 1,000 articles costs only a few tenths of a dollar.
  • Query latency: With an HNSW index, cosine retrieval across millions of vectors takes milliseconds; reranking adds another 100–300 ms.
  • Index construction: Building an HNSW index can be slow on large tables, so do it during off-peak hours.

Summary

A production solution for adding semantic search to a content site consists of pgvector for vector storage + an HNSW index + hybrid retrieval (vector + keyword RRF) + optional reranking + incremental updates.

The key points:

  1. Already using PostgreSQL → use pgvector. Do not run a separate vector service without a good reason.
  2. Hybrid retrieval is standard (semantic + keyword); pure vector search cannot handle exact terms well.
  3. Vectorize the title + summary for better quality than the body alone.
  4. Synchronize embeddings incrementally, or new content will not appear in search.

Once this is in place, your search box advances from "keywords must match exactly" to "understands natural language," delivering an immediate improvement without ever leaving the PostgreSQL stack you already know.

Related

Related Content