muddasser commited on
Commit
878760c
Β·
verified Β·
1 Parent(s): d444c45

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +326 -108
app.py CHANGED
@@ -1,7 +1,9 @@
1
- # app.py - Simple hybrid RAG (semantic + BM25, fused with RRF). PDF, DOCX, XLSX/CSV, TXT. Arabic + English.
 
2
  import io
3
  import os
4
  import re
 
5
  from collections import defaultdict
6
 
7
  import faiss
@@ -11,136 +13,352 @@ import pandas as pd
11
  import streamlit as st
12
  from docx import Document
13
  from langchain_groq import ChatGroq
 
14
  from rank_bm25 import BM25Okapi
15
  from sentence_transformers import SentenceTransformer
16
 
17
- EMBED_MODEL = "paraphrase-multilingual-MiniLM-L12-v2"
18
- LLM_MODEL = "llama-3.3-70b-versatile"
19
- CHUNK_WORDS, OVERLAP, RRF_K = 80, 15, 60
 
 
 
 
20
 
21
- st.set_page_config(page_title="RAG Assistant", layout="wide")
22
- st.title("RAG Assistant")
23
- st.caption("Semantic search + BM25, fused with Reciprocal Rank Fusion")
24
 
 
 
 
 
 
 
 
 
 
 
25
 
26
- # ---------- text helpers ----------
27
  def tokenize(text):
