Enterprise RAG with Hybrid Search

Build a document intelligence system with hybrid search and source citations.

Introduction

30 Second Summary

Imagine asking your company's AI assistant "What does policy POL-2024-0847 say?" and getting back three paragraphs about unrelated policies. That is the reality of most AI-powered search systems today, because they understand meaning but completely ignore exact identifiers.

In this project, you will build an enterprise RAG pipeline that ingests company policies, financial reports, and contracts. Your pipeline answers analyst questions with cited, traceable answers by combining semantic understanding with keyword precision.

What You'll Build

Picture typing "What's the uptime guarantee in contract MSA-2024-1192?" and getting back the exact clause with a citation pointing to the source document and section, in seconds.

By the end of this project, you'll have:

  • A working RAG pipeline where you ask a natural-language question and get an answer with inline citations tracing back to the exact document and section it came from.
  • A hybrid search system that reliably finds documents by both meaning and exact identifiers like policy numbers, contract clauses, and financial figures.
  • Metadata filtering that scopes searches to specific document types like policies, reports, or contracts so you get results from the sources that matter.
  • Secret Mission: Add a cross-encoder reranker to sharpen retrieval quality and measure the improvement.

Do I need to know anything before starting?

This project assumes you have basic Python experience and have worked with a simple RAG system before. You should be familiar with concepts like embeddings and vector databases.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Environment

Before you can build the RAG pipeline, you need three things running: a vector database to store document embeddings, a Python environment with the right packages, and a connection to the Gemini API for generating answers.

In this step, you'll start Qdrant in Docker, install the Python packages that handle embeddings and document parsing, and grab a free Gemini API key to power the generation side of the pipeline.

In this step, get ready to:
  • Start Qdrant in Docker with persistent storage.
  • Create a Python virtual environment and install project packages.
  • Get a free Gemini API key and test the connection.
Start Qdrant in Docker

Qdrant is the vector database that will store your document embeddings and power both dense and hybrid search later in the project. You'll run it as a Docker container so there's nothing to install on your machine beyond Docker itself.

Why Qdrant over ChromaDB or pgvector?

Qdrant has native hybrid search built into its Query API. It supports both dense and sparse vectors with Reciprocal Rank Fusion out of the box.

ChromaDB is simpler but lacks native sparse vector support. pgvector would require a full PostgreSQL setup. Qdrant runs via Docker with zero config and teaches the hybrid search pattern directly.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.

On Windows, Docker with WSL can cause file system issues when using bind mounts. A named Docker volume avoids this entirely.

  • Create a named Docker volume for Qdrant's data by running this command:
docker volume create qdrant-storage
  • Start the Qdrant container by running this command:
docker run -p 6333:6333 -p 6334:6334 -v qdrant-storage:/qdrant/storage qdrant/qdrant

What does this command do?

  • -p 6333:6333 exposes the Qdrant REST API and web dashboard on port 6333.
  • -p 6334:6334 exposes the gRPC API on port 6334.
  • -v qdrant-storage:/qdrant/storage mounts the named volume so your data persists even if the container stops.

Qdrant will take over this terminal

The Qdrant container runs in the foreground, so you'll see log output streaming. Leave this terminal running and open a new terminal window for all remaining commands in this project.

Docker will download the Qdrant image the first time you run this. You'll see pull progress followed by Qdrant's startup logs.

  • Open your browser and navigate to http://localhost:6333/dashboard to confirm Qdrant is running.

You should see the Qdrant Web UI with an empty collections list. This is your vector database ready and waiting for data.

Don't see the dashboard?

  • Check the terminal where you ran the docker run command. If you see an error about port 6333 already in use, another process is occupying that port. Stop it or pick a different port.
  • Make sure Docker Desktop is running. If it's not started, the docker command won't work.

Why can't I see the Qdrant dashboard at localhost:6333?

Create a Python virtual environment and install packages

A virtual environment keeps your project's packages isolated from the rest of your system. You'll install four key packages: the Qdrant client with built-in embedding support, the Google Gemini SDK, and a library for reading Word documents.

  • Open a new terminal window (keep the Qdrant terminal running).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir enterprise-rag
cd enterprise-rag
  • Create a Python virtual environment by running this command:

macOS

python3 -m venv venv

Windows

python -m venv venv
  • Activate the virtual environment by running this command:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

Your terminal prompt should now start with (venv), confirming the virtual environment is active.

  • Install the project dependencies by running this command:
pip install "qdrant-client[fastembed]" google-genai python-docx

What are these packages?

  • qdrant-client[fastembed] is the Qdrant Python client. The [fastembed] extra bundles the fastembed library for generating both dense and sparse embeddings locally without a GPU.
  • google-genai is the SDK for calling the Gemini API to generate answers from retrieved context.
  • python-docx reads and creates Word documents. You'll use it to build sample enterprise documents and parse them into chunks.

The install may take a couple of minutes. You'll see a progress bar as pip downloads and installs each package.

  • Confirm the Qdrant client installed correctly by running this command:
python -c "from qdrant_client import QdrantClient; print('Ready')"

You should see Ready printed in your terminal.

Seeing a ModuleNotFoundError?

  • Check that your terminal prompt starts with (venv). If it doesn't, reactivate your virtual environment using the activation command above.
  • If the install failed earlier, re-run the pip install command and check for error messages in the output.

Help me fix a ModuleNotFoundError for qdrant_client.

Get a Gemini API key and test the connection

The Gemini API will power the generation step of your RAG pipeline. It takes retrieved document chunks and turns them into coherent answers. The free tier gives you enough tokens to run this entire project at no cost.

  • Open https://aistudio.google.com/apikey in your browser.
  • Sign in with your Google account if prompted.
  • Click Create API Key.
  • Copy the key that appears. You'll only see it once, so paste it somewhere safe for now.

Is the Gemini API really free?

Yes. The Gemini API free tier provides free input and output tokens. No credit card is required. This project stays well within the free tier limits.

  • Set your API key as an environment variable by running this command (replace your-api-key-here with the key you just copied):

macOS

export GEMINI_API_KEY=your-api-key-here

Windows

set GEMINI_API_KEY=your-api-key-here

This variable is session-only

The environment variable only lasts for the current terminal session. If you close and reopen your terminal, you'll need to set it again before running any scripts.

Before you run the next command, do you think the API key will successfully connect to Gemini? What would you expect to see if it works?

  • Test the Gemini connection by running this command:
python -c "from google import genai; import os; client = genai.Client(api_key=os.getenv('GEMINI_API_KEY')); print(next(iter(client.models.list())).name); print('Gemini connected!')"

