// LOADING
// LOADING
// LOADING_ARTICLE
Keyword search fails the moment a user types something semantically correct but lexically different from what you wrote. Someone searching "how to avoid re-renders" will miss an article titled "optimizing React reconciliation" even though the intent matches perfectly. Embedding-based semantic search solves this by comparing meaning rather than tokens.
This post walks through building a working semantic search feature for a blog—chunking content, generating embeddings, storing them in a vector database, and querying at runtime from a Next.js API route. For the conceptual background on how embeddings work, Embeddings: The Foundation of AI Search is a good primer. For the broader retrieval-augmented generation pattern this sits inside, see Retrieval-Augmented Generation (RAG), Explained.
The pipeline has two phases:
Indexing (offline or at build time)
Querying (at request time)
Embedding models have token limits, and a 3,000-word article produces a single vector that blurs all its topics together. Chunking by paragraph or heading section gives better retrieval precision.
typescript// lib/chunk.ts export function chunkMarkdown(content: string, maxChars = 800): string[] { const paragraphs = content.split(/\n{2,}/); const chunks: string[] = []; let current = ''; for (const para of paragraphs) { if ((current + para).length > maxChars && current.length > 0) { chunks.push(current.trim()); current = para; } else { current += (current ? '\n\n' : '') + para; } } if (current.trim()) chunks.push(current.trim()); return chunks; }
800 characters is a rough starting point. Tune it based on your average paragraph length and the embedding model's token window.
For a content site, you can use the Claude API's messages endpoint with a prompt that produces a text embedding, or—more directly—use a dedicated embeddings service. The example below uses the Anthropic SDK to generate a short semantic summary per chunk, then embeds that summary with a lighter model. In production you would typically use a dedicated embeddings endpoint.
For the vector store, Pinecone, Qdrant, or even a local SQLite extension like sqlite-vss work well for a blog-scale dataset. This example uses Qdrant for clarity.
typescript// scripts/index-posts.ts import Anthropic from '@anthropic-ai/sdk'; import { QdrantClient } from '@qdrant/js-client-rest'; import { chunkMarkdown } from '../lib/chunk'; import { getAllPosts } from '../lib/posts'; const anthropic = new Anthropic(); const qdrant = new QdrantClient({ url: process.env.QDRANT_URL! }); const COLLECTION = 'blog_chunks'; const EMBED_MODEL = 'claude-haiku-4-5'; // fast, cost-efficient for batch indexing async function embedText(text: string): Promise<number[]> { // Use a message to extract a condensed semantic representation, // then hash it to a vector via your chosen embeddings service. // In this example we delegate to an external embeddings API // and use Claude only for summarisation before embedding. const msg = await anthropic.messages.create({ model: EMBED_MODEL, max_tokens: 256, messages: [{ role: 'user', content: `Summarise the following in one dense sentence for semantic search indexing:\n\n${text}`, }], }); const summary = (msg.content[0] as { text: string }).text; // Pass summary to your embeddings provider (e.g. voyage-3, text-embedding-3-small) return getEmbeddingFromProvider(summary); } async function indexAllPosts() { const posts = await getAllPosts(); const points: Array<{ id: string; vector: number[]; payload: object }> = []; for (const post of posts) { const chunks = chunkMarkdown(post.content); for (let i = 0; i < chunks.length; i++) { const vector = await embedText(chunks[i]); points.push({ id: `${post.slug}-${i}`, vector, payload: { slug: post.slug, title: post.title, chunk: chunks[i] }, }); } } await qdrant.upsert(COLLECTION, { points }); console.log(`Indexed ${points.length} chunks from ${posts.length} posts.`); } indexAllPosts();
Run this script once at build time, and again whenever you publish new content.
typescript// app/api/search/route.ts import { NextRequest, NextResponse } from 'next/server'; import { QdrantClient } from '@qdrant/js-client-rest'; const qdrant = new QdrantClient({ url: process.env.QDRANT_URL! }); export async function GET(req: NextRequest) { const query = req.nextUrl.searchParams.get('q')?.trim(); if (!query || query.length < 2) { return NextResponse.json({ results: [] }); } // Embed the query the same way you embedded the chunks const queryVector = await getEmbeddingFromProvider(query); const { points } = await qdrant.search('blog_chunks', { vector: queryVector, limit: 5, with_payload: true, score_threshold: 0.75, // drop low-confidence matches }); const results = points.map((p) => ({ slug: p.payload!['slug'], title: p.payload!['title'], excerpt: (p.payload!['chunk'] as string).slice(0, 160), score: p.score, })); // Deduplicate by slug, keeping highest score per post const seen = new Map<string, typeof results[0]>(); for (const r of results) { if (!seen.has(r.slug) || seen.get(r.slug)!.score < r.score) { seen.set(r.slug, r); } } return NextResponse.json({ results: [...seen.values()] }); }
For protecting this endpoint from abuse, Rate Limiting Your Next.js APIs covers the token-bucket and sliding-window approaches that apply here directly.
Wire the API to a debounced input. The Debouncing and Throttling in React post covers that pattern in detail. The key point is to avoid firing a request on every keystroke—wait for the user to pause (250–400 ms) before hitting the search route.
For a blog, two strategies work:
For caching search results at the HTTP layer, Caching Strategies: CDN, ISR, Redis is a useful companion. You can cache popular queries in Redis with a short TTL to reduce vector store hits.
score_threshold to avoid surfacing loosely related results. Start at 0.75 and tune based on qualitative testing.pgvector in a Postgres instance you already run is sufficient.You can see semantic search and other AI features in action in the projects section. If you are curious about broader AI integration patterns, Building AI Agents: Architecture and Patterns and Vector Databases and How Semantic Search Works extend these ideas further.
Semantic search is one of the highest-value AI features you can add to a content site. The implementation surface is small—a chunking utility, an indexing script, and one API route—but the experience improvement is immediately noticeable. The main investment is choosing and integrating a vector store; once that is done, adding search to additional content types is straightforward.