Full-Text Search vs Vector Search
Your users are complaining that search doesn't work. They type "running shoes" and get results, but they type "comfortable sneakers for jogging" and get nothing. Or they misspell "kubernetes" as "kuberntes" and see an empty page. You know you need to fix search, but the fix depends on which problem you actually have, and most developers mix them up.
Full-text search and vector search sound like they do the same thing. They don't. Full-text search matches words: it tokenizes your query, looks them up in an index, handles typos, and ranks by term relevance. Vector search matches meaning: it converts text into numerical embeddings and finds the closest matches by geometric distance, regardless of which words you used. One is great at "find documents containing these terms." The other is great at "find documents about this concept."
We'll run the same queries against the same movie dataset using Meilisearch (full-text) and Qdrant (vector) so you can see exactly where each one wins and where it falls flat.
Contents
- What Full-Text Search Does
- What Vector Search Does
- Set Up Both Engines with SpinDB
- The Movie Dataset
- Side-by-Side: Typo Queries
- Side-by-Side: Semantic Queries
- When You Need Both
- Decision Guide
- Next Steps
What Full-Text Search Does
Full-text search engines break text into tokens (words), build an inverted index (word-to-document lookup table), and rank matches using algorithms like BM25. The important features:
- Tokenization: "The Shawshank Redemption" becomes
["the", "shawshank", "redemption"] - Inverted index: each token points to the documents that contain it, so lookups are near-instant regardless of dataset size
- BM25 ranking: results are scored by term frequency, document length, and how rare the term is across the corpus. A movie with "redemption" in both the title and description ranks higher than one that mentions it once
- Typo tolerance: "shawshnk" still matches "shawshank" because the engine calculates edit distance and allows fuzzy matching
- Faceting: combine text search with structured filters ("sci-fi movies from 2010 or later matching 'space'")
This powers search bars, product catalogs, documentation sites, and autocomplete. Users type text, the engine finds documents that contain (or nearly contain) those words, and ranks them.
Tools: Meilisearch, Elasticsearch, Typesense, PostgreSQL full-text search.
What Vector Search Does
Vector search doesn't look at words at all. It converts text into embeddings: high-dimensional arrays of floating-point numbers generated by a machine learning model. Similar meaning produces similar numbers, regardless of word choice.
The core idea: "a deadly creature stalks a crew in deep space" and "scary movie set in space" share almost no words. But an embedding model puts them close together in vector space because they mean similar things.
- Embeddings: a model like
all-MiniLM-L6-v2turns any text into a 384-dimensional vector (an array of 384 numbers) - Similarity search: Qdrant finds the vectors closest to your query vector using distance metrics like cosine similarity
- Semantic understanding: "scary movie set in space" matches Alien even though the word "scary" never appears in Alien's description
This powers "find similar items," recommendation engines, RAG pipelines (retrieval-augmented generation), and any search where users describe what they want in natural language rather than typing exact keywords.
Tools: Qdrant, Weaviate, Pinecone, Milvus, pgvector.
Set Up Both Engines with SpinDB
We'll run both Meilisearch and Qdrant locally with SpinDB. No Docker, no manual config. (What is SpinDB?)
Install SpinDB globally:
npm i -g spindb # npm
pnpm add -g spindb # pnpmCreate and start both instances:
spindb create meili1 -e meilisearch --start
spindb create qdrant1 -e qdrant --startCheck they're up:
spindb url meili1
spindb url qdrant1http://127.0.0.1:7700
http://127.0.0.1:6333Two search engines running locally in under 30 seconds.
The Movie Dataset
Both engines will search the same 15 movies. This is the same dataset used in the Meilisearch and Qdrant getting-started posts:
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.',
},
]We'll run two types of queries:
- Typo queries where the user misspells a known title
- Semantic queries where the user describes what they want in natural language
This is where the two approaches diverge completely.
Side-by-Side: Typo Queries
Meilisearch
Index the movies and search with typos:
import { Meilisearch } from 'meilisearch'
const meili = new Meilisearch({ host: 'http://localhost:7700' })
// Index movies
const createTask = await meili.createIndex('movies', { primaryKey: 'id' })
await meili.waitForTask(createTask.taskUid)
const addTask = await meili.index('movies').addDocuments(movies)
await meili.waitForTask(addTask.taskUid)
// Search with typos
const typoQueries = ['shawshnk rdmption', 'scary space movie']
for (const query of typoQueries) {
const results = await meili.index('movies').search(query)
console.log(`\nMeilisearch: "${query}"`)
for (const hit of results.hits.slice(0, 3)) {
console.log(` ${hit.title} (${hit.year})`)
}
if (results.hits.length === 0) console.log(' (no results)')
}Meilisearch: "shawshnk rdmption"
The Shawshank Redemption (1994)
Meilisearch: "scary space movie"
(no results)"shawshnk rdmption" nails it. Butchered spelling, correct movie. But "scary space movie" returns nothing because no movie has the word "scary" in its title or description. Meilisearch can only match words that actually exist in the indexed text.
Qdrant
Now embed the same movies and run the same queries through Qdrant:
import { QdrantClient } from '@qdrant/js-client-rest'
import { pipeline } from '@xenova/transformers'
const qdrant = new QdrantClient({ url: 'http://localhost:6333' })
const embedder = await pipeline(
'feature-extraction',
'Xenova/all-MiniLM-L6-v2',
)
async function embed(text: string): Promise<number[]> {
const output = await embedder(text, {
pooling: 'mean',
normalize: true,
})
return Array.from(output.data)
}
// Create collection and insert embeddings
await qdrant.createCollection('movies', {
vectors: { size: 384, distance: 'Cosine' },
})
const points = await Promise.all(
movies.map(async (movie) => ({
id: movie.id,
vector: await embed(`${movie.title} ${movie.description}`),
payload: { title: movie.title, year: movie.year },
})),
)
await qdrant.upsert('movies', { points })
// Search with same queries
for (const query of typoQueries) {
const vector = await embed(query)
const results = await qdrant.search('movies', {
vector,
limit: 3,
})
console.log(`\nQdrant: "${query}"`)
for (const hit of results) {
console.log(
` ${hit.payload?.title} (${hit.payload?.year}) [score: ${hit.score.toFixed(3)}]`,
)
}
}Qdrant: "shawshnk rdmption"
The Grand Budapest Hotel (2014) [score: 0.182]
The Intouchables (2011) [score: 0.165]
Parasite (2019) [score: 0.154]
Qdrant: "scary space movie"
Alien (1979) [score: 0.612]
Interstellar (2014) [score: 0.432]
The Matrix (1999) [score: 0.328]"shawshnk rdmption" is garbage in Qdrant. The embedding model has never seen that misspelling, so it generates a meaningless vector and returns random results with low scores. But "scary space movie" is a home run. Alien is the top result even though "scary" never appears in its description. The model understands that "deadly extraterrestrial creature" is semantically close to "scary."
The Takeaway
| Query | Meilisearch | Qdrant |
|---|---|---|
| "shawshnk rdmption" (typo) | The Shawshank Redemption | Random noise |
| "scary space movie" (semantic) | No results | Alien |
Same dataset, same queries, completely different strengths.
Side-by-Side: Semantic Queries
Let's push the semantic side harder with queries that describe a concept rather than name a movie:
const semanticQueries = [
'movie about escaping prison',
'animated film for kids about family',
'someone trapped in a fake world',
'funny friendship between different social classes',
]Meilisearch Results
Meilisearch: "movie about escaping prison"
(no results)
Meilisearch: "animated film for kids about family"
(no results)
Meilisearch: "someone trapped in a fake world"
(no results)
Meilisearch: "funny friendship between different social classes"
(no results)Zero across the board. None of these queries contain words that appear in the movie titles or descriptions. Meilisearch isn't broken. It's doing exactly what it's designed to do: match text against text. The text just doesn't match.
Qdrant Results
Qdrant: "movie about escaping prison"
The Shawshank Redemption (1994) [score: 0.583]
The Matrix (1999) [score: 0.378]
Spirited Away (2001) [score: 0.334]
Qdrant: "animated film for kids about family"
Coco (2017) [score: 0.571]
Finding Nemo (2003) [score: 0.559]
Spirited Away (2001) [score: 0.488]
Qdrant: "someone trapped in a fake world"
The Matrix (1999) [score: 0.517]
Inception (2010) [score: 0.454]
Spirited Away (2001) [score: 0.382]
Qdrant: "funny friendship between different social classes"
The Intouchables (2011) [score: 0.596]
The Grand Budapest Hotel (2014) [score: 0.378]
Parasite (2019) [score: 0.361]Every result makes sense. "Movie about escaping prison" finds Shawshank even though "prison" never appears in its description (it says "imprisoned"). "Animated film for kids about family" returns Coco, Finding Nemo, and Spirited Away. "Someone trapped in a fake world" gets The Matrix. The embedding model connects meaning across completely different vocabulary.
When You Need Both
Sometimes you need both. A documentation site where users search for "configuraton" (typo) but also ask "how do I set up authentication?" (semantic). An e-commerce platform where users type "niike shoes" (typo) but also browse "comfortable running shoes for flat feet" (semantic).
Two approaches:
Option 1: Weaviate Hybrid Search
Weaviate combines BM25 keyword scoring with vector similarity in a single query. You control the balance with an alpha parameter: 0 is pure keyword, 1 is pure vector, and anything in between blends both.
spindb create weaviate1 -e weaviate --startWeaviate can also generate embeddings for you with built-in vectorizer modules, so you skip managing an embedding pipeline. If you need one search engine that does both, Weaviate is the most practical choice.
Option 2: Run Both Meilisearch + Qdrant
For full control, run both engines side by side. Route queries based on intent:
- User types into a search bar with autocomplete? Send it to Meilisearch.
- User asks a natural language question? Send it to Qdrant.
- Not sure? Send it to both and merge the results.
Both engines run as lightweight local processes through SpinDB:
spindb listmeili1 meilisearch running http://127.0.0.1:7700
qdrant1 qdrant running http://127.0.0.1:6333The downside: two indexes, two ingestion pipelines, and merging logic. But you get maximum flexibility and can tune each engine independently.
Decision Guide
Don't overthink it.
Start with full-text search if:
- Users type into a search bar and expect instant results
- Typo tolerance matters (search bars, autocomplete, product catalogs)
- You need faceted filtering (genre + year + text query)
- Queries are words that appear in the data (titles, names, descriptions)
- You want something running in 5 minutes with no ML pipeline
Start with vector search if:
- You need to find semantically similar content
- Users describe what they want in natural language ("sad movie about a robot")
- You're building a RAG pipeline for an LLM
- You need recommendation features ("show me more like this")
- Your data has rich descriptions where meaning matters more than exact words
Consider hybrid search (Weaviate or both) if:
- You need typo tolerance and semantic understanding
- Your search bar handles both product lookups and natural language questions
- You're building a documentation site where users sometimes know the term and sometimes don't
Search box? Full-text search. Semantic similarity? Vector search. Both? Weaviate's hybrid search, or Meilisearch and Qdrant side by side.
Next Steps
The getting-started guides walk through each engine in depth with full, runnable TypeScript code:
- Getting Started with Meilisearch for full-text search with typo tolerance, faceting, and filtering
- Getting Started with Qdrant for vector search with embeddings and semantic similarity
- Getting Started with Weaviate for hybrid search that combines both approaches
All three engines are available through SpinDB:
spindb create meili1 -e meilisearch --start
spindb create qdrant1 -e qdrant --start
spindb create weaviate1 -e weaviate --start
spindb listSpinDB runs 20+ database engines from one CLI, so you can run whichever combination your project needs. Layerbase Desktop is there if you prefer a GUI, and Layerbase Cloud for managed instances.