You should see a model name printed (something like models/gemini-2.5-flash) followed by Gemini connected!. This confirms your API key is valid and the SDK can reach Google's servers.

Seeing an authentication error?

  • Double-check that you set the GEMINI_API_KEY environment variable in the same terminal where you're running the test. If you switched terminals, the variable won't carry over.
  • Make sure there are no extra spaces or quotes around your API key in the export or set command.
  • Try generating a new API key at Google AI Studio if your current key doesn't work.

Help me fix my Gemini API connection error.

Your environment is locked in. Qdrant is running, your packages are installed, and Gemini is connected. Next up, you'll create realistic enterprise documents and ingest them into your vector database.

Create and Ingest Enterprise Documents

Your environment is up and running. Now you need the raw material your RAG pipeline will search through: enterprise documents.

Real enterprise knowledge bases hold documents with specific identifiers like policy numbers and contract clause references. In this step, you'll create realistic sample documents and build a pipeline that ingests them into Qdrant with metadata attached to every chunk.

In this step, get ready to:
  • Create three enterprise documents (a policy, a financial report, and a contract) with specific identifiers.
  • Build an ingestion pipeline that parses, chunks, embeds, and stores documents in Qdrant.
  • Verify the collection is visible in the Qdrant dashboard.
Create sample enterprise documents

You'll use python-docx to generate three DOCX files in a documents/ folder. Each document contains unique identifiers like policy number POL-2024-0847 that will become critical when you test search quality in the next step.

  • In PyCharm, right-click your project root in the Project tool window.
  • Select New, then Python File.
  • Name it create_docs and press Enter. PyCharm adds the .py extension automatically.
  • Start building the document generator by pasting this code into create_docs.py:
from docx import Document
import os


def create_sample_documents():
    os.makedirs("documents", exist_ok=True)

    # Create Remote Work Policy document
    doc1 = Document()
    doc1.add_heading("Company Remote Work Policy", level=1)
    doc1.add_paragraph("Policy Number: POL-2024-0847")
    doc1.add_paragraph("Effective Date: January 15, 2024")
    doc1.add_paragraph("Department: Human Resources")
    doc1.add_heading("Section 1: Eligibility", level=2)
    doc1.add_paragraph(
        "All full-time employees who have completed their probationary period "
        "of 90 days are eligible to apply for remote work arrangements. "
        "Contractors and part-time employees must obtain written approval from "
        "their department head and the VP of Operations before requesting "
        "remote work status."
    )
  • Add the remaining policy sections directly below the code you just pasted:
    doc1.add_heading("Section 2: Equipment and Expenses", level=2)
    doc1.add_paragraph(
        "The company will provide a one-time stipend of $1,500 for home office "
        "setup. This covers ergonomic furniture, monitors, and peripherals. "
        "Employees must submit receipts within 60 days of purchase. Internet "
        "reimbursement of up to $75 per month is available upon submission of "
        "monthly invoices to the Finance department."
    )
    doc1.add_heading("Section 3: Performance Monitoring", level=2)
    doc1.add_paragraph(
        "Remote employees are required to maintain a minimum productivity score "
        "of 85% as measured by the quarterly OKR review process. Managers must "
        "conduct weekly one-on-one check-ins via video call. Failure to meet "
        "performance targets for two consecutive quarters may result in "
        "revocation of remote work privileges."
    )
    doc1.save("documents/remote_work_policy.docx")

What does this code do?

This creates a Remote Work Policy document with policy number POL-2024-0847. It has three structured sections covering eligibility, expenses, and performance monitoring.

The specific policy number is deliberate. Later, you'll see that basic vector search cannot find documents by identifiers like this because embedding models treat them as meaningless tokens.

  • Save create_docs.py.
  • Add the Q3 Financial Report below the doc1.save() line:
    # Create Q3 Financial Report
    doc2 = Document()
    doc2.add_heading("Q3 2024 Financial Report", level=1)
    doc2.add_paragraph("Report ID: FIN-Q3-2024")
    doc2.add_paragraph("Prepared by: Sarah Chen, CFO")
    doc2.add_paragraph("Date: October 15, 2024")
    doc2.add_heading("Revenue Summary", level=2)
    doc2.add_paragraph(
        "Total revenue for Q3 2024 reached $47.3 million, representing a 12% "
        "increase over Q3 2023. The Enterprise Solutions division contributed "
        "$28.1 million, while the SMB segment generated $19.2 million. The APAC "
        "region showed the strongest growth at 23%, driven by new partnerships "
        "in Japan and South Korea."
    )
    doc2.add_heading("Operating Expenses", level=2)
    doc2.add_paragraph(
        "Total operating expenses were $38.7 million, with R&D spending at "
        "$15.2 million (32% of revenue). Sales and marketing expenses decreased "
        "to $12.8 million from $14.1 million in Q2, reflecting improved "
        "efficiency in the digital acquisition channels. General and "
        "administrative costs held steady at $10.7 million."
    )
    doc2.add_heading("Key Metrics", level=2)
    doc2.add_paragraph(
        "Customer acquisition cost (CAC) decreased from $847 to $723, a 14.6% "
        "improvement. Net revenue retention rate improved to 118% from 112% in "
        "the prior quarter. The Falcon Project, our next-generation analytics "
        "platform, is on track for beta release in Q1 2025 with an allocated "
        "budget of $4.2 million."
    )
    doc2.save("documents/q3_financial_report.docx")
  • Add the Vendor Agreement below the doc2.save() line:
    # Create Vendor Agreement
    doc3 = Document()
    doc3.add_heading("Master Service Agreement", level=1)
    doc3.add_paragraph(
        "Contract Number: MSA-2024-1192"
    )
    doc3.add_paragraph(
        "Between: Acme Corporation ('Client') and CloudScale Inc. ('Provider')"
    )
    doc3.add_paragraph("Effective Date: March 1, 2024")
    doc3.add_heading("Section 4.2.1: Data Processing", level=2)
    doc3.add_paragraph(
        "The Provider shall process all Client data exclusively within data "
        "centers located in the United States and European Union. Any transfer "
        "of data to facilities outside these regions requires prior written "
        "consent from the Client's Data Protection Officer. The Provider must "
        "maintain SOC 2 Type II certification and undergo annual third-party "
        "security audits."
    )
    doc3.add_heading("Section 5.1: Service Level Agreement", level=2)
    doc3.add_paragraph(
        "The Provider guarantees 99.95% uptime for all production services, "
        "measured monthly. Scheduled maintenance windows are limited to Sundays "
        "between 02:00-06:00 UTC. For each 0.1% below the guaranteed uptime, "
        "the Client receives a 5% credit on the monthly service fee, up to a "
        "maximum of 30% of the monthly fee."
    )
  • Save create_docs.py.
  • Add the final termination section and the closing code below the SLA paragraph:
    doc3.add_heading("Section 7.3: Termination", level=2)
    doc3.add_paragraph(
        "Either party may terminate this agreement with 90 days written notice. "
        "Upon termination, the Provider must return or destroy all Client data "
        "within 30 days and provide written certification of data destruction. "
        "The Provider will assist with data migration to a successor provider "
        "for up to 60 days at standard rates."
    )
    doc3.save("documents/vendor_agreement.docx")

    print("Created 3 sample documents in ./documents/")


