AI Search w Next.js: Wyszukiwanie semantyczne z Upstash Vector

Opublikowano
10 kwietnia 2026
Aktualizacja
30 czerwca 2026
Czas czytania
5 min czytania

Wyszukiwanie semantyczne porównuje znaczenie zapytania ze znaczeniem treści. „Jak przyspieszyć stronę" i „Optymalizacja Core Web Vitals" mają bliskie wektory w przestrzeni — system je dopasuje, nawet jeśli nie ma wspólnych słów.

To jest inny problem niż chatbot . W tym artykule budujemy wyszukiwarkę: użytkownik wpisuje frazę, a aplikacja zwraca listę najlepszych dokumentów. RAG może używać takiego samego indeksu, ale dokłada do tego generowanie odpowiedzi modelem językowym.

Jak działa wyszukiwanie wektorowe

  1. Model danych: wybierasz, co jest jednym dokumentem w indeksie — artykuł, produkt, fragment FAQ, wpis dokumentacji albo oferta.
  2. Tekst do embedowania: składasz tytuł, opis, najważniejsze atrybuty i treść w jeden normalizowany tekst.
  3. Indeksowanie: każdy dokument zamieniasz na wektor () i zapisujesz w Upstash Vector razem z metadanymi.
  4. Zapytanie: tekst wyszukiwania zamieniasz na wektor tym samym modelem.
  5. Porównanie: baza wektorowa znajduje najbliższe wektory () i zwraca score.
  6. Ranking i filtr: odrzucasz wyniki poniżej progu, nakładasz metadane i sortujesz albo łączysz wynik z full-text search.

Kiedy Upstash Vector ma sens

Upstash Vector pasuje do projektów, w których chcesz szybko uruchomić semantyczną wyszukiwarkę bez utrzymywania własnej bazy wektorowej. Najlepiej sprawdza się przy katalogach produktów, bazach wiedzy, blogach, dokumentacji, marketplace'ach i panelach B2B, gdzie wyszukiwanie musi rozumieć intencję użytkownika.

Nie używałbym go jako zamiennika każdej wyszukiwarki, ponieważ w sytuacji gdy użytkownicy wpisują głównie numery katalogowe, SKU, identyfikatory faktur albo dokładne nazwy własne, full-text search nadal jest potrzebny. I właśnie dlatego produkcyjna wersja często kończy jako hybryda wektorów (odpowiadają za znaczenie), a full-text (odpowiada za precyzję).

Implementacja z Upstash Vector

Upstash Vector to serverless baza wektorowa z HTTP API, w Next.js możesz użyć jej z , Server Action albo joba indeksującego. Do embeddingów sięgamy przez . Minimalny zestaw pakietów:

Code
npm install @upstash/vector ai @ai-sdk/openai

W .env.local trzymaj sekrety po stronie serwera:

Code
UPSTASH_VECTOR_REST_URL="https://..."
UPSTASH_VECTOR_REST_TOKEN="..."
OPENAI_API_KEY="sk-..."

Model dokumentu do indeksu

Najczęstszy błąd to embedowanie przypadkowego HTML-a albo całego obiektu JSON. Indeks powinien mieć stabilny kształt: ID, tekst do porównywania i metadane potrzebne do linkowania, filtrowania oraz renderowania wyniku.

Code
// lib/search-documents.ts
export interface SearchDocument {
  id: string
  title: string
  description: string
  content: string
  slug: string
  type: 'article' | 'product' | 'faq'
  locale: 'pl' | 'en'
  category: string
  updatedAt: string
}
 
export function toSearchText(doc: SearchDocument) {
  return [
    `Tytuł: ${doc.title}`,
    `Opis: ${doc.description}`,
    `Kategoria: ${doc.category}`,
    doc.content,
  ]
    .filter(Boolean)
    .join('\n\n')
    .slice(0, 8000)
}

