Build Semantic Search with Qdrant and TypeScript
Keyword search works when the query and document use the same language. It struggles when a customer asks for a "receipt" but the help center calls the same document an "invoice."
Semantic search uses embeddings to compare meaning rather than literal words. Qdrant stores those vectors and combines similarity search with filters such as category, tenant, language, or access level.
Who this is for: TypeScript developers adding semantic search to a small catalog, help center, or retrieval service.
Outcome: A runnable support-article search with local embeddings, a payload index, and a category filter.
Time: About 20 minutes, plus the first embedding-model download.
Prerequisites: Node.js 20 or newer and pnpm. No Docker, Cloud account, or embedding API key is required.
Start Qdrant locally
Install the Layerbase CLI, then create a Qdrant instance:
npm i -g layerbase
lbase create qdrant-search -e qdrant --start
lbase url qdrant-searchThe last command prints the actual endpoint:
http://127.0.0.1:6333If that port is occupied, use the URL printed by the CLI.
Create the TypeScript project:
mkdir qdrant-support-search
cd qdrant-support-search
pnpm init
pnpm add @qdrant/js-client-rest@1.17 @huggingface/transformers@4
pnpm add -D tsx typescriptBuild the search
Create search.ts:
import { pipeline } from '@huggingface/transformers'
import { QdrantClient } from '@qdrant/js-client-rest'
type SupportArticle = {
id: number
title: string
category: 'account' | 'billing' | 'performance' | 'recovery'
body: string
}
const articles: SupportArticle[] = [
{
id: 1,
title: 'Reset your password',
category: 'account',
body: 'Request a password reset link from the sign-in page.',
},
{
id: 2,
title: 'Configure single sign-on',
category: 'account',
body: 'Connect an identity provider with SAML for organization sign-in.',
},
{
id: 3,
title: 'Download monthly invoices',
category: 'billing',
body: 'Open billing history to download past invoices as PDF files.',
},
{
id: 4,
title: 'Fix a declined card',
category: 'billing',
body: 'Update the payment method after a bank rejects a subscription charge.',
},
{
id: 5,
title: 'Restore a deleted project',
category: 'recovery',
body: 'Recover a recently deleted project from an available backup.',
},
{
id: 6,
title: 'Troubleshoot slow searches',
category: 'performance',
body: 'Inspect filters and indexes when search response time increases.',
},
]
const client = new QdrantClient({
url: process.env.QDRANT_URL ?? 'http://127.0.0.1:6333',
apiKey: process.env.QDRANT_API_KEY,
})
const embed = await pipeline(
'feature-extraction',
'Xenova/all-MiniLM-L6-v2',
)
async function vectorFor(text: string): Promise<number[]> {
const output = await embed(text, {
pooling: 'mean',
normalize: true,
})
return Array.from(output.data as Float32Array)
}
const COLLECTION = 'support_articles_tutorial_v1'
const VECTOR_SIZE = 384
const existing = await client.getCollections()
if (existing.collections.some(({ name }) => name === COLLECTION)) {
await client.deleteCollection(COLLECTION)
}
await client.createCollection(COLLECTION, {
vectors: {
size: VECTOR_SIZE,
distance: 'Cosine',
},
})
// Create indexes before ingest so Qdrant can build filter-aware structures.
await client.createPayloadIndex(COLLECTION, {
field_name: 'category',
field_schema: 'keyword',
wait: true,
})
const vectors = await Promise.all(
articles.map((article) =>
vectorFor(`${article.title}. ${article.body}`),
),
)
await client.upsert(COLLECTION, {
wait: true,
points: articles.map((article, index) => ({
id: article.id,
vector: vectors[index],
payload: article,
})),
})
console.log(`Indexed ${articles.length} support articles`)
const query = 'I need a receipt for an old payment'
const literalMatches = articles.filter((article) =>
article.body.toLowerCase().includes('receipt'),
)
console.log(`Literal matches for "receipt": ${literalMatches.length}`)
const queryVector = await vectorFor(query)
const results = await client.query(COLLECTION, {
query: queryVector,
limit: 2,
with_payload: true,
filter: {
must: [
{
key: 'category',
match: { value: 'billing' },
},
],
},
})
console.log('\nSemantic matches in billing:')
for (const point of results.points) {
const article = point.payload as unknown as SupportArticle
console.log(` ${article.title}`)
}This script deletes and recreates only support_articles_tutorial_v1. Do not reuse that collection name for application data.
Run it with the URL returned by the CLI:
QDRANT_URL="$(lbase url qdrant-search)" pnpm tsx search.tsThe first run downloads the embedding model. Later runs use the local cache.
Expected output:
Indexed 6 support articles
Literal matches for "receipt": 0
Semantic matches in billing:
Download monthly invoices
Fix a declined cardThe literal search finds nothing because none of the article bodies uses the word receipt. The semantic query still finds the invoice article, while the payload filter prevents unrelated account or recovery documents from entering the result set.
Why the payload index comes first
Qdrant can store arbitrary JSON payloads next to vectors, but it does not automatically index every payload field. That would waste memory and slow writes.
Create payload indexes for fields used regularly in filters. Qdrant recommends creating them before ingest because its filterable vector index can use those fields while building the search graph. The Qdrant indexing guide also explains the memory and storage tradeoff.
For a multi-tenant application, index and require the tenant identifier in every query. Filtering only in application code after vector search risks returning the wrong tenant's data.
Keep the embedding contract stable
A collection is tied to its vector size and distance metric. Search quality also depends on the exact embedding model and preprocessing used for both documents and queries.
Record these with the collection:
- Model name and version.
- Vector size and distance metric.
- Text fields and formatting sent to the model.
- Normalization and chunking rules.
Changing any of them can make old and new vectors incomparable. Do not update half a collection with a new model.
For a safe migration, create a versioned collection such as support_articles_v2, re-embed the source documents, and run a fixed set of relevance checks. Switch a Qdrant alias only after the new collection passes. Rollback is the reverse alias switch, so keep the old collection until the new one has handled production traffic successfully.
Keep the source text outside Qdrant or in its payload unless Qdrant is deliberately the system of record. If the vector index can be rebuilt from durable source documents and a recorded model version, recovery is much simpler.
Move the proven workflow to Layerbase Cloud
After the local result is correct, create Qdrant on Layerbase Cloud. Copy the HTTPS endpoint and API key from Quick Connect:
QDRANT_URL="https://your-host.cloud.layerbase.dev" \
QDRANT_API_KEY="your-api-key" \
pnpm tsx search.tsKeep the API key in server-side environment variables. Do not put it in browser code or commit it to a repository.
As verified on July 23, 2026, Qdrant requires the $15 per month Pro plan. Check current pricing before purchasing because plan details can change.
Cloud is useful when the search endpoint must stay reachable without managing the Qdrant process yourself. It does not choose an embedding model, design tenant isolation, judge relevance, or plan reindexing for you. Those remain application responsibilities.
When Qdrant is the wrong fit
Qdrant is a good fit when similarity search is a primary access pattern and metadata filters narrow the result set. It is usually the wrong first choice when:
- Exact keyword matching already solves the problem.
- Relational joins and transactions dominate the workload.
- The dataset is small enough for an existing PostgreSQL plus pgvector deployment.
- The team cannot reproduce its embeddings or evaluate search relevance.
- A dedicated service adds more operational cost than the search feature justifies.
Read full-text search versus vector search before replacing a keyword index. For a comparison between dedicated vector engines, see Qdrant versus Weaviate.
The Layerbase CLI is the fastest local path. Layerbase Desktop provides the same engine workflow in a GUI on macOS, Windows, and Linux. Move to Cloud after the collection design and relevance tests are repeatable.
Keep reading
- Qdrant 1.18 is on Layerbase, and the Headline is QuantizationQdrant 1.18 is the new default line on Layerbase. Here is what a vector database is actually doing, why quantization decides what your index costs to run, and what landed between 1.16 and 1.18.
- Run Postgres, Valkey, and Qdrant in one Lovable appA serious Lovable app usually needs more than one database: durable state, a cache, and vector search. Here is how to wire all three in, the correct way, from a browser SPA that has no server of its own.
- Add vector search to a Lovable app with QdrantAI-powered Lovable apps eventually need vector storage for RAG, semantic search, or recommendations. Here is the working version with Qdrant on Layerbase Cloud.
- Full-Text Search vs Vector SearchLearn when to use full-text search, vector search, or both by running the same queries against Meilisearch and Qdrant side by side.