if __name__ == "__main__":
    create_sample_documents()

Why these three documents?

Each document represents a different enterprise document type: policy, financial report, and contract. The specific identifiers (POL-2024-0847, FIN-Q3-2024, MSA-2024-1192) are not random. They simulate how real enterprise documents are numbered.

In Step 3, you'll query for these exact identifiers and see dense vector search fail to find them. That failure is the whole reason hybrid search exists.

Before you run this, what do you expect the output to say?

  • Save create_docs.py and run the script in your terminal:
python create_docs.py

You should see:

Created 3 sample documents in ./documents/
  • Confirm the documents were created by running:
ls documents/

You should see remote_work_policy.docx, q3_financial_report.docx, and vendor_agreement.docx listed.

Don't see the documents?

  • Make sure you saved create_docs.py before running it.
  • Check that your terminal is in the same directory as create_docs.py. Run ls to confirm you can see the script.
  • If you see an import error for docx, make sure your virtual environment is activated and python-docx is installed.

Still stuck? Help me debug why create_docs.py is not creating my documents.

✔️ Awesome, I've got everything!

Great. Make sure create_docs.py is saved before moving on.

ⓧ I'd like to double check the full code

Since create_docs.py is a long file, scroll through your code and confirm each section is present, in this order:

  • Imports (from docx import Document and import os) and the create_sample_documents() function definition with os.makedirs.
  • Doc 1: Remote Work Policy with policy number POL-2024-0847 and three sections (Eligibility, Equipment, Performance).
  • Doc 2: Q3 Financial Report with report ID FIN-Q3-2024 and three sections (Revenue, Operating Expenses, Key Metrics).
  • Doc 3: Vendor Agreement with contract number MSA-2024-1192 and three sections (Data Processing, SLA, Termination).
  • The print() statement and the if __name__ == "__main__" block at the bottom.
Build the document ingestion pipeline

With documents on disk, you need a pipeline that reads each file, splits it into searchable chunks by section, and stores those chunks in Qdrant with the metadata needed for citation and filtering later.

  • Create a new Python file in PyCharm by right-clicking the project root, selecting New, then Python File.
  • Name it ingest and press Enter.
  • Start ingest.py with the imports, constants, and a helper function that classifies documents by filename:
import os
from docx import Document as DocxDocument
from qdrant_client import QdrantClient, models
from fastembed import TextEmbedding

# Collection name and embedding model constants
COLLECTION_NAME = "enterprise_docs"
DENSE_MODEL = "sentence-transformers/all-MiniLM-L6-v2"


# Classify document type based on filename keywords
def classify_document(filename):
    name = filename.lower()
    if "policy" in name:
        return "policy"
    elif "report" in name or "financial" in name:
        return "report"
    elif "agreement" in name or "contract" in name:
        return "contract"
    return "general"
  • Add the parsing function that splits each DOCX into chunks by section heading:
# Parse a DOCX file into chunks split by section headings
def parse_docx(filepath):
    doc = DocxDocument(filepath)
    filename = os.path.basename(filepath)
    doc_type = classify_document(filename)

    chunks = []
    current_section = "Header"
    current_text = []

    for para in doc.paragraphs:
        if para.style.name.startswith("Heading"):
            if current_text:
                text = " ".join(current_text)
                if len(text.strip()) > 50:
                    chunks.append({
                        "text": text.strip(),
                        "source": filename,
                        "section": current_section,
                        "doc_type": doc_type,
                    })
                current_text = []
            current_section = para.text
        else:
            if para.text.strip():
                current_text.append(para.text.strip())

What does the parser do?

The classify_document function tags each file as policy, report, or contract based on keywords in the filename. This doc_type metadata is what enables filtered searches later.

The parse_docx function walks through every paragraph in the DOCX. When it hits a heading, it saves the accumulated text as a chunk with metadata (source filename, section title, document type). Chunks under 50 characters are skipped to avoid storing tiny fragments.

  • Save ingest.py.
  • Add the remaining parser logic and the start of the ingestion function below the current_text.append line:
    if current_text:
        text = " ".join(current_text)
        if len(text.strip()) > 50:
            chunks.append({
                "text": text.strip(),
                "source": filename,
                "section": current_section,
                "doc_type": doc_type,
            })

    return chunks


def ingest_documents():
    client = QdrantClient(url="http://localhost:6333")
    dense_model = TextEmbedding(DENSE_MODEL)

    # Parse all DOCX files and generate embeddings
    all_chunks = []
    doc_dir = "documents"
    for filename in os.listdir(doc_dir):
        if filename.endswith(".docx"):
            filepath = os.path.join(doc_dir, filename)
            chunks = parse_docx(filepath)
            all_chunks.extend(chunks)
            print(f"Parsed {filename}: {len(chunks)} chunks")

    texts = [chunk["text"] for chunk in all_chunks]
    print("Generating dense embeddings...")
    dense_vectors = list(dense_model.embed(texts))
  • Add the collection creation logic below the dense_vectors line:
    # Create the Qdrant collection for dense vectors
    if client.collection_exists(COLLECTION_NAME):
        client.delete_collection(COLLECTION_NAME)

    client.create_collection(
        collection_name=COLLECTION_NAME,
        vectors_config={
            "dense": models.VectorParams(
                size=len(dense_vectors[0]),
                distance=models.Distance.COSINE,
            )
        },
    )

What is happening here?

The ingest_documents function connects to your running Qdrant instance and loads the sentence-transformers/all-MiniLM-L6-v2 model via fastembed. It parses every DOCX file into chunks, then generates a dense embedding vector for each chunk.

