Getting Started with Qdrant

QdrantDatabasesVector Search

Every traditional database (PostgreSQL, MySQL, MongoDB) searches by matching exact values: strings, numbers, dates. That works fine until you need to search by meaning.

Say you have a database of movies and a user searches for "scary movie set in space." You'd run something like WHERE description LIKE '%scary%'. But no movie description actually contains the word "scary." It might say "deadly extraterrestrial creature" or "stalks them through narrow corridors." The meaning matches. The words don't. The query returns nothing.

Vector databases solve this. You convert text into embeddings: arrays of numbers that represent meaning. Text with similar meaning produces similar numbers, regardless of wording. Qdrant stores these embeddings and finds the closest matches in milliseconds.

By the end of this post you'll have a working semantic movie search in a single TypeScript file, running against a local Qdrant instance (or a Layerbase Cloud one if you prefer).

Contents

Create a Qdrant Instance

Local with SpinDB

You can have Qdrant running locally in about 10 seconds with SpinDB, a CLI that downloads and manages database binaries for you. No Docker. (What is SpinDB?)

Install SpinDB globally:

bash
npm i -g spindb    # npm
pnpm add -g spindb # pnpm

Or run it directly without installing:

bash
npx spindb create qdrant1 -e qdrant --start  # npm
pnpx spindb create qdrant1 -e qdrant --start # pnpm

If you installed globally, create and start a Qdrant instance:

bash
spindb create qdrant1 -e qdrant --start

SpinDB downloads the Qdrant binary for your platform, configures it, and starts the server. Verify it's running:

bash
spindb url qdrant1
text
http://127.0.0.1:6333

Leave the server running. We'll connect to it from TypeScript in the next section.

Layerbase Cloud

Don't want to run it locally? Layerbase Cloud gives you a managed Qdrant instance in a few clicks. You'll get a connection URL and API key from the dashboard's Quick Connect panel.

Cloud instances use TLS, so the connection code is slightly different:

typescript
const client = new QdrantClient({
  url: 'https://cloud.layerbase.dev:11010',
  apiKey: 'YOUR_API_KEY',
})

Everything else in this guide works identically whether you're running locally or on Layerbase Cloud. Just swap in your connection details.

Set Up the Project

bash
mkdir qdrant-movie-search && cd qdrant-movie-search
pnpm init
pnpm add @qdrant/js-client-rest @xenova/transformers
pnpm add -D tsx typescript

Create a file called search.ts. All the code in this post goes into that one file.

The Movie Dataset

Here are 15 movies with plot descriptions. In a real application this data would come from your database or API. The descriptions are what we'll convert into embeddings:

typescript
const movies = [
  {
    id: 1,
    title: 'Alien',
    year: 1979,
    genres: ['sci-fi', 'horror'],
    description:
      'The crew of a deep-space mining ship encounters a deadly extraterrestrial creature that stalks them through narrow corridors.',
  },
  {
    id: 2,
    title: 'The Shawshank Redemption',
    year: 1994,
    genres: ['drama'],
    description:
      'A banker wrongly imprisoned for murder befriends a fellow inmate and uses patience, cunning, and a rock hammer to find freedom.',
  },
  {
    id: 3,
    title: 'Inception',
    year: 2010,
    genres: ['sci-fi', 'thriller'],
    description:
      "A thief who enters people's dreams takes on the job of planting an idea in a CEO's mind, navigating increasingly unstable layers of consciousness.",
  },
  {
    id: 4,
    title: 'The Intouchables',
    year: 2011,
    genres: ['comedy', 'drama'],
    description:
      'A wealthy quadriplegic hires an unlikely caretaker from the projects, and they form a life-changing bond through humor and honesty.',
  },
  {
    id: 5,
    title: 'Spirited Away',
    year: 2001,
    genres: ['animation', 'fantasy'],
    description:
      'A young girl navigates a world of spirits and gods to rescue her parents from a witch who runs a magical bathhouse.',
  },
  {
    id: 6,
    title: 'Mad Max: Fury Road',
    year: 2015,
    genres: ['action', 'sci-fi'],
    description:
      "In a post-apocalyptic wasteland, a war captain defects with the tyrant's captive wives and drives across the desert with a lone drifter.",
  },
  {
    id: 7,
    title: 'Interstellar',
    year: 2014,
    genres: ['sci-fi', 'drama'],
    description:
      "A former pilot travels through a wormhole near Saturn to find a habitable planet as Earth's crops fail and humanity faces extinction.",
  },
  {
    id: 8,
    title: 'The Grand Budapest Hotel',
    year: 2014,
    genres: ['comedy', 'drama'],
    description:
      'A legendary hotel concierge and his protégé become entangled in theft, murder, and the upheaval of a continent between two wars.',
  },
  {
    id: 9,
    title: 'Parasite',
    year: 2019,
    genres: ['thriller', 'drama'],
    description:
      'A poor family infiltrates a wealthy household one job at a time, until a hidden secret in the basement throws everything into chaos.',
  },
  {
    id: 10,
    title: 'Finding Nemo',
    year: 2003,
    genres: ['animation', 'comedy'],
    description:
      "An overprotective clownfish crosses the ocean to rescue his son, who was captured by a diver and placed in a dentist's fish tank.",
  },
  {
    id: 11,
    title: 'The Matrix',
    year: 1999,
    genres: ['sci-fi', 'action'],
    description:
      'A computer hacker discovers that reality is a simulation built by machines, and joins a rebellion to free humanity from digital enslavement.',
  },
  {
    id: 12,
    title: 'WALL-E',
    year: 2008,
    genres: ['animation', 'sci-fi'],
    description:
      "A lonely trash-compacting robot on an abandoned Earth falls in love with a sleek probe robot and follows her into space, inadvertently deciding humanity's future.",
  },
  {
    id: 13,
    title: 'Jaws',
    year: 1975,
    genres: ['thriller', 'horror'],
    description:
      'A police chief, a marine biologist, and a grizzled fisherman hunt a great white shark that is terrorizing a summer beach community.',
  },
  {
    id: 14,
    title: 'Coco',
    year: 2017,
    genres: ['animation', 'fantasy'],
    description:
      "A boy who dreams of being a musician accidentally ends up in the Land of the Dead and discovers the truth about his family's musical past.",
  },
  {
    id: 15,
    title: 'The Silence of the Lambs',
    year: 1991,
    genres: ['thriller', 'horror'],
    description:
      'An FBI trainee seeks the help of an imprisoned cannibal psychiatrist to catch a serial killer who is kidnapping women.',
  },
]

