Guide

Vector search with Next.js and OpenAI

Learn how to build a ChatGPT-style doc search powered by Next.js, OpenAI, and Zuvo.

While our Headless Vector search provides a toolkit for generative Q&A, in this tutorial we'll go more in-depth, build a custom ChatGPT-like search experience from the ground-up using Next.js. You will:

  1. Convert your markdown into embeddings using OpenAI.
  2. Store you embeddings in Postgres using pgvector.
  3. Deploy a function for answering your users' questions.

You can read our Zuvo Clippy blog post for a full example.

We assume that you have a Next.js project with a collection of .mdx files nested inside your pages directory. We will start developing locally with the Zuvo CLI and then push our local database changes to our hosted Zuvo project. You can find the full Next.js example on GitHub.

Create a project

  1. Create a new project in the Zuvo Studio.
  2. Enter your project details.
  3. Wait for the new database to launch.

Prepare the database

Prepare the database schema. We can use the "OpenAI Vector Search" quickstart in the SQL Editor, or you can copy/paste the SQL below and run it yourself.

Dashboard
  1. Go to the SQL Editor page in the Dashboard.
  2. Click OpenAI Vector Search.
  3. Click Run.
SQL

Set up Zuvo locally

Make sure you have the latest version of the Zuvo CLI installed.

Initialize Zuvo in the root directory of your app.

    supabase init

Create a migrations file

To make changes to our local database, we need to create a new migration. This will create a new .sql file in our supabase/migrations folder, where we can write SQL that will be applied to our local database when starting Zuvo locally.

      supabase migration new init

Enable the pgvector extension

Copy the following SQL line into the newly created migration file to enable the pgvector extension.

      -- Enable pgvector extension
      create extension if not exists vector with schema public;

Create the database schema

Copy these SQL queries to your migration file. It will create two tables in our database schema.

    -- Stores the checksum of our pages.
    -- This ensures that we only regenerate embeddings
    -- when the page content has changed.
    create table "public"."nods_page" (
      id bigserial primary key,
      parent_page_id bigint references public.nods_page,
      path text not null unique,
      checksum text,
      meta jsonb,
      type text,
      source text
    );

    -- Grant the privileges the roles need
    GRANT SELECT ON public.nods_page TO anon;

    alter table "public"."nods_page"
      enable row level security;

    create policy "Allow public read access to nods_page"
      on public.nods_page
      for select
      to anon
      using (true);

    -- Stores the actual embeddings with some metadata
    create table "public"."nods_page_section" (
      id bigserial primary key,
      page_id bigint not null references public.nods_page on delete cascade,
      content text,
      token_count int,
      embedding extensions.vector(1536),
      slug text,
      heading text
    );

    -- Grant the privileges the roles need
    GRANT SELECT ON public.nods_page_section TO anon;

    alter table "public"."nods_page_section"
      enable row level security;

    create policy "Allow public read access to nods_page_section"
      on public.nods_page_section
      for select
      to anon
      using (true);

Create similarity search database function

Anytime the user sends a query, we want to find the content that's relevant to their questions. We can do this using pgvector's similarity search.

For complex SQL operations, wrap them in database functions that you can call from the frontend using RPC.

    -- Create embedding similarity search functions
    create or replace function match_page_sections(
        embedding extensions.vector(1536),
        match_threshold float,
        match_count int,
        min_content_length int
    )
    returns table (
        id bigint,
        page_id bigint,
        slug text,
        heading text,
        content text,
        similarity float
    )
    language plpgsql
    as $$
    #variable_conflict use_variable
    begin
      return query
      select
        nods_page_section.id,
        nods_page_section.page_id,
        nods_page_section.slug,
        nods_page_section.heading,
        nods_page_section.content,
        (nods_page_section.embedding <#> embedding) * -1 as similarity
      from nods_page_section

      -- We only care about sections that have a useful amount of content
      where length(nods_page_section.content) >= min_content_length

      -- The dot product is negative because of a Postgres limitation, so we negate it
      and (nods_page_section.embedding <#> embedding) * -1 > match_threshold

      -- OpenAI embeddings are normalized to length 1, so
      -- cosine similarity and dot product will produce the same results.
      -- Using dot product which can be computed slightly faster.
      --
      -- For the different syntaxes, see https://github.com/pgvector/pgvector
      order by nods_page_section.embedding <#> embedding

      limit match_count;
    end;
    $$;

Start Zuvo Locally

Start Zuvo locally. At this point all files in supabase/migrations will be applied to your database and you're ready to go.

      supabase start

Push changes to your Zuvo database

Once ready, you can link your local project to your cloud hosted Zuvo project and push the local changes to your hosted instance.

      supabase link --project-ref=your-project-ref

      supabase db push

Pre-process the knowledge base at build time

With our database set up, we need to process and store all .mdx files in the pages directory. You can find the full script here, or follow the steps below:

Generate Embeddings

Create a new file lib/generate-embeddings.ts and copy the code over from GitHub.

      curl \
      https://raw.githubusercontent.com/supabase-community/nextjs-openai-doc-search/main/lib/generate-embeddings.ts \
      -o "lib/generate-embeddings.ts"

Set up environment variables

We need some environment variables to run the script. Add them to your .env file and make sure your .env file is not committed to source control! You can get your local Zuvo credentials by running supabase status.

      NEXT_PUBLIC_SUPABASE_URL=
      NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
      SUPABASE_SECRET_KEY=

      # Get your key at https://platform.openai.com/account/api-keys
      OPENAI_API_KEY=

Run script at build time

Include the script in your package.json script commands to enable Vercel to automatically run it at build time.

      "scripts": {
        "dev": "next dev",
        "build": "pnpm run embeddings && next build",
        "start": "next start",
        "embeddings": "tsx lib/generate-embeddings.ts"
      },

Create text completion with OpenAI API

Anytime a user asks a question, we need to create an embedding for their question, perform a similarity search, and then send a text completion request to the OpenAI API with the query and then context content merged together into a prompt.

All of this is glued together in a Vercel Edge Function, the code for which can be found on GitHub.

Create Embedding for Question

In order to perform similarity search we need to turn the question into an embedding.

      const embeddingResponse = await fetch('https://api.openai.com/v1/embeddings', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${openAiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          model: 'text-embedding-ada-002',
          input: sanitizedQuery.replaceAll('\n', ' '),
        }),
      })

      if (embeddingResponse.status !== 200) {
        throw new ApplicationError('Failed to create embedding for question', embeddingResponse)
      }

      const {
        data: [{ embedding }],
      } = await embeddingResponse.json()