The collection is created with cosine distance, which measures how similar two vectors are by direction. If the collection already exists, it gets deleted first so you can re-run safely.

  • Save ingest.py.
  • Add the final block that builds points and uploads them to Qdrant:
    # Build points with dense vectors and metadata payload
    points = []
    for i, chunk in enumerate(all_chunks):
        points.append(
            models.PointStruct(
                id=i,
                vector={
                    "dense": dense_vectors[i].tolist(),
                },
                payload={
                    "text": chunk["text"],
                    "source": chunk["source"],
                    "section": chunk["section"],
                    "doc_type": chunk["doc_type"],
                },
            )
        )

    client.upsert(collection_name=COLLECTION_NAME, points=points)
    print(f"\nIngested {len(points)} chunks into Qdrant")


if __name__ == "__main__":
    ingest_documents()

What is a point in Qdrant?

Each point is a single entry in the collection. It has a unique ID, a vector (the dense embedding), and a payload (the text content plus all the metadata you attached). The payload is what powers citation and filtering. When a search returns this point, you can read the source and section fields to tell the user exactly where the information came from.

  • Save ingest.py.

✔️ Awesome, I've got everything!

Great. Make sure ingest.py is saved before running it.

ⓧ I'd like to double check the full code

Scroll through ingest.py and confirm each section is present, in this order:

  • Imports: os, DocxDocument, QdrantClient, models, TextEmbedding.
  • Constants: COLLECTION_NAME = "enterprise_docs" and DENSE_MODEL.
  • The classify_document() function returning policy, report, contract, or general.
  • The parse_docx() function with the heading-based chunking loop and the trailing-text handler.
  • The ingest_documents() function: client setup, DOCX parsing loop, embedding generation, collection creation with VectorParams, point building, and client.upsert().
  • The if __name__ == "__main__" block calling ingest_documents().

Before you run the ingestion, what do you expect to happen when fastembed loads the embedding model for the first time?

  • Run the ingestion pipeline:
python ingest.py

First run downloads the model

The first time you run this, fastembed automatically downloads the sentence-transformers/all-MiniLM-L6-v2 model (~90 MB). You'll see a download progress bar. Subsequent runs load from cache and skip the download.

You should see output showing chunk counts per document, followed by the total ingestion count:

Parsed remote_work_policy.docx: 3 chunks
Parsed q3_financial_report.docx: 3 chunks
Parsed vendor_agreement.docx: 3 chunks

Ingested 9 chunks into Qdrant

Why 3 chunks per document?

Each document has a header section and 3 content sections split by headings. The header section's text is short metadata (policy number, date, etc.) that typically falls under the 50-character threshold, so it gets filtered out. That leaves 3 content chunks per document.

  • Open the Qdrant dashboard at http://localhost:6333/dashboard in your browser.
  • Click on the enterprise_docs collection.

You should see points stored in the collection. Each point holds a chunk's dense vector and its metadata payload.

Collection not showing up?

  • Make sure your Qdrant Docker container is still running. Check with docker ps to confirm.
  • Verify that ingest.py printed the ingestion count without errors. If you see a connection error, Qdrant may have stopped.
  • If you see an import error for fastembed, make sure your virtual environment is activated.

Still stuck? Help me debug why my documents are not appearing in the Qdrant dashboard.

Your documents are ingested and searchable. Next up, you'll build a RAG pipeline that queries these chunks and generates answers. You'll also see exactly where dense-only search falls short.

Build Basic RAG and Hit the Wall

Your documents are ingested and stored in Qdrant, ready to be queried. Now it's time to build the RAG pipeline that turns those stored chunks into actual answers.

You'll wire up a search function, connect it to Gemini for answer generation, and run two test queries. The first query will work beautifully. The second will expose a critical gap that every enterprise RAG system has to solve.

In this step, get ready to:
  • Build a search function that retrieves document chunks from Qdrant.
  • Add a generation function that produces answers using Gemini.
  • Test with two query types and discover the limitation of dense-only search.
Create rag.py with the dense search function

The search function needs to take a question, convert it into a vector using the same embedding model from ingestion, and ask Qdrant for the closest matching chunks.

  • Create a new file called rag.py in your project folder and add the following code:
import os
from google import genai
from qdrant_client import QdrantClient, models
from fastembed import TextEmbedding

# Collection and model names must match ingest.py
COLLECTION_NAME = "enterprise_docs"
DENSE_MODEL = "sentence-transformers/all-MiniLM-L6-v2"

# Initialize embedding model, Qdrant client, and Gemini client
dense_model = TextEmbedding(DENSE_MODEL)
qdrant = QdrantClient(url="http://localhost:6333")
gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))

def dense_search(query, limit=5):
    # Embed the query using the same model as ingestion
    query_dense = list(dense_model.embed([query]))[0].tolist()
    # Search Qdrant for the most similar chunks
    results = qdrant.query_points(
        collection_name=COLLECTION_NAME,
        query=query_dense,
        using="dense",
        with_payload=True,
        limit=limit,
    ).points
    return results

What does this code do?

The module-level code loads three clients that the rest of the script will use. TextEmbedding handles converting text into vectors. QdrantClient connects to your running Qdrant instance. genai.Client connects to the Gemini API using your stored key.

The dense_search function takes a query string, embeds it with the same model used during ingestion, and calls query_points() to find the top 5 most similar chunks by cosine distance.

  • Save rag.py.
  • Verify the module loads correctly by running this command:
python rag.py

The script loads without errors and exits. If this is the first time fastembed runs, you may see a model download message before it finishes.

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is activated. You should see the environment name in your terminal prompt.
  • Confirm GEMINI_API_KEY is set in this terminal session. The genai.Client call will fail if the variable is missing.

Help me debug a ModuleNotFoundError when running rag.py

Add the answer generation function

Retrieving chunks is only half the pipeline. You need a function that sends those chunks to Gemini along with the user's question, so the model can synthesize a grounded answer.

  • In rag.py, add the generate_answer function below dense_search:
def generate_answer(query, search_results):
    # Format each retrieved chunk with a source label
    context_parts = []
    for i, result in enumerate(search_results, 1):
        payload = result.payload
        context_parts.append(
            f"[Source {i}] Document: {payload['source']}, "
            f"Section: {payload['section']}\n"
            f"{payload['text']}"
        )
    context = "\n\n".join(context_parts)
    # Build a prompt that constrains Gemini to the provided sources
    prompt = (
        "You are an enterprise analyst assistant. Answer the question based "
        "ONLY on the provided sources.\n\n"
        f"Sources:\n{context}\n\n"
        f"Question: {query}\n\nAnswer:"
    )
    response = gemini.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
    )
    return response.text

