| import streamlit as st |
| import torch |
| import io |
| import re |
| import numpy as np |
| import pandas as pd |
| from threading import Thread |
|
|
| |
| from transformers.models.auto.tokenization_auto import AutoTokenizer |
| from transformers.models.auto.modeling_auto import AutoModelForCausalLM |
| from transformers.generation.streamers import TextIteratorStreamer |
|
|
| |
| from langchain_huggingface import HuggingFaceEmbeddings |
| from sentence_transformers import CrossEncoder |
|
|
| |
| from langchain_community.vectorstores import FAISS |
| from pypdf import PdfReader |
|
|
| |
| from rank_bm25 import BM25Okapi |
|
|
| |
| |
| |
| |
| try: |
| torch.set_num_threads(2) |
| torch.set_num_interop_threads(1) |
| except RuntimeError: |
| pass |
|
|
|
|
| |
| |
|
|
| st.set_page_config(page_title="Hybrid RAG + Reranker", page_icon="🔍", layout="wide") |
| st.title("🔍 Hybrid RAG + Reranker") |
|
|
| |
|
|
| with st.sidebar.expander("⚙️ How does this system work?", expanded=False): |
| st.markdown(""" |
| **Step 1 - Dual retrieval (hybrid)** |
| |
| When you ask a question, two search engines work in parallel over your chunks: |
| |
| - **BM25**: classic keyword search (like Google in the 1990s). Strong on names, dates, and exact terms. Very fast, no AI model. |
| - **FAISS**: semantic vector search. Understands meaning even when the words differ. |
| |
| Each returns its own **top 5 chunks** -> we merge them -> **10 candidate chunks**. |
| |
| --- |
| |
| **Step 2 - Reranking (cross-encoder)** |
| |
| A small model (~70M params) reads each *(question, chunk)* pair together and assigns a true relevance score. |
| |
| Unlike embeddings that encode the question and chunk **separately**, the cross-encoder reads them **together** -> it actually understands the relationship. |
| |
| Result: we keep the **2 best chunks** out of the 10. |
| |
| --- |
| |
| **Step 3 - Generation** |
| |
| The LLM receives only these 2 highly relevant chunks and generates the answer. One LLM call, clean context = precise and fast response. |
| """) |
|
|
| |
| @st.cache_resource(show_spinner="Loading models... (first run ~2 min)") |
| def load_models(): |
|
|
| |
| embeddings = HuggingFaceEmbeddings( |
| model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", |
| model_kwargs={'device': 'cpu'}, |
| encode_kwargs={'normalize_embeddings': True, 'batch_size': 16} |
| ) |
|
|
| |
| |
| |
| reranker = CrossEncoder( |
| "cross-encoder/ms-marco-MiniLM-L-6-v2", |
| max_length=512, |
| device="cpu" |
| ) |
|
|
| |
| model_id = "Qwen/Qwen2.5-0.5B-Instruct" |
| tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True) |
| |
|
|
| llm = AutoModelForCausalLM.from_pretrained( |
| model_id, |
| torch_dtype=torch.bfloat16, |
| low_cpu_mem_usage=True |
| ) |
| |
| |
| dummy = tokenizer("warmup", return_tensors="pt") |
| with torch.inference_mode(): |
| llm.generate(**dummy, max_new_tokens=1, pad_token_id=tokenizer.eos_token_id) |
|
|
| return embeddings, reranker, tokenizer, llm |
|
|
| embeddings, reranker, tokenizer, llm = load_models() |
|
|
| |
| EOS_IDS = [tokenizer.eos_token_id, tokenizer.convert_tokens_to_ids("<|im_end|>")] |
| MAX_TOKENS_ANSWER = 384 |
|
|
| |
| |
| |
| for key, default in [ |
| ("vector_db", None), |
| ("bm25", None), |
| ("chunks_text", None), |
| ("current_file_name", None) |
| ]: |
| if key not in st.session_state: |
| st.session_state[key] = default |
|
|
| |
| uploaded_files = st.sidebar.file_uploader("📂 Upload your PDFs (multiple allowed)", type=["pdf"], accept_multiple_files=True) |
|
|
| if not uploaded_files: |
| for key in ["vector_db", "bm25", "chunks_text", "current_file_names", "chunks_source"]: |
| st.session_state[key] = None |
|
|
| else: |
| |
| filenames = [f.name for f in uploaded_files] |
|
|
| |
| if st.session_state.get("current_file_names") != filenames: |
| for key in ["vector_db", "bm25", "chunks_text", "chunks_source"]: |
| st.session_state[key] = None |
| st.session_state.current_file_names = filenames |
|
|
| if st.session_state.vector_db is None: |
| with st.spinner("Indexing documents... ⏳"): |
| all_chunks = [] |
| all_sources = [] |
|
|
| |
| for f in uploaded_files: |
| try: |
| reader = PdfReader(io.BytesIO(f.getvalue())) |
| full_text = "\n".join(page.extract_text() or "" for page in reader.pages) |
| normalized_text = re.sub(r"\s+", " ", full_text).strip() |
|
|
| def split_text_into_chunks(text: str, chunk_size: int = 500, chunk_overlap: int = 200) -> list[str]: |
| if not text: |
| return [] |
|
|
| chunks = [] |
| start = 0 |
| text_length = len(text) |
| while start < text_length: |
| end = min(start + chunk_size, text_length) |
| chunks.append(text[start:end].strip()) |
| if end >= text_length: |
| break |
| start = max(end - chunk_overlap, 0) |
| return [chunk for chunk in chunks if chunk] |
|
|
| chunks = split_text_into_chunks(normalized_text) |
| all_chunks.extend(chunks) |
| all_sources.extend([f.name] * len(chunks)) |
| except Exception as e: |
| st.sidebar.warning(f"Could not process {f.name}: {e}") |
|
|
| |
| st.session_state.vector_db = FAISS.from_texts(all_chunks, embeddings) |
|
|
| |
| tokenized = [t.lower().split() for t in all_chunks] |
| st.session_state.bm25 = BM25Okapi(tokenized) |
| st.session_state.chunks_text = all_chunks |
| st.session_state.chunks_source = all_sources |
|
|
| st.sidebar.success(f"✅ Indexed {len(all_chunks)} chunks from {len(filenames)} files") |
|
|
|
|
| |
| scope_options = ["All files"] |
| if st.session_state.get("current_file_names"): |
| scope_options += st.session_state.current_file_names |
| selected_scope = st.sidebar.selectbox("Index scope", scope_options, index=0) |
| selected_file = None if selected_scope == "All files" else selected_scope |
|
|
|
|
|
|
|
|
| |
|
|
| def hybrid_search(question: str, k: int = 10, source_filter: str | None = None) -> list[str]: |
| """ |
| Hybrid search: BM25 + FAISS -> merge the results. |
| |
| Why k=5 for each? |
| We retrieve broadly on purpose (10 candidates total) |
| because the reranker will sort and keep the true best matches. |
| BM25 catches what FAISS misses (exact terms) and vice versa. |
| """ |
| question_lower = question.lower() |
|
|
| |
| bm25_scores = st.session_state.bm25.get_scores(question_lower.split()) |
| top_bm25_idx = np.argsort(bm25_scores)[::-1][:k] |
| bm25_results = [] |
| for i in top_bm25_idx: |
| text = st.session_state.chunks_text[i] |
| if source_filter is None or st.session_state.chunks_source[i] == source_filter: |
| bm25_results.append(text) |
|
|
| |
| faiss_docs = st.session_state.vector_db.similarity_search(question, k=k) |
| faiss_results = [] |
| for d in faiss_docs: |
| |
| text = getattr(d, 'page_content', str(d)) |
| |
| try: |
| idx = st.session_state.chunks_text.index(text) |
| source = st.session_state.chunks_source[idx] |
| except Exception: |
| source = None |
| if source_filter is None or source == source_filter: |
| faiss_results.append(text) |
|
|
| |
| seen = set() |
| merged = [] |
| for text in bm25_results + faiss_results: |
| if text not in seen: |
| seen.add(text) |
| merged.append(text) |
|
|
| return merged |
|
|
|
|
| def rerank(question: str, candidates: list[str], top_n: int = 2): |
| pairs = [(question, chunk) for chunk in candidates] |
| scores = reranker.predict(pairs) |
| ranked = sorted(zip(scores, candidates), reverse=True) |
| best_score = ranked[0][0] |
| best_chunks = [chunk for _, chunk in ranked[:top_n]] |
| return best_chunks, float(best_score) |
|
|
|
|
| def build_prompt(system: str, user: str) -> str: |
| return ( |
| f"<|im_start|>system\n{system}\n<|im_end|>\n" |
| f"<|im_start|>user\n{user}\n<|im_end|>\n" |
| f"<|im_start|>assistant\n" |
| ) |
|
|
|
|
| def stream_llm(prompt: str, placeholder) -> str: |
| """Streamed generation with an animated cursor.""" |
| inputs = tokenizer(prompt, return_tensors="pt") |
| streamer = TextIteratorStreamer( |
| tokenizer, skip_prompt=True, skip_special_tokens=True |
| ) |
|
|
| def _generate(): |
| with torch.inference_mode(): |
| llm.generate( |
| **inputs, |
| streamer=streamer, |
| max_new_tokens=MAX_TOKENS_ANSWER, |
| do_sample=False, |
| repetition_penalty=1.3, |
| pad_token_id=tokenizer.eos_token_id, |
| eos_token_id=EOS_IDS |
| ) |
|
|
| Thread(target=_generate).start() |
|
|
| full_text = "" |
| for token in streamer: |
| full_text += token |
| placeholder.markdown(full_text + "▌") |
| placeholder.markdown(full_text) |
| return full_text |
|
|
| def clean_chunk(text: str) -> str: |
| """Mark truncated chunks instead of sending them raw.""" |
| text = text.strip() |
| |
| if text and text[0].islower(): |
| text = "(...) " + text |
| return text |
|
|
|
|
| def classify_question_style(question: str) -> str: |
| question_lower = question.lower() |
|
|
| value_markers = [ |
| "value", "values", "calculate", "calculation", "compute", "how many", "how much", |
| "what is the amount", "report the number", "report the value", "percentage", "percent", |
| "%", "ratio", "emissions", "ghg", "co2", "scope 1", "scope 2", "scope 3", "tonnes", "tons" |
| ] |
| structured_markers = [ |
| "list", "provide", "state", "report", "identify", "name", "names", "describe", |
| "what are", "which", "give the", "enumerate", "include" |
| ] |
|
|
| if any(marker in question_lower for marker in value_markers): |
| return "value_only" |
| if any(marker in question_lower for marker in structured_markers): |
| return "structured" |
| return "paragraph" |
|
|
|
|
| def build_answer_system_prompt(answer_style: str) -> str: |
| if answer_style == "value_only": |
| return ( |
| "You are a precise extraction engine. " |
| "Return only the exact final value, number, percentage, or unit requested by the question. " |
| "Do not add explanation, labels, or narrative text. " |
| "If the answer requires a calculation, compute it from the context and return the final value only. " |
| "If the answer is not present, say: Information not found." |
| ) |
|
|
| if answer_style == "structured": |
| return ( |
| "You are a structured extractor. " |
| "Return a concise answer using short sentences or bullet points, preserving all requested facts. " |
| "Do not compress multiple requested fields into one vague paragraph. " |
| "If the question asks for several items, answer each item explicitly. " |
| "Do not omit numbers, names, dates, locations, or amounts that are relevant. " |
| "If the answer is not present, say: Information not found." |
| ) |
|
|
| return ( |
| "You are a fact extractor. " |
| "Use the context to answer fully and faithfully, without compressing away important facts. " |
| "If the answer contains multiple facts, include them all in a clear paragraph. " |
| "Do not omit numbers, names, dates, locations, or amounts that are relevant. " |
| "Never generate URLs, emails, menus, or language lists. " |
| "If the context contains '(...)', it is an incomplete sentence, so reconstruct the meaning. " |
| "If you cannot find the answer, say: Information not found." |
| ) |
|
|
|
|
| def build_answer_instruction(answer_style: str) -> str: |
| if answer_style == "value_only": |
| return ( |
| "Answer format: output only the final value, number, percentage, or unit. " |
| "Do not explain your reasoning." |
| ) |
| if answer_style == "structured": |
| return ( |
| "Answer format: use short structured points or a compact list, one item per requested field." |
| ) |
| return ( |
| "Answer format: provide a full, faithful paragraph with all relevant facts." |
| ) |
|
|
|
|
| def generate_answer(question: str, context: str, batch_mode: bool = False) -> str: |
| answer_style = classify_question_style(question) |
| prompt = build_prompt( |
| system=build_answer_system_prompt(answer_style), |
| user=( |
| f"CONTEXT:\n{context}\n\n" |
| f"QUESTION: {question}\n\n" |
| f"{build_answer_instruction(answer_style)}" |
| ) |
| ) |
|
|
| inputs = tokenizer(prompt, return_tensors="pt") |
| with torch.inference_mode(): |
| output = llm.generate( |
| **inputs, |
| max_new_tokens=MAX_TOKENS_ANSWER if batch_mode else MAX_TOKENS_ANSWER, |
| do_sample=False, |
| repetition_penalty=1.05 if answer_style == "value_only" else 1.15, |
| pad_token_id=tokenizer.eos_token_id, |
| eos_token_id=EOS_IDS |
| ) |
|
|
| generated_tokens = output[0][inputs["input_ids"].shape[-1]:] |
| answer_text = tokenizer.decode(generated_tokens, skip_special_tokens=True).strip() |
| return answer_text |
|
|
| |
| |
| |
| if st.session_state.vector_db is not None: |
|
|
| question = st.text_input( |
| "Ask your question:", |
| placeholder="Type a question" |
| ) |
|
|
| if st.button("Run analysis", type="primary") and question: |
| st.markdown("---") |
|
|
| |
| with st.spinner("🔎 Hybrid retrieval (BM25 + vector search)..."): |
| candidates = hybrid_search(question, k=5, source_filter=selected_file) |
|
|
| |
| with st.expander(f"📚 {len(candidates)} candidate chunks retrieved (before reranking)"): |
| for i, c in enumerate(candidates): |
| st.caption(f"**Chunk {i+1}**: {c[:150]}...") |
|
|
| |
| with st.spinner("⚖️ Reranking (selecting the 2 best chunks)..."): |
| best_chunks, confidence = rerank(question, candidates, top_n=2) |
|
|
| st.caption(f"Relevance score: `{confidence:.2f}`") |
| |
| if confidence < -2.0: |
| st.warning("❌ No relevant passage found for this question.") |
| st.stop() |
|
|
| with st.expander("✅ 2 chunks kept after reranking"): |
| for i, c in enumerate(best_chunks): |
| st.info(f"**Chunk {i+1}**: {c}") |
|
|
| |
| context = "\n\n".join([clean_chunk(c) for c in best_chunks]) |
|
|
| st.markdown("### 🎯 Answer:") |
| placeholder = st.empty() |
| placeholder.markdown(generate_answer(question, context)) |
|
|
| |
| |
| |
| st.markdown("---") |
| st.markdown("## 📋 Batch Analysis: All Indicators") |
|
|
| @st.cache_resource(show_spinner=False) |
| def load_indicators(): |
| """Parse the indicators and questions from the file.""" |
| indicators = [] |
| try: |
| with open("all_indicators_with_questions - Copy.txt", "r", encoding="utf-8") as f: |
| lines = f.readlines() |
| |
| i = 0 |
| while i < len(lines): |
| line = lines[i].strip() |
| |
| |
| if line and line[0].isdigit() and "|" in line: |
| parts = line.split("|") |
| if len(parts) >= 3: |
| id_part = parts[0].strip().split(".")[-1].strip() |
| category = parts[1].strip() |
| indicator_name = parts[2].strip() |
| |
| |
| i += 1 |
| if i < len(lines) and lines[i].strip().startswith("Question:"): |
| question_text = lines[i].strip()[10:].strip() |
| indicators.append({ |
| "id": id_part, |
| "category": category, |
| "indicator": indicator_name, |
| "question": question_text, |
| "answer": "" |
| }) |
| i += 1 |
| except Exception as e: |
| st.error(f"Error loading indicators: {str(e)}") |
| |
| return indicators |
|
|
| indicators = load_indicators() |
|
|
| if indicators: |
| col1, col2 = st.columns([3, 1]) |
| with col1: |
| st.write(f"**Found {len(indicators)} indicators**") |
| with col2: |
| run_batch_analysis = st.button("🚀 Analyze All", type="primary", use_container_width=True) |
|
|
| if run_batch_analysis: |
| |
| progress_bar = st.progress(0) |
| status_text = st.empty() |
| results_placeholder = st.empty() |
|
|
| results = [] |
|
|
| for idx, ind in enumerate(indicators): |
| |
| progress = (idx) / len(indicators) |
| progress_bar.progress(progress) |
| status_text.write(f"Processing {idx + 1}/{len(indicators)}: **{ind['id']}** - {ind['indicator'][:50]}...") |
|
|
| |
| question = ind["question"] |
| |
| try: |
| |
| candidates = hybrid_search(question, k=5, source_filter=selected_file) |
| |
| if candidates: |
| |
| best_chunks, confidence = rerank(question, candidates, top_n=2) |
| |
| |
| if confidence >= -2.0: |
| context = "\n\n".join([clean_chunk(c) for c in best_chunks]) |
| answer_text = generate_answer(question, context, batch_mode=True) |
| |
| results.append({ |
| **ind, |
| "answer": answer_text, |
| "confidence": f"{confidence:.2f}" |
| }) |
| else: |
| results.append({**ind, "answer": "No relevant passage found.", "confidence": f"{confidence:.2f}"}) |
| else: |
| results.append({**ind, "answer": "No relevant passages retrieved.", "confidence": "N/A"}) |
| |
| except Exception as e: |
| results.append({**ind, "answer": f"Error: {str(e)[:50]}", "confidence": "N/A"}) |
|
|
| |
| progress_bar.progress(1.0) |
| status_text.write(f"✅ Completed: {len(results)} indicators analyzed") |
|
|
| |
| if results: |
| st.subheader("📊 Analysis Results") |
| |
| |
| df_results = pd.DataFrame([ |
| { |
| "ID": r["id"], |
| "Category": r["category"], |
| "Indicator": r["indicator"], |
| "Question": r["question"], |
| "Answer": r["answer"], |
| "Score": r["confidence"] |
| } |
| for r in results |
| ]) |
|
|
| df_display = df_results.copy() |
| df_display["Indicator"] = df_display["Indicator"].apply(lambda value: value[:40] + "..." if len(value) > 40 else value) |
| df_display["Question"] = df_display["Question"].apply(lambda value: value[:60] + "..." if len(value) > 60 else value) |
| df_display["Answer"] = df_display["Answer"].apply(lambda value: value[:120] + "..." if len(value) > 120 else value) |
| |
| st.dataframe(df_display, use_container_width=True, height=500) |
| |
| |
| csv_output = df_results.to_csv(index=False) |
| st.download_button( |
| label="📥 Download Results as CSV", |
| data=csv_output, |
| file_name="indicators_analysis_results.csv", |
| mime="text/csv" |
| ) |
| else: |
| st.warning("⚠️ Could not load indicators. Make sure 'all_indicators_with_questions - Copy.txt' is in the current directory.") |
|
|
| else: |
| st.info("⬅️ Upload a PDF file in the sidebar to begin.") |