A Retrieval-Augmented Generation system that routes between a local FAISS knowledge base and live web search based on confidence scoring, built with LangGraph, Qwen 2.5, and Tavily - deployed free on HuggingFace Spaces using ZeroGPU.
This system answers questions by first checking a local FAISS vector store for relevant, previously-indexed knowledge. If the best match falls below a confidence threshold, it falls back to a live Tavily web search and indexes those results into FAISS so the next related question is answered from cache instead of hitting the API again.
Every response includes conversation-aware follow-up handling (a bare "who scored?" after a football question resolves correctly) and deterministically-extracted source citations regardless of whether the underlying LLM remembers to cite inline.
"What was the Brazil vs Haiti World Cup 2026 result?"
-> FAISS empty -> Tavily search -> indexed -> answered, cited
"Who scored the goals?"
-> follow-up detected -> enriched query -> FAISS hit (cached) -> answered
User Query
β
βΌ
βββββββββββββββββββββββ
β retrieve_node β FAISS similarity search
β (confidence score) β sim = 1 / (1 + L2_distance)
ββββββββββββ¬βββββββββββ
β
sim β₯ 0.60?
ββββββββββββ΄βββββββββββ
β β
YES NO
β β
β βΌ
β βββββββββββββββββββββββ
β β web_node β Tavily search + FAISS indexing
β β (index + answer) β
β ββββββββββββ¬βββββββββββ
β β
ββββββββββββ¬βββββββββββ
βΌ
βββββββββββββββββββββββ
β answer_node β Qwen 2.5, greedy decoding + deterministic citations
β (history-aware) β
ββββββββββββ¬βββββββββββ
βΌ
Answer + Sources
A clean three-node LangGraph StateGraph - no ReAct prompting, no regex parsing of model output. Routing is a single confidence-score comparison, and generation is deterministic (greedy decoding) for reproducible, citation-grounded answers.
This repository intentionally keeps three notebooks, each representing a real stage of the build β not a cleaned-up final draft. The middle notebook documents an approach that didn't ship; it's kept because the reasoning behind abandoning it is as informative as the approach that worked.
| Notebook | Environment | Outcome |
|---|---|---|
01_agentic_rag_notebook_version |
Google Colab, free T4 GPU | β Working - Qwen 2.5-7B, 4-bit quantized, full pipeline |
02_agentic_rag_hf_cpu_deployment |
HuggingFace Spaces, CPU Basic | |
03_agentic_rag_hf_zerogpu_deployment |
HuggingFace Spaces, ZeroGPU | β
Deployed - Qwen 2.5-7B restored, @spaces.GPU |
Why three attempts, not one:
-
Notebook 01 was developed and debugged on Colab's free T4 GPU: Qwen 2.5-7B-Instruct with 4-bit NF4 quantization (
bitsandbytes), a Gradio UI with real token streaming viaTextIteratorStreamer, and FAISS-backed session memory. This is the reference implementation. -
Notebook 02 targeted HuggingFace Spaces' CPU Basic tier for free hosting. This required downgrading to Qwen 2.5-3B (no GPU means no 4-bit quantization), removing
torch.compile, and switching the streaming approach entirely - frommodel.generate()+ a background thread to a simpler "generate complete response, then simulate streaming word-by-word" pattern, after the threaded approach caused multi-minute hangs fromtorch.compilerecompiling on every call. Mid-deployment, HuggingFace changed its free-tier policy: CPU Basic now requires a PRO subscription for Gradio/Docker Spaces. Thecreate_repo()call returned402 Payment Required. -
Notebook 03 pivoted to ZeroGPU β HuggingFace's free shared-GPU tier, which remained available without a subscription. This meant restoring the 7B model and 4-bit quantization, but required learning ZeroGPU's specific execution model: real GPU access only exists inside functions decorated with
@spaces.GPU, model weights must be placed oncudaat module scope (not lazily), andtorch.compileisn't supported on this tier either. The final architecture isolates GPU access to a single small function - everything else (retrieval, prompt building, history handling) runs on CPU and never competes for the shared GPU slot.
Each pivot was driven by a concrete constraint, not a style preference - which is why all three are documented here rather than only the one that shipped.
- Confidence-based routing - FAISS similarity scores decide whether a question is answered from cache or triggers a live web search
- Session memory β Tavily results are indexed into FAISS immediately, so repeated or related questions don't re-spend API credits
- Multi-turn conversation β a lightweight three-signal heuristic (short message + reference word + question starter) detects genuine follow-ups and enriches the retrieval query without polluting unrelated new questions
- Deterministic citations β source URLs are extracted directly from retrieved context and appended after every answer, independent of whether the model remembered to cite inline
- Live timing metrics β retrieval time, web search time, generation time, and tokens/sec are surfaced per query in the UI
- Graph-native pipeline β no ReAct scratchpad, no regex parsing of model output; routing is a single numeric comparison
| Component | Technology |
|---|---|
| Orchestration | LangGraph |
| Vector store | FAISS + sentence-transformers/all-MiniLM-L6-v2 |
| Web search | Tavily Search API |
| LLM | Qwen/Qwen2.5-7B-Instruct (4-bit NF4, bitsandbytes) |
| GPU access (deployed) | HuggingFace ZeroGPU (@spaces.GPU) |
| UI | Gradio (ChatInterface, streaming via sync generators) |
- Open
notebooks/01_agentic_rag_notebook_version.ipynbin Google Colab - Set the runtime to a T4 GPU (Runtime -> Change runtime type)
- Add your
TAVILY_API_KEYto Colab Secrets (key icon, left sidebar) - Run all cells - the last cell launches a Gradio UI with a public link
Same with the notebook version, since all the modules are created and available in the notebook, all that needs to be set is the HuggingFace Hub and the Tavily API key. Also, the HuggingFace Space need to be set manually for the ZeroGPU tier, and to be loaded programmatically from the notebook setting using huggingface_hub.upload_file function. All are demonstrated and implemented in the notebook.
Note:
space/requirements.txtincludesbitsandbytesandspaces, both of which expect a CUDA-capable environment. Running this locally without a GPU will not work as-is - see02_agentic_rag_hf_cpu_deploymentfor a CPU-only reference (Qwen 2.5-3B, no quantization).
| Variable | Where | Purpose |
|---|---|---|
TAVILY_API_KEY |
Colab Secrets / HF Space secrets / env var | Web search |
CONFIDENCE_THRESHOLD |
config.py |
FAISS similarity cutoff for routing (default 0.60) |
MAX_PROMPT_TOKENS |
config.py |
Prompt token budget before left-trimming |
ZEROGPU_DURATION |
config.py |
Max seconds per @spaces.GPU call (ZeroGPU only) |
-
FAISS confidence scoring:
similarity_search_with_score()returns L2 distance, not similarity. Converting viasim = 1 / (1 + L2)gives a bounded(0, 1]score that's stable enough to threshold on directly. -
text_gen_pipeline()over directmodel.generate(): an earlier attempt calledmodel.generate()from a background thread withTextIteratorStreamerfor true token streaming. On atorch.compile-d model this triggered full JIT recompilation on the first direct call, hanging for several minutes. Routing through the HFpipeline()object avoided this entirely; streaming became "generate fully, then reveal word-by-word" instead of true token streaming β a reasonable trade for reliability. -
_safe_content()defensive helper: Gradio 6.x can store chat message content as either a plain string or a list of typed content blocks ([{"type": "text", "text": "..."}]), even in text-only conversations. Code that assumed.strip()always worked crashed on the second turn of every conversation until this was normalized in one place. -
Follow-up detection needs all three signals, not one: initially, any short message got the previous question prepended to its retrieval query. This polluted genuinely new questions ("What is machine learning?" right after a football question). Requiring a short message and a reference word and a question starter together turned out to reliably separate real follow-ups from new topics.
-
Module-scope GPU placement on ZeroGPU: counter-intuitively, model weights must be moved to
cudaat import time, not lazily inside the@spaces.GPU-decorated function -import spacespatchestorchto intercept that placement correctly even before a real GPU is attached.
- FAISS index is in-memory and resets on Space restart - no persistent vector store across sessions (by design, for a free-tier deployment)
- ZeroGPU is a shared resource; occasional queueing under load is expected
- Follow-up detection is a heuristic, not a learned classifier - it will occasionally misclassify an ambiguous short question
- Citations are extracted from retrieved context, not verified against the generated claim sentence-by-sentence
- Persistent vector store option (e.g., a hosted FAISS/pgvector backend)
- Evaluate swapping the follow-up heuristic for a lightweight classifier
- Add a
docs/write-up with benchmark numbers (retrieval time, tok/s, end-to-end latency) across all three deployment environments
This project is licensed under the Apache license 2.0.
see LICENSE for details.
Built by @Saint Jeane.