Skip to content

Add vector search to a Lovable app with Qdrant

6 min readLovableQdrantVectorRAGAIDatabases

Lovable is a good fit for AI apps because the UI generation is fast and the iteration loop is tight. The point you hit eventually is that the app needs to store embeddings: for RAG over a knowledge base, for semantic search across user content, or for recommendations. Supabase has pgvector and that works at small scale. As the collection grows, a dedicated vector database starts to pull ahead, and that is when teams reach for Qdrant. This post wires Qdrant into a Lovable app in that shape.

One thing to get straight before any code. A Lovable app is a Vite React single-page app that ships to the browser, with no server of its own, and its only backend is Supabase. So the embedding calls to OpenAI and the vector calls to Qdrant cannot run in your frontend: an API key in a browser bundle is a key anyone can read and spend. They run in a Supabase Edge Function (Deno), the one place a Lovable app has server-side compute, or in your own API if you have moved off Lovable's hosting. Connect a Postgres database to a Lovable app walks through that architecture in full; this post assumes it and puts the vector layer in the same place.

Contents

pgvector or Qdrant?

If your app already has a Postgres database (it probably does) and you have fewer than 100,000 vectors, use pgvector. The setup is just CREATE EXTENSION vector; and you query embeddings with the same SQL connection your app already uses. One database to back up, one connection pool, one bill.

Switch to Qdrant when one of these is true:

  • Your collection has grown large enough that pgvector query latency has crept up and manual index tuning is no longer keeping pace.
  • You want filtered vector search (find similar items where category = 'tech' and price < 50). pgvector technically supports this but the planner often picks the wrong index.
  • You want to update vectors at high rate without write contention against your transactional Postgres.
  • You want to keep your transactional Postgres small and predictable, and the vector data is large.

A typical Lovable AI app starts with pgvector and grows into Qdrant. The migration is straightforward because both store the same shape of data, just under different APIs. Vector databases compared in 2026 has the full breakdown if you want it.

The rest of this post assumes you have decided on Qdrant.

Create the database

Visit layerbase.com/create/qdrant, name it (something like lovable-rag), and click Sign in and create. Provisioning is about ten seconds.

Qdrant is on the Pro plan, $15 a month flat. That is the whole bill: Pro also covers up to ten databases and the rest of the engine catalog, with no per-query or per-row meters.

The dashboard gives you two things you need:

text
QDRANT_URL=https://your-host.cloud.layerbase.dev
QDRANT_API_KEY=<long random string>

Qdrant on Layerbase is HTTPS-only and the API key goes in the api-key header. The official client handles both for you.

Wire it in through an edge function

Qdrant's API is plain HTTPS, so a browser could technically call it. You still would not: the request needs the api-key header, and any key you put in the frontend ships in the bundle. OpenAI is worse, an embedding key in the browser is money anyone can spend. Both belong server-side, which for a Lovable app means a Supabase Edge Function.

Store the secrets on the Supabase side rather than as Lovable env vars, because Lovable env vars land in the browser bundle:

bash
supabase secrets set QDRANT_URL="https://your-host.cloud.layerbase.dev"
supabase secrets set QDRANT_API_KEY="<your key>"
supabase secrets set OPENAI_API_KEY="<your OpenAI key>"

Edge Functions run on Deno and pull dependencies with npm: specifiers, so there is nothing to install in your frontend. Share one client module across the functions:

ts
// supabase/functions/_shared/clients.ts
import { QdrantClient } from 'npm:@qdrant/js-client-rest'
import OpenAI from 'npm:openai'

export const qdrant = new QdrantClient({
  url: Deno.env.get('QDRANT_URL')!,
  apiKey: Deno.env.get('QDRANT_API_KEY')!,
})

export const openai = new OpenAI({ apiKey: Deno.env.get('OPENAI_API_KEY')! })

Before you can insert vectors you need a collection. Collections in Qdrant are like tables. Creating one is a one-time admin task, so run it from your own machine, where the keys live in your shell and not in the app, rather than shipping it:

ts
// setup/create-collection.ts - run once locally, never bundled into the app
import { QdrantClient } from '@qdrant/js-client-rest'

const qdrant = new QdrantClient({
  url: process.env.QDRANT_URL!,
  apiKey: process.env.QDRANT_API_KEY!,
})

await qdrant.createCollection('documents', {
  vectors: {
    size: 1536, // OpenAI text-embedding-3-small dimension
    distance: 'Cosine',
  },
})

size: 1536 matches OpenAI's text-embedding-3-small. If you use a different embedding model, set the size to whatever that model outputs.

Indexing a document is embed-then-upsert, and both halves run inside the function:

