muddasser commited on
Commit
b901e18
·
verified ·
1 Parent(s): 46e86fa

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +108 -326
app.py CHANGED
@@ -1,9 +1,7 @@
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,352 +11,136 @@ import pandas as pd
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.")
 
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
  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.")