Spaces:
Sleeping
Sleeping
| """ | |
| RAG Document Q&A | |
| ---------------- | |
| Upload documents (PDF / .txt) or use the built-in sample job postings, then ask | |
| questions. Answers are generated by an LLM but grounded ONLY in the retrieved | |
| passages, with inline [n] citations back to the source. | |
| Pipeline: ingest -> chunk -> embed (sentence-transformers) -> index (FAISS) | |
| -> retrieve (cosine top-k) -> generate (Groq, OpenAI-compatible API) | |
| Everything except the final generation runs locally on the free Spaces CPU. | |
| Generation is a single API call to Groq's free tier (no credit card). | |
| """ | |
| import os | |
| import numpy as np | |
| import gradio as gr | |
| from sentence_transformers import SentenceTransformer | |
| import faiss | |
| from pypdf import PdfReader | |
| from openai import OpenAI | |
| # ----------------------------- Config -------------------------------------- | |
| EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2" # ~80 MB, runs on CPU | |
| GROQ_MODEL = "openai/gpt-oss-20b" # current Groq production model. | |
| # Model IDs change. List active ones any time at: | |
| # https://console.groq.com/docs/models (or GET /openai/v1/models) | |
| CHUNK_SIZE = 200 # words per chunk | |
| CHUNK_OVERLAP = 40 # words shared between neighbouring chunks | |
| TOP_K = 4 # passages retrieved per question | |
| # Load the embedding model once at startup (reused for every request). | |
| embedder = SentenceTransformer(EMBED_MODEL) | |
| def get_llm_client(): | |
| """Return a Groq client via the OpenAI-compatible API, or None if no key.""" | |
| key = os.environ.get("GROQ_API_KEY") | |
| if not key: | |
| return None | |
| return OpenAI(api_key=key, base_url="https://api.groq.com/openai/v1") | |
| # ------------------- Sample corpus (so the demo works instantly) ----------- | |
| # Three short, realistic working-student postings with overlapping and | |
| # distinct skills, so the example questions return interesting answers. | |
| SAMPLE_DOCS = { | |
| "werkstudent_data_science_munich.txt": ( | |
| "Working Student Data Science (m/w/d), Munich. You will support our team " | |
| "in building data pipelines and prototyping machine learning models for " | |
| "product analytics. Requirements: currently enrolled in computer science, " | |
| "data science, statistics or a related field. Strong Python skills and " | |
| "solid knowledge of SQL. Experience with pandas and scikit-learn. " | |
| "Familiarity with a cloud platform (AWS preferred) is a plus. English is " | |
| "our working language; German is advantageous but not required. " | |
| "16 hours per week, hybrid." | |
| ), | |
| "ml_engineer_working_student_berlin.txt": ( | |
| "Machine Learning Working Student (m/f/d), Berlin. Join us to bring models " | |
| "into production. You will work on deep learning models using PyTorch, help " | |
| "deploy them as APIs, and experiment with large language models and " | |
| "retrieval-augmented generation. Requirements: completed coursework in " | |
| "machine learning or neural networks, good Python, hands-on experience with " | |
| "PyTorch or TensorFlow, and Git. Bonus: Hugging Face Transformers, Docker, " | |
| "AWS SageMaker. Fluent English required. Fully remote within Germany." | |
| ), | |
| "data_analyst_working_student_frankfurt.txt": ( | |
| "Working Student Data Analyst (m/w/d), Frankfurt am Main. You will build " | |
| "dashboards and reports and derive insights from customer data. " | |
| "Requirements: enrolled in a quantitative field. Strong SQL, comfortable " | |
| "with Power BI or Tableau, and good Excel skills. Some Python for data " | |
| "cleaning is welcome. German at B2 level is required for this client-facing " | |
| "role; English is a plus. 20 hours per week, on-site." | |
| ), | |
| } | |
| # ------------------------------ Ingest ------------------------------------- | |
| def extract_file(path: str) -> str: | |
| """Read text from a PDF or plain-text file.""" | |
| if path.lower().endswith(".pdf"): | |
| reader = PdfReader(path) | |
| return "\n".join((page.extract_text() or "") for page in reader.pages) | |
| with open(path, "r", encoding="utf-8", errors="ignore") as f: | |
| return f.read() | |
| def chunk_text(text: str, source: str, | |
| size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP): | |
| """Split text into overlapping word windows, tagged with their source.""" | |
| words = text.split() | |
| chunks, start = [], 0 | |
| while start < len(words): | |
| end = start + size | |
| piece = " ".join(words[start:end]).strip() | |
| if piece: | |
| chunks.append({"text": piece, "source": source}) | |
| if end >= len(words): | |
| break | |
| start += size - overlap | |
| return chunks | |
| # ------------------------- Embed + index ----------------------------------- | |
| def build_index(chunks): | |
| """Embed chunks and build a cosine-similarity FAISS index.""" | |
| texts = [c["text"] for c in chunks] | |
| emb = embedder.encode(texts, convert_to_numpy=True, | |
| normalize_embeddings=True).astype(np.float32) | |
| index = faiss.IndexFlatIP(emb.shape[1]) # inner product on unit vectors = cosine | |
| index.add(emb) | |
| return index | |
| # ------------------------------ Retrieve ----------------------------------- | |
| def retrieve(query, index, chunks, k=TOP_K): | |
| q = embedder.encode([query], convert_to_numpy=True, | |
| normalize_embeddings=True).astype(np.float32) | |
| scores, idxs = index.search(q, min(k, len(chunks))) | |
| out = [] | |
| for score, i in zip(scores[0], idxs[0]): | |
| if i != -1: | |
| out.append({**chunks[i], "score": float(score)}) | |
| return out | |
| # ------------------------------ Generate ----------------------------------- | |
| def generate_answer(query, retrieved, client): | |
| context = "\n\n".join( | |
| f"[{i+1}] (source: {r['source']})\n{r['text']}" | |
| for i, r in enumerate(retrieved) | |
| ) | |
| system = ( | |
| "You answer questions using ONLY the numbered context passages provided. " | |
| "Cite the passages you rely on inline with their bracket numbers, e.g. [1] " | |
| "or [2][3]. If the answer is not contained in the context, say you don't " | |
| "have enough information rather than guessing." | |
| ) | |
| user = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer (with citations):" | |
| resp = client.chat.completions.create( | |
| model=GROQ_MODEL, | |
| messages=[{"role": "system", "content": system}, | |
| {"role": "user", "content": user}], | |
| temperature=0.2, | |
| max_tokens=600, | |
| ) | |
| return resp.choices[0].message.content | |
| # --------------------------- Gradio handlers ------------------------------- | |
| def do_build(files): | |
| """Build (or rebuild) the index from uploaded files, or the sample corpus.""" | |
| all_chunks = [] | |
| if files: | |
| for path in files: | |
| all_chunks += chunk_text(extract_file(path), os.path.basename(path)) | |
| n_docs = len(files) | |
| else: | |
| for name, text in SAMPLE_DOCS.items(): | |
| all_chunks += chunk_text(text, name) | |
| n_docs = len(SAMPLE_DOCS) | |
| if not all_chunks: | |
| return None, "No readable text found. Try a different file." | |
| index = build_index(all_chunks) | |
| state = {"index": index, "chunks": all_chunks} | |
| src = "uploaded document(s)" if files else "built-in sample job postings" | |
| return state, f"Indexed {len(all_chunks)} chunks from {n_docs} {src}. Ask away below." | |
| def do_ask(query, state): | |
| if not state: | |
| return "Build the index first (the sample corpus loads automatically).", "" | |
| if not query.strip(): | |
| return "Please enter a question.", "" | |
| retrieved = retrieve(query, state["index"], state["chunks"]) | |
| sources_md = "\n\n".join( | |
| f"**[{i+1}]** *{r['source']}* β similarity {r['score']:.2f}\n\n> {r['text'][:300]}β¦" | |
| for i, r in enumerate(retrieved) | |
| ) | |
| client = get_llm_client() | |
| if client is None: | |
| return ( | |
| "**No `GROQ_API_KEY` set**, so generation is off β but retrieval works: " | |
| "the relevant passages are shown under *Retrieved sources*. Add a free " | |
| "Groq key as a Space secret to enable generated answers.", | |
| sources_md, | |
| ) | |
| try: | |
| answer = generate_answer(query, retrieved, client) | |
| except Exception as e: # surface API errors instead of crashing the UI | |
| answer = f"Generation error: {e}\n\n(The retrieved passages are still shown below.)" | |
| return answer, sources_md | |
| # ------------------------------- UI ---------------------------------------- | |
| with gr.Blocks(title="RAG Document Q&A") as demo: | |
| gr.Markdown( | |
| "# π RAG Document Q&A\n" | |
| "Ask questions and get answers **grounded in your documents**, with inline " | |
| "`[n]` citations. Upload your own PDFs/text, or just use the built-in " | |
| "sample job postings (loaded automatically).\n\n" | |
| "*Retrieval (embeddings + FAISS) runs locally; only the final answer is " | |
| "generated via Groq's free API.*" | |
| ) | |
| state = gr.State() | |
| with gr.Row(): | |
| files = gr.File( | |
| label="Upload PDF or .txt (optional β leave empty to use sample postings)", | |
| file_count="multiple", file_types=[".pdf", ".txt"], type="filepath", | |
| ) | |
| build_btn = gr.Button("Build / Rebuild index", variant="secondary") | |
| status = gr.Markdown() | |
| query = gr.Textbox(label="Your question", lines=2, | |
| placeholder="e.g. What skills do these data science roles have in common?") | |
| ask_btn = gr.Button("Ask", variant="primary") | |
| answer = gr.Markdown() | |
| with gr.Accordion("Retrieved sources", open=False): | |
| sources = gr.Markdown() | |
| gr.Examples( | |
| examples=[ | |
| "What skills do these roles have in common?", | |
| "Which positions require cloud or AWS experience?", | |
| "Do any of these jobs need German language skills?", | |
| "Which role is the best fit for someone strong in PyTorch?", | |
| ], | |
| inputs=query, | |
| ) | |
| build_btn.click(do_build, inputs=[files], outputs=[state, status]) | |
| ask_btn.click(do_ask, inputs=[query, state], outputs=[answer, sources]) | |
| # Auto-build the sample corpus on load so the demo is never an empty box. | |
| demo.load(lambda: do_build(None), outputs=[state, status]) | |
| if __name__ == "__main__": | |
| demo.launch() | |