muddasser commited on
Commit
d98355d
ยท
verified ยท
1 Parent(s): e67b1ea

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +333 -0
app.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py - Hybrid Memory-Augmented Graph RAG with Groq
2
+ import hashlib
3
+ import os
4
+ from collections import defaultdict
5
+ from datetime import datetime
6
+
7
+ import networkx as nx
8
+ import numpy as np
9
+ import spacy
10
+ import streamlit as st
11
+ from langchain_community.vectorstores import FAISS
12
+ from langchain_huggingface import HuggingFaceEmbeddings
13
+ from langchain_groq import ChatGroq
14
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
15
+ from pypdf import PdfReader
16
+ from rank_bm25 import BM25Okapi
17
+ from sentence_transformers import CrossEncoder, SentenceTransformer
18
+ from sklearn.metrics.pairwise import cosine_similarity
19
+
20
+ # ========================= CONSTANTS =========================
21
+ EMBED_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
22
+ RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
23
+ LLM_MODEL_NAME = "llama-3.3-70b-versatile"
24
+ DEFAULT_TOP_K = 5
25
+ RRF_K = 60
26
+
27
+ # ========================= HELPERS =========================
28
+ def get_groq_api_key():
29
+ try:
30
+ return st.secrets.get("GROQ_API_KEY") or os.getenv("GROQ_API_KEY")
31
+ except:
32
+ return os.getenv("GROQ_API_KEY")
33
+
34
+ def rrf_score(rank: int, k: int = RRF_K) -> float:
35
+ return 1.0 / (k + rank)
36
+
37
+ # ========================= MEMORY LAYER =========================
38
+ class HybridMemory:
39
+ def __init__(self):
40
+ self.embeddings = HuggingFaceEmbeddings(model_name=EMBED_MODEL_NAME)
41
+ self.vector_memory = None
42
+ self.graph_memory = nx.Graph()
43
+ self.memory_dir = "user_memory"
44
+ os.makedirs(self.memory_dir, exist_ok=True)
45
+ self.load_memory()
46
+
47
+ def load_memory(self):
48
+ vector_path = os.path.join(self.memory_dir, "vector_memory")
49
+ graph_path = os.path.join(self.memory_dir, "memory_graph.gpickle")
50
+
51
+ if os.path.exists(vector_path):
52
+ self.vector_memory = FAISS.load_local(vector_path, self.embeddings, allow_dangerous_deserialization=True)
53
+ if os.path.exists(graph_path):
54
+ self.graph_memory = nx.read_gpickle(graph_path)
55
+
56
+ def save_memory(self):
57
+ if self.vector_memory:
58
+ self.vector_memory.save_local(os.path.join(self.memory_dir, "vector_memory"))
59
+ nx.write_gpickle(self.graph_memory, os.path.join(self.memory_dir, "memory_graph.gpickle"))
60
+
61
+ def add_memory(self, text: str, memory_type: str = "fact", metadata: dict = None):
62
+ if metadata is None:
63
+ metadata = {}
64
+ metadata.update({
65
+ "timestamp": datetime.now().isoformat(),
66
+ "type": memory_type,
67
+ })
68
+
69
+ if self.vector_memory is None:
70
+ self.vector_memory = FAISS.from_texts([text], self.embeddings, metadatas=[metadata])
71
+ else:
72
+ self.vector_memory.add_texts([text], metadatas=[metadata])
73
+
74
+ self._add_to_graph(text)
75
+ self.save_memory()
76
+
77
+ def _add_to_graph(self, text: str):
78
+ try:
79
+ nlp = st.session_state.get("nlp")
80
+ if nlp:
81
+ doc = nlp(text[:30000])
82
+ entities = [ent.text.strip().lower() for ent in doc.ents if len(ent.text.strip()) > 1]
83
+ for ent in entities:
84
+ if not self.graph_memory.has_node(ent):
85
+ self.graph_memory.add_node(ent, count=1)
86
+ else:
87
+ self.graph_memory.nodes[ent]["count"] = self.graph_memory.nodes[ent].get("count", 0) + 1
88
+ for i in range(len(entities)):
89
+ for j in range(i + 1, len(entities)):
90
+ a, b = entities[i], entities[j]
91
+ if self.graph_memory.has_edge(a, b):
92
+ self.graph_memory[a][b]["weight"] += 1
93
+ else:
94
+ self.graph_memory.add_edge(a, b, weight=1)
95
+ except:
96
+ pass
97
+
98
+ def retrieve_memory(self, query: str, top_k: int = 6):
99
+ if not self.vector_memory:
100
+ return []
101
+ docs = self.vector_memory.similarity_search(query, k=top_k)
102
+ return [{"text": doc.page_content, "metadata": doc.metadata} for doc in docs]
103
+
104
+ def get_stats(self):
105
+ return {
106
+ "vector_memories": len(self.vector_memory.index_to_docstore_id) if self.vector_memory else 0,
107
+ "graph_nodes": self.graph_memory.number_of_nodes(),
108
+ "graph_edges": self.graph_memory.number_of_edges()
109
+ }
110
+
111
+ # ========================= ORIGINAL PIPELINE FUNCTIONS =========================
112
+ @st.cache_resource
113
+ def load_embed_model(): return SentenceTransformer(EMBED_MODEL_NAME)
114
+ @st.cache_resource
115
+ def load_reranker(): return CrossEncoder(RERANKER_MODEL)
116
+ @st.cache_resource
117
+ def load_lc_embeddings(): return HuggingFaceEmbeddings(model_name=EMBED_MODEL_NAME)
118
+ @st.cache_resource
119
+ def load_spacy():
120
+ try:
121
+ return spacy.load("en_core_web_sm")
122
+ except OSError:
123
+ from spacy.cli import download
124
+ download("en_core_web_sm")
125
+ return spacy.load("en_core_web_sm")
126
+
127
+ def load_pdf_documents(uploaded_files):
128
+ docs = []
129
+ for f in uploaded_files:
130
+ reader = PdfReader(f)
131
+ text = "\n".join([page.extract_text() or "" for page in reader.pages])
132
+ if text.strip():
133
+ docs.append({"text": text, "source": f.name})
134
+ return docs
135
+
136
+ def hybrid_chunking(text, embed_model, base=300, final=1000, thresh=0.6):
137
+ splitter = RecursiveCharacterTextSplitter(chunk_size=base, chunk_overlap=50)
138
+ base_chunks = splitter.split_text(text)
139
+ if not base_chunks: return []
140
+ embeddings = embed_model.encode(base_chunks, show_progress_bar=False)
141
+ chunks, current = [], base_chunks[0]
142
+ anchor = embeddings[0].reshape(1, -1)
143
+ for i in range(1, len(base_chunks)):
144
+ next_emb = embeddings[i].reshape(1, -1)
145
+ sim = cosine_similarity(anchor, next_emb)[0][0]
146
+ if sim > thresh and len(current) + len(base_chunks[i]) < final:
147
+ current += " " + base_chunks[i]
148
+ else:
149
+ chunks.append(current)
150
+ current = base_chunks[i]
151
+ anchor = next_emb
152
+ chunks.append(current)
153
+ return chunks
154
+
155
+ def chunk_documents(docs, embed_model, method, **kwargs):
156
+ result = []
157
+ for doc in docs:
158
+ if method == "Hybrid (Recursive + Semantic)":
159
+ texts = hybrid_chunking(doc["text"], embed_model, **kwargs)
160
+ else:
161
+ splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
162
+ texts = splitter.split_text(doc["text"])
163
+ for t in texts:
164
+ result.append({"text": t, "source": doc["source"]})
165
+ return result
166
+
167
+ def build_knowledge_graph(chunks, nlp):
168
+ G = nx.Graph()
169
+ entity_to_chunks = defaultdict(list)
170
+ for idx, chunk in enumerate(chunks):
171
+ doc = nlp(chunk["text"][:50000])
172
+ entities = [ent.text.strip().lower() for ent in doc.ents if len(ent.text.strip()) > 1]
173
+ for e in set(entities):
174
+ if not G.has_node(e):
175
+ G.add_node(e)
176
+ entity_to_chunks[e].append(idx)
177
+ for i in range(len(entities)):
178
+ for j in range(i+1, len(entities)):
179
+ a, b = entities[i], entities[j]
180
+ if G.has_edge(a, b):
181
+ G[a][b]["weight"] += 1
182
+ else:
183
+ G.add_edge(a, b, weight=1)
184
+ return G, dict(entity_to_chunks)
185
+
186
+ def build_indexes(chunks, lc_embeddings):
187
+ texts = [c["text"] for c in chunks]
188
+ bm25 = BM25Okapi([t.lower().split() for t in texts])
189
+ faiss_index = FAISS.from_texts(texts, lc_embeddings)
190
+ chunk_lookup = {c["text"]: c for c in chunks}
191
+ return bm25, faiss_index, chunk_lookup
192
+
193
+ def hybrid_memory_retrieve(query, chunks, bm25, faiss_index, chunk_lookup,
194
+ graph, entity_to_chunks, reranker, nlp, memory,
195
+ top_k=5, hops=2):
196
+ # Document + Graph Retrieval (simplified)
197
+ texts = [c["text"] for c in chunks]
198
+ bm25_scores = bm25.get_scores(query.lower().split())
199
+ bm25_idx = np.argsort(bm25_scores)[::-1][:top_k*3]
200
+ faiss_docs = faiss_index.similarity_search(query, k=top_k*3)
201
+
202
+ candidates = []
203
+ seen = set()
204
+ for i in bm25_idx[:top_k*2]:
205
+ c = chunks[i]
206
+ if c["text"] not in seen:
207
+ candidates.append(c)
208
+ seen.add(c["text"])
209
+ for doc in faiss_docs:
210
+ c = chunk_lookup.get(doc.page_content)
211
+ if c and c["text"] not in seen:
212
+ candidates.append(c)
213
+ seen.add(c["text"])
214
+
215
+ # Memory Retrieval
216
+ mem_items = memory.retrieve_memory(query, top_k=top_k*2)
217
+
218
+ # Reranking
219
+ all_texts = [c["text"] for c in candidates] + [m["text"] for m in mem_items]
220
+ pairs = [(query, t) for t in all_texts]
221
+ scores = reranker.predict(pairs)
222
+
223
+ scored = sorted(zip(scores, all_texts), reverse=True)
224
+ final_texts = [t for _, t in scored[:top_k]]
225
+
226
+ final_chunks = []
227
+ for t in final_texts:
228
+ for c in candidates:
229
+ if c["text"] == t:
230
+ final_chunks.append(c)
231
+ break
232
+ else:
233
+ final_chunks.append({"text": t, "source": "๐Ÿง  Memory"})
234
+
235
+ return final_chunks[:top_k], {"memory_used": len(mem_items)}
236
+
237
+ def generate_answer(llm, query, retrieved):
238
+ context = "\n\n---\n\n".join([f"[Source: {c['source']}]\n{c['text']}" for c in retrieved])
239
+ prompt = f"""Answer using ONLY the provided context. Cite sources.
240
+ Context:
241
+ {context}
242
+
243
+ Question: {query}
244
+ Answer:"""
245
+ return llm.invoke(prompt).content
246
+
247
+ # ========================= STREAMLIT APP =========================
248
+ st.set_page_config(page_title="Hybrid Memory Graph RAG", page_icon="๐Ÿง ", layout="wide")
249
+ st.title("๐Ÿง  Hybrid Memory-Augmented Graph RAG")
250
+
251
+ # Session State
252
+ if "memory" not in st.session_state:
253
+ st.session_state.memory = HybridMemory()
254
+ if "chunks" not in st.session_state:
255
+ st.session_state.chunks = None
256
+
257
+ # Sidebar
258
+ with st.sidebar:
259
+ st.header("Configuration")
260
+ chunk_method = st.selectbox("Chunking Method", ["Hybrid (Recursive + Semantic)", "Recursive Only"])
261
+ top_k = st.slider("Top-K Results", 3, 15, DEFAULT_TOP_K)
262
+ graph_hops = st.slider("Graph Hops", 1, 3, 2)
263
+
264
+ if st.button("Clear Memory"):
265
+ st.session_state.memory = HybridMemory()
266
+ st.success("Memory cleared!")
267
+
268
+ # Load models
269
+ embed_model = load_embed_model()
270
+ reranker = load_reranker()
271
+ nlp = load_spacy()
272
+ lc_embeddings = load_lc_embeddings()
273
+
274
+ groq_key = get_groq_api_key()
275
+ if not groq_key:
276
+ st.error("Groq API key not found!")
277
+ st.stop()
278
+
279
+ llm = ChatGroq(temperature=0.2, model_name=LLM_MODEL_NAME, groq_api_key=groq_key)
280
+
281
+ # File Upload
282
+ uploaded_files = st.file_uploader("Upload PDFs", type=["pdf"], accept_multiple_files=True)
283
+
284
+ if uploaded_files and st.session_state.chunks is None:
285
+ with st.spinner("Processing documents..."):
286
+ docs = load_pdf_documents(uploaded_files)
287
+ chunks = chunk_documents(docs, embed_model, chunk_method)
288
+ bm25, faiss_index, chunk_lookup = build_indexes(chunks, lc_embeddings)
289
+ graph, entity_to_chunks = build_knowledge_graph(chunks, nlp)
290
+
291
+ st.session_state.chunks = chunks
292
+ st.session_state.bm25 = bm25
293
+ st.session_state.faiss_index = faiss_index
294
+ st.session_state.chunk_lookup = chunk_lookup
295
+ st.session_state.graph = graph
296
+ st.session_state.entity_to_chunks = entity_to_chunks
297
+ st.success(f"Loaded {len(chunks)} chunks")
298
+
299
+ # Query
300
+ query = st.text_input("Ask a question about your documents:")
301
+
302
+ if query and st.session_state.chunks:
303
+ with st.spinner("Retrieving from Documents + Memory..."):
304
+ retrieved, debug = hybrid_memory_retrieve(
305
+ query, st.session_state.chunks, st.session_state.bm25,
306
+ st.session_state.faiss_index, st.session_state.chunk_lookup,
307
+ st.session_state.graph, st.session_state.entity_to_chunks,
308
+ reranker, nlp, st.session_state.memory, top_k=top_k, hops=graph_hops
309
+ )
310
+
311
+ with st.spinner("Generating answer..."):
312
+ answer = generate_answer(llm, query, retrieved)
313
+
314
+ st.subheader("Answer")
315
+ st.write(answer)
316
+
317
+ # Store new memories
318
+ if retrieved:
319
+ summary = f"Q: {query}\nA: {answer[:300]}..."
320
+ st.session_state.memory.add_memory(summary, memory_type="conversation")
321
+
322
+ with st.expander("๐Ÿ“Š Memory Stats"):
323
+ st.json(st.session_state.memory.get_stats())
324
+
325
+ with st.expander("๐Ÿ” Retrieved Sources"):
326
+ for i, chunk in enumerate(retrieved, 1):
327
+ st.markdown(f"**{i}.** {chunk['source']}")
328
+ st.write(chunk["text"][:500] + "..." if len(chunk["text"]) > 500 else chunk["text"])
329
+
330
+ else:
331
+ st.info("Upload PDFs and ask a question to begin.")
332
+
333
+ st.caption("Hybrid Graph RAG + Persistent Memory โ€ข Powered by Groq + NetworkX + FAISS")