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
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.
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.
// 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.
3. Queries: Hybrid Semantic + Keyword Search
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:
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.
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:
// 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-smallcosts 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:
- Already using PostgreSQL → use pgvector. Do not run a separate vector service without a good reason.
- Hybrid retrieval is standard (semantic + keyword); pure vector search cannot handle exact terms well.
- Vectorize the title + summary for better quality than the body alone.
- 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
Article
Complete Technology Stack Analysis for LLM Application Development
Covers tech trends, engineering practices, and indie developer perspectives around large models, RAG, and AI.
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
What Is RAG, and Why Are Enterprise Knowledge Bases Using It?
Technical trends, engineering practices, and an independent developer's perspective on RAG and large language models.