Skip to main content

Firecrawl + Qdrant: Build a Vector Search Engine for Any Website

· 10 min read
Yassine El Haddad
Software Developer & Automation Specialist

I build production AI agents, web scrapers, and automation pipelines. Most of what I publish here comes from the actual problems they run into: proxies that get banned, anti-bot stacks that fingerprint your client, RAG that drifts when the underlying data moves. Stack: Python, TypeScript, Go, FastAPI, LangChain, Crawlee, Playwright, deployed on AWS, GCP, and Cloudflare.

You want semantic search over any website — not just keyword matching, but "find me everything about authentication errors" even when the page says "login failures." The two-tool stack that delivers this in under 200 lines of Python: Firecrawl for crawling and Qdrant for vector search.

This tutorial walks you from a blank Python project to a running semantic search API. You'll crawl a website with Firecrawl, chunk the markdown output, embed the chunks with OpenAI, upsert to a local Qdrant instance, and expose a FastAPI search endpoint that blends keyword and semantic results.

Why Firecrawl + Qdrant?

Most vector search tutorials skip the hardest part: getting clean text from real websites. Raw HTML ruins embeddings. Cookie banners, nav menus, and <script> blocks dilute semantic meaning and waste token budget.

Firecrawl solves the extraction layer. It handles JavaScript rendering, strips navigation boilerplate, and returns clean Markdown — exactly what an embedding model expects. Qdrant handles the storage and retrieval layer with native hybrid search (dense + sparse vectors), a Rust-powered engine, and a generous free tier.

Together they form a complete pipeline:

StageToolOutput
CrawlFirecrawl /crawl APIArray of {url, markdown} objects
ChunkPython RecursiveCharacterTextSplitter512-token text chunks
EmbedOpenAI text-embedding-3-small1536-dim float vectors
StoreQdrant collectionIndexed points with payload
SearchQdrant hybrid queryRanked results by relevance

Prerequisites

pip install firecrawl-py qdrant-client openai fastapi uvicorn langchain-text-splitters

You need:

  • A Firecrawl API key (free tier: 500 pages/month)
  • An OpenAI API key (or swap for a local model — covered below)
  • Docker for running Qdrant locally (or use Qdrant Cloud)

Start Qdrant:

docker run -p 6333:6333 qdrant/qdrant

Step 1 — Crawl a Website with Firecrawl

Firecrawl's /crawl endpoint recursively fetches every page on a domain and returns clean Markdown. One API call replaces weeks of custom spider code.

import os
from firecrawl import FirecrawlApp

app = FirecrawlApp(api_key=os.environ["FIRECRAWL_API_KEY"])

result = app.crawl_url(
"https://docs.qdrant.tech",
params={
"limit": 100, # max pages to crawl
"scrapeOptions": {
"formats": ["markdown"],
"onlyMainContent": True, # strips nav, footer, ads
},
},
)

pages = result.get("data", [])
print(f"Crawled {len(pages)} pages")
# [{'url': 'https://docs.qdrant.tech/...', 'markdown': '# Collections\n...'}]

onlyMainContent: True is the key flag. Firecrawl uses heuristic DOM analysis to remove boilerplate before returning Markdown — this keeps your embeddings focused on actual content.

Practical limits: The free Firecrawl tier allows 500 pages/month. For larger sites, use the maxDepth and includePaths parameters to focus the crawl on specific sections.

Step 2 — Chunk the Markdown

Embedding an entire page as one vector loses granularity. A 2,000-word article becomes a single point that represents everything and nothing specifically.

Chunk at 512 tokens with 50-token overlap. The overlap preserves context at chunk boundaries — a sentence explaining a concept won't be split from the code example that follows it.

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=["\n## ", "\n### ", "\n\n", "\n", " "],
)

chunks = []
for page in pages:
url = page["url"]
markdown = page.get("markdown", "")
if not markdown.strip():
continue

splits = splitter.split_text(markdown)
for i, text in enumerate(splits):
chunks.append({
"text": text,
"url": url,
"chunk_index": i,
})

print(f"Created {len(chunks)} chunks from {len(pages)} pages")

The separators list respects Markdown structure: it prefers splitting at H2/H3 headers before falling back to paragraphs, then sentences.

Step 3 — Generate Embeddings

text-embedding-3-small costs $0.02 per million tokens and produces 1536-dimension vectors. For a 100-page documentation site, expect 200-400 chunks (~50K tokens, well under $0.01).

from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

