snakeeee commited on
Commit
1505bbf
ยท
0 Parent(s):

Initial commit - Scholar RAG Engine

Browse files
Files changed (11) hide show
  1. .gitignore +5 -0
  2. README.md +0 -0
  3. chunking.py +69 -0
  4. ingestion.py +24 -0
  5. llm.py +57 -0
  6. main.py +90 -0
  7. requirements.txt +17 -0
  8. reranker.py +17 -0
  9. retrieval_colbert.py +83 -0
  10. scraper.py +20 -0
  11. templates/index.html +323 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ .DS_Store
README.md ADDED
File without changes
chunking.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ def chunk_text(text, source, chunk_size=120):
4
+
5
+ sentences = re.split(r'(?<=[.!?])\s+', text)
6
+
7
+ chunks = []
8
+ current = []
9
+ length = 0
10
+
11
+ for s in sentences:
12
+
13
+ s = s.strip()
14
+
15
+ # remove exam noise
16
+ if any(x in s for x in [
17
+ "APRIL/MAY",
18
+ "CO1",
19
+ "Marks",
20
+ "Bloom",
21
+ "Unit",
22
+ "Semester"
23
+ ]):
24
+ continue
25
+
26
+ words = s.split()
27
+
28
+ if len(words) < 5:
29
+ continue
30
+
31
+ if length + len(words) > chunk_size:
32
+
33
+ chunks.append({
34
+ "source": source,
35
+ "text": " ".join(current)
36
+ })
37
+
38
+ current = []
39
+ length = 0
40
+
41
+ current.append(s)
42
+ length += len(words)
43
+
44
+ if current:
45
+ chunks.append({
46
+ "source": source,
47
+ "text": " ".join(current)
48
+ })
49
+
50
+ return chunks
51
+ def compress_context(text, question):
52
+
53
+ sentences = text.split(". ")
54
+
55
+ keywords = question.lower().split()
56
+
57
+ scored = []
58
+
59
+ for s in sentences:
60
+
61
+ score = sum(1 for k in keywords if k in s.lower())
62
+
63
+ scored.append((score, s))
64
+
65
+ scored.sort(reverse=True)
66
+
67
+ top = [s for _, s in scored[:3]]
68
+
69
+ return ". ".join(top)
ingestion.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pypdf import PdfReader
2
+ import re
3
+
4
+ def extract_pdf(file):
5
+
6
+ reader = PdfReader(file)
7
+
8
+ text = ""
9
+
10
+ for page in reader.pages:
11
+
12
+ page_text = page.extract_text() or ""
13
+
14
+ # remove extra whitespace
15
+ page_text = re.sub(r"\s+", " ", page_text)
16
+
17
+ # remove exam formatting noise
18
+ page_text = re.sub(r"CO\d+", "", page_text)
19
+ page_text = re.sub(r"K\d+", "", page_text)
20
+ page_text = re.sub(r"\d+ Marks", "", page_text)
21
+
22
+ text += page_text + "\n"
23
+
24
+ return text
llm.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import os
3
+
4
+ GEMINI_API_KEY = os.getenv("GOOGLE_API_KEY")
5
+
6
+ def generate_answer(context, question):
7
+
8
+ prompt = f"""
9
+ You are answering exam questions.
10
+
11
+ Use the information in the context to answer the question directly.
12
+
13
+ Do NOT describe the context.
14
+ Do NOT say "the context says".
15
+
16
+ Give the final answer.
17
+
18
+ Context:
19
+ {context}
20
+
21
+ Question:
22
+ {question}
23
+
24
+ Answer:
25
+ """
26
+
27
+ url = f"https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContent?key={GEMINI_API_KEY}"
28
+
29
+ headers = {
30
+ "Content-Type": "application/json"
31
+ }
32
+
33
+ data = {
34
+ "contents":[
35
+ {
36
+ "parts":[
37
+ {"text": prompt}
38
+ ]
39
+ }
40
+ ],
41
+ "generationConfig":{
42
+ "temperature":0.3,
43
+ "maxOutputTokens":300
44
+ }
45
+ }
46
+
47
+ response = requests.post(url, headers=headers, json=data)
48
+
49
+ print("Gemini status:", response.status_code)
50
+
51
+ if response.status_code != 200:
52
+ print(response.text)
53
+ raise Exception("LLM failed")
54
+
55
+ result = response.json()
56
+
57
+ return result["candidates"][0]["content"]["parts"][0]["text"]
main.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, UploadFile, Form, Request
2
+ from fastapi.responses import HTMLResponse
3
+ from fastapi.templating import Jinja2Templates
4
+
5
+ from ingestion import extract_pdf
6
+ from chunking import chunk_text, compress_context
7
+ from retrieval_colbert import ColBERTRetriever
8
+ from reranker import rerank
9
+ from llm import generate_answer
10
+ from scraper import scrape_url
11
+
12
+ app = FastAPI()
13
+
14
+ templates = Jinja2Templates(directory="templates")
15
+
16
+ retriever = ColBERTRetriever()
17
+
18
+
19
+ @app.get("/", response_class=HTMLResponse)
20
+ async def home(request: Request):
21
+
22
+ return templates.TemplateResponse(
23
+ "index.html",
24
+ {"request": request}
25
+ )
26
+
27
+
28
+ @app.post("/upload")
29
+ async def upload(file: UploadFile):
30
+
31
+ text = extract_pdf(file.file)
32
+
33
+ chunks = chunk_text(text, file.filename)
34
+
35
+ retriever.build_index(chunks)
36
+
37
+ print("Index built with", len(chunks), "chunks")
38
+
39
+ return {"status": "indexed"}
40
+
41
+ @app.post("/scrape")
42
+ async def scrape(url: str = Form(...)):
43
+
44
+ text = scrape_url(url)
45
+
46
+ chunks = chunk_text(text, url)
47
+
48
+ retriever.build_index(chunks)
49
+
50
+ return {"status": "webpage indexed"}
51
+
52
+
53
+ @app.post("/ask")
54
+ async def ask(question: str = Form(...)):
55
+
56
+ retrieved = retriever.query(question, k=25)
57
+
58
+ if not retrieved:
59
+ return {
60
+ "answer":"Upload a PDF first",
61
+ "chunks":[]
62
+ }
63
+
64
+ reranked = rerank(question, retrieved)
65
+
66
+ top_chunks = reranked[:2]
67
+
68
+ context = "\n\n".join(
69
+ c["text"][:900]
70
+ for c in top_chunks
71
+ )
72
+ context = context.replace("\n"," ")
73
+
74
+ try:
75
+
76
+ answer = generate_answer(context, question)
77
+
78
+ except Exception as e:
79
+
80
+ print("LLM ERROR:", e)
81
+
82
+ answer = (
83
+ "โš ๏ธ LLM unavailable. Showing best result:\n\n"
84
+ + top_chunks[0]["text"][:600]
85
+ )
86
+
87
+ return {
88
+ "answer":answer,
89
+ "chunks":top_chunks
90
+ }
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ sentence-transformers
4
+ faiss-cpu
5
+ rank-bm25
6
+ numpy
7
+ pypdf
8
+ requests
9
+ beautifulsoup4
10
+ torch
11
+ transformers
12
+ jinja2
13
+ python-multipart
14
+ transformers
15
+ torch
16
+ faiss-cpu
17
+ numpy
reranker.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sentence_transformers import CrossEncoder
2
+
3
+ reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
4
+
5
+ def rerank(question,chunks):
6
+
7
+ pairs=[[question,c["text"]] for c in chunks]
8
+
9
+ scores=reranker.predict(pairs)
10
+
11
+ ranked=sorted(
12
+ zip(scores,chunks),
13
+ key=lambda x:x[0],
14
+ reverse=True
15
+ )
16
+
17
+ return [c for _,c in ranked]
retrieval_colbert.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import faiss
3
+ import numpy as np
4
+
5
+ from transformers import AutoTokenizer, AutoModel
6
+
7
+ MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
8
+
9
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
10
+ model = AutoModel.from_pretrained(MODEL_NAME)
11
+
12
+ class ColBERTRetriever:
13
+
14
+ def __init__(self):
15
+
16
+ self.chunks = []
17
+ self.doc_embeddings = []
18
+ self.index = None
19
+
20
+ # -----------------------------
21
+ # EMBED TEXT TOKENS
22
+ # -----------------------------
23
+
24
+ def embed(self, text):
25
+
26
+ inputs = tokenizer(
27
+ text,
28
+ return_tensors="pt",
29
+ truncation=True,
30
+ padding=True,
31
+ max_length=256
32
+ )
33
+
34
+ with torch.no_grad():
35
+ outputs = model(**inputs)
36
+
37
+ embeddings = outputs.last_hidden_state.squeeze(0)
38
+
39
+ return embeddings.numpy()
40
+
41
+ # -----------------------------
42
+ # BUILD INDEX
43
+ # -----------------------------
44
+
45
+ def build_index(self, chunks):
46
+
47
+ self.chunks = chunks
48
+ vectors = []
49
+
50
+ for c in chunks:
51
+
52
+ emb = self.embed(c["text"])
53
+ vectors.append(emb.mean(axis=0))
54
+
55
+ vectors = np.array(vectors).astype("float32")
56
+
57
+ dim = vectors.shape[1]
58
+
59
+ self.index = faiss.IndexFlatIP(dim)
60
+
61
+ self.index.add(vectors)
62
+
63
+ # -----------------------------
64
+ # QUERY
65
+ # -----------------------------
66
+
67
+ def query(self, question, k=20):
68
+
69
+ q_emb = self.embed(question) # token embeddings
70
+ scores = []
71
+
72
+ for chunk in self.chunks:
73
+
74
+ d_emb = self.embed(chunk["text"])
75
+
76
+ sim = np.matmul(q_emb, d_emb.T) # token similarity
77
+ score = sim.max(axis=1).sum() # MaxSim
78
+
79
+ scores.append(score)
80
+
81
+ idx = np.argsort(scores)[::-1][:k]
82
+
83
+ return [self.chunks[i] for i in idx]
scraper.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from bs4 import BeautifulSoup
3
+
4
+ def scrape_url(url):
5
+
6
+ headers = {"User-Agent":"Mozilla/5.0"}
7
+
8
+ r = requests.get(url, headers=headers)
9
+
10
+ soup = BeautifulSoup(r.text,"html.parser")
11
+
12
+ elements = soup.find_all(["h1","h2","h3","p","li"])
13
+
14
+ text = " ".join(
15
+ el.get_text(strip=True)
16
+ for el in elements
17
+ if el.get_text(strip=True)
18
+ )
19
+
20
+ return text
templates/index.html ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+
6
+ <title>Scholar RAG Engine</title>
7
+
8
+ <style>
9
+
10
+ :root{
11
+ --bg:#f2f2f2;
12
+ --card:#ffffff;
13
+ --text:#111;
14
+ --accent:#2d6cdf;
15
+ }
16
+
17
+ .dark{
18
+ --bg:#0f172a;
19
+ --card:#1e293b;
20
+ --text:#e5e7eb;
21
+ --accent:#3b82f6;
22
+ }
23
+
24
+ body{
25
+ font-family: Arial;
26
+ background:var(--bg);
27
+ color:var(--text);
28
+ padding:40px;
29
+ transition:0.3s;
30
+ }
31
+
32
+ .container{
33
+ max-width:900px;
34
+ margin:auto;
35
+ }
36
+
37
+ .card{
38
+ background:var(--card);
39
+ padding:25px;
40
+ margin-bottom:25px;
41
+ border-radius:12px;
42
+ box-shadow:0 4px 14px rgba(0,0,0,0.1);
43
+ }
44
+
45
+ button{
46
+ padding:10px 20px;
47
+ background:var(--accent);
48
+ color:white;
49
+ border:none;
50
+ border-radius:6px;
51
+ cursor:pointer;
52
+ }
53
+
54
+ button:hover{
55
+ opacity:0.9;
56
+ }
57
+
58
+ input[type=text]{
59
+ width:100%;
60
+ padding:10px;
61
+ margin-top:10px;
62
+ margin-bottom:10px;
63
+ border-radius:6px;
64
+ border:1px solid #ccc;
65
+ }
66
+
67
+ details{
68
+ margin-top:10px;
69
+ background:#f7f7f7;
70
+ padding:10px;
71
+ border-radius:6px;
72
+ }
73
+
74
+ .dark details{
75
+ background:#334155;
76
+ }
77
+
78
+ summary{
79
+ cursor:pointer;
80
+ font-weight:bold;
81
+ }
82
+
83
+ .toggle{
84
+ float:right;
85
+ }
86
+
87
+ .status{
88
+ font-size:14px;
89
+ opacity:0.8;
90
+ }
91
+
92
+ /* LOADER */
93
+
94
+ .loader{
95
+ display:none;
96
+ margin-top:10px;
97
+ font-size:14px;
98
+ color:var(--accent);
99
+ }
100
+
101
+ .spinner{
102
+ border:4px solid #f3f3f3;
103
+ border-top:4px solid var(--accent);
104
+ border-radius:50%;
105
+ width:18px;
106
+ height:18px;
107
+ animation:spin 1s linear infinite;
108
+ display:inline-block;
109
+ margin-right:8px;
110
+ }
111
+
112
+ @keyframes spin{
113
+ 0%{transform:rotate(0deg)}
114
+ 100%{transform:rotate(360deg)}
115
+ }
116
+
117
+ </style>
118
+
119
+ </head>
120
+
121
+ <body>
122
+
123
+ <div class="container">
124
+
125
+ <h1>
126
+ ๐Ÿ“š Scholar RAG Engine
127
+ <button class="toggle" onclick="toggleMode()">๐ŸŒ™</button>
128
+ </h1>
129
+
130
+ <!-- PDF Upload -->
131
+
132
+ <div class="card">
133
+
134
+ <h2>Upload PDF</h2>
135
+
136
+ <input type="file" id="pdf">
137
+
138
+ <br><br>
139
+
140
+ <button onclick="upload()">Upload & Index</button>
141
+
142
+ <div id="uploadLoader" class="loader">
143
+ <span class="spinner"></span> Indexing document...
144
+ </div>
145
+
146
+ <p id="uploadStatus" class="status"></p>
147
+
148
+ </div>
149
+
150
+ <!-- Website Scraper -->
151
+
152
+ <div class="card">
153
+
154
+ <h2>Scrape Website</h2>
155
+
156
+ <input type="text" id="url" placeholder="Paste website URL">
157
+
158
+ <button onclick="scrape()">Scrape & Index</button>
159
+
160
+ <div id="scrapeLoader" class="loader">
161
+ <span class="spinner"></span> Scraping website and indexing...
162
+ </div>
163
+
164
+ <p id="scrapeStatus" class="status"></p>
165
+
166
+ </div>
167
+
168
+ <!-- Ask Question -->
169
+
170
+ <div class="card">
171
+
172
+ <h2>Ask Question</h2>
173
+
174
+ <input type="text" id="question" placeholder="Ask something from indexed documents">
175
+
176
+ <button onclick="ask()">Ask</button>
177
+
178
+ <div id="askLoader" class="loader">
179
+ <span class="spinner"></span> Retrieving answer...
180
+ </div>
181
+
182
+ </div>
183
+
184
+ <!-- Answer -->
185
+
186
+ <div class="card">
187
+
188
+ <h2>Answer</h2>
189
+
190
+ <p id="answer">Answer will appear here</p>
191
+
192
+ <br>
193
+
194
+ <button onclick="toggleChunks()">Show Retrieved Chunks</button>
195
+
196
+ <div id="chunks" style="display:none;margin-top:15px;"></div>
197
+
198
+ </div>
199
+
200
+ </div>
201
+
202
+ <script>
203
+
204
+ function toggleMode(){
205
+ document.body.classList.toggle("dark")
206
+ }
207
+
208
+ async function upload(){
209
+
210
+ let file=document.getElementById("pdf").files[0]
211
+
212
+ if(!file){
213
+ alert("Please select a PDF")
214
+ return
215
+ }
216
+
217
+ document.getElementById("uploadLoader").style.display="block"
218
+
219
+ let formData=new FormData()
220
+
221
+ formData.append("file",file)
222
+
223
+ let res=await fetch("/upload",{
224
+ method:"POST",
225
+ body:formData
226
+ })
227
+
228
+ let data=await res.json()
229
+
230
+ document.getElementById("uploadLoader").style.display="none"
231
+
232
+ document.getElementById("uploadStatus").innerText="Status: "+data.status
233
+ }
234
+
235
+ async function scrape(){
236
+
237
+ let url=document.getElementById("url").value
238
+
239
+ if(!url){
240
+ alert("Enter a URL")
241
+ return
242
+ }
243
+
244
+ document.getElementById("scrapeLoader").style.display="block"
245
+
246
+ let formData=new FormData()
247
+
248
+ formData.append("url",url)
249
+
250
+ let res=await fetch("/scrape",{
251
+ method:"POST",
252
+ body:formData
253
+ })
254
+
255
+ let data=await res.json()
256
+
257
+ document.getElementById("scrapeLoader").style.display="none"
258
+
259
+ document.getElementById("scrapeStatus").innerText="Status: "+data.status
260
+ }
261
+
262
+ async function ask(){
263
+
264
+ let question=document.getElementById("question").value
265
+
266
+ if(!question){
267
+ alert("Enter a question")
268
+ return
269
+ }
270
+
271
+ document.getElementById("askLoader").style.display="block"
272
+
273
+ let formData=new FormData()
274
+
275
+ formData.append("question",question)
276
+
277
+ let res=await fetch("/ask",{
278
+ method:"POST",
279
+ body:formData
280
+ })
281
+
282
+ let data=await res.json()
283
+
284
+ document.getElementById("askLoader").style.display="none"
285
+
286
+ document.getElementById("answer").innerText=data.answer
287
+
288
+ let chunkDiv=document.getElementById("chunks")
289
+
290
+ chunkDiv.innerHTML=""
291
+
292
+ if(data.chunks){
293
+
294
+ data.chunks.forEach((c,i)=>{
295
+
296
+ chunkDiv.innerHTML+=`
297
+ <details>
298
+ <summary>Chunk ${i+1} (${c.source})</summary>
299
+ <p>${c.text}</p>
300
+ </details>
301
+ `
302
+
303
+ })
304
+
305
+ }
306
+
307
+ }
308
+
309
+ function toggleChunks(){
310
+
311
+ let div=document.getElementById("chunks")
312
+
313
+ if(div.style.display==="none")
314
+ div.style.display="block"
315
+ else
316
+ div.style.display="none"
317
+
318
+ }
319
+
320
+ </script>
321
+
322
+ </body>
323
+ </html>