DEV Community

Sohail Khan
Sohail Khan

Posted on

Your Users Are Searching. Your Search Is Just Matching Keywords.

Your users are searching. Your search is just matching keywords.

A client had a help center built in Next.js. Hundreds of articles. Users typed real questions like "how do I get a refund" and got zero results, because no article contained those exact words. Support tickets kept growing.

The wrong approach, and the most common one, is to keep patching keyword search. Add synonyms. Add tags. Add fuzzy matching. Every patch helps a few queries and breaks a few others. You are polishing a system that fundamentally cannot understand meaning.

The fix took me an afternoon: semantic search with embeddings.

The idea in one paragraph

An embedding turns text into a list of numbers that captures meaning. Two pieces of text that mean similar things get similar numbers, even when they share almost no words. So instead of asking the database "which articles contain these words", you ask "which articles mean something close to this question". That one change is the whole trick.

Step 1: embed every article on build

On build, generate one embedding per article and store it next to the row. You only do this when an article is created or updated, never at query time, so search stays fast.

ALTER TABLE articles ADD COLUMN embedding vector(1536);
Enter fullscreen mode Exit fullscreen mode
// scripts/embed-articles.ts
import { db } from "@/lib/db";
import { embed } from "@/lib/embed";

const articles = await db.query(
  `SELECT id, title, body FROM articles WHERE embedding IS NULL`
);

for (const article of articles) {
  const vector = await embed(`${article.title}\n${article.body}`);
  await db.query(
    `UPDATE articles SET embedding = $1 WHERE id = $2`,
    [vector, article.id]
  );
}
Enter fullscreen mode Exit fullscreen mode

The embed helper is a thin wrapper around whichever embeddings API you already use. It takes text in and returns numbers out. Nothing else in your app needs to know which provider sits behind it, which keeps the door open to swap providers later without touching search.

Step 2: embed the question, query the closest vectors

At query time, embed the user's question and ask the database for the closest vectors. In Postgres with pgvector, the <=> operator gives you cosine distance, so ordering by it returns the most similar articles first.

// app/api/search/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { embed } from "@/lib/embed";

export async function GET(request: Request) {
  const query = new URL(request.url).searchParams.get("q");
  if (!query) {
    return NextResponse.json({ results: [] });
  }

  const queryVector = await embed(query);
  const rows = await db.query(
    `SELECT id, title FROM articles
     ORDER BY embedding <=> $1
     LIMIT 5`,
    [queryVector]
  );

  return NextResponse.json({ results: rows });
}
Enter fullscreen mode Exit fullscreen mode

That is the entire search endpoint. "how do I get a refund" now matches the article titled "Return and refund policy" even though they share almost no words. No search service. No rebuild. Just one new column and one query.

Three details that make it work in production

First, embed the title together with the body. Titles carry a lot of signal, and a short title plus a long body embeds better than the body alone.

Second, keep the result count small. Five results is enough for a help center. Returning twenty just pushes the decision back onto the user, which is the problem you were solving.

Third, keep keyword search as a fallback, not the main path. Exact matches still matter: when a user pastes an error code or a product name, keyword search wins. Semantic search handles meaning. Keyword search handles exact strings. Use each for what it is good at.

What I would not do

I would not build a retrieval pipeline with chunking, reranking and a vector database on day one. For a help center with hundreds of articles, one embedding per article in the database you already run is enough. You can add the heavier machinery later, when the article count or the query volume actually demands it. Most teams never reach that point.

I would also not embed at query time for the articles themselves. That is the mistake that turns a fast search into a slow one. Articles change rarely, so embed them when they change and never again.

The honest summary

Semantic search is not magic and it is not an agent. It is one column, one embedding call per article at build time, and one query at search time. The reason it feels like a leap is that keyword search was never the right tool for questions phrased in a user's own words. Once search understands meaning instead of matching letters, the zero results page mostly disappears, and so do the tickets it used to create.

Have you added semantic search to an app yet, or are you still fighting keyword matching?

I'm Sohail Khan, a Senior Full Stack Developer building AI agents and automation. Tech Lead at LoopxCorp. I write about backend engineering, AI integration and practical system design.

Top comments (0)