ts
// supabase/functions/index-document/index.ts
import { qdrant, openai } from '../_shared/clients.ts'

Deno.serve(async (req) => {
  const { id, text } = await req.json()

  const embeddingResponse = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  })
  const vector = embeddingResponse.data[0].embedding

  await qdrant.upsert('documents', {
    points: [
      {
        id,
        vector,
        payload: { text }, // store the source text alongside the vector
      },
    ],
  })

  return new Response(JSON.stringify({ ok: true }), {
    headers: { 'Content-Type': 'application/json' },
  })
})

Searching is the same move in reverse, embed the query then hand the vector to Qdrant:

ts
// supabase/functions/search/index.ts
import { qdrant, openai } from '../_shared/clients.ts'

Deno.serve(async (req) => {
  const { query, limit = 5 } = await req.json()

  const embeddingResponse = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: query,
  })
  const vector = embeddingResponse.data[0].embedding

  const results = await qdrant.search('documents', {
    vector,
    limit,
    with_payload: true,
  })

  const hits = results.map((r) => ({
    id: r.id,
    score: r.score,
    text: r.payload?.text,
  }))

  return new Response(JSON.stringify(hits), {
    headers: { 'Content-Type': 'application/json' },
  })
})

Your React code never sees a key. It calls the functions the way it already calls Supabase functions:

ts
await supabase.functions.invoke('index-document', {
  body: { id, text },
})

const { data: results } = await supabase.functions.invoke('search', {
  body: { query, limit: 5 },
})

That is the whole core. Embed, upsert, embed query, search, with every key held server-side.

Use it for RAG

The standard retrieval-augmented-generation flow is: user asks a question, embed the question, find the top N similar documents, pass those documents to the LLM as context, return the answer. The chat completion is another OpenAI call, so it stays in the function alongside the rest:

ts
// supabase/functions/answer-question/index.ts
import { qdrant, openai } from '../_shared/clients.ts'

Deno.serve(async (req) => {
  const { question } = await req.json()

  const embeddingResponse = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: question,
  })
  const vector = embeddingResponse.data[0].embedding

  const docs = await qdrant.search('documents', {
    vector,
    limit: 5,
    with_payload: true,
  })
  const context = docs.map((d) => d.payload?.text).join('\n\n')

  const completion = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [
      {
        role: 'system',
        content: `Answer the user's question based only on this context:\n\n${context}`,
      },
      { role: 'user', content: question },
    ],
  })

  return new Response(
    JSON.stringify({ answer: completion.choices[0].message.content }),
    { headers: { 'Content-Type': 'application/json' } },
  )
})

The frontend calls it like any other function:

ts
const { data } = await supabase.functions.invoke('answer-question', {
  body: { question },
})

There are a hundred refinements you can make (re-ranking, hybrid search, chunk size tuning, query expansion). They all build on top of this shape. Start here, see if it answers your questions, then add complexity only where the answers are bad.

The thing pgvector struggles with that Qdrant handles cleanly is filtered vector search. If your documents have categories or user IDs and you want "find similar docs that belong to this user," Qdrant indexes the filter alongside the vector. It is one more argument on the same search call inside your function:

ts
const results = await qdrant.search('documents', {
  vector,
  limit: 5,
  filter: {
    must: [
      { key: 'user_id', match: { value: userId } },
    ],
  },
  with_payload: true,
})

Add user_id to the payload when you insert, and the filter just works. This is the most common reason teams move from pgvector to Qdrant in practice.

Local development with the Layerbase CLI

The Layerbase CLI, powered by SpinDB under the hood, runs Qdrant locally with one command:

bash
npm i -g layerbase
lbase create lovable-rag-dev --engine qdrant --start
lbase url lovable-rag-dev

Put the printed URL in .env.local:

text
QDRANT_URL=http://localhost:6333
QDRANT_API_KEY=

Local Qdrant does not require an API key, so the env var can be empty. What is the Layerbase CLI? covers the rest, including how to copy collection schemas between local and cloud.

Wrapping up

The short version:

  1. Create a Qdrant at layerbase.com/create/qdrant (Pro plan, $15 a month flat)
  2. Store QDRANT_URL, QDRANT_API_KEY, and OPENAI_API_KEY as Supabase Edge Function secrets, never as Lovable env vars
  3. Put the Qdrant and OpenAI clients in a shared edge-function module
  4. Embed and upsert in one edge function, embed the query and search in another, call both with supabase.functions.invoke
  5. Local dev with the Layerbase CLI

For most Lovable AI apps this is the whole vector layer. The collection grows, you stay on the same connection string, you do not pay per query.