def embed_batch(texts: list[str], batch_size: int = 100) -> list[list[float]]:
"""Embed texts in batches to respect API rate limits."""
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
response = client.embeddings.create(
model="text-embedding-3-small",
input=batch,
)
all_embeddings.extend([e.embedding for e in response.data])
return all_embeddings

texts = [c["text"] for c in chunks]
embeddings = embed_batch(texts)
print(f"Generated {len(embeddings)} embeddings of dim {len(embeddings[0])}")

Using a local model instead: If you prefer to avoid OpenAI costs, swap for sentence-transformers:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2") # 384 dims, runs on CPU
embeddings = model.encode([c["text"] for c in chunks]).tolist()

The Qdrant setup below works identically — just change size=384 instead of size=1536.

Step 4 — Upsert to Qdrant

Create a collection and load your vectors. Qdrant stores each vector as a "point" with a numeric ID, a vector, and a JSON payload for metadata filtering.

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

client_q = QdrantClient(host="localhost", port=6333)

COLLECTION_NAME = "website_search"

# Create collection (idempotent)
client_q.recreate_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)

# Build points
points = [
PointStruct(
id=i,
vector=embeddings[i],
payload={
"text": chunks[i]["text"],
"url": chunks[i]["url"],
"chunk_index": chunks[i]["chunk_index"],
},
)
for i in range(len(chunks))
]

# Upsert in batches of 100
batch_size = 100
for i in range(0, len(points), batch_size):
client_q.upsert(
collection_name=COLLECTION_NAME,
points=points[i : i + batch_size],
)

print(f"Upserted {len(points)} points to Qdrant")

After uploading, verify in the Qdrant dashboard at http://localhost:6333/dashboard.

Query by embedding the search string and finding nearest neighbors:

def semantic_search(query: str, limit: int = 5) -> list[dict]:
query_vector = client.embeddings.create(
model="text-embedding-3-small",
input=[query],
).data[0].embedding

results = client_q.search(
collection_name=COLLECTION_NAME,
query_vector=query_vector,
limit=limit,
with_payload=True,
)

return [
{
"score": r.score,
"url": r.payload["url"],
"text": r.payload["text"][:200],
}
for r in results
]

# Test it
hits = semantic_search("how to configure authentication")
for h in hits:
print(f"{h['score']:.3f} | {h['url']}")
print(f" {h['text']}\n")

Step 6 — Hybrid Search (Keyword + Semantic)

Pure semantic search misses exact product names, error codes, and version numbers. Hybrid search blends dense vector similarity with sparse BM25-style keyword matching.

Qdrant supports this natively via sparse vectors. You'll need fastembed to generate sparse vectors:

pip install fastembed
from qdrant_client.models import (
SparseVector,
SparseVectorParams,
NamedSparseVector,
NamedVector,
SearchRequest,
)
from fastembed import SparseTextEmbedding

sparse_model = SparseTextEmbedding(model_name="Qdrant/bm25")

# Recreate collection with both dense and sparse vectors
client_q.recreate_collection(
collection_name=COLLECTION_NAME,
vectors_config={"dense": VectorParams(size=1536, distance=Distance.COSINE)},
sparse_vectors_config={"sparse": SparseVectorParams()},
)

# Generate sparse embeddings
sparse_embeddings = list(sparse_model.embed([c["text"] for c in chunks]))

# Rebuild points with both vector types
points = [
PointStruct(
id=i,
vector={
"dense": embeddings[i],
"sparse": SparseVector(
indices=sparse_embeddings[i].indices.tolist(),
values=sparse_embeddings[i].values.tolist(),
),
},
payload={
"text": chunks[i]["text"],
"url": chunks[i]["url"],
},
)
for i in range(len(chunks))
]

client_q.upsert(collection_name=COLLECTION_NAME, points=points)


def hybrid_search(query: str, limit: int = 5) -> list[dict]:
# Dense query vector
dense_vec = client.embeddings.create(
model="text-embedding-3-small",
input=[query],
).data[0].embedding

# Sparse query vector
sparse_vec = list(sparse_model.embed([query]))[0]

results = client_q.query_points(
collection_name=COLLECTION_NAME,
prefetch=[
{"query": dense_vec, "using": "dense", "limit": 20},
{
"query": SparseVector(
indices=sparse_vec.indices.tolist(),
values=sparse_vec.values.tolist(),
),
"using": "sparse",
"limit": 20,
},
],
query={"fusion": "rrf"}, # Reciprocal Rank Fusion
limit=limit,
with_payload=True,
)

return [
{
"score": r.score,
"url": r.payload["url"],
"text": r.payload["text"][:200],
}
for r in results.points
]