Perform similarity search

Using the embeddingResponse we can now perform similarity search by performing a remote procedure call (RPC) to the database function we created earlier.

      const { error: matchError, data: pageSections } = await supabaseClient.rpc(
        'match_page_sections',
        {
          embedding,
          match_threshold: 0.78,
          match_count: 10,
          min_content_length: 50,
        }
      )

Perform text completion request

With the relevant content for the user's question identified, we can now build the prompt and make a text completion request via the OpenAI API.

If successful, the OpenAI API will respond with a text/event-stream response that we can forward to the client where we'll process the event stream to smoothly print the answer to the user.

      const prompt = codeBlock`
        ${oneLine`
          You are a very enthusiastic Zuvo representative who loves
          to help people! Given the following sections from the Zuvo
          documentation, answer the question using only that information,
          outputted in markdown format. If you are unsure and the answer
          is not explicitly written in the documentation, say
          "Sorry, I don't know how to help with that."
        `}

        Context sections:
        ${contextText}

        Question: """
        ${sanitizedQuery}
        """

        Answer as markdown (including related code snippets if available):
      `

      const completionOptions: CreateCompletionRequest = {
        model: 'gpt-3.5-turbo-instruct',
        prompt,
        max_tokens: 512,
        temperature: 0,
        stream: true,
      }

      const response = await fetch('https://api.openai.com/v1/completions', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${openAiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(completionOptions),
      })

      if (!response.ok) {
        const error = await response.json()
        throw new ApplicationError('Failed to generate completion', error)
      }

      // Proxy the streamed SSE response from OpenAI
      return new Response(response.body, {
        headers: {
          'Content-Type': 'text/event-stream',
        },
      })

Display the answer on the frontend

In a last step, we need to process the event stream from the OpenAI API and print the answer to the user. The full code for this can be found on GitHub.

const handleConfirm = React.useCallback(
  async (query: string) => {
    setAnswer(undefined)
    setQuestion(query)
    setSearch('')
    dispatchPromptData({ index: promptIndex, answer: undefined, query })
    setHasError(false)
    setIsLoading(true)

    const eventSource = new SSE(`api/vector-search`, {
      headers: {
        apikey: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? '',
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY}`,
        'Content-Type': 'application/json',
      },
      payload: JSON.stringify({ query }),
    })

    function handleError<T>(err: T) {
      setIsLoading(false)
      setHasError(true)
      console.error(err)
    }

    eventSource.addEventListener('error', handleError)
    eventSource.addEventListener('message', (e: any) => {
      try {
        setIsLoading(false)

        if (e.data === '[DONE]') {
          setPromptIndex((x) => {
            return x + 1
          })
          return
        }

        const completionResponse: CreateCompletionResponse = JSON.parse(e.data)
        const text = completionResponse.choices[0].text

        setAnswer((answer) => {
          const currentAnswer = answer ?? ''

          dispatchPromptData({
            index: promptIndex,
            answer: currentAnswer + text,
          })

          return (answer ?? '') + text
        })
      } catch (err) {
        handleError(err)
      }
    })

    eventSource.stream()

    eventSourceRef.current = eventSource

    setIsLoading(true)
  },
  [promptIndex, promptData]
)

Learn more

Want to learn more about the awesome tech that is powering this?