What does this code do?

  • The loop formats each retrieved chunk with a [Source N] label, the document filename, and the section name. This gives Gemini structured context to work with.
  • The prompt tells Gemini to answer based ONLY on the provided sources. This constraint prevents the model from hallucinating facts that aren't in your documents.
  • The function calls gemini.models.generate_content() with the gemini-2.5-flash model and returns the generated text.

This function can't be tested on its own yet. The next chunk adds the ask function that ties search and generation together.

  • Add the ask function and a __main__ block below generate_answer:
def ask(query):
    # Print the question and run the search
    print(f"\nQuestion: {query}")
    print("-" * 60)
    results = dense_search(query)
    # Display which sources were retrieved
    print(f"\nRetrieved {len(results)} sources:")
    for i, r in enumerate(results, 1):
        print(f"  [{i}] {r.payload['source']} - {r.payload['section']}")
    # Generate and print the answer
    answer = generate_answer(query, results)
    print(f"\nAnswer:\n{answer}")
    return answer

if __name__ == "__main__":
    print("=" * 60)
    print("TEST 1: Semantic query")
    print("=" * 60)
    ask("What are the company's policies on working from home?")

What does the ask function do?

The ask function is the glue between search and generation. It prints the question, calls dense_search to find relevant chunks, prints which documents were retrieved, and then passes everything to generate_answer for a final response.

The __main__ block runs a single semantic test query about remote work policies. This is the kind of broad, meaning-based question that dense vector search handles well.

  • Save rag.py and run your first RAG query:
python rag.py

You should see 5 retrieved sources, most of them from remote_work_policy.docx, followed by a generated answer that describes the company's remote work policies. The answer pulls real details from the document, like the 90-day probationary period and the $1,500 home office stipend.

Getting an API error from Gemini?

  • Confirm your GEMINI_API_KEY environment variable is set in this terminal session. If you opened a new terminal, you need to set it again.
  • Check that the key is valid by visiting Google AI Studio and verifying it appears in your API keys list.

Help me troubleshoot a Gemini API error in my RAG pipeline

Test an exact-match query and hit the wall

The semantic query worked. But enterprise analysts don't always ask broad questions. They search for specific policy numbers, contract IDs, and report codes. This is where dense-only search runs into trouble.

  • In rag.py, find the last line inside your if __name__ == "__main__": block:
    ask("What are the company's policies on working from home?")
  • Add this code directly below that line to create a second test:
    print("\n" + "=" * 60)
    print("TEST 2: Exact-match query")
    print("=" * 60)
    ask("What does policy POL-2024-0847 cover?")

Why this query?

The string POL-2024-0847 is the exact policy number from the remote work policy document you created earlier. An analyst searching by this identifier expects the system to return that specific document as the top result.

  • Save rag.py.

Before you run this, think about what you expect. The query asks specifically about policy POL-2024-0847. Do you think the dense search will find the right document?

  • Run the script to see both test results:
python rag.py

TEST 1 returns strong results from the policy document. TEST 2 tells a different story.

Look at the retrieved sources for TEST 2. The correct document may be missing entirely, or it may appear far down the list behind irrelevant chunks. The answer from Gemini may be vague or incorrect because the search fed it the wrong context.

Why does the exact-match query fail?

Dense embedding models like all-MiniLM-L6-v2 encode semantic meaning. They understand that "remote work" and "working from home" are related concepts. But they have no way to match the exact string POL-2024-0847.

To the embedding model, that policy number is a meaningless sequence of characters with no semantic weight. It gets scattered across the vector space instead of landing near the document that contains it. This is the fundamental gap in dense-only search.

This failure is intentional. You just hit the exact limitation that forces enterprise RAG systems to go beyond pure vector search.

✔️ Awesome, I've got everything!

Great. Double check you've saved rag.py.

ⓧ I'd like to double check the full code

Your rag.py should contain these sections in order. Scroll up and confirm each one is present.

  • Imports and configuration at the top (os, genai, QdrantClient, TextEmbedding, plus the three client initializations).
  • The dense_search function that embeds a query and calls query_points().
  • The generate_answer function that formats sources and calls Gemini.
  • The ask function that ties search and generation together.
  • The if __name__ == "__main__": block with TEST 1 (semantic query) and TEST 2 (exact-match query).

You've proven the core RAG pipeline works for semantic questions, and you've seen it fall short on exact-match queries. In the next step, you'll add a second type of vector that captures keyword-level matches and fuse both signals together to close this gap.

Upgrade to Hybrid Search with RRF

In the last step, you saw dense-only search fail on exact-match queries. The policy number POL-2024-0847 had no semantic meaning to the embedding model, so it ranked the wrong chunks highest.

The fix is hybrid search. You will add sparse vectors that capture keyword-level token matches, then fuse both signals with Reciprocal Rank Fusion (RRF). This combination is the production default for enterprise RAG.

In this step, get ready to:
  • Add sparse (SPLADE) embeddings to the ingestion pipeline and re-ingest all documents.
  • Replace the dense-only search with hybrid search using RRF in the query pipeline.
Add sparse embeddings to ingest.py

Dense embeddings encode what a sentence means, but they throw away the exact tokens. A sparse embedding model like SPLADE does the opposite. It assigns a weight to every token in the vocabulary, producing a vector that is mostly zeros except for the tokens that matter.

Why SPLADE instead of BM25?

Traditional BM25 counts raw word frequencies. SPLADE is a learned sparse model that uses a neural network to decide which tokens matter and how much weight they should get. It outperforms BM25 on retrieval benchmarks while still capturing keyword-level matches.

Both are available in fastembed, but SPLADE gives you better results without adding complexity.

  • In ingest.py, find the import line at the top of the file:
from fastembed import TextEmbedding
  • Add SparseTextEmbedding to the import, so the line now looks like this:
from fastembed import TextEmbedding, SparseTextEmbedding
  • Find the DENSE_MODEL constant just below the import:
DENSE_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
  • Add the sparse model constant directly below it, so both constants sit together:
DENSE_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
SPARSE_MODEL = "prithivida/Splade_PP_en_v1"
  • Now scroll down inside the ingest_documents() function. Find this line:
    dense_model = TextEmbedding(DENSE_MODEL)
  • Add the sparse model initialization directly below it:
    dense_model = TextEmbedding(DENSE_MODEL)
    sparse_model = SparseTextEmbedding(SPARSE_MODEL)
  • Further down in the same function, find these two lines that generate dense embeddings:
    print("Generating dense embeddings...")
    dense_vectors = list(dense_model.embed(texts))
  • Add the sparse embedding generation directly below them:
    print("Generating dense embeddings...")
    dense_vectors = list(dense_model.embed(texts))

    print("Generating sparse embeddings...")
    sparse_vectors = list(sparse_model.embed(texts))