Reciprocal Rank Fusion (RRF) merges the two ranked lists without requiring score normalization — it's the standard approach for combining heterogeneous retrieval signals.

Step 7 — Expose a FastAPI Search Endpoint

Wire everything into a minimal REST API:

from fastapi import FastAPI, Query
from pydantic import BaseModel

api = FastAPI(title="Website Search API")


class SearchResult(BaseModel):
score: float
url: str
text: str


@api.get("/search", response_model=list[SearchResult])
def search(
q: str = Query(..., description="Search query"),
limit: int = Query(5, ge=1, le=20),
mode: str = Query("hybrid", description="semantic | hybrid"),
):
if mode == "semantic":
return semantic_search(q, limit)
return hybrid_search(q, limit)


if __name__ == "__main__":
import uvicorn
uvicorn.run(api, host="0.0.0.0", port=8000)

Run it:

uvicorn main:api --reload

Test:

curl "http://localhost:8000/search?q=configure+authentication&mode=hybrid"

Complete Project Structure

website-search/
├── crawl.py # Firecrawl ingestion
├── embed.py # Chunking + embedding
├── index.py # Qdrant upsert
├── search.py # Semantic + hybrid search functions
├── main.py # FastAPI app
└── requirements.txt

requirements.txt:

firecrawl-py>=0.0.16
qdrant-client>=1.9.0
openai>=1.0.0
fastapi>=0.110.0
uvicorn>=0.29.0
langchain-text-splitters>=0.0.1
fastembed>=0.3.0

When to Use Apify Instead

Firecrawl is the right choice for documentation sites, marketing pages, and content-heavy domains where the crawl is straightforward. Use Apify instead when:

  • The target site requires authentication or session management
  • You need to handle JavaScript-heavy SPAs with complex interactions (infinite scroll, lazy loading)
  • The site uses aggressive anti-bot measures (Cloudflare Enterprise, DataDome)
  • You need scheduled recurring crawls with change detection

Apify's Website Content Crawler outputs the same clean Markdown format that Qdrant expects, so the chunking and indexing code above works unchanged — just swap the crawl step.

For more on RAG pipeline architecture including Qdrant ingestion patterns, see the RAG pipeline guide.

Performance Benchmarks

For a 100-page documentation site (Qdrant docs used as test case):

MetricValue
Crawl time (Firecrawl)~45 seconds
Pages crawled100
Chunks generated~380
Embedding time (OpenAI batch)~8 seconds
Qdrant upsert time~2 seconds
Search latency (semantic)~120ms
Search latency (hybrid)~180ms
Total pipeline cost<$0.01

FAQ

What is Qdrant?

Qdrant is an open-source vector database written in Rust. It stores high-dimensional vectors alongside JSON payloads and provides fast approximate nearest-neighbor search. It supports both dense vectors (from neural embeddings) and sparse vectors (for keyword search), enabling hybrid retrieval in a single query. You can run it locally via Docker or use Qdrant Cloud for a managed instance.

How do I build vector search from a website?

The four-step process: (1) crawl the website with a tool like Firecrawl to get clean Markdown text, (2) chunk the Markdown into 400-600 token segments, (3) embed each chunk with a model like text-embedding-3-small, (4) upsert the vectors to Qdrant with the source URL as metadata. Queries are processed the same way — embed the query string and return the nearest chunks by cosine similarity.

Can I use Firecrawl with Qdrant?

Yes. Firecrawl's /crawl endpoint returns {url, markdown} objects that feed directly into a chunking and embedding pipeline. The onlyMainContent: True scrape option removes navigation and boilerplate before you start chunking, which directly improves embedding quality. The tutorial above covers the complete integration.

Do I need OpenAI for embeddings?

No. The sentence-transformers library provides CPU-friendly models like all-MiniLM-L6-v2 (384 dims) that run locally for free. The trade-off is retrieval quality: OpenAI's text-embedding-3-small consistently outperforms open-source models on English documentation retrieval benchmarks. For production use cases where search quality matters, OpenAI's $0.02/million token pricing is negligible.

What is hybrid search and when should I use it?

Hybrid search combines dense vector similarity (semantic understanding) with sparse BM25-style keyword matching in a single query. Use hybrid when your content includes exact product names, error codes, API method names, or version numbers — terms where semantic search alone underperforms because the model lacks domain-specific training. The Reciprocal Rank Fusion (RRF) fusion strategy used here requires no tuning and consistently outperforms either method alone.


Ready to build? Get a Firecrawl API key and have your first site indexed in under 10 minutes.