Choose Your Embedding Provider

An embedding model converts text into vectors. This is the only part of the code that changes depending on your provider. Everything after this section works identically with either option.

Option A: Local Embeddings (No API Key)

This uses @xenova/transformers to run the all-MiniLM-L6-v2 model directly on your machine. The model downloads automatically on first run (~80MB). No accounts, no API keys.

typescript
import { pipeline } from '@xenova/transformers'

const extractor = await pipeline(
  'feature-extraction',
  'Xenova/all-MiniLM-L6-v2',
)

const VECTOR_SIZE = 384

async function getEmbeddings(texts: string[]): Promise<number[][]> {
  const embeddings: number[][] = []
  for (const text of texts) {
    const output = await extractor(text, { pooling: 'mean', normalize: true })
    embeddings.push(Array.from(output.data as Float32Array))
  }
  return embeddings
}

Option B: OpenAI Embeddings

If you have an OpenAI API key, you can use text-embedding-3-small instead. Install the SDK and swap in this code:

bash
pnpm add openai
typescript
import OpenAI from 'openai'

const openai = new OpenAI() // uses OPENAI_API_KEY env var

const VECTOR_SIZE = 1536

async function getEmbeddings(texts: string[]): Promise<number[][]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: texts,
  })
  return response.data.map((d) => d.embedding)
}

Pick one and add it to search.ts. The rest of the code calls getEmbeddings() and uses VECTOR_SIZE. It doesn't know or care which provider generated the vectors.

Connect and Create a Collection

A collection in Qdrant is like a table. Each collection stores points: a vector plus a JSON payload. You define the vector size and distance metric when you create it.

typescript
import { QdrantClient } from '@qdrant/js-client-rest'

const client = new QdrantClient({ url: 'http://localhost:6333' })

const COLLECTION = 'movies'

// Clean up from previous runs
const collections = await client.getCollections()
if (collections.collections.some((c) => c.name === COLLECTION)) {
  await client.deleteCollection(COLLECTION)
}

await client.createCollection(COLLECTION, {
  vectors: { size: VECTOR_SIZE, distance: 'Cosine' },
})

console.log(`Created collection "${COLLECTION}" (${VECTOR_SIZE} dimensions)`)

Cosine measures the angle between two vectors, ignoring magnitude. It's the standard choice for text embeddings. Qdrant also supports Euclid and Dot.

Generate Embeddings and Insert

Convert every movie description into a vector and insert it into Qdrant:

typescript
console.log('Generating embeddings...')

const descriptions = movies.map((m) => m.description)
const vectors = await getEmbeddings(descriptions)

await client.upsert(COLLECTION, {
  wait: true,
  points: movies.map((movie, i) => ({
    id: movie.id,
    vector: vectors[i],
    payload: {
      title: movie.title,
      year: movie.year,
      genres: movie.genres,
      description: movie.description,
    },
  })),
})

console.log(`Inserted ${movies.length} movies`)

Each point has an ID, the embedding vector, and a payload: the original metadata you want back in search results. The payload plays no role in similarity calculations. It's just along for the ride.

Semantic Search

Now try searching with natural language that doesn't appear in any movie description:

typescript
async function search(query: string, limit = 5) {
  const [queryVector] = await getEmbeddings([query])
  return client.query(COLLECTION, {
    query: queryVector,
    limit,
    with_payload: true,
  })
}