What do these changes do?

You now have two embedding models running side by side. The dense model (all-MiniLM-L6-v2) encodes semantic meaning. The sparse model (Splade_PP_en_v1) encodes keyword-level token weights. Every chunk now gets both representations.

Next, the collection needs to be told it stores two types of vectors. The point upload also needs to include both.

  • In the same function, find the existing client.create_collection() call. Replace the entire call (from client.create_collection( to its closing )) with this updated version:
    # Create collection with both dense and sparse vector support
    client.create_collection(
        collection_name=COLLECTION_NAME,
        vectors_config={
            "dense": models.VectorParams(
                size=len(dense_vectors[0]),
                distance=models.Distance.COSINE,
            )
        },
        sparse_vectors_config={
            "sparse": models.SparseVectorParams()
        },
    )

What changed in the collection config?

The vectors_config stays the same. The new sparse_vectors_config tells Qdrant that each point will also carry a sparse vector under the name "sparse". Qdrant stores dense and sparse vectors in separate indexes so both can be searched independently and fused later.

  • Find the existing points loop below. It starts with points = [] and ends just before the client.upsert() call. Replace that entire block with this updated version:
    # Build points with both dense and sparse vectors
    points = []
    for i, chunk in enumerate(all_chunks):
        sv = sparse_vectors[i]
        points.append(
            models.PointStruct(
                id=i,
                vector={
                    "dense": dense_vectors[i].tolist(),
                    "sparse": models.SparseVector(
                        indices=sv.indices.tolist(),
                        values=sv.values.tolist(),
                    ),
                },
                payload={
                    "text": chunk["text"],
                    "source": chunk["source"],
                    "section": chunk["section"],
                    "doc_type": chunk["doc_type"],
                },
            )
        )

What does this code do?

Each point's vector dict now carries two entries. The "dense" entry is the same list of floats as before.

The "sparse" entry uses models.SparseVector with two lists: indices (which vocabulary tokens have non-zero weight) and values (how strongly each token matters). This is what lets Qdrant match on exact keywords like "POL-2024-0847".

  • Save ingest.py.

Awesome, I've got everything!

Great. Double check you have saved the file before running it.

I'd like to double check the full code

Scroll up through your ingest.py and confirm each section is present, in order:

  • Imports: os, DocxDocument, QdrantClient, models, TextEmbedding, SparseTextEmbedding.
  • Constants: COLLECTION_NAME, DENSE_MODEL, SPARSE_MODEL.
  • classify_document and parse_docx: unchanged from before.
  • ingest_documents: initializes both dense_model and sparse_model, generates both embedding types, creates the collection with sparse_vectors_config, and uploads points with both vectors.
  • Re-ingest all documents with hybrid vectors by running:
python ingest.py

You should see the same parsed chunk counts as before, plus two new lines for sparse embedding generation. The final line should confirm all chunks were ingested into Qdrant.

  • Open http://localhost:6333/dashboard in your browser. Click on the enterprise_docs collection and confirm the points are stored.

Seeing a connection error?

Make sure your Qdrant Docker container is still running. If it stopped, restart it with the same command from Step 1.

If you see a model download progress bar the first time you run this, that is expected. The SPLADE model downloads once and is cached for future runs.

help me debug a connection or model download error when running ingest.py with sparse embeddings

Build the hybrid search function in rag.py

The collection now stores both vector types. The search function needs to query both and fuse the results.

  • In rag.py, find the import line at the top:
from fastembed import TextEmbedding
  • Add SparseTextEmbedding to the import, so the line now looks like this:
from fastembed import TextEmbedding, SparseTextEmbedding
  • Find the DENSE_MODEL constant:
DENSE_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
  • Add the sparse model constant directly below it:
DENSE_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
SPARSE_MODEL = "prithivida/Splade_PP_en_v1"
  • Find this line that initializes the dense model:
dense_model = TextEmbedding(DENSE_MODEL)
  • Add the sparse model initialization directly below it:
dense_model = TextEmbedding(DENSE_MODEL)
sparse_model = SparseTextEmbedding(SPARSE_MODEL)

Now replace the search function. The existing dense_search function queries only one vector type. The new hybrid_search function queries both and fuses the results with RRF.

  • Delete the entire dense_search function (from def dense_search through its return results). Replace it with this hybrid_search function:
def hybrid_search(query, limit=5):
    # Embed the query with both dense and sparse models
    query_dense = list(dense_model.embed([query]))[0].tolist()
    query_sparse_raw = list(sparse_model.embed([query]))[0]
    query_sparse = models.SparseVector(
        indices=query_sparse_raw.indices.tolist(),
        values=query_sparse_raw.values.tolist(),
    )

    # Search both indexes and fuse with Reciprocal Rank Fusion
    results = qdrant.query_points(
        collection_name=COLLECTION_NAME,
        prefetch=[
            models.Prefetch(
                query=query_dense,
                using="dense",
                limit=20,
            ),
            models.Prefetch(
                query=query_sparse,
                using="sparse",
                limit=20,
            ),
        ],
        query=models.FusionQuery(fusion=models.Fusion.RRF),
        with_payload=True,
        limit=limit,
    ).points

    return results

What does this code do?

The function embeds the query with both models, then sends both queries to Qdrant in a single query_points() call.

  • prefetch runs two separate searches. The dense prefetch finds the top 20 semantically similar chunks. The sparse prefetch finds the top 20 keyword-matching chunks.
  • models.FusionQuery(fusion=models.Fusion.RRF) merges both result sets using Reciprocal Rank Fusion. RRF scores each chunk based on its rank in each list, so a chunk that appears near the top of both lists scores highest.
  • limit=5 returns only the top 5 from the fused result.
  • Find the existing ask function. Delete it entirely and replace it with this updated version that calls hybrid_search and prints relevance scores:
def ask(query):
    print(f"\nQuestion: {query}")
    print("-" * 60)

    results = hybrid_search(query)

    # Print retrieved sources with their RRF fusion scores
    print(f"\nRetrieved {len(results)} sources:")
    for i, r in enumerate(results, 1):
        score_str = f"{r.score:.4f}" if r.score is not None else "N/A"
        print(
            f"  [{i}] {r.payload['source']} - "
            f"{r.payload['section']} (score: {score_str})"
        )

    answer = generate_answer(query, results)
    print(f"\nAnswer:\n{answer}")
    return answer

