Make SimplyRAG Fast on Hugging Face CPU
Optimize a streaming RAG app for CPU-hosted Hugging Face Spaces.
Introduction
30 Second Summary
Waiting for an answer can make even a useful document assistant feel broken. Limited hardware makes every extra processing step part of that delay.
In this project, you will optimize SimplyRAG into a CPU-first document assistant with a one-call RAG path. The deployed app streams cited Gemini answers with response timings on Hugging Face Spaces CPU Basic.
What You'll Build
Picture your uploaded PDF producing a cited answer that grows in place beside a live performance footer.
By the end of this project, you'll have:
- A PDF upload flow that indexes text and tables locally. Your document becomes searchable without extra model decisions at each query stage.
- A one-call RAG answer path that handles ordinary questions. It also handles summaries, comparisons, and table questions through one Gemini generation request.
- A streaming Gradio chat where cited text grows in place. Its footer reports retrieval time, first-token time, total time, and Model calls: 1.
- Secret Mission: Add an opt-in quality mode that lazily loads a CrossEncoder to rerank more candidates while keeping fast mode as the default.
Are there any prerequisites?
This project assumes that you have the SimplyRAG clone, a Gemini key, a Hugging Face account, access to the existing Space, and an active environment running Python 3.10 or higher. You also need a small PDF for repeatable testing.
Before We Start
This checkpoint locks in a repeatable test before you change SimplyRAG. A chosen PDF, an answerable question, and a reason for faster time to first token create the baseline for every later comparison.
Pin and Install the Fast-Path Stack
SimplyRAG currently leaves package versions open in requirements.txt. That can produce different environments locally and on Hugging Face Spaces.
In this step, you will pin the five CPU-compatible packages used by the fast path. You will install those exact versions in the active Python environment.
In this step, get ready to:
- Pin five CPU-compatible dependencies in requirements.txt.
- Install the pinned stack in the active Python environment.
- Confirm GEMINI_API_KEY exists without displaying its value.
Pin the runtime dependencies
A version pin tells pip to install one exact release. The same file can then reproduce the optimized runtime during a Space rebuild.
- Select requirements.txt from the SimplyRAG clone in your code editor.
- Replace the entire file with the pinned dependency list below:
chromadb==1.5.9
google-genai==2.24.0
gradio==6.28.0
pymupdf==1.28.2
sentence-transformers==6.1.0
What Does This Stack Change?
- Chroma stores vector records on disk.
- The Google Gen AI Python SDK streams chunks from Gemini.
- Gradio provides the chat interface.
- PyMuPDF extracts content from PDF files.
- Sentence Transformers creates local embeddings for retrieval.
- LangGraph is absent from the optimized runtime.
- LangChain is absent from the optimized runtime.
- BM25 is absent from the optimized runtime.
- The always-on reranker is absent from the optimized runtime.
- Save requirements.txt.
- Count the dependency lines in requirements.txt.
You should see five pinned entries. Each line contains a package name followed by an exact version.
Still Seeing the Old Dependencies?
- Confirm you edited the requirements.txt inside the SimplyRAG clone.
- Remove any old dependency lines that remain below the five pinned entries.
Help me compare my requirements file with the pinned fast-path stack.
✔️ Awesome, I've got everything!
Your requirements.txt now contains the complete pinned stack. Make sure the file is saved.
ⓧ I'd like to double check the full code
chromadb==1.5.9
google-genai==2.24.0
gradio==6.28.0
pymupdf==1.28.2
sentence-transformers==6.1.0
Sync the active environment
Pip now turns the five pinned lines into installed packages. Its dependency resolver checks whether those exact releases can coexist.
- Return to the terminal with your active Python environment.
- Navigate to the SimplyRAG clone in that terminal.
- Apply the versions from requirements.txt by running:
pip install -r requirements.txt
- Inspect the final terminal lines for a completed installation.
Pip should complete without a dependency-resolution error.
That resolves the reproducibility problem. Your active environment now contains the packages required by the CPU fast path.
Did the Installation Fail?
- Return to the SimplyRAG clone if the terminal cannot find requirements.txt.
- Compare every package pin with the saved file if dependency resolution fails.
- Confirm the active Python environment is still active if packages install elsewhere.
Help me diagnose my pinned dependency installation.
Confirm the complete setup
The fast path later reads GEMINI_API_KEY from the active shell. This check confirms availability while keeping the credential private.
Checking a credential can feel risky. You only need to reveal whether the variable exists.
- Use your shell's environment-variable presence check to confirm GEMINI_API_KEY exists.
- Keep the key value out of the terminal output.
Your shell should confirm that the variable is present without displaying the credential.
Is the Variable Missing?
- Reload the variable through the secure method you used before this project.
- Repeat the presence check without displaying the value.
- Keep the credential out of requirements.txt.
Help me check why the environment variable is unavailable.
Before you run this final check, do you expect the pinned stack to resolve cleanly a second time?
- Verify the pinned installation by running the same command again:
pip install -r requirements.txt
- Inspect the final terminal lines for a completed run with no dependency-resolution error.
You should see pip complete without a dependency-resolution error. The environment may confirm that the pinned packages are already installed.
Still Seeing a Resolution Error?
Compare each line in requirements.txt with the full-file reference above. A changed version can produce a different resolution result.
Help me resolve the remaining package conflict.
Your dependency stack is now reproducible. Next up, you will build the direct one-call pipeline that uses it.
Build the One-Call Fast Path
Your active Python environment now has a reproducible CPU stack. The next goal is a direct RAG path that can retrieve evidence with one generation request.
The first test focuses on missing local evidence. This gives you a clear retrieval signal before a Gemini request can muddy the result.
In this step, get ready to:
- Connect local embeddings to a persistent Chroma collection.
- Build local intent routing with bounded top-three retrieval.
- Prove the fast path stops before Gemini when no documents exist.
Build the persistent embedding store
Sentence Transformers converts text into vectors for semantic search. A persistent Chroma collection stores those vectors under chroma_db by default.
- Open src/embeddings.py in the code editor you use for SimplyRAG.
- Replace the existing imports and model setup with this section:
import os
import uuid
import chromadb
from sentence_transformers import SentenceTransformer
MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"
CHROMA_PATH = os.getenv("CHROMA_PATH", "chroma_db")
embedding_model = SentenceTransformer(MODEL_ID)
chroma_client = chromadb.PersistentClient(path=CHROMA_PATH)
collection = chroma_client.get_or_create_collection(name="documents")
What does this setup do?
- MODEL_ID pins the embedding model used for every stored chunk and query.
- CHROMA_PATH accepts a custom storage location through the environment.
- collection reuses the local documents collection when it already exists.
- Save src/embeddings.py.
- Confirm the file now ends with the get_or_create_collection call.
Imports showing as unresolved?
Confirm your editor is using the active Python environment from the previous step. If the imports still look unresolved, help me check my Python interpreter selection.
- Add the embedding and chunk-storage functions below the collection setup:
def generate_embeddings(texts):
return embedding_model.encode(texts).tolist()
def store_chunks(chunks):
if not chunks:
return 0
texts = [chunk["text"] for chunk in chunks]
metadatas = [chunk["metadata"] for chunk in chunks]
ids = [str(uuid.uuid4()) for _ in chunks]
collection.upsert(
ids=ids,
documents=texts,
embeddings=generate_embeddings(texts),
metadatas=metadatas,
)
return len(chunks)
How are chunks stored?
- generate_embeddings() converts a batch of chunk text into vectors.
- store_chunks() returns 0 when ingestion supplies no chunks.
- collection.upsert() stores aligned IDs, text, vectors, and metadata.
- Save src/embeddings.py.
- Confirm the final line returns the number of stored chunks.
Seeing an indentation warning?
Keep both functions at the left edge of the file. Keep the collection.upsert() arguments nested inside the function.
If the indentation still looks wrong, help me compare the function structure.
✔️ Awesome, I've got everything!
Your embedding store is ready. Double-check that you saved src/embeddings.py.
ⓧ I'd like to double check the full code
import os
import uuid
import chromadb
from sentence_transformers import SentenceTransformer
MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"
CHROMA_PATH = os.getenv("CHROMA_PATH", "chroma_db")
embedding_model = SentenceTransformer(MODEL_ID)
chroma_client = chromadb.PersistentClient(path=CHROMA_PATH)
collection = chroma_client.get_or_create_collection(name="documents")
def generate_embeddings(texts):
return embedding_model.encode(texts).tolist()
def store_chunks(chunks):
if not chunks:
return 0
texts = [chunk["text"] for chunk in chunks]
metadatas = [chunk["metadata"] for chunk in chunks]
ids = [str(uuid.uuid4()) for _ in chunks]
collection.upsert(
ids=ids,
documents=texts,
embeddings=generate_embeddings(texts),
metadatas=metadatas,
)
return len(chunks)
Assemble the one-call pipeline
The fast pipeline keeps routing and retrieval inside the application. It reserves the remote model call for generating the final cited answer.
- Create src/fast_pipeline.py inside the existing src folder with your code editor.
- Add the imports and shared configuration to the new file:
import os
import time
from google import genai
from google.genai import types
from src.embeddings import collection, embedding_model
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.6-flash")
MAX_CONTEXT_CHARS = 6000
TOP_K = 3
_api_key = os.getenv("GEMINI_API_KEY")
_client = genai.Client(api_key=_api_key) if _api_key else None
What does this configuration control?
- GEMINI_MODEL keeps the model configurable through the environment.
- MAX_CONTEXT_CHARS caps retrieved prompt content at 6000 characters.
- TOP_K limits the default retrieval set to three passages.
- _client exists only when GEMINI_API_KEY is available.
- Save src/fast_pipeline.py.
- Confirm the file sets TOP_K to 3.
Seeing a Google import warning?
Return to the active Python environment from the previous step. Confirm your editor uses that same interpreter.
If the warning remains, help me trace the installed SDK environment.
- Add the local intent classifier below the client setup:
def classify_intent(question):
text = question.lower()
if any(word in text for word in ("compare", "difference", "versus", " vs ")):
return "compare"
if any(word in text for word in ("table", "row", "column", "highest", "lowest", "total")):
return "table"
if any(word in text for word in ("summarize", "summary", "overview", "key points")):
return "summarize"
return "answer"
How does local routing work?
classify_intent() maps question keywords to comparison, table, summary, or direct-answer guidance. This decision uses local string checks.
- Save src/fast_pipeline.py.
- Confirm the classifier falls back to answer.
Classifier block showing an error?
Check that each keyword group uses matching parentheses. Check that every returned intent stays inside classify_intent().
For a closer comparison, help me inspect the classifier syntax.
- Start the dense retrieval function below the classifier:
def retrieve_documents(question, top_k=TOP_K):
record_count = collection.count()
if record_count == 0:
return []
query_embedding = embedding_model.encode([question]).tolist()
results = collection.query(
query_embeddings=query_embedding,
n_results=min(top_k, record_count),
include=["documents", "metadatas", "distances"],
)
How does retrieval begin?
- collection.count() detects whether local evidence exists.
- embedding_model.encode() converts the question into a query vector.
- min(top_k, record_count) prevents the query from requesting more results than the collection holds.
- Save src/fast_pipeline.py.
- Confirm the query requests documents, metadata, and distances.
Query block misaligned?
Keep the query arguments inside collection.query(). Keep the closing parenthesis aligned with the assignment.
If the structure remains unclear, help me align the retrieval query.
- Continue retrieve_documents() directly below the closing parenthesis:
documents = []
for text, metadata, distance in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0],
):
documents.append(
{
"text": text or "",
"metadata": metadata or {},
"distance": float(distance),
}
)
return documents
How are query results normalized?
The loop combines each returned passage with its metadata and distance. Empty text or metadata becomes a safe default value.
- Save src/fast_pipeline.py.
- Confirm retrieve_documents() returns the completed documents list.
Result loop showing an indentation error?
Keep documents = [] inside retrieve_documents(). Keep return documents aligned with the loop.
If the nesting is still uncertain, help me inspect the retrieval loop.
- Add the message-content normalizer below the retrieval function:
def _message_text(content):
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
return " ".join(part for part in parts if part)
return ""
Why normalize message content?
_message_text() accepts plain strings or structured text blocks. It produces one prompt-ready string from either format.
- Save src/fast_pipeline.py.
- Confirm unsupported content returns an empty string.
Message blocks failing to parse?
Check the dict branch for matching quotes around type and text.
For help finding a mismatch, help me inspect the message normalizer.
- Add the bounded history helper below _message_text().
def _recent_history(history):
lines = []
for message in (history or [])[-4:]:
if not isinstance(message, dict):
continue
text = _message_text(message.get("content", ""))
if text:
lines.append(f"{message.get('role', 'user')}: {text}")
return "\n".join(lines)
Why keep only recent history?
_recent_history() keeps the latest four messages. This limits conversation growth inside each prompt.
- Save src/fast_pipeline.py.
- Confirm the history slice ends with -4:.
History lines missing roles?
Check that the formatted string reads role from each message. Keep user as the fallback role.
If the formatted string is failing, help me check the history helper.
- Add the bounded context builder below the history helper:
def _context_text(documents):
if not documents:
return ""
per_document = MAX_CONTEXT_CHARS // len(documents)
blocks = []
for index, document in enumerate(documents, start=1):
metadata = document["metadata"]
source = metadata.get("source", "unknown")
page = metadata.get("page", "unknown")
excerpt = document["text"][:per_document]
blocks.append(f"[{index}] Source: {source}, page {page}\n{excerpt}")
return "\n\n".join(blocks)
How is context bounded?
_context_text() divides the character budget across the retrieved documents. Each block receives a numbered source label for later citations.
- Save src/fast_pipeline.py.
- Confirm every context block includes its source and page metadata.
Context labels looking malformed?
Check the final formatted string for matching braces around index, source, page, and excerpt.
For help tracing the label, help me inspect the context builder.
- Add the intent-specific prompt guidance below the context builder:
def _intent_guidance(intent):
guidance = {
"answer": "Answer the question directly.",
"summarize": "Summarize the main points without adding unsupported details.",
"compare": "Compare the requested items using only supported differences.",
"table": "Prioritize values and relationships found in table context.",
}
return guidance[intent]
What does the guidance change?
_intent_guidance() turns the local intent into a focused task instruction. The same generation request handles every intent.
- Save src/fast_pipeline.py.
- Confirm all four classifier outcomes have matching guidance entries.
Seeing a guidance lookup problem?
Compare the dictionary keys with the values returned by classify_intent(). Their spelling must match exactly.
If one key still fails, help me compare the intent names.
- Start stream_answer() below the prompt guidance:
def stream_answer(question, history=None):
if not question or not question.strip():
yield "Enter a question first."
return
if _client is None:
yield "GEMINI_API_KEY is not available in this environment."
return
started = time.perf_counter()
documents = retrieve_documents(question)
retrieval_ms = (time.perf_counter() - started) * 1000
if not documents:
yield "No indexed documents yet. Upload and index a PDF first."
return
What does the fast path check first?
- The first guard rejects an empty question.
- The second guard checks whether the Gemini client exists.
- The retrieval timer starts before local dense retrieval.
- The document guard stops the generator when the collection is empty.
- Save src/fast_pipeline.py.
- Confirm the document guard appears after retrieval timing is calculated.
Generator guards showing an error?
Keep each return aligned with its matching yield. Keep all guards inside stream_answer().
If the generator structure is unclear, help me inspect the early-return guards.
- Continue stream_answer() directly below the document guard:
intent = classify_intent(question)
prompt = f"""Question:
{question}
Recent conversation:
{_recent_history(history) or 'None'}
Retrieved context:
{_context_text(documents)}
Task:
{_intent_guidance(intent)}
Use only the retrieved context. Cite supporting passages with [1], [2], or [3].
If the context does not support an answer, say so clearly.
"""
How is the prompt assembled?
The prompt combines the question, recent history, bounded evidence, and local intent guidance. It also requires numbered citations from the retrieved context.
- Save src/fast_pipeline.py.
- Confirm the prompt contains separate question, conversation, context, and task sections.
Prompt string showing as unfinished?
Check that the prompt starts and ends with three double quotes. Keep every interpolated value inside matching braces.
If your editor still highlights the remainder of the file, help me find the unmatched prompt delimiter.
- Add the single streaming request directly below the prompt:
answer = ""
first_token_ms = None
response_stream = _client.models.generate_content_stream(
model=GEMINI_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
system_instruction="You are a concise document question-answering assistant.",
max_output_tokens=512,
),
)
for chunk in response_stream:
text = chunk.text or ""
if not text:
continue
if first_token_ms is None:
first_token_ms = (time.perf_counter() - started) * 1000
answer += text
yield answer
How does one-call streaming work?
- generate_content_stream() creates the pipeline's only model request.
- first_token_ms captures the delay before the first non-empty chunk.
- yield answer emits the growing response after each chunk.
- Save src/fast_pipeline.py.
- Confirm the model request appears once inside stream_answer().
Streaming loop showing an error?
Keep the generation configuration nested inside the request. Keep the chunk loop aligned with response_stream.
If the nesting remains unclear, help me inspect the streaming request.
- Finish stream_answer() with source labels and timing metrics:
total_ms = (time.perf_counter() - started) * 1000
source_lines = []
for index, document in enumerate(documents, start=1):
metadata = document["metadata"]
source_lines.append(
f"[{index}] {metadata.get('source', 'unknown')}, "
f"page {metadata.get('page', 'unknown')}"
)
metrics = (
f"Intent: {intent} | Retrieval: {retrieval_ms:.0f} ms | "
f"First token: {(first_token_ms or total_ms):.0f} ms | "
f"Total: {total_ms:.0f} ms | Model calls: 1"
)
yield answer + "\n\nSources\n" + "\n".join(source_lines) + "\n\n" + metrics
What appears in the final update?
The final yield appends numbered source locations. It also reports the selected intent, retrieval time, first-token time, total time, and one model call.
- Save src/fast_pipeline.py.
- Confirm the final metric string ends with Model calls: 1.
Footer string showing a syntax error?
Check the two adjacent formatted strings inside source_lines.append(). Check the closing parenthesis after the metrics string.
If the final yield remains highlighted, help me inspect the footer assembly.
✔️ Awesome, I've got everything!
Your one-call pipeline is assembled. Double-check that you saved src/fast_pipeline.py.
ⓧ I'd like to double check the full code
import os
import time
from google import genai
from google.genai import types
from src.embeddings import collection, embedding_model
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.6-flash")
MAX_CONTEXT_CHARS = 6000
TOP_K = 3
_api_key = os.getenv("GEMINI_API_KEY")
_client = genai.Client(api_key=_api_key) if _api_key else None
def classify_intent(question):
text = question.lower()
if any(word in text for word in ("compare", "difference", "versus", " vs ")):
return "compare"
if any(word in text for word in ("table", "row", "column", "highest", "lowest", "total")):
return "table"
if any(word in text for word in ("summarize", "summary", "overview", "key points")):
return "summarize"
return "answer"
def retrieve_documents(question, top_k=TOP_K):
record_count = collection.count()
if record_count == 0:
return []
query_embedding = embedding_model.encode([question]).tolist()
results = collection.query(
query_embeddings=query_embedding,
n_results=min(top_k, record_count),
include=["documents", "metadatas", "distances"],
)
documents = []
for text, metadata, distance in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0],
):
documents.append(
{
"text": text or "",
"metadata": metadata or {},
"distance": float(distance),
}
)
return documents
def _message_text(content):
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
return " ".join(part for part in parts if part)
return ""
def _recent_history(history):
lines = []
for message in (history or [])[-4:]:
if not isinstance(message, dict):
continue
text = _message_text(message.get("content", ""))
if text:
lines.append(f"{message.get('role', 'user')}: {text}")
return "\n".join(lines)
def _context_text(documents):
if not documents:
return ""
per_document = MAX_CONTEXT_CHARS // len(documents)
blocks = []
for index, document in enumerate(documents, start=1):
metadata = document["metadata"]
source = metadata.get("source", "unknown")
page = metadata.get("page", "unknown")
excerpt = document["text"][:per_document]
blocks.append(f"[{index}] Source: {source}, page {page}\n{excerpt}")
return "\n\n".join(blocks)
def _intent_guidance(intent):
guidance = {
"answer": "Answer the question directly.",
"summarize": "Summarize the main points without adding unsupported details.",
"compare": "Compare the requested items using only supported differences.",
"table": "Prioritize values and relationships found in table context.",
}
return guidance[intent]
def stream_answer(question, history=None):
if not question or not question.strip():
yield "Enter a question first."
return
if _client is None:
yield "GEMINI_API_KEY is not available in this environment."
return
started = time.perf_counter()
documents = retrieve_documents(question)
retrieval_ms = (time.perf_counter() - started) * 1000
if not documents:
yield "No indexed documents yet. Upload and index a PDF first."
return
intent = classify_intent(question)
prompt = f"""Question:
{question}
Recent conversation:
{_recent_history(history) or 'None'}
Retrieved context:
{_context_text(documents)}
Task:
{_intent_guidance(intent)}
Use only the retrieved context. Cite supporting passages with [1], [2], or [3].
If the context does not support an answer, say so clearly.
"""
answer = ""
first_token_ms = None
response_stream = _client.models.generate_content_stream(
model=GEMINI_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
system_instruction="You are a concise document question-answering assistant.",
max_output_tokens=512,
),
)
for chunk in response_stream:
text = chunk.text or ""
if not text:
continue
if first_token_ms is None:
first_token_ms = (time.perf_counter() - started) * 1000
answer += text
yield answer
total_ms = (time.perf_counter() - started) * 1000
source_lines = []
for index, document in enumerate(documents, start=1):
metadata = document["metadata"]
source_lines.append(
f"[{index}] {metadata.get('source', 'unknown')}, "
f"page {metadata.get('page', 'unknown')}"
)
metrics = (
f"Intent: {intent} | Retrieval: {retrieval_ms:.0f} ms | "
f"First token: {(first_token_ms or total_ms):.0f} ms | "
f"Total: {total_ms:.0f} ms | Model calls: 1"
)
yield answer + "\n\nSources\n" + "\n".join(source_lines) + "\n\n" + metrics
Prove the empty-index guard
The collection has no indexed PDF yet. This check reveals whether the pipeline detects that local state before spending a model request.
Before you run this, what do you expect the first generator update to report?
- Run the fast-path verification from the SimplyRAG repository folder:
python -c "from src.fast_pipeline import stream_answer; print(next(stream_answer('What is this document about?')))"
What should you see?
You should see No indexed documents yet. Upload and index a PDF first. The pipeline stops before the streaming request.
That is the fast path's first proof. Missing retrieval data now produces a precise local signal without consuming a Gemini call.
Seeing a different result?
If the output mentions the missing Gemini key, return to the shell where you confirmed the key exists. Run the check from that same active environment.
If the command reports a Python or import problem, compare both files with the full-code tabs. You can also ask for help with the verification command.
Your direct pipeline now guards local state before generation. Next up, you will repair PDF ingestion and give retrieval real evidence to search.
Repair and Streamline PDF Ingestion
The fast path correctly stopped before making a Gemini request because its Chroma collection was empty. That guard is already protecting your API budget.
The empty index now needs a working ingestion route. The committed src/multimodal.py module contains unresolved merge markers, so Python cannot reliably load it.
This repair uses PyMuPDF to extract text and tables on the CPU. Gemini image descriptions stay disabled by default to keep ingestion from adding remote calls.
In this step, get ready to:
- Repair the multimodal extraction module.
- Replace the ingestion loop so it processes every selected PDF.
- Index your test PDF and verify a positive chunk count.
Repair multimodal extraction
The multimodal module collects table chunks locally. Its image path remains available for cases where visual recall matters.
- In src/multimodal.py, select all of the existing content.
- Replace the selected content with this import and configuration block:
import os
import pymupdf
from google import genai
from google.genai import types
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.6-flash")
What This Block Adds
- The os module reads configuration from the current environment.
- PyMuPDF handles local PDF extraction.
- The Google Gen AI imports support optional image descriptions.
- GEMINI_MODEL keeps the model identifier configurable.
- Save src/multimodal.py.
- Confirm the file now begins with the import block and contains no merge markers.
Still See Merge Markers?
Select the entire original file before pasting the replacement block. Partial selection can leave a marker above or below the new code.
Help me remove the merge markers from src/multimodal.py.
- Add the image extraction function below the configuration block by pasting:
def extract_images_from_pdf(pdf_path):
images = []
document = pymupdf.open(pdf_path)
try:
for page_number, page in enumerate(document, start=1):
for image_index, image_info in enumerate(page.get_images(full=True)):
extracted = document.extract_image(image_info[0])
extension = extracted.get("ext", "png")
mime_type = "image/jpeg" if extension in {"jpg", "jpeg"} else f"image/{extension}"
images.append(
{
"page": page_number,
"image_index": image_index,
"bytes": extracted["image"],
"mime_type": mime_type,
}
)
finally:
document.close()
return images
How Image Extraction Works
- extract_images_from_pdf() opens the selected PDF and visits every page.
- get_images() finds the image references stored on each page.
- The returned dictionaries retain the page number, image index, bytes, and media type needed by the optional description path.
- The finally block closes the PDF even when extraction stops early.
- Save src/multimodal.py.
- Confirm extract_images_from_pdf() appears directly below GEMINI_MODEL.
Image Function Looks Incomplete?
Check that return images aligns with the opening function body. A misplaced indentation level can trap the return inside the page loop.
Help me check the indentation in extract_images_from_pdf().
- Add the table extraction function below extract_images_from_pdf() by pasting:
def extract_tables_from_pdf(pdf_path):
tables = []
document = pymupdf.open(pdf_path)
try:
for page_number, page in enumerate(document, start=1):
for table_index, table in enumerate(page.find_tables().tables):
tables.append(
{
"text": table.to_markdown(),
"metadata": {
"source": os.path.basename(pdf_path),
"page": page_number,
"content_type": "table",
"table_index": table_index,
},
}
)
finally:
document.close()
return tables
How Tables Become Searchable
- extract_tables_from_pdf() finds tables on every PDF page.
- to_markdown() turns each table into compact text that retrieval can search.
- The metadata records the source file, page number, content type, and table position.
- This path stays local, so table indexing does not spend a Gemini request.
- Save src/multimodal.py.
- Confirm the table metadata includes source, page, content_type, and table_index.
Table Metadata Missing?
Check that the metadata dictionary sits beside text in the appended table chunk. Nested metadata in the wrong place cannot be stored with the expected structure.
Help me compare my table chunk structure with extract_tables_from_pdf().
- Add the optional image description function below extract_tables_from_pdf() by pasting:
def describe_image(image_bytes, mime_type, page_context):
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
return ""
prompt = (
"Describe this image for document search. Include visible text, labels, "
f"numbers, and relationships. Context: {page_context}."
)
with genai.Client(api_key=api_key) as client:
response = client.models.generate_content(
model=GEMINI_MODEL,
contents=[
types.Part.from_bytes(data=image_bytes, mime_type=mime_type),
prompt,
],
)
return response.text or ""
Why Image Description Is Separate
- describe_image() returns an empty string when the Gemini key is unavailable.
- The prompt asks for searchable text, labels, numbers, and relationships from one image.
- The function creates its Gemini client only when an image description is requested.
- Separating this work keeps remote image processing outside the default table path.
- Save src/multimodal.py.
- Confirm describe_image() returns an empty string when GEMINI_API_KEY is unavailable.
Description Function Misaligned?
Check that the prompt closes before the client block begins. Also check that return response.text or "" remains inside the function.
Help me check describe_image().
- Add the multimodal coordinator below describe_image() by pasting:
def extract_multimodal_content(pdf_path):
enriched_chunks = extract_tables_from_pdf(pdf_path)
if os.getenv("ENABLE_IMAGE_DESCRIPTIONS", "0") != "1":
return enriched_chunks
for image in extract_images_from_pdf(pdf_path):
description = describe_image(
image["bytes"],
image["mime_type"],
f"Page {image['page']} of {os.path.basename(pdf_path)}",
)
if description:
enriched_chunks.append(
{
"text": description,
"metadata": {
"source": os.path.basename(pdf_path),
"page": image["page"],
"content_type": "image",
"image_index": image["image_index"],
},
}
)
return enriched_chunks
How the Default Path Stays Fast
- extract_multimodal_content() always starts with locally extracted table chunks.
- ENABLE_IMAGE_DESCRIPTIONS must equal 1 before image extraction begins.
- The default value 0 returns the table chunks immediately.
- Enabled image descriptions retain source, page, content type, and image position metadata.
- Save src/multimodal.py.
- Confirm the final function returns table chunks before image extraction when ENABLE_IMAGE_DESCRIPTIONS does not equal 1.
Images Run by Default?
Check the early condition around ENABLE_IMAGE_DESCRIPTIONS. The default value must remain 0.
Help me verify the default image-description switch in extract_multimodal_content().
✔️ Awesome, I've got everything!
Great. Double-check that you saved src/multimodal.py.
ⓧ I'd like to double check the full code
import os
import pymupdf
from google import genai
from google.genai import types
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.6-flash")
def extract_images_from_pdf(pdf_path):
images = []
document = pymupdf.open(pdf_path)
try:
for page_number, page in enumerate(document, start=1):
for image_index, image_info in enumerate(page.get_images(full=True)):
extracted = document.extract_image(image_info[0])
extension = extracted.get("ext", "png")
mime_type = "image/jpeg" if extension in {"jpg", "jpeg"} else f"image/{extension}"
images.append(
{
"page": page_number,
"image_index": image_index,
"bytes": extracted["image"],
"mime_type": mime_type,
}
)
finally:
document.close()
return images
def extract_tables_from_pdf(pdf_path):
tables = []
document = pymupdf.open(pdf_path)
try:
for page_number, page in enumerate(document, start=1):
for table_index, table in enumerate(page.find_tables().tables):
tables.append(
{
"text": table.to_markdown(),
"metadata": {
"source": os.path.basename(pdf_path),
"page": page_number,
"content_type": "table",
"table_index": table_index,
},
}
)
finally:
document.close()
return tables
def describe_image(image_bytes, mime_type, page_context):
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
return ""
prompt = (
"Describe this image for document search. Include visible text, labels, "
f"numbers, and relationships. Context: {page_context}."
)
with genai.Client(api_key=api_key) as client:
response = client.models.generate_content(
model=GEMINI_MODEL,
contents=[
types.Part.from_bytes(data=image_bytes, mime_type=mime_type),
prompt,
],
)
return response.text or ""
def extract_multimodal_content(pdf_path):
enriched_chunks = extract_tables_from_pdf(pdf_path)
if os.getenv("ENABLE_IMAGE_DESCRIPTIONS", "0") != "1":
return enriched_chunks
for image in extract_images_from_pdf(pdf_path):
description = describe_image(
image["bytes"],
image["mime_type"],
f"Page {image['page']} of {os.path.basename(pdf_path)}",
)
if description:
enriched_chunks.append(
{
"text": description,
"metadata": {
"source": os.path.basename(pdf_path),
"page": image["page"],
"content_type": "image",
"image_index": image["image_index"],
},
}
)
return enriched_chunks
Process every selected PDF
The ingestion module turns each PDF page into overlapping text chunks. It also adds the local table chunks from the repaired multimodal module.
The corrected loop accepts one PDF path or scans every PDF in data. This prevents directory ingestion from stopping after its first entry.
- In src/ingest.py, select all of the existing content.
- Replace the selected content with this import block:
import os
from pathlib import Path
import pymupdf
from src.multimodal import extract_multimodal_content
What the Imports Connect
- Path distinguishes a selected file from a directory.
- pymupdf reads text from each PDF page.
- extract_multimodal_content() contributes table chunks plus optional image descriptions.
- Save src/ingest.py.
- Confirm the file imports extract_multimodal_content() from src.multimodal.
Import Path Looks Different?
Check that the module path begins with src. The application runs from the repository folder, so the package path must match the existing project structure.
Help me check the imports in src/ingest.py.
- Add the page-loading function below the imports by pasting:
def load_pdf(file_path):
pages = []
document = pymupdf.open(file_path)
try:
for page_number, page in enumerate(document, start=1):
text = page.get_text().strip()
if text:
pages.append(
{
"text": text,
"source": os.path.basename(file_path),
"page": page_number,
}
)
finally:
document.close()
return pages
How Pages Keep Their Source
- load_pdf() visits each page in the selected PDF.
- Empty page text is skipped before a page record is created.
- Each retained page stores its text, source filename, and page number.
- The source metadata later lets the answer footer point back to the PDF.
- Save src/ingest.py.
- Confirm load_pdf() appends records only when extracted page text is present.
Page Records Look Empty?
Check that page.get_text().strip() runs before the empty-text condition. Also check that pages.append() remains inside that condition.
Help me inspect load_pdf().
- Add the text chunking function below load_pdf() by pasting:
def chunk_text(pages, chunk_size=512, overlap=50):
chunks = []
chunk_index = 0
step = chunk_size - overlap
for page in pages:
for start in range(0, len(page["text"]), step):
text = page["text"][start : start + chunk_size].strip()
if not text:
continue
chunks.append(
{
"text": text,
"metadata": {
"source": page["source"],
"page": page["page"],
"content_type": "text",
"chunk_index": chunk_index,
},
}
)
chunk_index += 1
return chunks
Why the Chunks Overlap
- chunk_text() creates text windows of 512 characters.
- The 50 character overlap preserves context near chunk boundaries.
- Every chunk retains source, page, content type, and a sequential chunk index.
- The resulting dictionaries match the structure expected by store_chunks().
- Save src/ingest.py.
- Confirm the chunk metadata sets content_type to text.
Chunk Loop Looks Misaligned?
Check that chunk_index += 1 aligns with chunks.append(). Keeping it inside the inner loop gives every stored text chunk a distinct position.
Help me check the loops in chunk_text().
- Add the ingestion coordinator below chunk_text() by pasting:
def ingest_pdf(path="data"):
source = Path(path)
if source.is_file():
pdf_paths = [source]
else:
source.mkdir(parents=True, exist_ok=True)
pdf_paths = sorted(source.glob("*.pdf"))
all_chunks = []
for pdf_path in pdf_paths:
all_chunks.extend(chunk_text(load_pdf(str(pdf_path))))
all_chunks.extend(extract_multimodal_content(str(pdf_path)))
return all_chunks
How the Coordinator Fixes Ingestion
- ingest_pdf() treats a direct file path as one selected PDF.
- A directory path creates the directory when needed before collecting its PDF files.
- The loop extends all_chunks with text chunks from every selected file.
- Each file also contributes its locally extracted table chunks.
- Save src/ingest.py.
- Confirm return all_chunks appears after the PDF loop.
Only One PDF Gets Processed?
Check the indentation of return all_chunks. A return inside the PDF loop stops directory ingestion after its first file.
Help me verify the loop in ingest_pdf().
✔️ Awesome, I've got everything!
Great. Double-check that you saved src/ingest.py.
ⓧ I'd like to double check the full code
import os
from pathlib import Path
import pymupdf
from src.multimodal import extract_multimodal_content
def load_pdf(file_path):
pages = []
document = pymupdf.open(file_path)
try:
for page_number, page in enumerate(document, start=1):
text = page.get_text().strip()
if text:
pages.append(
{
"text": text,
"source": os.path.basename(file_path),
"page": page_number,
}
)
finally:
document.close()
return pages
def chunk_text(pages, chunk_size=512, overlap=50):
chunks = []
chunk_index = 0
step = chunk_size - overlap
for page in pages:
for start in range(0, len(page["text"]), step):
text = page["text"][start : start + chunk_size].strip()
if not text:
continue
chunks.append(
{
"text": text,
"metadata": {
"source": page["source"],
"page": page["page"],
"content_type": "text",
"chunk_index": chunk_index,
},
}
)
chunk_index += 1
return chunks
def ingest_pdf(path="data"):
source = Path(path)
if source.is_file():
pdf_paths = [source]
else:
source.mkdir(parents=True, exist_ok=True)
pdf_paths = sorted(source.glob("*.pdf"))
all_chunks = []
for pdf_path in pdf_paths:
all_chunks.extend(chunk_text(load_pdf(str(pdf_path))))
all_chunks.extend(extract_multimodal_content(str(pdf_path)))
return all_chunks
Index your test PDF
The repaired modules now produce the chunk dictionaries expected by the persistent collection. Your test PDF provides the first real evidence for the one-call fast path.
- Create a folder named data in the existing SimplyRAG repository with your editor's file sidebar if the folder is missing.
- Drag your selected small test PDF into data with your editor's file sidebar.
- Rename the copied PDF to sample.pdf.
- Confirm the resulting repository path is data/sample.pdf.
Before you run the check, do you expect the repaired ingestion loop to return zero chunks or a positive count?
- Index data/sample.pdf into the persistent collection by running this command in the active Python environment:
python -c "from src.ingest import ingest_pdf; from src.embeddings import store_chunks; print(store_chunks(ingest_pdf('data/sample.pdf')))"
What Should You See?
The command ingests the PDF before passing every generated chunk to store_chunks(). The final printed line should be an integer greater than 0.
That positive count proves the text and table content reached the persistent documents collection.
- Confirm the final printed integer is greater than 0.
Still See a Zero Count?
Check that data/sample.pdf contains selectable text or detectable tables. A scanned image-only PDF produces no default chunks because image descriptions remain disabled.
Check that the filename is exactly sample.pdf inside the repository's data folder.
Help me diagnose a zero chunk count from data/sample.pdf.
You have repaired the ingestion path and filled the persistent collection with searchable PDF evidence. Next, you will stream answers through the Gradio interface.
Stream Answers Through Gradio
Your PDF chunks are now stored in Chroma. The one-call pipeline has evidence to retrieve.
However, a terminal generator is awkward to demo. Gradio can pass each Gemini chunk into one chat reply as it arrives.
The repository's original node-update stream exposed completed graph states. This interface displays the model's text progressively.
In this step, get ready to:
- Create a local Gradio interface for the fast pipeline.
- Index data/sample.pdf from the interface.
- Verify the prepared question through the final timing footer.
Create the Gradio application
The interface needs two small handlers. One indexes the selected PDF. The other passes Gradio's page-specific chat history into the existing stream.
- Select src/app.py in your code editor.
- Replace its contents with these imports and handlers:
import gradio as gr
from src.embeddings import store_chunks
from src.fast_pipeline import stream_answer
from src.ingest import ingest_pdf
def index_pdf(file_path):
if not file_path:
return "Choose a PDF before indexing."
chunks = ingest_pdf(file_path)
stored = store_chunks(chunks)
if stored == 0:
return "No extractable text or tables were found."
return f"Indexed {stored} chunks. You can now ask questions."
def respond(message, history):
yield from stream_answer(message, history)
What do these handlers do?
- The index_pdf() handler sends the selected file path through the existing ingestion flow.
- The stored value becomes the positive chunk count shown on the page.
- The respond() handler yields each update produced by stream_answer().
- The history parameter belongs to the current page session.
- Save src/app.py.
- Confirm the file currently ends with yield from stream_answer(message, history).
Seeing editor errors?
- Confirm your editor is using the active Python environment.
- Compare the three project imports with the file names inside src.
Ask for help with the handler imports..
A Gradio Blocks application groups the upload controls with the chat interface. The queue keeps CPU-heavy requests from piling up inside this local demo.
- Complete src/app.py by pasting this code below respond():
with gr.Blocks() as demo:
gr.Markdown(
"# SimplyRAG Fast Path\n"
"One-call document answers optimized for Hugging Face CPU Basic."
)
with gr.Row():
pdf_file = gr.File(label="PDF document", file_types=[".pdf"], type="filepath")
with gr.Column():
index_button = gr.Button("Index PDF", variant="primary")
index_status = gr.Markdown("No PDF indexed in this runtime yet.")
index_button.click(fn=index_pdf, inputs=pdf_file, outputs=index_status, concurrency_limit=1)
chatbot = gr.Chatbot(type="messages")
gr.ChatInterface(
fn=respond,
chatbot=chatbot,
examples=["Summarize the document.", "What are the key findings?", "Which values are highest in the table?"],
concurrency_limit=1,
api_name="chat",
)
demo.queue(max_size=8)
if __name__ == "__main__":
demo.launch()
How does the interface stream?
- The PDF document component provides a file path to index_pdf().
- The Index PDF event sends the returned status to index_status.
- The messages-format chatbot supplies the structured history expected by the fast pipeline.
- The generator yields become progressive updates to the current reply.
- The queue limits concurrent work for the CPU-hosted application.
- Save src/app.py.
- Launch the local application by running this command:
python -m src.app
What does this command do?
The command runs src.app as the main Python module. The process keeps the terminal occupied while the local server is active.
- Keep the process running during the remaining checks.
- Press Ctrl+C when you finish the local session.
You'll see the local address http://localhost:7860 in the terminal. That launch turns your generator into a local browser demo.
Local page not loading?
- Confirm the terminal process remains running.
- Check the terminal for the local address printed during startup.
- Confirm the application started from the active Python environment.
Ask for help with the Gradio launch..
✔️ Awesome, I've got everything!
Your handlers and interface are in place. The local server is ready for a PDF.
ⓧ I'd like to double check the full code
import gradio as gr
from src.embeddings import store_chunks
from src.fast_pipeline import stream_answer
from src.ingest import ingest_pdf
def index_pdf(file_path):
if not file_path:
return "Choose a PDF before indexing."
chunks = ingest_pdf(file_path)
stored = store_chunks(chunks)
if stored == 0:
return "No extractable text or tables were found."
return f"Indexed {stored} chunks. You can now ask questions."
def respond(message, history):
yield from stream_answer(message, history)
with gr.Blocks() as demo:
gr.Markdown(
"# SimplyRAG Fast Path\n"
"One-call document answers optimized for Hugging Face CPU Basic."
)
with gr.Row():
pdf_file = gr.File(label="PDF document", file_types=[".pdf"], type="filepath")
with gr.Column():
index_button = gr.Button("Index PDF", variant="primary")
index_status = gr.Markdown("No PDF indexed in this runtime yet.")
index_button.click(fn=index_pdf, inputs=pdf_file, outputs=index_status, concurrency_limit=1)
chatbot = gr.Chatbot(type="messages")
gr.ChatInterface(
fn=respond,
chatbot=chatbot,
examples=["Summarize the document.", "What are the key findings?", "Which values are highest in the table?"],
concurrency_limit=1,
api_name="chat",
)
demo.queue(max_size=8)
if __name__ == "__main__":
demo.launch()
Index the sample PDF
The upload event exercises the same ingestion functions you repaired earlier. Its status message gives you an immediate check that the browser supplied a usable file path.
- Navigate to http://localhost:7860 in your browser.
- Select data/sample.pdf with the PDF document control.
- Select Index PDF.
You'll see a positive chunk count in the status message. That upload path now works from the browser.
What does the chunk count prove?
The count proves that index_pdf() received the selected path. It also proves that the resulting chunks reached the persistent documents collection.
No positive chunk count?
- Reselect data/sample.pdf if the status asks for a PDF.
- Confirm the selected file contains extractable text or tables.
- Review the running terminal for an ingestion failure.
Ask for help with the upload event..
Verify progressive answers
The chat interface passes the current page history to respond(). Each value yielded by the fast pipeline updates the same assistant reply.
Before you send your question, predict which appears first: the answer text or the final footer.
- Enter your prepared question in the chat input.
- Submit the question by pressing Enter.
You'll see the answer grow in place while Gemini produces chunks. The completed reply then displays numbered Sources.
You'll also see a footer containing Intent, Retrieval, First token, Total, and Model calls: 1.
What does the footer show?
The first-token timing measures how long the learner waits before text begins to appear. The total timing measures the complete request.
The one-call count confirms that local intent routing and retrieval did not add extra Gemini requests. The numbered sources preserve the evidence used for the answer.
Answer not streaming?
- Confirm respond() uses yield from stream_answer(message, history).
- Restart the application from the shell where GEMINI_API_KEY remains available.
- Keep the browser page open until the final footer arrives.
Ask for help with progressive output..
Your local interface now streams cited answers with visible latency metrics. Next, you'll deploy the same fast path to Hugging Face Spaces CPU Basic.
Deploy and Measure on CPU Basic
Your local Gradio app already streams cited answers with timing metrics. The final proof comes from running that same path on Hugging Face Spaces with CPU Basic.
A local speedup remains unproven until the one-call path survives the two-vCPU environment. The deployed app also needs access to Gemini without placing your key in the repository.
In this step, get ready to:
- Configure your existing Space for CPU Basic.
- Deploy the fast path through a Space-connected Git remote.
- Measure initial and warmed-up response timings.
Protect the Space configuration
Hardware settings can feel risky. CPU Basic has no hourly hardware charge.
- Return to your existing Space page in Hugging Face Spaces.
- Select Settings on the Space page.
- Confirm that the current hardware shows CPU Basic.
- Add GEMINI_API_KEY as a Space secret.
- Paste your existing key into the secret value field.
- Save the secret through the Settings form.
- Confirm that the secret entry exists without revealing its value.
Why use a Space secret?
Hugging Face exposes Space secrets to the application as environment variables. Your existing code can read GEMINI_API_KEY without storing the key in a tracked file.
Secret not available to the Space?
- Check that the secret name uses the exact uppercase spelling GEMINI_API_KEY.
- Check that you saved the secret in the same Space you plan to rebuild.
- Keep the key out of requirements.txt and every file under src/.
Help me diagnose why my Hugging Face Space cannot access GEMINI_API_KEY.
Commit and push the fast path
A Git remote determines where your commit is pushed. Checking it first prevents the optimized files from landing only on GitHub.
Remote URLs can be fiddly to read. Look for one that identifies your existing Hugging Face Space.
- Inspect the repository's configured remotes by running this command:
git remote -v
What does this command show?
The command lists the destinations configured for fetching and pushing. A Space-connected destination lets your next push trigger the deployment rebuild.
✔️ A remote points to my Space
Your current clone can deploy directly to the existing Space.
- Continue in this clone for the staging and commit commands below.
ⓧ No remote points to my Space
Your current clone cannot deploy directly to the Space yet.
- Return to your existing Space repository page.
- Use the Space's documented clone instructions with your existing authenticated workflow.
- Copy requirements.txt, src/embeddings.py, src/ingest.py, src/multimodal.py, src/fast_pipeline.py, and src/app.py into the matching paths in the Space clone.
- Continue from the Space clone for the staging and commit commands below.
- Create the deployment commit by running these commands:
git add requirements.txt src/embeddings.py src/ingest.py src/multimodal.py src/fast_pipeline.py src/app.py
git commit -m "Add a streamed CPU fast path"
What do these commands record?
- The first command stages the six optimized project files for the next commit.
- The second command records those staged versions under the message Add a streamed CPU fast path.
- Review the terminal's commit summary.
You should see a new commit recorded with the message Add a streamed CPU fast path.
Commit not created?
- Check that you are running the commands inside the clone that contains requirements.txt and the src/ folder.
- Check that each staged path uses the same spelling shown in the command.
- Confirm that your Git identity is configured if Git asks who created the commit.
Help me diagnose why Git did not create my deployment commit.
Before you push, what do you expect the Space to do when it receives this commit?
- Push the deployment commit through the configured Space remote by running this command:
git push
What happens after the push?
Hugging Face automatically rebuilds the Space when a new commit arrives. The rebuild installs the pinned dependencies before restarting the application.
- Wait for the push to finish in the terminal.
You should see the push complete without an authentication or remote error. The Space now has the commit that starts its automatic rebuild.
Push rejected?
- Check that the selected remote belongs to the Space you configured earlier.
- Use your existing authenticated Hugging Face Git workflow if the push requests authorization.
- Use the Space's documented clone workflow if the current repository only targets GitHub.
Help me diagnose why my commit did not reach my Hugging Face Space.
Measure cold and warm responses
The first request can include model-loading work. A repeated request provides a warmer comparison for the fast path itself.
- Return to your existing Space page.
- Wait for the automatic rebuild to complete.
You should see the deployed PDF document upload control. You should also see the chat interface from your local app.
Remember the runtime storage limit
CPU Basic uses non-persistent runtime storage. The deployed Chroma index may disappear after a Space restart.
Uploading the PDF again rebuilds the deployed index. Your local chroma_db collection remains separate.
- Upload data/sample.pdf through the PDF document control.
- Select Index PDF.
- Confirm that the status reports a positive indexed chunk count.
Before you submit the prepared question, do you expect the deployed response to arrive all at once or grow progressively?
- Submit the same prepared question through the deployed chat interface.
You'll see the answer grow in place before the final response appears. The completed response includes numbered Sources plus a footer ending with Model calls: 1.
- Record the initial First token value as initial first-token timing.
- Record the initial Total value as initial total timing.
Your Space is now serving the optimized path. Progressive output and the one-call footer both work outside your local environment.
The warmed-up repeat helps separate startup work from repeated question latency. Use the same PDF plus the same question so the comparison stays focused.
- Submit the same prepared question one more time.
- Record the repeated First token value as warm first-token timing.
- Record the repeated Total value as warm total timing.
- Compare initial first-token timing with warm first-token timing.
- Compare initial total timing with warm total timing.
The repeated answer should still stream cited content. Its final footer should still report Model calls: 1.
You now have a deployed CPU Basic fast path plus cold and warm measurements. Those results show how the one-call design behaves under the Space's real compute limits.
Secret mission
Add a Lazy Quality Mode
Give SimplyRAG an optional precision boost without slowing every request. Quality mode retrieves eight candidates, reranks them on CPU, and keeps the one-call Gemini generation path.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep, pause, or delete the resources from this project. Your deployment on Hugging Face Spaces CPU Basic has no hourly hardware charge.
Cost warning
Standard Free Tier use of Gemini is free of charge.
Paid Standard accounts are billed by token usage. Choose Pause or Delete if you have finished testing the app.
Resources you used:
- The local chroma_db directory containing your persistent Chroma documents collection.
- The local data/sample.pdf test document.
- The rebuilt Hugging Face Space on CPU Basic.
- The GEMINI_API_KEY Space secret.
What About Packages and Model Caches?
This project installed packages into your existing Python environment. It also downloaded model files into that environment's cache.
Their exact locations depend on your existing environment, so the cleanup options below leave them untouched.
Keep everything running
Nothing needs to change. Choose this option if you plan to keep testing or demonstrating the deployed app.
- Leave the CPU Basic Space available for future document questions.
- Keep GEMINI_API_KEY stored as a Space secret.
- Retain chroma_db for future local questions.
- Retain data/sample.pdf for repeatable timing comparisons.
Remember the Runtime Index
The deployed Space uses non-persistent runtime disk. You may need to index the PDF again after the Space restarts.
Pause - I'll come back to this later
Pause the Space when you want to preserve its code without keeping the app available. Your local files remain ready for later tests.
- Return to the Settings page for your deployed Space.
- Select the option to pause the Space.
- Keep chroma_db in the local repository.
- Keep data/sample.pdf in the data directory.
- Resume the Space when you are ready to test it again.
- Index the PDF again if the previous runtime index is gone.
Delete - I don't want to use this again
Remove the deployed Space and local test data if you no longer need this project.
Save What You Need First
Deleting the Space permanently removes its deployed repository state. Removing the local files clears the indexed chunks and the test PDF copy.
- Copy any timing footers you want to keep.
- Return to the Settings page for your deployed Hugging Face Space.
- Remove GEMINI_API_KEY from the Space secrets list.
- Find the Space deletion controls on the same Settings page.
- Choose the option to delete the Space.
- Confirm the Space deletion.
Local cleanup differs between macOS and Windows. Use the tab for your computer.
macOS
- Press Cmd+Space to open the search bar.
- Type Finder into the search bar.
- Press Enter.
- Return to the local SimplyRAG repository from earlier.
- Move chroma_db to the Trash.
- Move data/sample.pdf to the Trash.
- Confirm that neither path appears in the repository.
- Empty the Trash when you are ready to remove both items permanently.
Windows
- Press the Windows key to open search.
- Type File Explorer into the search field.
- Press Enter.
- Return to the local SimplyRAG repository from earlier.
- Move chroma_db to the Recycle Bin.
- Move data/sample.pdf to the Recycle Bin.
- Confirm that neither path appears in the repository.
- Empty the Recycle Bin when you are ready to remove both items permanently.
Cannot Find the Local Files?
The chroma_db directory belongs in the local SimplyRAG repository. The test document appears at data/sample.pdf.
If either path is missing, you may be viewing a different clone. Help me locate the cleanup paths.
Nice Work!
Nice Work!
You did it! SimplyRAG now delivers streamed document answers through a one-call fast path on Hugging Face Spaces CPU Basic.
Your PDF answers now appear progressively with numbered sources. The timing footer makes the fast path measurable on constrained CPU hardware.
You've learned how to:
- Build a one-call RAG path that classifies intent locally. It retrieves three passages within a fixed context budget before making one Gemini generation request.
- Repair PDF ingestion so every selected document contributes text chunks plus locally extracted tables. Store those chunks in a persistent Chroma collection for repeatable retrieval.
- Stream model output through a Gradio chat interface with numbered citations. Display retrieval time, first-token time, total time, plus the model-call count. Deploy the same workflow to CPU Basic for a real performance comparison.
- Secret Mission: Add a lazy-loaded CrossEncoder quality mode that reranks eight candidates locally. Keep the best three passages for the prompt. Preserve fast mode as the default while retaining one generation request.
Ready to quiz yourself?