Building It for Real: Qdrant, FastEmbed & an Agent

Part of the free Generative AI course on LogicWiz, module: Finding the Needle: Hybrid Search & Retrieval.

Episode 45: Building It for Real — Qdrant, FastEmbed & an Agent

Four episodes of theory. "Now," said Arjun, cracking his knuckles, "we actually build the thing." And Nova's search went from a diagram to a running product.


The Plan: From Parts to Product

Over the last four episodes you built every part of a search brain — fast dense search (HNSW), smart sparse search (SPLADE over an inverted index), and a way to merge them (RRF). This episode assembles those parts into a running product, and the whole thing splits into just two phases:

  • Offline — do it once. Take the entire catalogue and index it: turn every product into a dense and a sparse vector and store both.
  • Online — every search. Take the shopper's query, search both indexes, fuse the results, and return the best products.

Think of a restaurant. Offline is the prep kitchen — chopping, marinating, stocking the line long before any guest arrives. Online is dinner service — plates fly out fast because the hard work is already done. Search works the same way: heavy lifting up front, instant answers at query time.

The Toolbox

Building this needs four tools. Here's the job each one does, in plain terms:

  • Qdrant — the warehouse (vector database). It stores your indexed products, and unlike most databases it holds both a dense and a sparse vector for each item in one place — so it runs true hybrid search out of the box. Open-source, and scales to production (high availability, horizontal scaling).
  • Hugging Face — the model store. Think "GitHub for AI models": it's where pre-trained models like SPLADE are downloaded from.
  • FastEmbed — the fast labelling machine (embeddings). A small Python library that turns text into vectors. Its trick: it runs the models in a slimmed-down format (ONNX — a compact, ready-to-run version of a model) that's fast on an ordinary CPU — about 50 MB, no expensive GPU and no 2 GB PyTorch install. (Trade-off: it can run models, not train them.)
  • The two models it runs: BAAI/bge-large-en-v1.5 for dense (meaning, 1024 numbers per vector) and prithivida/Splade_PP_en_v1 for sparse (expanded keywords).

Qdrant's Data Model

Qdrant maps cleanly onto a database you already know:

  • Collection — the top-level container, like a table. It defines the vector configuration (sizes, distance metric).
  • Point — the core unit, like a row. A point holds a unique id, one or more vectors (dense and sparse, side by side), and a payload.
  • Payload — key–value metadata (product title, text, id) used for filtering and, crucially, for showing the human-readable result. A point isn't just a vector; it's the granular thing that holds the vectors.

For hybrid search, a single point stores both a dense and a sparse vector under named keys (text-dense, text-sparse) so each can be searched independently.

To make that concrete, here's what one point — for our LumaGlow lamp — actually holds:

  • id: 1042 — a unique number for this product.
  • vectors:
    • text-dense: [0.21, -0.67, 0.05, …] — 1024 numbers capturing the meaning.
    • text-sparse: {bright: 1.7, lamp: 1.5, luminous: 0.9, light: 0.8, …} — the words (expanded by SPLADE), each with a weight.
  • payload: { title: "LumaGlow 1100-Lumen LED Desk Light", price: 39.99 } — the human-readable info you'll actually show the shopper.

The two vectors sit side by side under their named keys, so a search can hit either one; the payload is the bridge back from a matched vector to something a person can read.

The Data: Amazon ESCI (Our Ground Truth)

You can't tell if search is good without an answer key. The lab uses Amazon ESCI, a real product-search relevance dataset. Every query→product pair is labelled:

  • E — Exact: exactly what the user wanted.
  • S — Substitute: an acceptable alternative.
  • C — Complement: a related add-on.
  • I — Irrelevant: not it.

Concretely, for the query "bright desk lamp": the LumaGlow 1100-lumen lamp is Exact, a dimmer 900-lumen lamp is a Substitute, replacement bulbs are a Complement, and a coffee mug is Irrelevant.

This is the ground truth: run a query through your system, compare the products it returns against these labels, and you can measure precision — an Exact result is a win, an Irrelevant one is a failure.

Offline: Ingesting the Catalogue