const queries = [
  'scary movie set in space',
  'feel-good story about an unlikely friendship',
  'someone trapped in a system trying to break free',
  'animated film with a deep emotional message for kids',
]

for (const query of queries) {
  console.log(`\n"${query}"`)
  const { points } = await search(query)
  for (const point of points) {
    const p = point.payload as Record<string, unknown>
    console.log(`  ${point.score?.toFixed(3)}  ${p.title}`)
  }
}

Run it:

bash
npx tsx search.ts

Expected output (exact scores vary by embedding model):

text
"scary movie set in space"
  0.638  Alien
  0.451  Interstellar
  0.430  Jaws
  0.389  The Silence of the Lambs
  0.342  Mad Max: Fury Road

"feel-good story about an unlikely friendship"
  0.617  The Intouchables
  0.520  Finding Nemo
  0.497  WALL-E
  0.463  The Grand Budapest Hotel
  0.382  Coco

"someone trapped in a system trying to break free"
  0.583  The Matrix
  0.564  The Shawshank Redemption
  0.454  Mad Max: Fury Road
  0.391  Parasite
  0.350  Alien

"animated film with a deep emotional message for kids"
  0.621  Coco
  0.599  Spirited Away
  0.578  WALL-E
  0.489  Finding Nemo
  0.301  The Intouchables

Look at what just happened. None of those queries share meaningful words with the descriptions they matched:

  • "Scary" doesn't appear in any description, yet Alien ranks first because a creature stalking crew members through corridors means scary
  • "Friendship" doesn't appear in The Intouchables' description, yet "a life-changing bond" means the same thing
  • "Trapped" and "break free" don't appear in The Shawshank Redemption or The Matrix, yet "find freedom" and "free humanity from digital enslavement" carry the same meaning
  • "Emotional message for kids" matches Coco even though its description just mentions a boy, the Land of the Dead, and a family secret

This is what embeddings buy you. The vectors encode meaning, not words.

Why Keyword Search Can't Do This

To make the contrast concrete, check whether those search concepts appear as literal words in any description:

typescript
for (const keyword of ['scary', 'friendship', 'escape', 'heartfelt']) {
  const matches = movies.filter((m) =>
    m.description.toLowerCase().includes(keyword),
  )
  console.log(`Keyword "${keyword}": ${matches.length} results`)
}
text
Keyword "scary": 0 results
Keyword "friendship": 0 results
Keyword "escape": 0 results
Keyword "heartfelt": 0 results

Zero across the board. WHERE description LIKE '%scary%' returns nothing. Full-text search with stemming does slightly better, but it still can't bridge "scary" to "deadly creature stalking through corridors." That gap is exactly what vector search is built for.

Filtered Search

Qdrant can combine vector similarity with payload filters, which is where things get really useful. Find the most mind-bending sci-fi movie:

typescript
const [queryVector] = await getEmbeddings([
  'mind-bending concept that questions reality',
])
const filtered = await client.query(COLLECTION, {
  query: queryVector,
  limit: 3,
  with_payload: true,
  filter: {
    must: [{ key: 'genres', match: { any: ['sci-fi'] } }],
  },
})

console.log('\n"mind-bending reality" (sci-fi only)')
for (const point of filtered.points) {
  const p = point.payload as Record<string, unknown>
  console.log(`  ${point.score?.toFixed(3)}  ${p.title} (${p.year})`)
}
text
"mind-bending reality" (sci-fi only)
  0.571  The Matrix (1999)
  0.543  Inception (2010)
  0.401  Interstellar (2014)

Filters are applied before the vector search, so they stay efficient even on large collections. Qdrant supports must, should, and must_not clauses with match, range, and geo conditions on any payload field.

When to Reach for a Vector Database

Qdrant fits when any of these describe your problem:

  • Semantic search: find relevant results even when the user's words don't match the stored text
  • Recommendations: "find items similar to this one" over product catalogs, articles, or media
  • RAG pipelines: retrieve relevant context from a knowledge base to feed into an LLM
  • Image/audio similarity: any content that can be converted into an embedding can be searched
  • Anomaly detection: find data points that are far from their nearest neighbors

If your problem boils down to "find things similar to this," a vector database is probably what you need.

Wrapping Up

The full script is under 100 lines of real code. You defined a dataset, generated embeddings, stored them in Qdrant, and ran semantic searches that keyword matching can't touch. The same pattern scales from 15 movies to millions of documents.

The Qdrant documentation covers advanced features like named vectors, sparse vectors, multi-tenancy, quantization, and hybrid search combining dense and sparse approaches.

To manage your local Qdrant instance:

bash
spindb stop qdrant1    # Stop the server
spindb start qdrant1   # Start it again
spindb list            # See all your database instances

SpinDB manages 20+ engines from one CLI, so Qdrant can sit next to PostgreSQL, MongoDB, or whatever else your stack needs. Layerbase Desktop wraps the same thing in a GUI if you prefer that on macOS.

Something not working?