Co do limitu, chodzi o kontrolę kosztu i szumu. Ddługi dokument warto pociąć na fragmenty, jeśli jeden artykuł zawiera kilka niezależnych tematów, a dla krótkich wpisów, produktów i FAQ często wystarczy jeden wektor na rekord.

Indeksowanie i aktualizacja treści

Code
// scripts/index-search.ts
import { Index } from '@upstash/vector'
import { openai } from '@ai-sdk/openai'
import { embedMany } from 'ai'
 
import { SearchDocument, toSearchText } from '../lib/search-documents'
 
const index = new Index({
  url: process.env.UPSTASH_VECTOR_REST_URL!,
  token: process.env.UPSTASH_VECTOR_REST_TOKEN!,
})
 
export async function indexDocuments(documents: SearchDocument[]) {
  const { embeddings } = await embedMany({
    model: openai.embeddingModel('text-embedding-3-small'),
    values: documents.map(toSearchText),
  })
 
  const vectors = documents.map((doc, i) => ({
    id: doc.id,
    vector: embeddings[i],
    metadata: {
      title: doc.title,
      slug: doc.slug,
      type: doc.type,
      locale: doc.locale,
      category: doc.category,
      excerpt: doc.description,
      updatedAt: doc.updatedAt,
    },
  }))
 
  await index.upsert(vectors)
  console.log(`Zaindeksowano ${vectors.length} dokumentów`)
}

Ten sam mechanizm powinien działać przy publikacji, edycji i usunięciu treści. Indeks nie może być jednorazowym skryptem odpalanym ręcznie przed launch'em.

Code
// app/api/reindex/route.ts
import { Index } from '@upstash/vector'
 
export const runtime = 'nodejs'
 
const index = new Index({
  url: process.env.UPSTASH_VECTOR_REST_URL!,
  token: process.env.UPSTASH_VECTOR_REST_TOKEN!,
})
 
export async function POST(request: Request) {
  const secret = request.headers.get('x-reindex-secret')
 
  if (secret !== process.env.REINDEX_SECRET) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const event = await request.json()
 
  if (event.type === 'deleted') {
    await index.delete(event.id)
    return Response.json({ ok: true })
  }
 
  // Pobierz aktualny dokument z CMS/bazy i wywołaj indexDocuments([doc]).
  // Ten endpoint powinien być idempotentny: ten sam dokument ma zawsze to samo ID.
  return Response.json({ ok: true })
}

Route Handler — wyszukiwanie

Code
// app/api/search/route.ts
import { Index } from '@upstash/vector'
import { openai } from '@ai-sdk/openai'
import { embed } from 'ai'
 
export const runtime = 'edge'
 
const index = new Index({
  url: process.env.UPSTASH_VECTOR_REST_URL!,
  token: process.env.UPSTASH_VECTOR_REST_TOKEN!,
})
 
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const query = searchParams.get('q')?.trim()
  const localeParam = searchParams.get('locale')
  const typeParam = searchParams.get('type')
  const minScoreParam = Number(searchParams.get('minScore') ?? 0.68)
 
  const locale = localeParam === 'en' ? 'en' : 'pl'
  const allowedTypes = new Set(['article', 'product', 'faq'])
  const type = typeParam && allowedTypes.has(typeParam) ? typeParam : undefined
  const minScore = Number.isFinite(minScoreParam) ? minScoreParam : 0.68
 
  if (!query || query.length < 2) {
    return Response.json({ results: [] })
  }
 
  const { embedding } = await embed({
    model: openai.embeddingModel('text-embedding-3-small'),
    value: query,
  })
 
  const filter = [`locale = '${locale}'`, type ? `type = '${type}'` : null]
    .filter(Boolean)
    .join(' AND ')
 
  const results = await index.query({
    vector: embedding,
    topK: 12,
    includeMetadata: true,
    filter,
  })
 
  const formatted = results
    .filter((result) => result.score >= minScore)
    .map((r) => ({
      title: r.metadata?.title,
      slug: r.metadata?.slug,
      type: r.metadata?.type,
      category: r.metadata?.category,
      excerpt: r.metadata?.excerpt,
      score: Number(r.score.toFixed(3)),
    }))
 
  return Response.json({ results: formatted })
}

