Getting Started with Qdrant
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
- Set Up the Project
- The Movie Dataset
- Choose Your Embedding Provider
- Connect and Create a Collection
- Generate Embeddings and Insert
- Semantic Search
- Filtered Search
- When to Reach for a Vector Database
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:
npm i -g spindb # npm
pnpm add -g spindb # pnpmOr run it directly without installing:
npx spindb create qdrant1 -e qdrant --start # npm
pnpx spindb create qdrant1 -e qdrant --start # pnpmIf you installed globally, create and start a Qdrant instance:
spindb create qdrant1 -e qdrant --startSpinDB downloads the Qdrant binary for your platform, configures it, and starts the server. Verify it's running:
spindb url qdrant1http://127.0.0.1:6333Leave 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:
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
mkdir qdrant-movie-search && cd qdrant-movie-search
pnpm init
pnpm add @qdrant/js-client-rest @xenova/transformers
pnpm add -D tsx typescriptCreate 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:
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.
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:
pnpm add openaiimport 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.
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:
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:
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:
npx tsx search.tsExpected output (exact scores vary by embedding model):
"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 IntouchablesLook 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:
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`)
}Keyword "scary": 0 results
Keyword "friendship": 0 results
Keyword "escape": 0 results
Keyword "heartfelt": 0 resultsZero 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:
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})`)
}"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:
spindb stop qdrant1 # Stop the server
spindb start qdrant1 # Start it again
spindb list # See all your database instancesSpinDB 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.