28
- text = re.sub(r"[\u064B-\u065F\u0640]", "", text) # diacritics, tatweel
29
- text = re.sub("[Ψ₯Ψ£Ψ’]", "Ψ§", text).replace("Ω‰", "ي").replace("Ψ©", "Ω‡")
30
- return re.findall(r"\w+", text.lower())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
- def read_file(name, data):
34
- """Return a list of (label, text) pieces."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  lower = name.lower()
36
  if lower.endswith(".pdf"):
37
- doc = fitz.open(stream=data, filetype="pdf")
38
- return [(f"{name}, p.{i}", p.get_text()) for i, p in enumerate(doc, 1)]
39
  if lower.endswith(".docx"):
40
- d = Document(io.BytesIO(data))
41
- text = "\n".join(p.text for p in d.paragraphs)
42
- for t in d.tables:
43
- text += "\n" + "\n".join(" | ".join(c.text for c in r.cells) for r in t.rows)
44
- return [(name, text)]
45
  if lower.endswith((".xlsx", ".csv")):
46
- sheets = ({"": pd.read_csv(io.BytesIO(data), encoding="utf-8-sig")} if lower.endswith(".csv")
47
- else pd.read_excel(io.BytesIO(data), sheet_name=None))
48
- pieces = []
49
- for sheet, df in sheets.items():
50
- rows = [" | ".join(f"{c}: {v}" for c, v in r.items() if pd.notna(v))
51
- for _, r in df.iterrows()]
52
- for i in range(0, len(rows), 10): # 10 rows per piece
53
- pieces.append((f"{name}, {sheet} rows {i + 1}+", "\n".join(rows[i:i + 10])))
54
- return pieces
55
- return [(name, data.decode("utf-8", errors="ignore"))] # txt / md
56
-
57
-
58
- def chunk(pieces):
59
- chunks, step = [], CHUNK_WORDS - OVERLAP
60
- for label, text in pieces:
61
- words = text.split()
62
- for i in range(0, len(words), step):
63
- part = " ".join(words[i:i + CHUNK_WORDS])
64
- if len(part) > 20:
65
- chunks.append({"label": label, "text": part})
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  return chunks
67
 
 
 
68
 
69
- # ---------- index + search ----------
70
- @st.cache_resource(show_spinner="Loading embedding model (first run takes a minute)...")
71
  def load_embedder():
72
- return SentenceTransformer(EMBED_MODEL, device="cpu")
73
 
 
 
 
 
 
 
 
 
74
 
75
- def embed(embedder, texts):
76
- return embedder.encode(texts, normalize_embeddings=True, show_progress_bar=False).astype("float32")
 
 
 
 
77
 
 
 
 
78
 
79
- def build_index(chunks, embedder):
80
- vecs = embed(embedder, [c["text"] for c in chunks])
81
- index = faiss.IndexFlatIP(vecs.shape[1])
82
- index.add(vecs)
83
- return {"chunks": chunks, "faiss": index, "bm25": BM25Okapi([tokenize(c["text"]) for c in chunks])}
84
-
85
-
86
- def search(query, idx, embedder, top_k):
87
- pool = min(len(idx["chunks"]), top_k * 4)
88
- _, ids = idx["faiss"].search(embed(embedder, [query]), pool)
89
- bm = idx["bm25"].get_scores(tokenize(query))
90
- bm_ids = [i for i in np.argsort(-bm)[:pool] if bm[i] > 0]
91
- scores = defaultdict(float)
92
- for ranking in (ids[0], bm_ids):
93
- for r, i in enumerate(ranking):
94
- if i >= 0:
95
- scores[int(i)] += 1 / (RRF_K + r + 1)
96
- return sorted(scores, key=scores.get, reverse=True)[:top_k]
97
-
98
-
99
- # ---------- app ----------
100
- key = os.getenv("GROQ_API_KEY")
101
- if not key:
102
- try:
103
- key = st.secrets.get("GROQ_API_KEY")
104
- except Exception:
105
- pass
106
- if not key:
107
- st.error("GROQ_API_KEY not found. Add it as a secret in the Space settings.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  st.stop()
 
 
 
 
 
 
 
109
 
110
- top_k = st.sidebar.slider("Chunks sent to the LLM", 3, 10, 5)
111
- files = st.file_uploader("Upload documents", type=["pdf", "docx", "xlsx", "csv", "txt", "md"],
112
- accept_multiple_files=True)
113
-
114
- if files:
115
- embedder = load_embedder()
116
- sig = tuple((f.name, f.size) for f in files)
117
- if st.session_state.get("sig") != sig:
118
- with st.spinner("Indexing..."):
119
- pieces = [p for f in files for p in read_file(f.name, f.getvalue())]
120
- chunks = chunk(pieces)
121
- if not chunks:
122
- st.error("No readable text found (scanned PDFs are not supported).")
123
- st.stop()
 
 
 
 
 
 
 
 
 
 
124
  st.session_state.index = build_index(chunks, embedder)
125
- st.session_state.sig = sig
126
- st.success(f"Indexed {len(st.session_state.index['chunks'])} chunks")
127
-
128
- query = st.text_input("Ask a question (Arabic or English):")
129
- if st.button("Ask") and query.strip():
130
- idx = st.session_state.index
131
- hits = [idx["chunks"][i] for i in search(query, idx, embedder, top_k)]
132
- context = "\n\n---\n\n".join(f"[{c['label']}]\n{c['text']}" for c in hits)
133
- prompt = (
134
- "Answer ONLY from the context below, in the same language as the question. "
135
- "Cite sources like [file, p.N]. If the answer is not in the context, say it was "
136
- f"not found in the documents.\n\nContext:\n{context}\n\nQuestion: {query}\nAnswer:"
137
- )
138
- llm = ChatGroq(temperature=0.1, model_name=LLM_MODEL, groq_api_key=key)
139
- with st.spinner("Generating answer..."):
140
- st.write(llm.invoke(prompt).content)
141
- with st.expander("Retrieved chunks"):
142
- for c in hits:
143
- st.markdown(f"**{c['label']}**")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  st.text(c["text"])
145
  else:
146
- st.info("Upload documents to begin.")
 
1
+ # app.py - Hybrid RAG (FAISS semantic + BM25, fused with RRF) for multi-format documents.
2
+ # Handles: PDFs (text + tables), DOCX (with tables), PPTX, XLSX/CSV, TXT/MD. Arabic + English.
3
  import io
4
  import os
5
  import re
6
+ import unicodedata
7
  from collections import defaultdict
8
 
9
  import faiss
 
13
  import streamlit as st
14
  from docx import Document
15
  from langchain_groq import ChatGroq
16
+ from pptx import Presentation
17
  from rank_bm25 import BM25Okapi
18
  from sentence_transformers import SentenceTransformer
19
 
20
+ # ========================= CONSTANTS =========================
21
+ EMBED_MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2" # Arabic + English, 384-dim
22
+ LLM_MODEL_NAME = "llama-3.3-70b-versatile"
23
+ CHUNK_WORDS = 80 # stays under the embedding model's 128-token limit
24
+ CHUNK_OVERLAP = 15
25
+ TABLE_CHUNK_WORDS = 60 # table/spreadsheet rows are token-heavy, so smaller groups
26
+ RRF_K = 60
27
 
28
+ # ========================= TEXT NORMALIZATION =========================
29
+ DIACRITICS_RE = re.compile(r"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06ED]")
30
+ DIGIT_MAP = str.maketrans("Ω Ω‘Ω’Ω£Ω€Ω₯Ω¦Ω§Ω¨Ω©Ϋ°Ϋ±Ϋ²Ϋ³Ϋ΄Ϋ΅ΫΆΫ·ΫΈΫΉ", "01234567890123456789")
31
 
32
+ def light_clean(text):
33
+ text = unicodedata.normalize("NFKC", text)
34
+ text = DIACRITICS_RE.sub("", text)
35
+ return text.replace("\u0640", "")
36
+
37
+ def normalize(text):
38
+ text = light_clean(text).translate(DIGIT_MAP)
39
+ text = re.sub("[Ψ₯Ψ£Ψ’Ω±]", "Ψ§", text)
40
+ text = text.replace("Ω‰", "ي").replace("Ψ©", "Ω‡")
41
+ return text.lower()
42
 
 
43
  def tokenize(text):
44
+ tokens = re.findall(r"[\w\+#]+", normalize(text))
45
+ return [t[2:] if len(t) > 4 and t.startswith("Ψ§Ω„") else t for t in tokens]
46
+
47
+ # ========================= EXTRACTION =========================
48
+ def make_block(text, source, loc, kind):
49
+ return {"text": text, "source": source, "loc": loc, "kind": kind}
50
+
51
+ def pdf_tables(page):
52
+ out = []
53
+ try:
54
+ for t in page.find_tables().tables:
55
+ lines = []
56
+ for r in t.extract():
57
+ cells = [(c or "").replace("\n", " ").strip() for c in r]
58
+ if any(cells):
59
+ lines.append(" | ".join(cells))
60
+ if len(lines) >= 2:
61
+ out.append("\n".join(lines))
62
+ except Exception:
63
+ pass
64
+ return out
65
+
66
+ def extract_pdf(name, data):
67
+ blocks = []
68
+ stats = {"pages": 0, "tables": 0, "notes": []}
69
+ doc = fitz.open(stream=data, filetype="pdf")
70
+ empty_pages = []
71
+ for i, page in enumerate(doc, 1):
72
+ stats["pages"] += 1
73
+ loc = f"p.{i}"
74
+ text = unicodedata.normalize("NFKC", page.get_text("text") or "").strip()
75
+ for tbl in pdf_tables(page):
76
+ blocks.append(make_block(tbl, name, loc, "table"))
77
+ stats["tables"] += 1
78
+ if len(text) < 50:
79
+ empty_pages.append(i)
80
+ if text:
81
+ blocks.append(make_block(text, name, loc, "text"))
82
+ if empty_pages:
83
+ shown = ", ".join(map(str, empty_pages[:15])) + ("..." if len(empty_pages) > 15 else "")
84
+ stats["notes"].append(f"Little or no text on page(s) {shown}. Scanned pages cannot be read by this app.")
85
+ return blocks, stats
86
+
87
+ def extract_docx(name, data):
88
+ doc = Document(io.BytesIO(data))
89
+ blocks, tables = [], 0
90
+ text = "\n".join(p.text for p in doc.paragraphs if p.text.strip())
91
+ if text.strip():
92
+ blocks.append(make_block(text, name, None, "text"))
93
+ for table in doc.tables:
94
+ lines = []
95
+ for row in table.rows:
96
+ cells = [c.text.replace("\n", " ").strip() for c in row.cells]
97
+ if any(cells):
98
+ lines.append(" | ".join(cells))
99
+ if len(lines) >= 2:
100
+ blocks.append(make_block("\n".join(lines), name, None, "table"))
101
+ tables += 1
102
+ return blocks, {"pages": 1, "tables": tables,
103
+ "notes": ["Images embedded inside the DOCX are not read."]}
104
+
105
+ def extract_pptx(name, data):
106
+ prs = Presentation(io.BytesIO(data))
107
+ blocks, tables = [], 0
108
+ for i, slide in enumerate(prs.slides, 1):
109
+ loc = f"slide {i}"
110
+ texts = []
111
+ for shape in slide.shapes:
112
+ if shape.has_text_frame and shape.text_frame.text.strip():
113
+ texts.append(shape.text_frame.text.strip())
114
+ if getattr(shape, "has_table", False) and shape.has_table:
115
+ lines = [" | ".join(c.text.strip() for c in row.cells) for row in shape.table.rows]
116
+ if len(lines) >= 2:
117
+ blocks.append(make_block("\n".join(lines), name, loc, "table"))
118
+ tables += 1
119
+ if slide.has_notes_slide and slide.notes_slide.notes_text_frame.text.strip():
120
+ texts.append("Notes: " + slide.notes_slide.notes_text_frame.text.strip())
121
+ if texts:
122
+ blocks.append(make_block("\n".join(texts), name, loc, "text"))
123
+ return blocks, {"pages": len(prs.slides), "tables": tables,
124
+ "notes": ["Pictures and charts on slides are not read."]}
125
 
126
+ def df_to_rows(df):
127
+ df = df.dropna(how="all").dropna(axis=1, how="all").fillna("")
128
+ headers = [str(c) for c in df.columns]
129
+ lines = []
130
+ for _, row in df.iterrows():
131
+ cells = []
132
+ for h, v in zip(headers, row):
133
+ v = str(v).strip()
134
+ if v:
135
+ cells.append(v if h.startswith("Unnamed") else f"{h}: {v}")
136
+ if cells:
137
+ lines.append(" | ".join(cells))
138
+ return "\n".join(lines)
139
 
140
+ def extract_spreadsheet(name, data):
141
+ blocks = []
142
+ if name.lower().endswith(".csv"):
143
+ df = None
144
+ for enc in ("utf-8-sig", "cp1256", "latin-1"):
145
+ try:
146
+ df = pd.read_csv(io.BytesIO(data), encoding=enc)
147
+ break
148
+ except Exception:
149
+ continue
150
+ sheets = {"": df} if df is not None else {}
151
+ else:
152
+ sheets = pd.read_excel(io.BytesIO(data), sheet_name=None)
153
+ for sheet, df in sheets.items():
154
+ text = df_to_rows(df)
155
+ if text:
156
+ blocks.append(make_block(text, name, f"sheet {sheet}" if sheet else None, "rows"))
157
+ return blocks, {"pages": len(sheets), "tables": len(blocks), "notes": []}
158
+
159
+ def extract_text_file(name, data):
160
+ text = data.decode("utf-8", errors="ignore")
161
+ blocks = [make_block(text, name, None, "text")] if text.strip() else []
162
+ return blocks, {"pages": 1, "tables": 0, "notes": []}
163
+
164
+ @st.cache_data(show_spinner=False)
165
+ def extract_file(name, data):
166
  lower = name.lower()
167
  if lower.endswith(".pdf"):
168
+ return extract_pdf(name, data)
 
169
  if lower.endswith(".docx"):
170
+ return extract_docx(name, data)
171
+ if lower.endswith(".pptx"):
172
+ return extract_pptx(name, data)
 
 
173
  if lower.endswith((".xlsx", ".csv")):
174
+ return extract_spreadsheet(name, data)
175
+ if lower.endswith((".txt", ".md")):
176
+ return extract_text_file(name, data)
177
+ return [], {"pages": 0, "tables": 0, "notes": ["Unsupported file type."]}
178
+
179
+ # ========================= CHUNKING =========================
180
+ def make_chunk(block, text):
181
+ return {"text": text, "source": block["source"], "loc": block["loc"], "kind": block["kind"]}
182
+
183
+ def chunk_blocks(blocks):
184
+ chunks = []
185
+ step = max(1, CHUNK_WORDS - CHUNK_OVERLAP)
186
+ for b in blocks:
187
+ if b["kind"] in ("table", "rows"):
188
+ lines = [l for l in b["text"].split("\n") if l.strip()]
189
+ header = lines[0] if b["kind"] == "table" else None
190
+ body = lines[1:] if b["kind"] == "table" else lines
191
+ group, wc = [], 0
192
+ for line in body:
193
+ lw = len(line.split())
194
+ if group and wc + lw > TABLE_CHUNK_WORDS:
195
+ chunks.append(make_chunk(b, "\n".join(([header] if header else []) + group)))
196
+ group, wc = [], 0
197
+ group.append(line)
198
+ wc += lw
199
+ if group:
200
+ chunks.append(make_chunk(b, "\n".join(([header] if header else []) + group)))
201
+ else:
202
+ words = b["text"].split()
203
+ for i in range(0, len(words), step):
204
+ piece = " ".join(words[i:i + CHUNK_WORDS])
205
+ if len(piece.strip()) > 20:
206
+ chunks.append(make_chunk(b, piece))
207
  return chunks
208
 
209
+ def cite_label(c):
210
+ return f"{c['source']}, {c['loc']}" if c["loc"] else c["source"]
211
 
212
+ # ========================= INDEX + HYBRID SEARCH =========================
213
+ @st.cache_resource(show_spinner="Loading embedding model...")
214
  def load_embedder():
215
+ return SentenceTransformer(EMBED_MODEL_NAME, cache_folder="/tmp/model_cache", device="cpu")
216
 
217
+ def build_index(chunks, embedder):
218
+ texts = [light_clean(c["text"]) for c in chunks]
219
+ emb = embedder.encode(texts, batch_size=32, convert_to_numpy=True,
220
+ normalize_embeddings=True, show_progress_bar=False).astype("float32")
221
+ faiss_index = faiss.IndexFlatIP(emb.shape[1]) # inner product on normalized vectors = cosine
222
+ faiss_index.add(emb)
223
+ bm25 = BM25Okapi([tokenize(c["text"]) for c in chunks])
224
+ return {"chunks": chunks, "faiss": faiss_index, "bm25": bm25}
225
 
226
+ def hybrid_search(query, index, embedder, top_k, w_sem):
227
+ """FAISS semantic + BM25, fused with Reciprocal Rank Fusion."""
228
+ q_vec = embedder.encode([light_clean(query)], convert_to_numpy=True,
229
+ normalize_embeddings=True, show_progress_bar=False).astype("float32")
230
+ n = len(index["chunks"])
231
+ pool = min(n, top_k * 4)
232
 
233
+ scores, ids = index["faiss"].search(q_vec, pool)
234
+ sem_scores = {int(i): float(s) for i, s in zip(ids[0], scores[0]) if i >= 0}
235
+ best_sem = float(scores[0][0])
236
 
237
+ bm = index["bm25"].get_scores(tokenize(query))
238
+ bm_rank = np.argsort(-bm)[:pool]
239
+
240
+ fused = defaultdict(float)
241
+ for r, i in enumerate(ids[0]):
242
+ if i >= 0:
243
+ fused[int(i)] += w_sem / (RRF_K + r + 1)
244
+ for r, i in enumerate(bm_rank):
245
+ if bm[i] > 0:
246
+ fused[int(i)] += (1 - w_sem) / (RRF_K + r + 1)
247
+
248
+ top = sorted(fused, key=fused.get, reverse=True)[:top_k]
249
+ return top, best_sem, sem_scores, bm
250
+
251
+ # ========================= GENERATION =========================
252
+ def generate_answer(llm, query, retrieved):
253
+ context = "\n\n---\n\n".join(f"[{cite_label(c)}]\n{c['text']}" for c in retrieved)
254
+ prompt = f"""You are a document assistant. Answer ONLY from the context below.
255
+ - Answer in the same language as the question.
256
+ - After each fact, cite its source exactly as shown in the context, e.g. [file, p.N].
257
+ - Table rows use " | " between cells; the first line of a table chunk is its header.
258
+ - If the context does not contain the answer, say it was not found in the documents and
259
+ suggest contacting the document owner. Do not guess.
260
+
261
+ Context:
262
+ {context}
263
+
264
+ Question: {query}
265
+ Answer:"""
266
+ return llm.invoke(prompt).content
267
+
268
+ # ========================= STREAMLIT APP =========================
269
+ st.set_page_config(page_title="Hybrid RAG Assistant", page_icon="πŸ“š", layout="wide")
270
+ st.title("πŸ“š Hybrid RAG Assistant")
271
+ st.caption("FAISS semantic search + BM25, fused with Reciprocal Rank Fusion")
272
+
273
+ with st.sidebar:
274
+ st.header("Settings")
275
+ top_k = st.slider("Chunks sent to the LLM (Top-K)", 3, 10, 5)
276
+ w_sem = st.slider("Semantic weight (BM25 gets the rest)", 0.0, 1.0, 0.6, 0.1)
277
+ min_rel = st.slider("Minimum relevance to answer", 0.0, 0.8, 0.25, 0.05,
278
+ help="If the best semantic match is below this, the app says 'not found' instead of guessing.")
279
+
280
+ embedder = load_embedder()
281
+
282
+ groq_key = None
283
+ try:
284
+ groq_key = st.secrets.get("GROQ_API_KEY")
285
+ except Exception:
286
+ pass
287
+ groq_key = groq_key or os.getenv("GROQ_API_KEY")
288
+ if not groq_key:
289
+ st.error("GROQ_API_KEY not found. Add it to Streamlit secrets or environment variables.")
290
  st.stop()
291
+ llm = ChatGroq(temperature=0.1, model_name=LLM_MODEL_NAME, groq_api_key=groq_key)
292
+
293
+ uploaded_files = st.file_uploader(
294
+ "Upload documents",
295
+ type=["pdf", "docx", "pptx", "xlsx", "csv", "txt", "md"],
296
+ accept_multiple_files=True,
297
+ )
298
 
299
+ # Rebuild the index whenever the set of uploaded files changes
300
+ if uploaded_files:
301
+ signature = tuple(sorted((f.name, f.size) for f in uploaded_files))
302
+ if st.session_state.get("signature") != signature:
303
+ all_blocks, report = [], {}
304
+ with st.spinner("Reading documents..."):
305
+ for f in uploaded_files:
306
+ try:
307
+ blocks, stats = extract_file(f.name, f.getvalue())
308
+ except Exception as e:
309
+ blocks, stats = [], {"pages": 0, "tables": 0, "notes": [f"Could not read file: {e}"]}
310
+ all_blocks.extend(blocks)
311
+ report[f.name] = stats
312
+ chunks = chunk_blocks(all_blocks)
313
+ if not chunks:
314
+ st.error("No readable text found in the uploaded files.")
315
+ st.session_state.pop("index", None)
316
+ st.session_state.pop("signature", None)
317
+ with st.expander("Extraction report", expanded=True):
318
+ for fname, s in report.items():
319
+ for note in s["notes"]:
320
+ st.write(f"**{fname}**: {note}")
321
+ st.stop()
322
+ with st.spinner("Building FAISS + BM25 indexes..."):
323
  st.session_state.index = build_index(chunks, embedder)
324
+ st.session_state.report = report
325
+ st.session_state.signature = signature
326
+ st.success(f"Indexed {len(st.session_state.index['chunks'])} chunks from {len(uploaded_files)} file(s)")
327
+
328
+ with st.expander("πŸ“‹ Extraction report"):
329
+ for fname, s in st.session_state.report.items():
330
+ st.write(f"**{fname}**: {s['pages']} page(s)/sheet(s), {s['tables']} table(s)")
331
+ for note in s["notes"]:
332
+ st.caption(f"⚠️ {note}")
333
+ else:
334
+ st.session_state.pop("index", None)
335
+ st.session_state.pop("signature", None)
336
+
337
+ # Query form: the LLM is only called when the user presses the button
338
+ with st.form("ask"):
339
+ query = st.text_input("Ask a question about your documents (Arabic or English):")
340
+ submitted = st.form_submit_button("Ask")
341
+
342
+ if submitted and query.strip():
343
+ index = st.session_state.get("index")
344
+ if not index:
345
+ st.warning("Upload at least one document first.")
346
+ else:
347
+ top, best_sem, sem_scores, bm = hybrid_search(query, index, embedder, top_k, w_sem)
348
+ retrieved = [index["chunks"][i] for i in top]
349
+
350
+ st.subheader("Answer")
351
+ if not retrieved or best_sem < min_rel:
352
+ st.info("Not found in the uploaded documents.")
353
+ else:
354
+ with st.spinner("Generating answer..."):
355
+ st.write(generate_answer(llm, query, retrieved))
356
+
357
+ with st.expander("πŸ” Retrieved chunks"):
358
+ for rank, i in enumerate(top, 1):
359
+ c = index["chunks"][i]
360
+ sem_txt = f"{sem_scores[i]:.2f}" if i in sem_scores else "n/a"
361
+ st.markdown(f"**{rank}. {cite_label(c)}** ({c['kind']}) Β· semantic {sem_txt} Β· BM25 {bm[i]:.2f}")
362
  st.text(c["text"])
363
  else:
364
+ st.info("Upload documents, type a question, and press Ask.")