FixIt AI: Troubleshooting API
Build a grounded FastAPI troubleshooting API with Qdrant, Gemini, and citations.
Introduction
30 Second Summary
Troubleshooting advice can sound convincing even when it comes from the wrong manual. One mixed-up error code can turn a quick fix into unsafe guidance.
In this project, you will build a citation-first troubleshooting API using FastAPI, Qdrant, and Gemini. It uses retrieval-augmented generation (RAG) to return an answer supported by the selected washer manual or a clear abstention.
What You'll Build
When you finish, you can choose a fictional washer model in FastAPI's interactive documentation, ask how to fix E24, and see a short answer backed by the correct manual page.
By the end of this project, you'll have:
- A visible dense retrieval test that exposes evidence from FX-W300 and FX-W500 in the same result set.
- A model-specific hybrid search that uses payload filtering to isolate FX-W300. Reciprocal Rank Fusion ranks page 12 first for E24.
- A grounded answer with structured page sources for supported questions. Unsupported questions return I couldn't find that in this manual. through deterministic abstention.
- Secret Mission: Add a repeatable two-case grounding check that verifies model isolation plus exact abstention behavior.
Are there any prerequisites?
You need a Windows machine with VS Code, Docker Desktop, Python 3.10 or newer, and a Gemini API key. Step 1 sets up the pinned packages plus the local Qdrant service.
Before We Start
Before the hands-on work begins, this is your moment to define what FixIt AI can safely answer. Every response must use evidence from the selected washer manual with citations or a clear abstention.
Set Up the Windows Workspace
FixIt AI needs a stable workspace before it can search manuals or answer troubleshooting questions. You will prepare that workspace in VS Code with an isolated Python environment.
Pinned dependencies prevent package drift between sessions. A persistent Qdrant database in Docker Desktop keeps your local data available while you build the API.
In this step, get ready to:
- Prepare an isolated Python workspace with pinned dependencies.
- Store your Gemini API key as a Windows user environment variable.
- Run a persistent local Qdrant container.
Prepare the Python workspace
A virtual environment keeps this project's packages separate from packages used by other Python projects. The project requires Python 3.10 or newer because every pinned package supports that baseline.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open VS Code.
VS Code opens with its editor area ready for your project folder.
- Select File from the top menu.
- Select Open Folder....
- Select your Desktop in the folder picker.
- Select New Folder.
- Enter fixit-ai-api as the folder name.
- Select the new fixit-ai-api folder.
- Select Select Folder to open it as your workspace.
You will see fixit-ai-api at the top of the Explorer sidebar. New integrated terminals now start inside this folder.
- Select View from the top menu.
- Select Terminal to open an integrated PowerShell terminal.
Before you check, do you expect the installed Python version to meet the 3.10 minimum?
- Check the installed Python version by running this command:
python --version
What does this check do?
This command shows which Python interpreter the python command currently selects. Its version determines whether the pinned packages can be installed.
✔️ I see version 3.10 or higher
Your Python version meets the project requirement. You can continue with the virtual environment.
ⓧ I see an older version
The installed version is below the requirement for the pinned packages. Update Python before creating the virtual environment.
- Open the official Python downloads for Windows page.
- Download a stable Python release numbered 3.10 or newer.
- Run the downloaded installer.
- Select Add Python to PATH on the first installer screen.
- Select Install Now.
- Return to VS Code after the installation completes.
- Create a fresh terminal from the Terminal menu.
- Repeat the version check by running:
python --version
What should this confirm?
The fresh terminal reads the updated Windows environment. Continue when the reported Python version is 3.10 or newer.
Still seeing the older version?
An older Python entry may still have priority in your Windows path. Reopen the installer to enable its path option.
If the older version remains selected, use the official Windows Python guide to review conflicting installations. Help me resolve an older Python version in VS Code.
ⓧ Command not found
Windows cannot currently find a Python installation. Install a supported release before continuing.
- Open the official Python downloads for Windows page.
- Download a stable Python release numbered 3.10 or newer.
- Run the downloaded installer.
- Select Add Python to PATH on the first installer screen.
- Select Install Now.
- Return to VS Code after the installation completes.
- Create a fresh terminal from the Terminal menu.
- Confirm the installation by running:
python --version
What should this confirm?
The fresh terminal should report Python 3.10 or newer. This confirms that Windows can locate the interpreter.
Still seeing command not found?
Reopen the Python installer to enable its path option. Create another VS Code terminal after saving the change.
If Windows still cannot find Python, use the official Windows Python guide to review the installation. Help me make Python available in VS Code.
- Create the project virtual environment by running this command:
python -m venv .venv
What does this command do?
The command creates a virtual environment named .venv inside fixit-ai-api. This folder holds the isolated Python interpreter and installed packages.
You will see a new .venv folder in the VS Code Explorer sidebar.
- Activate the virtual environment by running this command:
.\.venv\Scripts\Activate.ps1
What does activation change?
Activation makes this terminal use the interpreter and packages inside .venv. The environment name appears at the beginning of the PowerShell prompt.
Having trouble activating the environment?
- Confirm that .venv appears directly inside the fixit-ai-api folder.
- Confirm that the current VS Code terminal uses PowerShell.
- Create the virtual environment again if its folder is missing.
- Help me activate this Windows virtual environment.
The dependency file locks each library to the version tested for this project. This keeps later retrieval behavior consistent.
- Select the New File icon beside fixit-ai-api in the Explorer sidebar.
- Name the file requirements.txt.
- Add the pinned dependencies by pasting this content:
fastapi==0.143.1
uvicorn==0.54.0
qdrant-client==1.19.1
google-genai==2.31.0
pydantic==2.14.1
What do these dependencies provide?
- FastAPI provides the troubleshooting API routes you build later.
- Uvicorn runs the API on your computer.
- Qdrant Python Client connects Python to the local vector database.
- Google Gen AI SDK provides the embedding and generation clients.
- Pydantic validates the request data sent to the API.
- Save requirements.txt.
- Confirm that requirements.txt appears in the VS Code Explorer sidebar.
Can't find requirements.txt?
Check that the file sits directly inside fixit-ai-api. Confirm that Windows did not add another file extension.
Help me correct my requirements file location.
✔️ Awesome, I've got everything!
Your pinned dependency file is saved inside fixit-ai-api.
ⓧ I'd like to double check the full code
Compare your complete requirements.txt with this reference:
fastapi==0.143.1
uvicorn==0.54.0
qdrant-client==1.19.1
google-genai==2.31.0
pydantic==2.14.1
Each dependency and version must match this reference exactly.
The virtual environment contains generated files that should stay out of source control. A .gitignore file records those exclusions before the project grows.
- Select the New File icon beside fixit-ai-api in the Explorer sidebar.
- Name the file .gitignore.
- Add the project exclusions by pasting this content:
.venv/
__pycache__/
*.pyc
What does this file exclude?
The rules exclude the local virtual environment and Python's generated cache files. Your source files remain available for version control.
- Save .gitignore.
- Confirm that .gitignore appears in the VS Code Explorer sidebar.
Don't see .gitignore?
Confirm that the filename begins with a dot. Remove any extra text extension added to the end.
Help me create the .gitignore file correctly.
✔️ Awesome, I've got everything!
Your source-control exclusions are saved inside fixit-ai-api.
ⓧ I'd like to double check the full code
Compare your complete .gitignore with this reference:
.venv/
__pycache__/
*.pyc
These three rules match the complete project configuration.
- Install the pinned dependencies into the active virtual environment by running:
pip install -r requirements.txt
What does this installation do?
The command reads every pin from requirements.txt. It installs those exact package versions inside the active .venv environment.
The terminal downloads the packages and finishes without an installation error. Your isolated Python toolchain is now ready for the API code.
Did the package installation fail?
- Confirm that the virtual environment name appears at the beginning of your terminal prompt.
- Confirm that requirements.txt is saved directly inside fixit-ai-api.
- Confirm that your internet connection can reach the Python package registry.
- Help me diagnose the failed dependency installation.
Store the Gemini API key
The Google Gen AI SDK automatically reads GEMINI_API_KEY from the environment. A user-scoped environment variable keeps the key outside your project files.
Credentials deserve care, so this process stores your key without displaying it in source code. Avoid screenshots while the key value is visible.
- Press the Windows key to open Windows search.
- Type Edit environment variables for your account into the search field.
- Select Edit environment variables for your account from the results.
The Windows environment editor opens with separate areas for user variables and system variables. Your API key belongs in the user area.
- Select New... under User variables.
- Enter GEMINI_API_KEY in the Variable name field.
- Paste your existing Gemini API key into the Variable value field.
- Select OK to save the new user variable.
- Select OK in the environment variables window.
Windows has saved the key for your user account. A fresh terminal receives the updated environment when it starts.
- Switch back to VS Code.
- Select the trash can icon in the terminal panel to close the current terminal.
- Select Terminal from the top menu.
- Select New Terminal to create a terminal with the updated environment.
- Activate the existing virtual environment in the new terminal by running:
.\.venv\Scripts\Activate.ps1
Why create a new terminal?
A terminal inherits environment variables when it starts. The new terminal can provide GEMINI_API_KEY to the SDK without placing the credential in your files.
Will the SDK miss the key?
- Confirm that the user variable name is exactly GEMINI_API_KEY.
- Close every old VS Code terminal created before you saved the variable.
- Restart VS Code if a fresh terminal still uses the old environment.
- Help me make the Gemini API key available safely.
Run Qdrant with persistent storage
The local Qdrant server stores the manual vectors you create later. A named Docker volume preserves that data when the container stops.
- Press the Windows key to open Windows search.
- Type Docker Desktop into the search field.
- Press Enter to open Docker Desktop.
- Wait until Docker Desktop shows that its engine is ready.
The first launch can pause while Docker downloads the Qdrant image.
- Switch back to the new VS Code terminal.
- Create the persistent volume and start Qdrant by running:
docker volume create qdrant-storage; docker run -d --name fixit-qdrant -p 6333:6333 -p 6334:6334 -v qdrant-storage:/qdrant/storage qdrant/qdrant:v1.19.2
What does this command create?
- The first command creates the persistent qdrant-storage volume.
- The second command starts Qdrant from the qdrant/qdrant:v1.19.2 image.
- The container uses the name fixit-qdrant.
- Port 6333 exposes the REST API and dashboard.
- Port 6334 exposes the gRPC interface.
- The volume is mounted at /qdrant/storage inside the container.
The terminal prints the new volume name and a container identifier. Those results confirm that Docker created both resources.
Before the final check, what Python version and browser page do you expect to see?
- Confirm the Python version in the active workspace by running:
python --version
What should this confirm?
The command should report Python 3.10 or newer. This confirms that the workspace uses a supported interpreter.
You will see Python 3.10 or newer in the terminal.
- Open the Qdrant dashboard in your browser.
You will see the Qdrant dashboard load at http://localhost:6333/dashboard. This proves that the fixit-qdrant container is running on your computer.
Dashboard not loading?
- Confirm that Docker Desktop still shows its engine as ready.
- Check the terminal for a Docker networking or image-download failure.
- Confirm that the dashboard URL uses port 6333.
- Help me diagnose the local Qdrant container.
Your Windows workspace now has isolated dependencies, protected credentials, and persistent vector storage. Next, you will seed six fictional manual pages and retrieve the first matching page.
Seed a Searchable Manual Corpus
Your local Qdrant service is ready. The FixIt AI API still needs evidence that it can search.
A small fictional corpus removes PDF parsing noise from the learning loop. You will index six manual pages with dense embeddings plus sparse lexical vectors.
The seed script finishes by retrieving one relevant page in the terminal. That result gives you the first working slice of RAG in FixIt AI.
In this step, get ready to:
- Create six page-level excerpts across two fictional washer manuals.
- Build the retrieval module that prepares dense plus lexical vectors.
- Run the seed script to create the searchable collection.
Create the manual fixture
Each fixture record represents one manual page. Its payload keeps the manual name, appliance model, page number, and troubleshooting text beside the vectors.
- Select the fixit-ai-api folder in the Explorer view.
- Select the New Folder action.
- Enter data as the folder name.
- Press Enter.
- Select the new data folder.
- Select the New File... action.
- Enter manual_pages.json as the file name.
- Press Enter.
The editor now shows an empty data/manual_pages.json file inside your project.
- Populate the fixture by pasting this code into data/manual_pages.json:
[
{"id":1,"manual":"FixIt Demo Washer 300 Manual","appliance_model":"FX-W300","page":12,"text":"Error E24 means the drain path is blocked. Turn the washer off, unplug it, open the lower service flap, and clean the pump filter. Inspect the drain hose for a kink before restarting the washer."},
{"id":2,"manual":"FixIt Demo Washer 300 Manual","appliance_model":"FX-W300","page":13,"text":"If the washer does not drain but no code appears, inspect the drain hose for a low loop that can siphon water from the drum. Reposition the hose before running another cycle."},
{"id":3,"manual":"FixIt Demo Washer 300 Manual","appliance_model":"FX-W300","page":14,"text":"Error E11 means water did not enter quickly enough. Confirm the supply valve is fully open, then clean the inlet screen. Disconnect power and close the water supply before removing the hose."},
{"id":4,"manual":"FixIt Demo Washer 500 Manual","appliance_model":"FX-W500","page":22,"text":"Error E24 means the controller cannot communicate with the drain pump. Disconnect power for two minutes. If the code returns, stop using the washer and arrange service because the pump connector must be inspected by a technician."},
{"id":5,"manual":"FixIt Demo Washer 500 Manual","appliance_model":"FX-W500","page":23,"text":"Error E31 means the water level is too high. Turn off the water supply, disconnect power, and do not open the door while water is visible in the drum. Arrange service."},
{"id":6,"manual":"FixIt Demo Washer 500 Manual","appliance_model":"FX-W500","page":24,"text":"For slow draining without an error code, clean the external lint trap and inspect the drain hose. The sealed internal pump is not user-serviceable."}
]
How is the fixture structured?
- Each id gives Qdrant a unique point identifier.
- The manual field preserves the source title for later citations.
- The appliance_model field records which washer owns the evidence.
- The page field preserves the source location.
- The text field holds the excerpt that retrieval searches.
- Save data/manual_pages.json from the VS Code File menu.
- Expand the data folder in the Explorer view.
You should see manual_pages.json beneath the data folder. The editor should show six records with IDs from 1 through 6.
Seeing JSON warnings?
Check that every record except the last ends with a comma. Confirm the file begins with an opening square bracket and ends with a closing square bracket.
If the warning remains, help me find the JSON syntax problem.
✔️ Awesome, I've got everything!
Great. Your six page-level records are saved in data/manual_pages.json.
ⓧ I'd like to double check the full code
Compare your fixture with this complete file.
[
{"id":1,"manual":"FixIt Demo Washer 300 Manual","appliance_model":"FX-W300","page":12,"text":"Error E24 means the drain path is blocked. Turn the washer off, unplug it, open the lower service flap, and clean the pump filter. Inspect the drain hose for a kink before restarting the washer."},
{"id":2,"manual":"FixIt Demo Washer 300 Manual","appliance_model":"FX-W300","page":13,"text":"If the washer does not drain but no code appears, inspect the drain hose for a low loop that can siphon water from the drum. Reposition the hose before running another cycle."},
{"id":3,"manual":"FixIt Demo Washer 300 Manual","appliance_model":"FX-W300","page":14,"text":"Error E11 means water did not enter quickly enough. Confirm the supply valve is fully open, then clean the inlet screen. Disconnect power and close the water supply before removing the hose."},
{"id":4,"manual":"FixIt Demo Washer 500 Manual","appliance_model":"FX-W500","page":22,"text":"Error E24 means the controller cannot communicate with the drain pump. Disconnect power for two minutes. If the code returns, stop using the washer and arrange service because the pump connector must be inspected by a technician."},
{"id":5,"manual":"FixIt Demo Washer 500 Manual","appliance_model":"FX-W500","page":23,"text":"Error E31 means the water level is too high. Turn off the water supply, disconnect power, and do not open the door while water is visible in the drum. Arrange service."},
{"id":6,"manual":"FixIt Demo Washer 500 Manual","appliance_model":"FX-W500","page":24,"text":"For slow draining without an error code, clean the external lint trap and inspect the drain hose. The sealed internal pump is not user-serviceable."}
]
Build the retrieval module
The retrieval module turns each excerpt into two searchable representations. Gemini creates the semantic representation while a term-frequency vocabulary preserves exact words and error codes.
Why combine two vector types?
Dense vectors place similar meanings near each other. They help a symptom such as a washer failing to empty match an excerpt about draining.
Sparse vectors preserve exact vocabulary positions. They give identifiers such as E24 a direct lexical signal.
- Select the fixit-ai-api folder in the Explorer view.
- Select the New File... action.
- Enter rag.py as the file name.
- Press Enter.
- Add the imports, shared constants, fixture loader, service clients, and token rules by pasting this code:
import json
import re
from collections import Counter
from pathlib import Path
from typing import Literal
from google import genai
from google.genai import types
from qdrant_client import QdrantClient, models
COLLECTION_NAME = "fixit_manuals"
QDRANT_URL = "http://localhost:6333"
EMBEDDING_MODEL = "gemini-embedding-2"
GENERATION_MODEL = "gemini-3.8-flash"
EMBEDDING_SIZE = 768
ABSTENTION = "I couldn't find that in this manual."
BASE_DIR = Path(__file__).resolve().parent
DATA_PATH = BASE_DIR / "data" / "manual_pages.json"
with DATA_PATH.open("r", encoding="utf-8") as file:
MANUAL_PAGES = json.load(file)
qdrant = QdrantClient(url=QDRANT_URL)
gemini = genai.Client()
TOKEN_PATTERN = re.compile(r"[a-z0-9]+")
STOPWORDS = {"a","an","can","do","does","how","i","in","is","it","model","my","of","the","this","to","what","why"}
What sets up the module?
- The imports provide file loading, token counting, Gemini access, and Qdrant models.
- The collection constant keeps every operation pointed at fixit_manuals.
- The model constants select gemini-embedding-2 for retrieval and gemini-3.8-flash for later answer generation.
- The fixture loader reads data/manual_pages.json relative to rag.py.
- The client objects connect to the local vector database and the Gemini API.
- Save rag.py from the VS Code File menu.
The editor now shows the shared retrieval settings at the top of rag.py. The constants include the local Qdrant URL plus the two Gemini model IDs.
Is the fixture path underlined?
Confirm that rag.py sits directly inside fixit-ai-api. Confirm that manual_pages.json sits inside the neighboring data folder.
If the path still looks wrong, help me check the fixture location.
- Add tokenization, vocabulary construction, lexical vectors, and Gemini embeddings below STOPWORDS by pasting this code:
def tokenize(text: str) -> list[str]:
return TOKEN_PATTERN.findall(text.lower())
VOCABULARY = sorted({token for page in MANUAL_PAGES for token in tokenize(page["text"])})
TOKEN_TO_INDEX = {token: index for index, token in enumerate(VOCABULARY)}
def lexical_vector(text: str) -> models.SparseVector:
counts = Counter(tokenize(text))
indexed_counts = sorted((TOKEN_TO_INDEX[token], count) for token, count in counts.items() if token in TOKEN_TO_INDEX)
return models.SparseVector(indices=[index for index, _ in indexed_counts], values=[float(count) for _, count in indexed_counts])
def embed_text(text: str) -> list[float]:
result = gemini.models.embed_content(model=EMBEDDING_MODEL, contents=text, config=types.EmbedContentConfig(output_dimensionality=EMBEDDING_SIZE))
return list(result.embeddings[0].values)
def embed_document(page: dict) -> list[float]:
return embed_text(f"title: {page['manual']} | text: {page['text']}")
def embed_query(question: str) -> list[float]:
return embed_text(f"task: search result | query: {question}")
How are vectors created?
- The tokenizer converts text to lowercase alphanumeric terms.
- The vocabulary gives every known fixture term a stable numeric position.
- The lexical function stores the positions and counts of terms found in a piece of text.
- The shared embedding function asks Gemini for a 768-dimensional vector.
- The document formatter includes the manual title with its excerpt.
- The query formatter identifies the input as a search query.
- Save rag.py from the VS Code File menu.
- Open the file symbols from the VS Code navigation bar.
You should see tokenize, lexical_vector, embed_text, embed_document, and embed_query listed as functions.
Are the vector functions missing?
Check that each def line begins at the far left. Confirm that each function body uses one consistent indentation level.
If the symbols remain missing, help me inspect the Python structure.
- Add match serialization and collection seeding below embed_query by pasting this code:
def serialize_match(point) -> dict:
payload = point.payload or {}
return {"score":round(point.score,4),"manual":payload.get("manual"),"appliance_model":payload.get("appliance_model"),"page":payload.get("page"),"text":payload.get("text")}
def seed_collection() -> int:
if qdrant.collection_exists(collection_name=COLLECTION_NAME):
qdrant.delete_collection(collection_name=COLLECTION_NAME)
qdrant.create_collection(
collection_name=COLLECTION_NAME,
vectors_config={"dense": models.VectorParams(size=EMBEDDING_SIZE, distance=models.Distance.COSINE)},
sparse_vectors_config={"lexical": models.SparseVectorParams()},
)
qdrant.create_payload_index(collection_name=COLLECTION_NAME, field_name="appliance_model", field_schema=models.PayloadSchemaType.KEYWORD)
points = [models.PointStruct(id=page["id"], vector={"dense": embed_document(page), "lexical": lexical_vector(page["text"])}, payload=page) for page in MANUAL_PAGES]
qdrant.upsert(collection_name=COLLECTION_NAME, points=points, wait=True)
return len(points)
How is the collection seeded?
- The serializer turns a Qdrant result into fields that an API can return later.
- The seed function replaces an existing collection with a clean copy.
- The collection stores named dense vectors using cosine distance.
- The collection also stores named lexical sparse vectors.
- The keyword index prepares appliance_model for exact filtering.
- The upsert sends all six payloads with both vector types.
- Save rag.py from the VS Code File menu.
- Return to the file symbols in VS Code.
You should now see serialize_match and seed_collection beneath the embedding functions.
Is collection seeding underlined?
Check the indentation inside seed_collection. Confirm that the final return remains inside the function.
If VS Code still reports a problem, help me debug the collection setup.
- Add the dense preview and model-filter helpers below seed_collection by pasting this code:
def preview_dense(question: str) -> dict:
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=embed_query(question), using="dense", with_payload=True, limit=1)
return serialize_match(response.points[0])
def build_model_filter(appliance_model: str | None) -> models.Filter | None:
if appliance_model is None:
return None
return models.Filter(must=[models.FieldCondition(key="appliance_model", match=models.MatchValue(value=appliance_model))])
What do these helpers prepare?
- The preview function embeds a question and returns the best dense match.
- The filter helper returns no constraint when an appliance model is absent.
- A supplied appliance model becomes an exact payload condition.
- Save rag.py from the VS Code File menu.
- Return to the file symbols in VS Code.
You should see preview_dense and build_model_filter in the function list.
Is the filter helper incomplete?
Confirm that the None branch stays inside build_model_filter. Check that the final filter return aligns with the if statement.
If the structure remains unclear, help me fix the model filter.
- Add reusable dense and hybrid retrieval below build_model_filter by pasting this code:
def retrieve(question: str, appliance_model: str | None = None, mode: Literal["dense", "hybrid"] = "dense") -> list[dict]:
dense_query = embed_query(question)
query_filter = build_model_filter(appliance_model)
if mode == "dense":
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=dense_query, using="dense", query_filter=query_filter, with_payload=True, limit=6)
return [serialize_match(point) for point in response.points]
sparse_query = lexical_vector(question)
if not sparse_query.indices:
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=dense_query, using="dense", query_filter=query_filter, with_payload=True, limit=6)
return [serialize_match(point) for point in response.points]
response = qdrant.query_points(
collection_name=COLLECTION_NAME,
prefetch=[models.Prefetch(query=dense_query, using="dense", limit=8), models.Prefetch(query=sparse_query, using="lexical", limit=8)],
query=models.RrfQuery(rrf=models.Rrf()),
query_filter=query_filter,
with_payload=True,
limit=6,
)
return [serialize_match(point) for point in response.points]
How does retrieval choose a path?
- Every request creates a dense query embedding.
- Dense mode searches the named dense vector directly.
- Hybrid mode also creates a lexical sparse vector.
- A query with no known lexical terms falls back to dense search.
- A query with lexical terms prefetches both result lists.
- Reciprocal Rank Fusion combines the two rankings into one result list.
- Save rag.py from the VS Code File menu.
- Return to the file symbols in VS Code.
You should now see the retrieve function beneath the filter helper.
Is the retrieval function malformed?
Check that both early returns remain inside their matching if blocks. Confirm that the final return remains inside retrieve.
If the nesting still looks wrong, help me inspect the retrieval branches.
- Add the deterministic evidence check below retrieve by pasting this code:
def has_evidence_overlap(question: str, matches: list[dict]) -> bool:
question_tokens = set(tokenize(question)) - STOPWORDS
context_tokens = set()
for match in matches:
context_tokens.update(tokenize(match["text"] or ""))
return bool(question_tokens and question_tokens.intersection(context_tokens))
How does the evidence check work?
- The question tokens drop common words from STOPWORDS.
- The context token set collects terms from every retrieved excerpt.
- The function succeeds only when a meaningful question term overlaps the evidence.
- Save rag.py from the VS Code File menu.
- Return to the file symbols in VS Code.
You should see has_evidence_overlap after retrieve.
Is the overlap loop outside the function?
Confirm that the for loop sits inside has_evidence_overlap. Confirm that its update line has one additional indentation level.
If the loop remains unclear, help me correct the evidence check.
- Add the grounded troubleshooting function below has_evidence_overlap by pasting this code:
def troubleshoot(question: str, appliance_model: str) -> dict:
matches = retrieve(question=question, appliance_model=appliance_model, mode="hybrid")
if not matches or not has_evidence_overlap(question, matches):
return {"answer":ABSTENTION,"sources":[],"retrieval_mode":"hybrid"}
top_matches = matches[:3]
context = "\n\n".join(f"Manual: {match['manual']}\nModel: {match['appliance_model']}\nPage: {match['page']}\nExcerpt: {match['text']}" for match in top_matches)
prompt = (
"You are FixIt AI, a citation-first appliance troubleshooting assistant.\n"
"Use only the supplied manual excerpts. Do not add causes, steps, or safety advice that are absent from the excerpts.\n"
f"If the excerpts do not answer the question, reply exactly: {ABSTENTION}\n"
"Write a short answer and cite every factual sentence as [manual, p. page].\n\n"
f"Question: {question}\n\nManual excerpts:\n{context}"
)
interaction = gemini.interactions.create(model=GENERATION_MODEL, input=prompt)
answer = (interaction.output_text or "").strip() or ABSTENTION
sources = [{"manual":match["manual"],"appliance_model":match["appliance_model"],"page":match["page"],"excerpt":match["text"]} for match in top_matches]
return {"answer":answer,"sources":sources,"retrieval_mode":"hybrid"}
How is generation kept grounded?
- The function retrieves evidence for one appliance model in hybrid mode.
- The overlap check returns the fixed abstention before generation when evidence is missing.
- The top three matches become labeled manual excerpts.
- The prompt limits the answer to supplied evidence.
- The response returns the generated text beside structured source records.
- Save rag.py from the VS Code File menu.
- Return to the file symbols in VS Code.
You should see troubleshoot as the final function. Your retrieval module now contains every stage from fixture loading through grounded generation.
Does the prompt show syntax warnings?
Check that each prompt fragment remains inside parentheses. Confirm that the source dictionary closes before the final return.
If the warning remains, help me repair the troubleshoot function.
✔️ Awesome, I've got everything!
Excellent. Save rag.py before moving to the seed script.
ⓧ I'd like to double check the full code
Compare your retrieval module with this complete file.
import json
import re
from collections import Counter
from pathlib import Path
from typing import Literal
from google import genai
from google.genai import types
from qdrant_client import QdrantClient, models
COLLECTION_NAME = "fixit_manuals"
QDRANT_URL = "http://localhost:6333"
EMBEDDING_MODEL = "gemini-embedding-2"
GENERATION_MODEL = "gemini-3.8-flash"
EMBEDDING_SIZE = 768
ABSTENTION = "I couldn't find that in this manual."
BASE_DIR = Path(__file__).resolve().parent
DATA_PATH = BASE_DIR / "data" / "manual_pages.json"
with DATA_PATH.open("r", encoding="utf-8") as file:
MANUAL_PAGES = json.load(file)
qdrant = QdrantClient(url=QDRANT_URL)
gemini = genai.Client()
TOKEN_PATTERN = re.compile(r"[a-z0-9]+")
STOPWORDS = {"a","an","can","do","does","how","i","in","is","it","model","my","of","the","this","to","what","why"}
def tokenize(text: str) -> list[str]:
return TOKEN_PATTERN.findall(text.lower())
VOCABULARY = sorted({token for page in MANUAL_PAGES for token in tokenize(page["text"])})
TOKEN_TO_INDEX = {token: index for index, token in enumerate(VOCABULARY)}
def lexical_vector(text: str) -> models.SparseVector:
counts = Counter(tokenize(text))
indexed_counts = sorted((TOKEN_TO_INDEX[token], count) for token, count in counts.items() if token in TOKEN_TO_INDEX)
return models.SparseVector(indices=[index for index, _ in indexed_counts], values=[float(count) for _, count in indexed_counts])
def embed_text(text: str) -> list[float]:
result = gemini.models.embed_content(model=EMBEDDING_MODEL, contents=text, config=types.EmbedContentConfig(output_dimensionality=EMBEDDING_SIZE))
return list(result.embeddings[0].values)
def embed_document(page: dict) -> list[float]:
return embed_text(f"title: {page['manual']} | text: {page['text']}")
def embed_query(question: str) -> list[float]:
return embed_text(f"task: search result | query: {question}")
def serialize_match(point) -> dict:
payload = point.payload or {}
return {"score":round(point.score,4),"manual":payload.get("manual"),"appliance_model":payload.get("appliance_model"),"page":payload.get("page"),"text":payload.get("text")}
def seed_collection() -> int:
if qdrant.collection_exists(collection_name=COLLECTION_NAME):
qdrant.delete_collection(collection_name=COLLECTION_NAME)
qdrant.create_collection(
collection_name=COLLECTION_NAME,
vectors_config={"dense": models.VectorParams(size=EMBEDDING_SIZE, distance=models.Distance.COSINE)},
sparse_vectors_config={"lexical": models.SparseVectorParams()},
)
qdrant.create_payload_index(collection_name=COLLECTION_NAME, field_name="appliance_model", field_schema=models.PayloadSchemaType.KEYWORD)
points = [models.PointStruct(id=page["id"], vector={"dense": embed_document(page), "lexical": lexical_vector(page["text"])}, payload=page) for page in MANUAL_PAGES]
qdrant.upsert(collection_name=COLLECTION_NAME, points=points, wait=True)
return len(points)
def preview_dense(question: str) -> dict:
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=embed_query(question), using="dense", with_payload=True, limit=1)
return serialize_match(response.points[0])
def build_model_filter(appliance_model: str | None) -> models.Filter | None:
if appliance_model is None:
return None
return models.Filter(must=[models.FieldCondition(key="appliance_model", match=models.MatchValue(value=appliance_model))])
def retrieve(question: str, appliance_model: str | None = None, mode: Literal["dense", "hybrid"] = "dense") -> list[dict]:
dense_query = embed_query(question)
query_filter = build_model_filter(appliance_model)
if mode == "dense":
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=dense_query, using="dense", query_filter=query_filter, with_payload=True, limit=6)
return [serialize_match(point) for point in response.points]
sparse_query = lexical_vector(question)
if not sparse_query.indices:
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=dense_query, using="dense", query_filter=query_filter, with_payload=True, limit=6)
return [serialize_match(point) for point in response.points]
response = qdrant.query_points(
collection_name=COLLECTION_NAME,
prefetch=[models.Prefetch(query=dense_query, using="dense", limit=8), models.Prefetch(query=sparse_query, using="lexical", limit=8)],
query=models.RrfQuery(rrf=models.Rrf()),
query_filter=query_filter,
with_payload=True,
limit=6,
)
return [serialize_match(point) for point in response.points]
def has_evidence_overlap(question: str, matches: list[dict]) -> bool:
question_tokens = set(tokenize(question)) - STOPWORDS
context_tokens = set()
for match in matches:
context_tokens.update(tokenize(match["text"] or ""))
return bool(question_tokens and question_tokens.intersection(context_tokens))
def troubleshoot(question: str, appliance_model: str) -> dict:
matches = retrieve(question=question, appliance_model=appliance_model, mode="hybrid")
if not matches or not has_evidence_overlap(question, matches):
return {"answer":ABSTENTION,"sources":[],"retrieval_mode":"hybrid"}
top_matches = matches[:3]
context = "\n\n".join(f"Manual: {match['manual']}\nModel: {match['appliance_model']}\nPage: {match['page']}\nExcerpt: {match['text']}" for match in top_matches)
prompt = (
"You are FixIt AI, a citation-first appliance troubleshooting assistant.\n"
"Use only the supplied manual excerpts. Do not add causes, steps, or safety advice that are absent from the excerpts.\n"
f"If the excerpts do not answer the question, reply exactly: {ABSTENTION}\n"
"Write a short answer and cite every factual sentence as [manual, p. page].\n\n"
f"Question: {question}\n\nManual excerpts:\n{context}"
)
interaction = gemini.interactions.create(model=GENERATION_MODEL, input=prompt)
answer = (interaction.output_text or "").strip() or ABSTENTION
sources = [{"manual":match["manual"],"appliance_model":match["appliance_model"],"page":match["page"],"excerpt":match["text"]} for match in top_matches]
return {"answer":answer,"sources":sources,"retrieval_mode":"hybrid"}
Seed the collection and preview retrieval
The seed script gives the module a small executable entry point. One run rebuilds the collection from the fixture before testing the first dense query.
- Select the fixit-ai-api folder in the Explorer view.
- Select the New File... action.
- Enter seed.py as the file name.
- Press Enter.
- Add the seed entry point by pasting this code:
from rag import COLLECTION_NAME, preview_dense, seed_collection
if __name__ == "__main__":
count = seed_collection()
print(f"Seeded {count} manual pages into {COLLECTION_NAME}.")
preview = preview_dense("washer will not drain")
print(f"Dense preview: {preview['manual']}, page {preview['page']}")
What does the seed script do?
- The imports expose the collection name plus the two operations needed for this run.
- The main guard keeps seeding tied to direct script execution.
- The first print reports how many manual pages reached Qdrant.
- The preview searches for washer will not drain.
- The final print names the best manual page.
- Save seed.py from the VS Code File menu.
The Explorer view should now list seed.py beside rag.py.
Is seed.py in the wrong folder?
Confirm that seed.py sits directly inside fixit-ai-api beside rag.py. Moving it into the data folder prevents the import from resolving as expected.
If the import remains unresolved, help me check the seed script location.
✔️ Awesome, I've got everything!
Great. Save seed.py before running it.
ⓧ I'd like to double check the full code
Compare your entry point with this complete file.
from rag import COLLECTION_NAME, preview_dense, seed_collection
if __name__ == "__main__":
count = seed_collection()
print(f"Seeded {count} manual pages into {COLLECTION_NAME}.")
preview = preview_dense("washer will not drain")
print(f"Dense preview: {preview['manual']}, page {preview['page']}")
Before you run the script, consider which manual page you expect a dense search for a draining symptom to rank first.
- Seed the collection and run the dense preview by running this command in the terminal from earlier:
python seed.py
What does this command trigger?
Python executes seed.py. The script recreates the collection before uploading six points and requesting one dense match.
The first run can take a little longer while Gemini creates six document embeddings plus one query embedding. The terminal remains busy until those requests finish.
You should see Seeded 6 manual pages into fixit_manuals. followed by a line beginning with Dense preview:. The preview line names a manual and page.
That is your first retrieval result working. FixIt AI can now turn a symptom into a matching manual page.
Did the seed run fail?
- Return to Docker Desktop to confirm the fixit-qdrant container is still running.
- Confirm that the active terminal still shows the .venv environment.
- Confirm that the terminal was opened after you added GEMINI_API_KEY to your Windows user variables.
- Check that manual_pages.json remains inside the data folder.
- If the run still fails, help me diagnose the seed failure.
Your manual corpus is searchable through a real vector database. Next, you will expose dense retrieval through the API and inspect what happens when product identity is missing.
Expose Dense Retrieval
Your six manual pages are already searchable in Qdrant. The terminal preview proved that dense retrieval can find one relevant page.
A single top match hides the rest of the evidence. This step exposes the full result list through FastAPI so you can judge whether it is safe for generation.
In this step, get ready to:
- Define the FastAPI application with request models for retrieval and troubleshooting.
- Add status routes and workflow routes to main.py.
- Run an unfiltered dense request for E24 and inspect every returned appliance model.
Define the API contract
Request models give each endpoint a predictable input shape. Pydantic uses these classes to validate fields before your retrieval functions receive them.
- Create main.py inside fixit-ai-api by pasting this application foundation:
from typing import Literal
from fastapi import FastAPI
from pydantic import BaseModel
from rag import COLLECTION_NAME, qdrant, retrieve, troubleshoot
app = FastAPI(title="FixIt AI", version="1.0.0")
class RetrieveRequest(BaseModel):
question: str
appliance_model: str | None = None
mode: Literal["dense", "hybrid"] = "dense"
class TroubleshootRequest(BaseModel):
question: str
appliance_model: str
What does this code do?
- The imports connect the API to the existing collection client and retrieval functions in rag.py.
- The app object names the service that Uvicorn serves.
- RetrieveRequest accepts a question plus an optional appliance model. Its mode defaults to dense.
- TroubleshootRequest requires an appliance model because troubleshooting needs product identity.
- Save main.py.
- Start the API from the active VS Code terminal by running this command:
uvicorn main:app
What does this command do?
Uvicorn imports the app object from main.py. It serves the application at http://127.0.0.1:8000.
- Visit http://127.0.0.1:8000/docs in your browser.
You should see interactive documentation titled FixIt AI. That page confirms the application imports successfully.
- Press Ctrl+C in the terminal to stop the server.
Does the API fail to start?
- Confirm that main.py is inside the same fixit-ai-api folder as rag.py.
- Check that your VS Code terminal still shows the active virtual environment.
- Confirm that you saved every import exactly as shown.
- Help me debug the FastAPI startup failure.
Add the API routes
HTTP routes turn Python functions into addresses that a browser or client can call. The first pair proves that the service is running and that the seeded collection still exists.
- Add the root and health routes below TroubleshootRequest in main.py by pasting this code:
@app.get("/")
def root() -> dict:
return {"message":"FixIt AI is running","docs":"/docs"}
@app.get("/health")
def health() -> dict:
return {"status":"ok","collection_ready":qdrant.collection_exists(collection_name=COLLECTION_NAME)}
How do the status routes work?
- The root route returns a short service message plus the documentation path.
- The health route asks Qdrant whether fixit_manuals exists.
- collection_ready turns that collection check into a visible health signal.
- Save main.py.
- Start the updated API by running this command:
uvicorn main:app
What happens on this run?
Uvicorn loads the two new route decorators when it imports main.py. The running service can now report its own state and the collection state.
- Visit http://127.0.0.1:8000/health in your browser.
You should see {"status":"ok","collection_ready":true}. That result proves the API can reach your seeded Qdrant collection.
- Press Ctrl+C in the terminal to stop the server.
Is the collection unavailable?
- Confirm that the fixit-qdrant container is still running.
- Restart Uvicorn after saving main.py if the browser still shows an older response.
- Rerun the seeding script from the previous step if collection_ready is false.
- Help me restore the FixIt AI health check.
Workflow routes pass validated request fields into the functions already defined in rag.py. Their return values are dictionaries that FastAPI can serialize for the response.
- Add the retrieval and troubleshooting routes below health() in main.py by pasting this code:
@app.post("/retrieve")
def retrieve_pages(request: RetrieveRequest) -> dict:
return {"matches":retrieve(question=request.question, appliance_model=request.appliance_model, mode=request.mode)}
@app.post("/troubleshoot")
def troubleshoot_appliance(request: TroubleshootRequest) -> dict:
return troubleshoot(question=request.question, appliance_model=request.appliance_model)
How do the workflow routes work?
- retrieve_pages() forwards all three retrieval fields to retrieve().
- The retrieval route wraps serializable page matches under matches.
- troubleshoot_appliance() forwards the selected model plus the question to troubleshoot().
- Save main.py.
- Start the complete API by running this command:
uvicorn main:app
What is available now?
Uvicorn now serves the root, health, retrieval, and troubleshooting routes. FastAPI generates an interactive request form for each route that uses a Pydantic model.
- Refresh http://127.0.0.1:8000/docs in your browser.
You should see operations for GET /, GET /health, POST /retrieve, and POST /troubleshoot.
Are the workflow routes missing?
- Confirm that both route decorators appear below the request model classes in main.py.
- Save main.py before restarting Uvicorn.
- Refresh the documentation after the server restarts.
- Help me find the missing FastAPI routes.
✔️ Awesome, I've got everything!
Your API is running. Double check that you saved main.py.
ⓧ I'd like to double check the full code
Compare your main.py with this complete file:
from typing import Literal
from fastapi import FastAPI
from pydantic import BaseModel
from rag import COLLECTION_NAME, qdrant, retrieve, troubleshoot
app = FastAPI(title="FixIt AI", version="1.0.0")
class RetrieveRequest(BaseModel):
question: str
appliance_model: str | None = None
mode: Literal["dense", "hybrid"] = "dense"
class TroubleshootRequest(BaseModel):
question: str
appliance_model: str
@app.get("/")
def root() -> dict:
return {"message":"FixIt AI is running","docs":"/docs"}
@app.get("/health")
def health() -> dict:
return {"status":"ok","collection_ready":qdrant.collection_exists(collection_name=COLLECTION_NAME)}
@app.post("/retrieve")
def retrieve_pages(request: RetrieveRequest) -> dict:
return {"matches":retrieve(question=request.question, appliance_model=request.appliance_model, mode=request.mode)}
@app.post("/troubleshoot")
def troubleshoot_appliance(request: TroubleshootRequest) -> dict:
return troubleshoot(question=request.question, appliance_model=request.appliance_model)
What should match?
The file should contain two request models followed by four route functions. The retrieval route should pass question, appliance_model, and mode into retrieve().
Reveal the mixed-model evidence
The retrieval form now lets you inspect the complete evidence list behind a question. This first request uses dense mode without supplying the optional appliance model.
- Return to the FastAPI documentation from earlier.
- Select the POST /retrieve operation.
- Enable request editing for the operation.
- Replace the request body with this JSON object:
{"question":"How do I fix E24?","mode":"dense"}
What is this request asking for?
- question asks retrieval to find evidence related to the E24 error code.
- mode selects the named dense vector in Qdrant.
- appliance_model remains absent because the field is optional.
Before you submit this request, consider whether one error code will keep the result list inside one washer manual.
- Submit the retrieval request.
You should see matches from both FX-W300 and FX-W500. The results include the Washer 300 explanation on page 12 plus the Washer 500 explanation on page 22.
Why is this result unsafe?
The same E24 code has different meanings in the two fictional manuals. One page describes a blocked drain path.
The other page describes a controller communication problem. Generation from both pages could combine incompatible instructions into one plausible answer.
You caught the retrieval failure before it could become a confident troubleshooting answer. Next, you will isolate one appliance model and strengthen retrieval with dense plus lexical evidence.
Filter and Fuse Retrieval
Your dense endpoint now exposes the risk hidden inside an unfiltered search. The E24 request retrieves conflicting evidence from two washer models.
This step isolates evidence with a Qdrant payload filter. The selected appliance model becomes a required retrieval boundary.
Hybrid retrieval combines a Gemini dense query with a lexical sparse query. Reciprocal Rank Fusion merges both rankings so symptom language and exact codes can reach the right page.
In this step, get ready to:
- Constrain retrieval to the selected appliance model.
- Build a hybrid query that fuses dense vectors with sparse vectors through Reciprocal Rank Fusion.
- Verify that the filtered E24 request ranks the correct manual page first.
Build the model filter
A payload filter limits which points Qdrant may consider during retrieval. The filter compares each point's appliance_model payload with the model supplied by the request.
- Switch back to rag.py in VS Code.
- Find the existing retrieve() function.
- Add the filter helper immediately above retrieve() by pasting this code:
def build_model_filter(appliance_model: str | None) -> models.Filter | None:
if appliance_model is None:
return None
return models.Filter(must=[models.FieldCondition(key="appliance_model", match=models.MatchValue(value=appliance_model))])
What does this filter do?
- The None path preserves unfiltered retrieval when a request does not include an appliance model.
- The FieldCondition checks the appliance_model payload attached to every manual page.
- The MatchValue comparison admits points whose model value exactly matches the request.
- Save rag.py.
- Switch back to the integrated terminal from earlier.
- Stop Uvicorn with Ctrl+C if the server is still running.
- Confirm the new helper imports successfully by running this command:
uvicorn main:app
What does this check prove?
Uvicorn imports main.py. That import also loads rag.py, so a normal server startup confirms the new helper has valid Python syntax.
You should see the server start at http://127.0.0.1:8000. Your filter helper is now part of the running retrieval module.
- Stop Uvicorn with Ctrl+C before editing the retriever.
Does the server fail during import?
- Check that build_model_filter() sits above retrieve() with no extra indentation.
- Confirm the helper uses models.Filter, models.FieldCondition, and models.MatchValue exactly as shown.
- Ask for help with the import failure.
Fuse dense and lexical retrieval
Dense vectors capture semantic similarity between phrases such as a washer failing to drain. Sparse lexical vectors preserve exact terms such as E24.
The retriever runs both searches against the same model filter. Qdrant then applies Reciprocal Rank Fusion to combine their ranked results.
- Select the existing retrieve() function in rag.py.
- Replace the selected function by pasting this complete version:
def retrieve(question: str, appliance_model: str | None = None, mode: Literal["dense", "hybrid"] = "dense") -> list[dict]:
dense_query = embed_query(question)
query_filter = build_model_filter(appliance_model)
if mode == "dense":
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=dense_query, using="dense", query_filter=query_filter, with_payload=True, limit=6)
return [serialize_match(point) for point in response.points]
sparse_query = lexical_vector(question)
if not sparse_query.indices:
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=dense_query, using="dense", query_filter=query_filter, with_payload=True, limit=6)
return [serialize_match(point) for point in response.points]
response = qdrant.query_points(
collection_name=COLLECTION_NAME,
prefetch=[models.Prefetch(query=dense_query, using="dense", limit=8), models.Prefetch(query=sparse_query, using="lexical", limit=8)],
query=models.RrfQuery(rrf=models.Rrf()),
query_filter=query_filter,
with_payload=True,
limit=6,
)
return [serialize_match(point) for point in response.points]
How does hybrid retrieval work?
- The dense mode applies query_filter to the existing semantic search.
- The hybrid mode creates a sparse query from the question's lexical tokens.
- Each Prefetch searches one named vector before the rankings are fused.
- The RrfQuery rewards pages that rank well across the dense and lexical result lists.
- The empty sparse-vector path falls back to filtered dense retrieval.
- Save rag.py.
Before you restart the API, do you expect a syntax mistake in the new function to appear during startup or during the browser request?
- Restart the FastAPI service by running this command:
uvicorn main:app
What does this command run?
Uvicorn imports the app object from main.py. The running service exposes the existing retrieval form through FastAPI's interactive documentation.
You should see a successful startup message for http://127.0.0.1:8000. This confirms the filtered hybrid retriever loads with the API.
Seeing a startup failure?
- Confirm retrieve() remains at the top indentation level in rag.py.
- Check that both models.Prefetch calls sit inside the prefetch list.
- Ask for help comparing the retriever with the expected hybrid query.
✔️ Awesome, I've got everything!
Great. Your saved rag.py now contains the model filter and hybrid retriever.
ⓧ I'd like to double check the full code
Compare your complete rag.py with this reference:
import json
import re
from collections import Counter
from pathlib import Path
from typing import Literal
from google import genai
from google.genai import types
from qdrant_client import QdrantClient, models
COLLECTION_NAME = "fixit_manuals"
QDRANT_URL = "http://localhost:6333"
EMBEDDING_MODEL = "gemini-embedding-2"
GENERATION_MODEL = "gemini-3.8-flash"
EMBEDDING_SIZE = 768
ABSTENTION = "I couldn't find that in this manual."
BASE_DIR = Path(__file__).resolve().parent
DATA_PATH = BASE_DIR / "data" / "manual_pages.json"
with DATA_PATH.open("r", encoding="utf-8") as file:
MANUAL_PAGES = json.load(file)
qdrant = QdrantClient(url=QDRANT_URL)
gemini = genai.Client()
TOKEN_PATTERN = re.compile(r"[a-z0-9]+")
STOPWORDS = {"a","an","can","do","does","how","i","in","is","it","model","my","of","the","this","to","what","why"}
def tokenize(text: str) -> list[str]:
return TOKEN_PATTERN.findall(text.lower())
VOCABULARY = sorted({token for page in MANUAL_PAGES for token in tokenize(page["text"])})
TOKEN_TO_INDEX = {token: index for index, token in enumerate(VOCABULARY)}
def lexical_vector(text: str) -> models.SparseVector:
counts = Counter(tokenize(text))
indexed_counts = sorted((TOKEN_TO_INDEX[token], count) for token, count in counts.items() if token in TOKEN_TO_INDEX)
return models.SparseVector(indices=[index for index, _ in indexed_counts], values=[float(count) for _, count in indexed_counts])
def embed_text(text: str) -> list[float]:
result = gemini.models.embed_content(model=EMBEDDING_MODEL, contents=text, config=types.EmbedContentConfig(output_dimensionality=EMBEDDING_SIZE))
return list(result.embeddings[0].values)
def embed_document(page: dict) -> list[float]:
return embed_text(f"title: {page['manual']} | text: {page['text']}")
def embed_query(question: str) -> list[float]:
return embed_text(f"task: search result | query: {question}")
def serialize_match(point) -> dict:
payload = point.payload or {}
return {"score":round(point.score,4),"manual":payload.get("manual"),"appliance_model":payload.get("appliance_model"),"page":payload.get("page"),"text":payload.get("text")}
def seed_collection() -> int:
if qdrant.collection_exists(collection_name=COLLECTION_NAME):
qdrant.delete_collection(collection_name=COLLECTION_NAME)
qdrant.create_collection(
collection_name=COLLECTION_NAME,
vectors_config={"dense": models.VectorParams(size=EMBEDDING_SIZE, distance=models.Distance.COSINE)},
sparse_vectors_config={"lexical": models.SparseVectorParams()},
)
qdrant.create_payload_index(collection_name=COLLECTION_NAME, field_name="appliance_model", field_schema=models.PayloadSchemaType.KEYWORD)
points = [models.PointStruct(id=page["id"], vector={"dense": embed_document(page), "lexical": lexical_vector(page["text"])}, payload=page) for page in MANUAL_PAGES]
qdrant.upsert(collection_name=COLLECTION_NAME, points=points, wait=True)
return len(points)
def preview_dense(question: str) -> dict:
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=embed_query(question), using="dense", with_payload=True, limit=1)
return serialize_match(response.points[0])
def build_model_filter(appliance_model: str | None) -> models.Filter | None:
if appliance_model is None:
return None
return models.Filter(must=[models.FieldCondition(key="appliance_model", match=models.MatchValue(value=appliance_model))])
def retrieve(question: str, appliance_model: str | None = None, mode: Literal["dense", "hybrid"] = "dense") -> list[dict]:
dense_query = embed_query(question)
query_filter = build_model_filter(appliance_model)
if mode == "dense":
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=dense_query, using="dense", query_filter=query_filter, with_payload=True, limit=6)
return [serialize_match(point) for point in response.points]
sparse_query = lexical_vector(question)
if not sparse_query.indices:
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=dense_query, using="dense", query_filter=query_filter, with_payload=True, limit=6)
return [serialize_match(point) for point in response.points]
response = qdrant.query_points(
collection_name=COLLECTION_NAME,
prefetch=[models.Prefetch(query=dense_query, using="dense", limit=8), models.Prefetch(query=sparse_query, using="lexical", limit=8)],
query=models.RrfQuery(rrf=models.Rrf()),
query_filter=query_filter,
with_payload=True,
limit=6,
)
return [serialize_match(point) for point in response.points]
def has_evidence_overlap(question: str, matches: list[dict]) -> bool:
question_tokens = set(tokenize(question)) - STOPWORDS
context_tokens = set()
for match in matches:
context_tokens.update(tokenize(match["text"] or ""))
return bool(question_tokens and question_tokens.intersection(context_tokens))
def troubleshoot(question: str, appliance_model: str) -> dict:
matches = retrieve(question=question, appliance_model=appliance_model, mode="hybrid")
if not matches or not has_evidence_overlap(question, matches):
return {"answer":ABSTENTION,"sources":[],"retrieval_mode":"hybrid"}
top_matches = matches[:3]
context = "\n\n".join(f"Manual: {match['manual']}\nModel: {match['appliance_model']}\nPage: {match['page']}\nExcerpt: {match['text']}" for match in top_matches)
prompt = (
"You are FixIt AI, a citation-first appliance troubleshooting assistant.\n"
"Use only the supplied manual excerpts. Do not add causes, steps, or safety advice that are absent from the excerpts.\n"
f"If the excerpts do not answer the question, reply exactly: {ABSTENTION}\n"
"Write a short answer and cite every factual sentence as [manual, p. page].\n\n"
f"Question: {question}\n\nManual excerpts:\n{context}"
)
interaction = gemini.interactions.create(model=GENERATION_MODEL, input=prompt)
answer = (interaction.output_text or "").strip() or ABSTENTION
sources = [{"manual":match["manual"],"appliance_model":match["appliance_model"],"page":match["page"],"excerpt":match["text"]} for match in top_matches]
return {"answer":answer,"sources":sources,"retrieval_mode":"hybrid"}
How to use this reference
Compare the filter and retrieval functions first. The surrounding code should remain unchanged from your existing rag.py file.
Test the isolated hybrid result
The earlier dense request returned E24 evidence from both appliance models. This check supplies product identity and uses hybrid mode.
- Return to the browser tab showing http://127.0.0.1:8000/docs.
- Expand the POST /retrieve endpoint form.
Before you submit the request, which model do you expect every returned match to belong to?
- Replace the request body by pasting this JSON:
{"question":"How do I fix E24?","appliance_model":"FX-W300","mode":"hybrid"}
What does this request test?
- The appliance_model value activates the exact payload filter.
- The hybrid mode runs the dense and lexical prefetch searches.
- The E24 identifier gives the lexical vector an exact term to rank.
- Submit the request from the endpoint form.
Every returned match should show FX-W300 as its appliance_model value. Page 12 should rank first.
That closes the unsafe retrieval gap. The E24 evidence now stays inside the selected washer manual.
Still seeing mixed models?
- Confirm the request includes FX-W300 under appliance_model.
- Check that query_filter=query_filter appears in the hybrid query_points() call.
- Restart Uvicorn after saving rag.py so the service imports the latest function.
- Ask for help diagnosing mixed-model retrieval.
Your retriever now isolates the selected manual and handles both symptom language and exact error codes. Next, you'll turn that evidence into a cited answer with a deterministic abstention path.
Generate Cited Answers
Your filtered hybrid search now keeps each appliance inside its own manual. A useful RAG answer still needs a final boundary that keeps generation grounded in retrieved evidence.
The FastAPI route checks for evidence before asking Gemini to write an answer. Supported questions receive citations plus structured sources. Unsupported questions receive one deterministic abstention.
In this step, get ready to:
- Trace the deterministic evidence gate in rag.py.
- Verify a supported FX-W300 request returns cited page 12 evidence.
- Verify an unsupported Bluetooth request returns the exact abstention.
Trace the evidence gate
A grounded answer begins with relevant retrieval. It also needs a hard rule for missing evidence.
How does grounded generation work?
- The has_evidence_overlap() function removes common words from the question. It checks whether the remaining tokens appear in the retrieved excerpts.
- The troubleshoot() function returns I couldn't find that in this manual. when the overlap check fails.
- A supported question sends the top three matches to Gemini through an excerpt-only prompt.
- The response keeps each source's manual name. It also keeps the appliance model. The page number and excerpt remain available for auditing.
- Switch back to rag.py in VS Code.
- Locate the has_evidence_overlap() function.
- Identify where question_tokens excludes STOPWORDS.
- Locate the troubleshoot() function.
- Follow the early return that pairs ABSTENTION with an empty sources list.
- Follow the supported branch that builds context from the top three matches.
- Confirm the final source objects contain manual, appliance_model, page, and excerpt.
✔️ I've confirmed the grounding path
Your existing rag.py now has a deterministic gate before generation. Your main.py route exposes that complete troubleshooting path.
ⓧ I'd like to double check the full code
- Compare your rag.py with this complete reference:
import json
import re
from collections import Counter
from pathlib import Path
from typing import Literal
from google import genai
from google.genai import types
from qdrant_client import QdrantClient, models
COLLECTION_NAME = "fixit_manuals"
QDRANT_URL = "http://localhost:6333"
EMBEDDING_MODEL = "gemini-embedding-2"
GENERATION_MODEL = "gemini-3.8-flash"
EMBEDDING_SIZE = 768
ABSTENTION = "I couldn't find that in this manual."
BASE_DIR = Path(__file__).resolve().parent
DATA_PATH = BASE_DIR / "data" / "manual_pages.json"
with DATA_PATH.open("r", encoding="utf-8") as file:
MANUAL_PAGES = json.load(file)
qdrant = QdrantClient(url=QDRANT_URL)
gemini = genai.Client()
TOKEN_PATTERN = re.compile(r"[a-z0-9]+")
STOPWORDS = {"a","an","can","do","does","how","i","in","is","it","model","my","of","the","this","to","what","why"}
def tokenize(text: str) -> list[str]:
return TOKEN_PATTERN.findall(text.lower())
VOCABULARY = sorted({token for page in MANUAL_PAGES for token in tokenize(page["text"])})
TOKEN_TO_INDEX = {token: index for index, token in enumerate(VOCABULARY)}
def lexical_vector(text: str) -> models.SparseVector:
counts = Counter(tokenize(text))
indexed_counts = sorted((TOKEN_TO_INDEX[token], count) for token, count in counts.items() if token in TOKEN_TO_INDEX)
return models.SparseVector(indices=[index for index, _ in indexed_counts], values=[float(count) for _, count in indexed_counts])
def embed_text(text: str) -> list[float]:
result = gemini.models.embed_content(model=EMBEDDING_MODEL, contents=text, config=types.EmbedContentConfig(output_dimensionality=EMBEDDING_SIZE))
return list(result.embeddings[0].values)
def embed_document(page: dict) -> list[float]:
return embed_text(f"title: {page['manual']} | text: {page['text']}")
def embed_query(question: str) -> list[float]:
return embed_text(f"task: search result | query: {question}")
def serialize_match(point) -> dict:
payload = point.payload or {}
return {"score":round(point.score,4),"manual":payload.get("manual"),"appliance_model":payload.get("appliance_model"),"page":payload.get("page"),"text":payload.get("text")}
def seed_collection() -> int:
if qdrant.collection_exists(collection_name=COLLECTION_NAME):
qdrant.delete_collection(collection_name=COLLECTION_NAME)
qdrant.create_collection(
collection_name=COLLECTION_NAME,
vectors_config={"dense": models.VectorParams(size=EMBEDDING_SIZE, distance=models.Distance.COSINE)},
sparse_vectors_config={"lexical": models.SparseVectorParams()},
)
qdrant.create_payload_index(collection_name=COLLECTION_NAME, field_name="appliance_model", field_schema=models.PayloadSchemaType.KEYWORD)
points = [models.PointStruct(id=page["id"], vector={"dense": embed_document(page), "lexical": lexical_vector(page["text"])}, payload=page) for page in MANUAL_PAGES]
qdrant.upsert(collection_name=COLLECTION_NAME, points=points, wait=True)
return len(points)
def preview_dense(question: str) -> dict:
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=embed_query(question), using="dense", with_payload=True, limit=1)
return serialize_match(response.points[0])
def build_model_filter(appliance_model: str | None) -> models.Filter | None:
if appliance_model is None:
return None
return models.Filter(must=[models.FieldCondition(key="appliance_model", match=models.MatchValue(value=appliance_model))])
def retrieve(question: str, appliance_model: str | None = None, mode: Literal["dense", "hybrid"] = "dense") -> list[dict]:
dense_query = embed_query(question)
query_filter = build_model_filter(appliance_model)
if mode == "dense":
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=dense_query, using="dense", query_filter=query_filter, with_payload=True, limit=6)
return [serialize_match(point) for point in response.points]
sparse_query = lexical_vector(question)
if not sparse_query.indices:
response = qdrant.query_points(collection_name=COLLECTION_NAME, query=dense_query, using="dense", query_filter=query_filter, with_payload=True, limit=6)
return [serialize_match(point) for point in response.points]
response = qdrant.query_points(
collection_name=COLLECTION_NAME,
prefetch=[models.Prefetch(query=dense_query, using="dense", limit=8), models.Prefetch(query=sparse_query, using="lexical", limit=8)],
query=models.RrfQuery(rrf=models.Rrf()),
query_filter=query_filter,
with_payload=True,
limit=6,
)
return [serialize_match(point) for point in response.points]
def has_evidence_overlap(question: str, matches: list[dict]) -> bool:
question_tokens = set(tokenize(question)) - STOPWORDS
context_tokens = set()
for match in matches:
context_tokens.update(tokenize(match["text"] or ""))
return bool(question_tokens and question_tokens.intersection(context_tokens))
def troubleshoot(question: str, appliance_model: str) -> dict:
matches = retrieve(question=question, appliance_model=appliance_model, mode="hybrid")
if not matches or not has_evidence_overlap(question, matches):
return {"answer":ABSTENTION,"sources":[],"retrieval_mode":"hybrid"}
top_matches = matches[:3]
context = "\n\n".join(f"Manual: {match['manual']}\nModel: {match['appliance_model']}\nPage: {match['page']}\nExcerpt: {match['text']}" for match in top_matches)
prompt = (
"You are FixIt AI, a citation-first appliance troubleshooting assistant.\n"
"Use only the supplied manual excerpts. Do not add causes, steps, or safety advice that are absent from the excerpts.\n"
f"If the excerpts do not answer the question, reply exactly: {ABSTENTION}\n"
"Write a short answer and cite every factual sentence as [manual, p. page].\n\n"
f"Question: {question}\n\nManual excerpts:\n{context}"
)
interaction = gemini.interactions.create(model=GENERATION_MODEL, input=prompt)
answer = (interaction.output_text or "").strip() or ABSTENTION
sources = [{"manual":match["manual"],"appliance_model":match["appliance_model"],"page":match["page"],"excerpt":match["text"]} for match in top_matches]
return {"answer":answer,"sources":sources,"retrieval_mode":"hybrid"}
How do these pieces fit together?
- The retrieval functions isolate the selected appliance model before generation begins.
- The evidence gate handles unsupported questions without relying on a model-generated judgment.
- The strict prompt limits Gemini to retrieved excerpts. It requests a citation for every factual sentence.
- The structured source list preserves the evidence independently of the generated wording.
- Compare your main.py with this complete reference:
from typing import Literal
from fastapi import FastAPI
from pydantic import BaseModel
from rag import COLLECTION_NAME, qdrant, retrieve, troubleshoot
app = FastAPI(title="FixIt AI", version="1.0.0")
class RetrieveRequest(BaseModel):
question: str
appliance_model: str | None = None
mode: Literal["dense", "hybrid"] = "dense"
class TroubleshootRequest(BaseModel):
question: str
appliance_model: str
@app.get("/")
def root() -> dict:
return {"message":"FixIt AI is running","docs":"/docs"}
@app.get("/health")
def health() -> dict:
return {"status":"ok","collection_ready":qdrant.collection_exists(collection_name=COLLECTION_NAME)}
@app.post("/retrieve")
def retrieve_pages(request: RetrieveRequest) -> dict:
return {"matches":retrieve(question=request.question, appliance_model=request.appliance_model, mode=request.mode)}
@app.post("/troubleshoot")
def troubleshoot_appliance(request: TroubleshootRequest) -> dict:
return troubleshoot(question=request.question, appliance_model=request.appliance_model)
How does the route connect?
The TroubleshootRequest model requires a question plus an appliance model. The troubleshoot_appliance() route passes both values into the grounded troubleshooting pipeline.
Verify a supported answer
The first request follows the supported branch. The filtered retrieval should carry the FX-W300 page 12 excerpt into the generated answer.
- Return to the browser tab showing http://127.0.0.1:8000/docs from earlier.
- Expand POST /troubleshoot.
- Click Try it out.
- Set question to How do I fix E24?.
- Set appliance_model to FX-W300.
Before you execute the request, which manual page do you expect to appear in the structured sources?
- Click Execute.
You should see a short answer with inline page citations. The sources array should include page 12 for FX-W300.
That response proves the generation path stayed attached to the selected manual's evidence.
Missing page 12 from the sources?
- Confirm the request question is exactly How do I fix E24?.
- Confirm the appliance model is exactly FX-W300.
- Return to the Qdrant dashboard from earlier. Confirm the fixit_manuals collection remains available.
- Confirm the FastAPI service from earlier is still running.
- Help me debug a missing page 12 source.
Prove the API abstains
The second request probes the unsupported branch. It asks about a capability that does not appear anywhere in the six-page corpus.
- Replace the question value with Does this model support Bluetooth pairing?.
- Leave appliance_model set to FX-W300.
Before you execute the request, do you expect the evidence gate to allow generation?
- Click Execute.
You should see the exact answer I couldn't find that in this manual. The sources array should be empty.
Why is this abstention deterministic?
The meaningful Bluetooth question tokens do not overlap with the retrieved manual text. The evidence gate returns the fixed abstention before generation begins.
This keeps unsupported answers stable across repeated requests. It also avoids treating a fusion score as calibrated confidence.
You have now proved both safety paths. FixIt AI cites page-level evidence when support exists. It declines the question when the selected manual has no answer.
Secret mission
Add a Two-Case Grounding Check
A successful demo proves the pipeline worked once. Build a repeatable evaluation that checks the supported FX-W300 E24 question, confirms model isolation, and protects the exact Bluetooth abstention behavior.
Clean Up Your Resources
Clean Up Your Resources
Your local Qdrant container has no ongoing cloud cost. Choose whether to keep the API available, pause its processes, or remove its local data.
Cost warning
Gemini API requests can incur token charges when your key belongs to a paid project. The Free Tier covers the small request volume used in this project.
Free Tier content may be used to improve Google's products.
- Review your usage in Google AI Studio if your key belongs to a paid project.
- Keep your Gemini API key out of source control.
Resources you used:
- The Uvicorn process that serves the FastAPI API at http://127.0.0.1:8000.
- The running Docker container named fixit-qdrant.
- The persistent Docker volume named qdrant-storage. It stores the six-point fixit_manuals collection with its dense and lexical vectors.
Keep everything running
No action is needed. Choose this option if you plan to keep testing or extending FixIt AI.
- Leave the VS Code terminal running while you test the API.
- Keep fixit-qdrant running so the API can query your manual collection.
- Keep qdrant-storage so your seeded collection persists across container restarts.
- Run evaluate.py only when you want to repeat the two grounding checks.
Pause - I'll come back to this later
Stop the active processes to free your terminal and local memory. Your project files remain available for the next session.
- Press Ctrl+C in the terminal that runs Uvicorn.
- Pause the local Qdrant container by running this command:
docker stop fixit-qdrant
What Does This Command Do?
This stops the container while preserving qdrant-storage. Your fixit_manuals collection remains available for your next session.
- Resume the local Qdrant container later by running this command:
docker start fixit-qdrant
What Happens When You Resume?
This restarts the same container. Qdrant reconnects to the persistent volume that holds your seeded collection.
- Reactivate the existing virtual environment before restarting the API by running these commands:
.\.venv\Scripts\Activate.ps1
uvicorn main:app
What Do These Commands Restore?
The first command restores the pinned Python environment. The second command serves FixIt AI again at http://127.0.0.1:8000.
Delete - I don't want to use this again
Deleting the volume is the irreversible part. These commands permanently erase the seeded manual collection while leaving your project files untouched.
- Press Ctrl+C in the terminal that runs Uvicorn.
- Remove the local Qdrant container and its persistent data by running these commands:
docker stop fixit-qdrant
docker rm fixit-qdrant
docker volume rm qdrant-storage
What Gets Removed?
- The first command stops the running Qdrant container.
- The second command removes the fixit-qdrant container.
- The final command deletes qdrant-storage. This also deletes the fixit_manuals collection stored inside it.
Your Python files remain available if you want to rebuild the collection later.
- Confirm that all three commands complete without an error.
Nice Work!
Nice Work!
You did it! You built FixIt AI, a citation-first RAG API for appliance troubleshooting. Your FastAPI endpoint now grounds Gemini answers in model-specific manual evidence from Qdrant.
You've learned how to:
- Build a searchable manual corpus from six page-level records using named dense and lexical vectors.
- Expose the cross-model retrieval failure. Fix it with appliance-model filtering plus Reciprocal Rank Fusion.
- Return grounded answers with structured page sources. Trigger a deterministic abstention when the selected manual lacks supporting evidence.
- Complete the Secret Mission by adding a two-case grounding check. It confirms FX-W300 source isolation. It also confirms the exact abstention for the unsupported Bluetooth question.
Ready to quiz yourself?