Endpoint działa na , bo zapytanie do Upstash Vector i embedding query idą przez HTTP. Nie potrzebujesz Node'owego runtime'u, a edge daje niższe opóźnienie bliżej użytkownika.

Filtry po metadanych są bardo istotne, ponieważ bez nich polskie zapytanie może zwrócić angielski dokument, wyszukiwarka produktów zacznie mieszać wpisy blogowe z FAQ, a wewnętrzny panel może pokazać treści spoza uprawnień użytkownika.

Frontend — komponent wyszukiwarki

Code
'use client'
 
import { useState, useEffect, useCallback } from 'react'
 
interface SearchResult {
  title: string
  slug: string
  excerpt: string
}
 
export function SemanticSearch() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<SearchResult[]>([])
 
  const search = useCallback(async (q: string) => {
    if (q.length < 2) {
      setResults([])
      return
    }
 
    const res = await fetch(
      `/api/search?q=${encodeURIComponent(q)}&locale=pl&type=article`,
    )
    const data = await res.json()
    setResults(data.results)
  }, [])
 
  useEffect(() => {
    const timer = setTimeout(() => search(query), 300)
    return () => clearTimeout(timer)
  }, [query, search])
 
  return (
    <div className="relative mx-auto max-w-xl">
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Szukaj artykułów... (np. 'jak poprawić wydajność strony')"
      />
 
      {results.map((result) => (
        <a key={result.slug} href={`/blog/${result.slug}`}>
          <h3>{result.title}</h3>
          <p>{result.excerpt}...</p>
        </a>
      ))}
    </div>
  )
}

Debounce na 300 ms to minimum, a bez niego każde wciśnięcie klawisza odpala zapytanie i embedding. Na produkcji dorzuć jeszcze AbortController, żeby anulować poprzednie zapytanie przy szybkim pisaniu, stan ładowania i obsługę błędu fetch. Surowego score'u nie pokazuj użytkownikowi końcowemu, ponieważ jest przydatny do debugowania i strojenia progu, ale w interfejsie lepiej pokazać tytuł, opis i ewentualnie wyróżnione dopasowanie.

Hybrid search — wektory + full-text

Najlepiej działa . Wektory łapią znaczenie, full-text łapie dokładne frazy, czyli nazwy własne, numery katalogowe, terminy, których nie chcesz „interpretować":

Code
type RankedResult = {
  id: string
  score: number
  source: 'vector' | 'text'
}
 
function reciprocalRankFusion(
  lists: RankedResult[][],
  k = 60,
): Map<string, number> {
  const scores = new Map<string, number>()
 
  for (const list of lists) {
    list.forEach((result, index) => {
      const current = scores.get(result.id) ?? 0
      scores.set(result.id, current + 1 / (k + index + 1))
    })
  }
 
  return scores
}
 
export async function hybridSearch(query: string) {
  const [vectorResults, textResults] = await Promise.all([
    vectorSearch(query), // semantyka, synonimy, intencja
    fullTextSearch(query), // dokładne frazy, SKU, nazwy własne
  ])
 
  const rrfScores = reciprocalRankFusion([vectorResults, textResults])
 
  return [...rrfScores.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10)
    .map(([id, score]) => ({ id, score }))
}

To nie musi być pierwszy etap MVP, bo na start wystarczy czyste vector search. Hybrydę warto dodać, gdy pojawiają się zapytania typu „SKU-1839", „Next.js 16", „GA4", nazwy produktów albo dokładne tytuły dokumentów.

Jak oceniać jakość wyników