This is the "prep kitchen" phase — done once, before any shopper arrives. Let's follow a single product all the way in, so the steps are concrete. Take the LumaGlow lamp:

  1. Clean the text. Join its title and description into one tidy string — the raw material both models will read.
  2. Embed it twice. Hand that string to FastEmbed, which produces a dense vector (from bge — the meaning) and a sparse vector (from SPLADE — the expanded keywords). One product, two vectors.
  3. Wrap it in a point. Bundle a unique id, both vectors (under text-dense and text-sparse), and a payload (the title and price you'll display).
  4. Upsert into Qdrant. Store the point. ("Upsert" = insert if new, update if the id already exists.)

Now repeat that for every product in the catalogue. When it finishes, the whole catalogue is indexed and ready — the heavy work is behind you.

Here's the real ingestion code doing exactly those four steps, in a loop:

{{visual:qdrant-ingest-walkthrough}}

Run the offline half yourself — it indexes a few products into a real Qdrant, then opens up one stored point so you can see the dense vector, sparse vector, and payload sitting together:

{{cell:l45-ingest-run}}

💡 Why upsert (not insert)? Upsert inserts a new id or updates an existing one, so re-running the pipeline is safe and idempotent — no duplicate points, no manual dedup. And why store the original text in the payload? Because dense vectors are destructive — you can't recover the words from them — so the payload is your only way back to human-readable results.

Online: One Query, Two Searches, One Fused List

This is "dinner service" — it runs on every search, and it has to be fast. Follow a single query, "bright lamp," through it:

  1. Embed the query twice — dense (meaning) and sparse (expanded keywords) — the exact same two models used on the products, so query and products speak the same language.
  2. Search both indexes. The dense query vector goes to the HNSW index; the sparse one goes to the inverted index. Each returns its own ranked list of point ids. (Qdrant does both in a single batched call, so it's one round trip, not two.)
  3. Fuse with RRF. Merge the two ranked lists by rank — the method you built last episode — so products that both sides rank highly float to the top.
  4. Fetch the payloads. Look up the winning point ids to pull their titles and prices — the results you actually show.

Four steps, and it all happens in milliseconds because the indexing was done offline. Here's the real hybrid-query code:

{{visual:hybrid-query-walkthrough}}

Now run the online half — this cell stands alone (it re-indexes a few products so you can run it on its own), then embeds the query both ways, searches both indexes, and fuses them with RRF, all on a real Qdrant:

{{cell:l45-hybrid-run}}

Then you evaluate the fused results against the ESCI ground truth — the same precision idea from Episode 39, now on real output. You'll build that evaluation harness yourself in this lesson's lab.

🧭 Notice the pieces clicking together: HNSW indexes the dense side, an inverted index the sparse side, query_batch_points fires both at once, RRF fuses by rank, and the payload gives you titles to show. Every concept from this chapter, in one pipeline.

Wrapping Search as an Agent Tool

Everything so far gives you a function you can call. The final step makes it something an AI agent can call on its own. You take the whole hybrid-search pipeline, wrap it in a single function — say hybrid_search(query) — and hand that function to an agent as a tool (exactly the MCP/A2A pattern from Chapter IX).

Then you give the agent a short instruction: "You're a product-search assistant. When a shopper asks for something, call hybrid_search to find matching products, then answer in plain language." From there the agent decides when to search and how to word the reply.

Here's the flow when a shopper types a question:

  1. The shopper asks something conversational — "what's a good bright lamp for a small desk?"
  2. The agent reads it, realises it needs products, and calls hybrid_search("bright desk lamp") — the whole offline/online machine you just built runs under the hood.
  3. Hybrid search returns the top products; the agent turns that raw list into a friendly recommendation.

{{visual:agent-tool-walkthrough}}

Run a real one — this hands your hybrid search to an actual LLM as a tool, then asks it a plain-language question and lets it decide to search (needs your OpenAI key, set above):

{{cell:l45-agent-run}}

So the shopper never types keywords or picks filters — they just ask, and Nova reasons, searches, and recommends the right product: the LumaGlow 1100-Lumen lamp she couldn't surface back in Episode 39.

What Nova Learned

Nova began this chapter unable to connect "bright lamp" to "LumaGlow 1100-Lumen." She ends it with a production retrieval brain: dense search made fast by HNSW, sparse search made smart by SPLADE over an inverted index, the two fused by RRF, sharpened when needed by a cross-encoder, served from Qdrant, and wrapped as a tool her agent can call.

The real lesson of hybrid search isn't any single algorithm — it's that precision and meaning aren't rivals. Match the architecture to how real people actually search, and the needle stops hiding in the haystack.