What changed in the ask function?

It now calls hybrid_search instead of dense_search. It also prints the RRF fusion score for each result, so you can see how Qdrant ranked the combined signals.

  • Save rag.py.

Awesome, I've got everything!

Great. Double check you have saved the file before running it.

I'd like to double check the full code

Scroll up through your rag.py and confirm each section is present, in order:

  • Imports: os, genai, QdrantClient, models, TextEmbedding, SparseTextEmbedding.
  • Constants: COLLECTION_NAME, DENSE_MODEL, SPARSE_MODEL.
  • Global models: dense_model, sparse_model, qdrant, gemini.
  • hybrid_search function: embeds with both models, uses prefetch with dense and sparse, fuses with Fusion.RRF.
  • generate_answer function: unchanged from before.
  • ask function: calls hybrid_search and prints RRF scores.
  • __main__ block: TEST 1 (semantic query) and TEST 2 (exact-match query).

Before you run this, think back to Step 3. The exact-match query about POL-2024-0847 failed with dense-only search. Do you think adding sparse vectors will fix it?

  • Run the updated RAG pipeline:
python rag.py

TEST 1 (the semantic query about remote work policies) should still return relevant results from the policy document. The dense signal handles that well on its own.

TEST 2 is the moment of truth. The exact-match query "What does policy POL-2024-0847 cover?" should now return the correct policy document as the top result. The sparse vector matched the token "POL-2024-0847" directly, and RRF boosted that result to the top of the fused ranking.

Results look the same as before?

Make sure you ran python ingest.py before running python rag.py. The collection needs to be re-created with both vector types and the documents re-ingested with sparse embeddings.

Check that your rag.py calls hybrid_search in the ask function, not the old dense_search. If you still have the old function defined, delete it to confirm nothing references it.

help me debug why hybrid search results look the same as dense-only search

Your RAG pipeline now handles both semantic meaning and exact keyword matches. Next up, you will add source citations to every answer and metadata filtering so analysts can scope searches to specific document types.

Add Source Citations and Metadata Filtering

Your hybrid search pipeline finds the right documents for both semantic and exact-match queries. However, a search result without a traceable source is a black box that no compliance team will approve.

In this step, you will upgrade the generation prompt so every answer cites its exact source document and section. You will also add metadata filtering so analysts can scope queries to specific document types like contracts or policies.

In this step, get ready to:
  • Update the system prompt so Gemini cites every factual claim with a [Source N] reference.
  • Add metadata filtering to scope searches by document type (policy, report, contract).
  • Run all three test queries and confirm citations and filtering work end to end.
Instruct Gemini to cite its sources

Your generate_answer function already labels each retrieved chunk with a [Source N] tag when building the context. The missing piece is the prompt itself: Gemini needs explicit instructions to use those tags in its answer.

  • In rag.py, scroll to the generate_answer function and find the prompt variable assignment:
    prompt = (
        "You are an enterprise analyst assistant. Answer the question based "
        "ONLY on the provided sources.\n\n"
        f"Sources:\n{context}\n\n"
        f"Question: {query}\n\nAnswer:"
    )
  • Replace that entire prompt block with the following version, which tells Gemini to cite sources and admit when information is missing:
    prompt = (
        "You are an enterprise analyst assistant. Answer the question based "
        "ONLY on the provided sources. For every factual claim in your answer, "
        "include a citation in the format [Source N]. If the sources do not "
        "contain enough information to answer, say so explicitly.\n\n"
        f"Sources:\n{context}\n\n"
        f"Question: {query}\n\n"
        "Answer with citations:"
    )

What changed in the prompt?

Two key additions make the output traceable. The instruction "For every factual claim in your answer, include a citation in the format [Source N]" forces Gemini to ground each statement in a specific retrieved chunk. The fallback "If the sources do not contain enough information to answer, say so explicitly" prevents hallucinated answers when the evidence is thin.

The final line changed from Answer: to Answer with citations: to reinforce the citation requirement right before generation begins.

Before you run the script, what do you think the answers will look like now compared to before? Will the source legend printed above the answer match the citations inside it?

  • Save rag.py and run the script to test citations by running:
python rag.py

Both test answers now include inline citations like [Source 1] and [Source 2]. The source legend printed above each answer shows the exact document name and section each citation refers to.

Answers still missing citations?

Check that you replaced the entire prompt variable, including the closing parenthesis. The new version has 8 lines, not 5.

Make sure the last line inside the prompt reads "Answer with citations:" instead of the old f"Question: {query}\n\nAnswer:". The question and answer lines are now separate strings.

Still stuck? Help me debug why my Gemini prompt isn't producing citations in the answer.

Add metadata filtering to narrow search results

Enterprise knowledge bases hold many document types. An analyst asking about uptime guarantees wants results from contracts only, not policies or financial reports. Qdrant supports payload filtering, which lets you constrain search results to chunks that match a specific metadata value.

Each chunk already has a doc_type field in its payload (set during ingestion). You will wire that field into the search function as an optional filter.

  • In rag.py, find the hybrid_search function signature:
def hybrid_search(query, limit=5):
  • Add a doc_type_filter parameter so the signature looks like this:
def hybrid_search(query, doc_type_filter=None, limit=5):
  • Inside hybrid_search, find the blank line between the query_sparse assignment and the results = qdrant.query_points( call. Add the following filter construction code between them:
    # Build an optional filter to narrow results by document type
    query_filter = None
    if doc_type_filter:
        query_filter = models.Filter(
            must=[
                models.FieldCondition(
                    key="doc_type",
                    match=models.MatchValue(value=doc_type_filter),
                )
            ]
        )

What does this filter do?

When doc_type_filter is provided (for example, "contract"), Qdrant will only consider chunks whose doc_type payload field matches that value. Chunks from other document types are excluded before scoring even begins.

The models.FieldCondition with models.MatchValue performs an exact match on the payload field. The must list means all conditions in it must be true (you only have one here, but you could add more).

  • Still inside the hybrid_search function, find this line inside the qdrant.query_points() call:
        query=models.FusionQuery(fusion=models.Fusion.RRF),
        with_payload=True,
  • Add query_filter=query_filter, between those two lines so it looks like this:
        query=models.FusionQuery(fusion=models.Fusion.RRF),
        query_filter=query_filter,
        with_payload=True,

Now the ask function needs to accept and pass through the filter parameter.

  • Scroll down to the ask function and find these lines at the top:
def ask(query):
    print(f"\nQuestion: {query}")
    print("-" * 60)
    results = hybrid_search(query)
  • Replace those four lines with the following, which adds the filter parameter, prints it when active, and passes it to hybrid_search:
def ask(query, doc_type_filter=None):
    print(f"\nQuestion: {query}")
    if doc_type_filter:
        print(f"Filter: doc_type = {doc_type_filter}")
    print("-" * 60)

    results = hybrid_search(query, doc_type_filter)
  • Save rag.py and run the script to confirm the existing tests still pass with the refactored code:
python rag.py

Both tests print cited answers just like before. The filter parameter defaults to None, so nothing changed for unfiltered queries.

Seeing a TypeError about arguments?

Make sure doc_type_filter=None comes before limit=5 in the hybrid_search signature. Parameter order matters in Python.

Also verify that the ask function passes doc_type_filter to hybrid_search(query, doc_type_filter), not just hybrid_search(query).

Need more help? Help me fix a TypeError in my hybrid_search or ask function after adding the doc_type_filter parameter.

Time to exercise the filter. You will add a third test query that asks about the uptime guarantee but restricts results to contracts only.

  • Scroll down to the if __name__ == "__main__": block at the bottom of rag.py. Add the following test after the existing TEST 2 block:
    print("\n" + "=" * 60)
    print("TEST 3: Filtered query (contracts only)")
    print("=" * 60)
    ask("What is the uptime guarantee?", doc_type_filter="contract")

What does this test prove?

The question "What is the uptime guarantee?" could match chunks in any document type. By passing doc_type_filter="contract", only chunks from the vendor agreement are eligible. If filtering works, every retrieved source will be from vendor_agreement.docx.

Before you run, predict: will the filtered query return sources from all three documents, or only from the vendor agreement?

  • Save rag.py and run all three tests by running:
python rag.py

TEST 3 prints Filter: doc_type = contract and every retrieved source is from vendor_agreement.docx. The answer cites only contract sections and correctly states the 99.95% uptime guarantee.

Filtered query returning results from all document types?

Make sure you added query_filter=query_filter, inside the qdrant.query_points() call. Without that line, the filter is built but never passed to Qdrant.

Also check that the key in FieldCondition is "doc_type". This must match the exact payload field name used in ingest.py.

Still stuck? Help me debug why my Qdrant metadata filter isn't narrowing search results by document type.

✔️ Awesome, I've got everything!

Great. Double check you have saved rag.py and that all three tests print cited answers with the filtered query returning only contract sources.

ⓧ I'd like to double check the full code

The full rag.py file is longer than a quick scan, so confirm each section is present in order:

  • Imports and constants at the top (COLLECTION_NAME, DENSE_MODEL, SPARSE_MODEL).
  • Model and client initializations (dense_model, sparse_model, qdrant, gemini).
  • hybrid_search(query, doc_type_filter=None, limit=5) with filter construction and query_filter passed to query_points().
  • generate_answer(query, search_results) with the updated citation prompt ending in "Answer with citations:".
  • ask(query, doc_type_filter=None) with filter printing and passthrough.
  • if __name__ == "__main__": block with three tests: semantic, exact-match, and filtered (contracts only).

Every answer your system produces is now traceable to a specific document and section. Analysts can also narrow their search to exactly the document type they need. In the Secret Mission, you will add a cross-encoder reranker to sharpen retrieval quality even further.

Secret mission

Add Cross-Encoder Reranking

Your hybrid search pipeline handles both semantic and exact-match queries. Can you push retrieval quality further by adding a cross-encoder reranker that directly compares each result against the query?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • A Qdrant Docker container running on port 6333.
  • A Docker volume called qdrant-storage storing your vector data.
  • A Python virtual environment with installed packages (qdrant-client, google-genai, python-docx).
  • Project files (create_docs.py, ingest.py, rag.py) and a documents/ folder with sample DOCX files.
  • A Gemini API key on the free tier (no charges incurred).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your enterprise RAG pipeline.

  • Your Qdrant container continues running on port 6333. Your data persists in the qdrant-storage volume across restarts.
  • Your Gemini API key is on the free tier with no charges. It stays active until you delete it.
  • Your GEMINI_API_KEY environment variable is session-scoped. It clears automatically when you close the terminal window.

Pause - I'll come back to this later

Stop the Qdrant container to free up memory while keeping your data and project files intact.

  • Find your Qdrant container ID by running:
docker ps
  • Look for the container running the qdrant/qdrant image. Copy its container ID (the first column).
  • Stop the container by running:
docker stop <container_id>
  • Your vector data is safely preserved in the qdrant-storage volume. When you are ready to resume, restart the container by running:
docker start <container_id>

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Find your Qdrant container ID by running:
docker ps -a
  • Look for the container running the qdrant/qdrant image and copy its container ID.
  • Stop and remove the Qdrant container by running:
docker stop <container_id>
docker rm <container_id>
  • Remove the Docker volume that stores your vector data by running:
docker volume rm qdrant-storage
  • Deactivate your Python virtual environment (if it is still active) by running:
deactivate
  • Delete your project folder from the Desktop. Navigate to your Desktop and remove the folder that contains create_docs.py, ingest.py, and rag.py.

On macOS or Linux, run this from your Desktop (replacing the folder name with yours):

cd ~/Desktop
rm -rf your-project-folder

On Windows, run this from your Desktop (replacing the folder name with yours):

cd ~\Desktop
rmdir /s /q your-project-folder
  • To delete your Gemini API key, visit the API keys page in Google AI Studio. Find your key in the list and delete it from the interface. This is optional since the free tier incurs no charges.

Nice Work!

Nice Work!

Nice work! You just built a production-grade enterprise document intelligence system that combines semantic understanding with keyword precision to answer analyst questions with traceable, cited answers.

What you learned:

  • Built an enterprise RAG pipeline that ingests DOCX documents, preserves their structure, and stores them with rich metadata in Qdrant.
  • Experienced the exact failure mode of dense-only vector search on keyword-specific queries and fixed it with hybrid search using dense + sparse (SPLADE) vectors and Reciprocal Rank Fusion.
  • Added grounded source citations so every answer traces back to a specific document and section, and implemented metadata filtering to scope searches by document type.
  • Secret Mission: Added cross-encoder reranking to further sharpen retrieval quality and measured the improvement.

Ready to quiz yourself?