Nie stroisz wyszukiwarki po jednym przykładzie z demo, przygotuj mały zestaw zapytań testowych:

  1. Zapytania semantyczne: „jak przyspieszyć stronę", „strona wolno działa na telefonie", „automatyzacja obsługi leadów".
  2. Zapytania dokładne: nazwy produktów, tytuły artykułów, numery katalogowe, skróty technologii.
  3. Zapytania puste intencyjnie: frazy, dla których nie powinno być wyniku.
  4. Zapytania wielojęzyczne: jeśli indeksujesz kilka języków, sprawdź, czy filtr locale nie miesza wyników.

Dla każdego zapytania zapisz oczekiwany top 1 albo top 3, a potem mierz:

  • hit rate@3 — czy oczekiwany wynik jest w pierwszych trzech pozycjach

  • zero-results rate — jak często użytkownik nie dostaje nic

  • bad-results rate — jak często dostaje wynik pozornie podobny, ale bezużyteczny

  • click-through z wyników — czy użytkownicy klikają to, co wyszukiwarka uważa za trafne

Dopiero na tej podstawie ustawiaj próg podobieństwa, decyduj o dzieleniu długich treści na fragmenty i o dołożeniu wyszukiwania po dokładnych słowach. W przeciwnym razie łatwo zbudować wyszukiwarkę, która wygląda inteligentnie w prezentacji, ale gubi najważniejsze zapytania użytkowników.

Bezpieczne automatyzacje procesów i agenci AI w n8n, Make i Claude.
Automatyzacja AI

Często zadawane pytania

Ile kosztuje wyszukiwanie semantyczne?

Koszt zależy od modelu embeddingów, liczby zapytań oraz rozmiaru indeksu. Dla małej bazy wiedzy to zwykle tani element architektury. Embeddingi liczy się raz przy indeksowaniu, a ponownie tylko dla krótkiego tekstu zapytania. Przy dużym ruchu monitoruj osobno koszt embeddings (per token), storage w bazie wektorowej i liczbę zapytań wykonywanych przez autocomplete.

Zwykle mówimy o setkach milisekund, ale wynik zależy od regionu, providera embeddingów, wielkości indeksu i tego, czy łączysz wektory z dodatkowymi filtrami. Dla autocomplete ważniejsze od mediany jest stabilne p95 i sensowny debounce (np. 300 ms), żeby nie odpalać zapytania przy każdym wciśnięciu klawisza.

Tak. Indeksuj nazwy, opisy i atrybuty produktów. Wyszukiwanie semantyczne umożliwi wpisanie przez użytkownika „ciepłe buty na zimę", a wyszukiwarka znajdzie „Trapery zimowe ocieplane" mimo braku wspólnych słów.

Próg score >= 0.68 z przykładu to wskaźnik na start. Zbyt wysoki próg odsiewa trafne wyniki, a zbyt niski wpuszcza szum. Najlepiej przejść przez realne zapytania użytkowników, zobaczyć rozkład score'ów dla trafień i nietrafień, i ustawić próg empirycznie. Wszystko zależy od modelu, języka i charakteru treści.

Bo każda metoda łapie co innego. Wektory rozumieją intencję i synonimy, ale potrafią rozmyć dokładne dopasowania (numery katalogowe, nazwy własne, precyzyjne frazy). Full-text trafia w dokładne tokeny. Hybrid search (np. przez Reciprocal Rank Fusion) łączy obie listy wyników i w praktyce daje najlepszą trafność.

Nie. Wyszukiwanie semantyczne zwraca listę trafnych dokumentów lub produktów. RAG używa podobnego etapu wyszukiwania, ale dokleja znaleziony kontekst do promptu i generuje odpowiedź modelem językowym. Ten artykuł skupia się na wyszukiwarce.

Traktuj indeks jak osobną projekcję danych. Przy publikacji, edycji lub usunięciu dokumentu odpal webhook albo job, który ponownie przelicza embedding i robi upsert lub delete po stabilnym ID. Nie zostawiaj indeksu jako jednorazowego skryptu uruchamianego ręcznie.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty side-projecty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów