Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -5,21 +5,20 @@ import os
|
|
| 5 |
import uvicorn
|
| 6 |
import re
|
| 7 |
import math
|
| 8 |
-
import time
|
| 9 |
import multiprocessing
|
| 10 |
import urllib.parse
|
| 11 |
import difflib
|
|
|
|
|
|
|
|
|
|
| 12 |
from datetime import datetime
|
| 13 |
from collections import Counter, defaultdict
|
| 14 |
from contextlib import asynccontextmanager
|
| 15 |
from typing import List, Dict, Any, Tuple, Set
|
| 16 |
|
| 17 |
from fastapi import FastAPI, Query, Form
|
| 18 |
-
from fastapi.responses import JSONResponse
|
| 19 |
from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
from huggingface_hub import snapshot_download
|
| 21 |
-
import aiohttp
|
| 22 |
-
from bs4 import BeautifulSoup
|
| 23 |
import torch
|
| 24 |
|
| 25 |
# Hugging Face imports
|
|
@@ -39,10 +38,6 @@ HF_TOKEN = os.getenv("HF_TOKEN")
|
|
| 39 |
AI_RESPONSE_CACHE = {}
|
| 40 |
MAX_AI_CACHE_SIZE = 10000
|
| 41 |
|
| 42 |
-
CONTENT_CACHE = {}
|
| 43 |
-
MAX_CONTENT_CONCURRENCY = 10
|
| 44 |
-
content_semaphore = asyncio.Semaphore(MAX_CONTENT_CONCURRENCY)
|
| 45 |
-
|
| 46 |
# --- 1. DETERMINISTIC PREPROCESSING & CONSTANTS ---
|
| 47 |
STOP_WORDS = {"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "is", "are", "by", "this", "that", "it", "what", "who", "where", "when", "why", "how"}
|
| 48 |
ADULT_KEYWORDS = {
|
|
@@ -78,7 +73,6 @@ def normalize_text(text: str) -> str:
|
|
| 78 |
if not text: return ""
|
| 79 |
text = str(text).lower()
|
| 80 |
text = re.sub(r'[\'’]', '', text)
|
| 81 |
-
# \w allows words from any language (Hindi, Marathi, etc.)
|
| 82 |
text = re.sub(r'[^\w\s\-]', ' ', text)
|
| 83 |
return " ".join(text.split())
|
| 84 |
|
|
@@ -91,146 +85,126 @@ def tokenize(text: str, remove_stopwords: bool = True) -> List[str]:
|
|
| 91 |
# --- 2. DETERMINISTIC QUERY INTENT CLASSIFIER ---
|
| 92 |
def detect_query_intent(query_tokens: List[str]) -> Dict[str, Any]:
|
| 93 |
intents = []
|
| 94 |
-
|
| 95 |
news_terms = {"latest", "news", "today", "breaking", "recent", "updates", "current", "aaj"}
|
| 96 |
if any(term in query_tokens for term in news_terms): intents.append("NEWS")
|
| 97 |
-
|
| 98 |
def_terms = {"definition", "meaning", "kya", "matlab"}
|
| 99 |
if any(term in query_tokens for term in def_terms): intents.append("DEFINITION")
|
| 100 |
-
|
| 101 |
how_terms = {"tutorial", "guide", "steps", "learn", "how", "kaise"}
|
| 102 |
if any(term in query_tokens for term in how_terms): intents.append("HOW_TO")
|
| 103 |
-
|
| 104 |
comp_terms = {"vs", "versus", "compare", "comparison", "difference", "best", "accha"}
|
| 105 |
if any(term in query_tokens for term in comp_terms): intents.append("COMPARISON")
|
| 106 |
-
|
| 107 |
local_terms = {"near", "restaurant", "restaurants", "location", "weather", "paas"}
|
| 108 |
if any(term in query_tokens for term in local_terms): intents.append("LOCAL")
|
| 109 |
-
|
| 110 |
if len(query_tokens) <= 2 and not intents: intents.append("NAVIGATIONAL")
|
| 111 |
-
|
| 112 |
return {"intent": intents[0] if intents else "GENERAL", "signals": intents}
|
| 113 |
|
| 114 |
-
# --- 3.
|
| 115 |
-
async def extract_page_content(url: str) -> Dict[str, Any]:
|
| 116 |
-
if url in search_index.url_to_doc:
|
| 117 |
-
doc = search_index.url_to_doc[url]
|
| 118 |
-
if doc.get("_fast_content"):
|
| 119 |
-
return {"url": url, "title": doc.get("_fast_title", ""), "content": doc.get("_fast_content", ""), "success": True}
|
| 120 |
-
|
| 121 |
-
if url in CONTENT_CACHE:
|
| 122 |
-
return CONTENT_CACHE[url]
|
| 123 |
-
|
| 124 |
-
async with content_semaphore:
|
| 125 |
-
try:
|
| 126 |
-
async with aiohttp.ClientSession() as session:
|
| 127 |
-
async with session.get(url, timeout=3.0, headers={"User-Agent": "Mozilla/5.0 NebulixBot"}) as resp:
|
| 128 |
-
if resp.status != 200:
|
| 129 |
-
return {"url": url, "title": "", "content": "", "success": False, "error": f"HTTP {resp.status}"}
|
| 130 |
-
|
| 131 |
-
html = await resp.text()
|
| 132 |
-
soup = BeautifulSoup(html, "html.parser")
|
| 133 |
-
|
| 134 |
-
for tag in soup(['script', 'style', 'nav', 'footer', 'header', 'aside', 'noscript', 'form', 'button', 'iframe']):
|
| 135 |
-
tag.decompose()
|
| 136 |
-
|
| 137 |
-
blocks = []
|
| 138 |
-
for tag in soup.find_all(['h1', 'h2', 'h3', 'p', 'article', 'main', 'li']):
|
| 139 |
-
text = tag.get_text(separator=' ', strip=True)
|
| 140 |
-
if len(text) > 30: blocks.append(text)
|
| 141 |
-
|
| 142 |
-
clean_blocks = []
|
| 143 |
-
seen_blocks = set()
|
| 144 |
-
for b in blocks:
|
| 145 |
-
if b not in seen_blocks:
|
| 146 |
-
seen_blocks.add(b)
|
| 147 |
-
clean_blocks.append(b)
|
| 148 |
-
|
| 149 |
-
content_str = "\n\n".join(clean_blocks)
|
| 150 |
-
page_title = soup.title.string.strip() if soup.title and soup.title.string else ""
|
| 151 |
-
|
| 152 |
-
result = {"url": url, "title": page_title, "content": content_str, "success": True}
|
| 153 |
-
CONTENT_CACHE[url] = result
|
| 154 |
-
return result
|
| 155 |
-
except Exception as e:
|
| 156 |
-
return {"url": url, "title": "", "content": "", "success": False, "error": str(e)}
|
| 157 |
-
|
| 158 |
def rank_chunks_for_rag(text: str, query_tokens: List[str]) -> List[str]:
|
| 159 |
if not text: return []
|
| 160 |
chunks = text.split("\n\n")
|
| 161 |
q_set = set(query_tokens)
|
| 162 |
scored_chunks = []
|
| 163 |
-
|
| 164 |
for c in chunks:
|
| 165 |
c_toks = set(tokenize(c))
|
| 166 |
score = len(q_set.intersection(c_toks))
|
| 167 |
if score > 0: scored_chunks.append((score, c))
|
| 168 |
-
|
| 169 |
if not scored_chunks and chunks: scored_chunks = [(0, c) for c in chunks[:3]]
|
| 170 |
scored_chunks.sort(reverse=True, key=lambda x: x[0])
|
| 171 |
return [c for score, c in scored_chunks[:2]]
|
| 172 |
|
| 173 |
-
|
|
|
|
| 174 |
class HybridSearchIndex:
|
| 175 |
def __init__(self):
|
| 176 |
self.docs = []
|
| 177 |
-
self.url_to_doc = {}
|
| 178 |
self.total_docs = 0
|
|
|
|
| 179 |
self.vocab = set()
|
| 180 |
self.high_freq_vocab = []
|
| 181 |
self.df = defaultdict(int)
|
| 182 |
self.avg_doc_len = 0.0
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
self.suggestions = []
|
| 185 |
self.k1 = 1.5
|
| 186 |
self.b = 0.75
|
| 187 |
|
| 188 |
-
def
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
for
|
| 213 |
-
|
| 214 |
-
self.df[term] += 1
|
| 215 |
-
self.vocab.add(term)
|
| 216 |
-
|
| 217 |
-
if not doc.get("_is_adult", False):
|
| 218 |
-
clean_title = [w for w in title_tokens if w not in ADULT_KEYWORDS and len(w) > 2]
|
| 219 |
-
for w in clean_title: suggestion_counter[w] += 1
|
| 220 |
-
|
| 221 |
-
d_clean = doc.get("_fast_domain", "").split('.')[0]
|
| 222 |
-
if len(d_clean) > 2 and d_clean not in ADULT_KEYWORDS: suggestion_counter[d_clean] += 5
|
| 223 |
|
|
|
|
| 224 |
self.total_docs = len(self.docs)
|
| 225 |
-
self.avg_doc_len = total_len / max(1, self.total_docs)
|
| 226 |
-
self.suggestions = suggestion_counter.most_common(20000)
|
| 227 |
-
# Store high freq words for fast spell correction (Did you mean)
|
| 228 |
self.high_freq_vocab = [word for word, count in Counter(self.df).most_common(15000)]
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
def idf(self, term: str) -> float:
|
| 232 |
df = self.df.get(term, 0)
|
| 233 |
-
total = max(1, self.total_docs)
|
| 234 |
return math.log(1.0 + (total - df + 0.5) / (df + 0.5))
|
| 235 |
|
| 236 |
def _get_candidates(self, query_tokens: List[str]) -> Set[int]:
|
|
@@ -238,7 +212,6 @@ class HybridSearchIndex:
|
|
| 238 |
for term in query_tokens:
|
| 239 |
if term in self.inverted_index: candidates.update(self.inverted_index[term])
|
| 240 |
|
| 241 |
-
# Fast fallback for slight misspellings during retrieval
|
| 242 |
if len(candidates) < 10 and self.high_freq_vocab:
|
| 243 |
for term in query_tokens:
|
| 244 |
if term not in self.inverted_index:
|
|
@@ -263,7 +236,7 @@ class HybridSearchIndex:
|
|
| 263 |
scores = {"bm25": 0.0, "title": 0.0, "phrase": 0.0, "exact": 0.0, "url": 0.0, "domain": 0.0, "quality": 0.0, "freshness": 0.0, "proximity": 0.0, "root_domain": 0.0, "headings": 0.0, "body": 0.0}
|
| 264 |
|
| 265 |
doc_len = doc.get("_token_len", 1)
|
| 266 |
-
avg_dl = max(1.0, self.avg_doc_len)
|
| 267 |
|
| 268 |
content_lower = content.lower()
|
| 269 |
snippet_lower = snippet.lower()
|
|
@@ -330,27 +303,23 @@ class HybridSearchIndex:
|
|
| 330 |
if len(desc_toks) >= 10: q_score += 0.2
|
| 331 |
if doc.get("url", "").startswith("https"): q_score += 0.2
|
| 332 |
|
| 333 |
-
# Code-based Domain Authority Boost
|
| 334 |
if domain.endswith(".edu") or domain.endswith(".gov"): q_score += 0.4
|
| 335 |
elif domain.endswith(".org"): q_score += 0.15
|
| 336 |
|
| 337 |
if len(title_toks) > 0 and (len(set(title_toks)) / len(title_toks)) < 0.5: q_score -= 0.3
|
| 338 |
scores["quality"] = max(0.0, min(1.0, q_score))
|
| 339 |
|
| 340 |
-
if url == "" or url == "/":
|
| 341 |
-
scores["root_domain"] = 1.0
|
| 342 |
|
| 343 |
return scores
|
| 344 |
|
| 345 |
def search(self, query: str, debug: bool = False) -> Tuple[List[dict], Dict[str, Any], str]:
|
| 346 |
-
# Handle search request if indexing is still loading or totally empty
|
| 347 |
if self.total_docs == 0:
|
| 348 |
return [], {"intent": "GENERAL", "signals": []}, None
|
| 349 |
|
| 350 |
query_norm = normalize_text(query)
|
| 351 |
raw_tokens = query_norm.split()
|
| 352 |
|
| 353 |
-
# Spell Correction ("Did you mean / Showing results for")
|
| 354 |
corrected_tokens = []
|
| 355 |
is_corrected = False
|
| 356 |
|
|
@@ -359,7 +328,6 @@ class HybridSearchIndex:
|
|
| 359 |
if t in self.vocab or t in EXPANSION_DICT or t in ADULT_KEYWORDS:
|
| 360 |
corrected_tokens.append(t)
|
| 361 |
else:
|
| 362 |
-
# Fast correction using pre-computed high frequency words
|
| 363 |
matches = difflib.get_close_matches(t, self.high_freq_vocab, n=1, cutoff=0.85)
|
| 364 |
if matches:
|
| 365 |
corrected_tokens.append(matches[0])
|
|
@@ -377,11 +345,9 @@ class HybridSearchIndex:
|
|
| 377 |
if t in EXPANSION_DICT: expanded_tokens.extend(EXPANSION_DICT[t])
|
| 378 |
|
| 379 |
sorted_tokens = list(dict.fromkeys(expanded_tokens))
|
| 380 |
-
if not sorted_tokens:
|
| 381 |
-
sorted_tokens = corrected_tokens
|
| 382 |
|
| 383 |
sorted_tokens.sort(key=lambda t: self.idf(t), reverse=True)
|
| 384 |
-
|
| 385 |
intent_info = detect_query_intent(sorted_tokens)
|
| 386 |
intent = intent_info["intent"]
|
| 387 |
user_wants_adult = any(aw in query_norm for aw in ADULT_KEYWORDS)
|
|
@@ -438,7 +404,7 @@ class HybridSearchIndex:
|
|
| 438 |
|
| 439 |
if final_score > 0.05:
|
| 440 |
doc_out = {
|
| 441 |
-
"doc_id":
|
| 442 |
"title": doc.get("_fast_title", ""),
|
| 443 |
"description": doc.get("_fast_desc", ""),
|
| 444 |
"snippet": doc.get("_fast_snippet", ""),
|
|
@@ -469,8 +435,7 @@ class HybridSearchIndex:
|
|
| 469 |
search_index = HybridSearchIndex()
|
| 470 |
|
| 471 |
def load_and_index_dataset():
|
| 472 |
-
print("📦 Automatically Downloading '
|
| 473 |
-
dataset_path = "."
|
| 474 |
try:
|
| 475 |
dataset_path = snapshot_download(
|
| 476 |
repo_id="Nebulixlabs/web-search-dataset",
|
|
@@ -480,14 +445,15 @@ def load_and_index_dataset():
|
|
| 480 |
)
|
| 481 |
except Exception as e:
|
| 482 |
print("⚠️ Direct HF Hub fetch failed, attempting local 'data' folder mapping. Error:", e)
|
|
|
|
| 483 |
|
| 484 |
-
raw_data = []
|
| 485 |
data_folder = os.path.join(dataset_path, "data")
|
| 486 |
-
if not os.path.exists(data_folder):
|
| 487 |
-
data_folder = "data"
|
| 488 |
|
| 489 |
if os.path.exists(data_folder):
|
| 490 |
jsonl_files = [f for f in os.listdir(data_folder) if f.endswith(".jsonl")]
|
|
|
|
|
|
|
| 491 |
for file in jsonl_files:
|
| 492 |
file_path = os.path.join(data_folder, file)
|
| 493 |
with open(file_path, "r", encoding="utf-8") as f:
|
|
@@ -495,39 +461,16 @@ def load_and_index_dataset():
|
|
| 495 |
if not line.strip(): continue
|
| 496 |
try:
|
| 497 |
doc = json.loads(line)
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
metadata = doc.get("metadata") or {}
|
| 501 |
-
content_dict = doc.get("content") or {}
|
| 502 |
-
|
| 503 |
-
title_str = metadata.get("title") or ""
|
| 504 |
-
desc_str = metadata.get("description") or ""
|
| 505 |
-
snippet_str = content_dict.get("snippet") or ""
|
| 506 |
-
main_text = content_dict.get("main_text") or ""
|
| 507 |
-
|
| 508 |
-
headings = content_dict.get("headings") or {}
|
| 509 |
-
h1_str = " ".join(headings.get("h1") or [])
|
| 510 |
-
h2_str = " ".join(headings.get("h2") or [])
|
| 511 |
-
|
| 512 |
-
parsed_url = urllib.parse.urlparse(url)
|
| 513 |
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
doc["_fast_desc"] = desc_str
|
| 518 |
-
doc["_fast_snippet"] = snippet_str
|
| 519 |
-
doc["_fast_content"] = main_text
|
| 520 |
-
doc["_fast_headings"] = h1_str + " " + h2_str
|
| 521 |
-
|
| 522 |
-
combined_adult_check = f"{title_str} {desc_str} {url} {snippet_str} {main_text[:200]}".lower()
|
| 523 |
-
# Fast Regex Adult Match
|
| 524 |
-
doc["_is_adult"] = bool(ADULT_REGEX.search(combined_adult_check))
|
| 525 |
-
|
| 526 |
-
raw_data.append(doc)
|
| 527 |
except Exception:
|
| 528 |
pass
|
| 529 |
-
|
| 530 |
-
search_index.
|
| 531 |
|
| 532 |
# --- AI GENERATION MODULE ---
|
| 533 |
def load_ai_models():
|
|
@@ -554,22 +497,19 @@ async def run_ai_task(messages, max_tokens: int, query_key: str):
|
|
| 554 |
|
| 555 |
try: await asyncio.wait_for(ai_semaphore.acquire(), timeout=0.1)
|
| 556 |
except asyncio.TimeoutError:
|
| 557 |
-
return "⚠️ AI Servers are fully occupied right now. Displaying your ultra-fast search results below!
|
| 558 |
|
| 559 |
try:
|
| 560 |
def _gen():
|
| 561 |
tokenizer = ai_generator.tokenizer
|
| 562 |
try: prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 563 |
except: prompt = "\n".join([f"{m['role'].capitalize()}: {m['content']}" for m in messages]) + "\nAssistant: "
|
| 564 |
-
|
| 565 |
result = ai_generator(prompt, max_new_tokens=max_tokens, do_sample=False, truncation=True, return_full_text=False)
|
| 566 |
return result[0]['generated_text'].strip()
|
| 567 |
|
| 568 |
response = await asyncio.to_thread(_gen)
|
| 569 |
-
|
| 570 |
if len(AI_RESPONSE_CACHE) > MAX_AI_CACHE_SIZE: AI_RESPONSE_CACHE.clear()
|
| 571 |
AI_RESPONSE_CACHE[query_key] = response
|
| 572 |
-
|
| 573 |
return response
|
| 574 |
finally:
|
| 575 |
ai_semaphore.release()
|
|
@@ -591,11 +531,6 @@ async def get_suggestions(q: str = Query(..., min_length=1)):
|
|
| 591 |
matches = [word for word, count in search_index.suggestions if word.startswith(q)][:7]
|
| 592 |
return {"query": q, "suggestions": matches}
|
| 593 |
|
| 594 |
-
@app.get("/content")
|
| 595 |
-
async def get_content_endpoint(url: str = Query(...)):
|
| 596 |
-
res = await extract_page_content(url)
|
| 597 |
-
return res
|
| 598 |
-
|
| 599 |
@app.get("/search")
|
| 600 |
async def pure_search(q: str = Query(...), content: bool = Query(False), debug: bool = Query(False)):
|
| 601 |
req_id = str(uuid.uuid4())
|
|
@@ -603,8 +538,7 @@ async def pure_search(q: str = Query(...), content: bool = Query(False), debug:
|
|
| 603 |
top_20 = results[:STRICT_RESULT_LIMIT]
|
| 604 |
|
| 605 |
for r in top_20:
|
| 606 |
-
if content:
|
| 607 |
-
r["content"] = r.get("_internal_content", "")
|
| 608 |
r.pop("_internal_content", None)
|
| 609 |
|
| 610 |
return {
|
|
@@ -628,14 +562,9 @@ async def process_aimode(q: str, include_content: bool, debug: bool, max_ai_toke
|
|
| 628 |
r.pop("_internal_content", None)
|
| 629 |
|
| 630 |
return {
|
| 631 |
-
"request_id": req_id,
|
| 632 |
-
"
|
| 633 |
-
"
|
| 634 |
-
"ai_response": AI_RESPONSE_CACHE[cache_key],
|
| 635 |
-
"results_count": len(top_20),
|
| 636 |
-
"results": top_20,
|
| 637 |
-
"sources": [],
|
| 638 |
-
"cached": True
|
| 639 |
}
|
| 640 |
|
| 641 |
query_tokens = tokenize(q)
|
|
@@ -646,9 +575,7 @@ async def process_aimode(q: str, include_content: bool, debug: bool, max_ai_toke
|
|
| 646 |
valid_sources_count = 0
|
| 647 |
for r in top_20[:3]:
|
| 648 |
doc_content = r.get("_internal_content", "")
|
| 649 |
-
|
| 650 |
-
if include_content:
|
| 651 |
-
r["content"] = doc_content
|
| 652 |
|
| 653 |
if doc_content and valid_sources_count < 3:
|
| 654 |
best_chunks = rank_chunks_for_rag(doc_content, query_tokens)
|
|
@@ -657,8 +584,7 @@ async def process_aimode(q: str, include_content: bool, debug: bool, max_ai_toke
|
|
| 657 |
used_sources.append({"id": valid_sources_count+1, "title": r["title"], "url": r["url"]})
|
| 658 |
valid_sources_count += 1
|
| 659 |
|
| 660 |
-
for r in top_20:
|
| 661 |
-
r.pop("_internal_content", None)
|
| 662 |
|
| 663 |
system_prompt = (
|
| 664 |
"You are a helpful AI. Answer directly using the context. "
|
|
@@ -682,14 +608,9 @@ async def process_aimode(q: str, include_content: bool, debug: bool, max_ai_toke
|
|
| 682 |
ai_reply = await run_ai_task(messages, max_tokens=max_ai_tokens, query_key=cache_key)
|
| 683 |
|
| 684 |
return {
|
| 685 |
-
"request_id": req_id,
|
| 686 |
-
"
|
| 687 |
-
"
|
| 688 |
-
"ai_response": ai_reply,
|
| 689 |
-
"results_count": len(top_20),
|
| 690 |
-
"results": top_20,
|
| 691 |
-
"sources": used_sources,
|
| 692 |
-
"cached": False
|
| 693 |
}
|
| 694 |
|
| 695 |
@app.get("/aimode")
|
|
|
|
| 5 |
import uvicorn
|
| 6 |
import re
|
| 7 |
import math
|
|
|
|
| 8 |
import multiprocessing
|
| 9 |
import urllib.parse
|
| 10 |
import difflib
|
| 11 |
+
import gc
|
| 12 |
+
import sys
|
| 13 |
+
import array
|
| 14 |
from datetime import datetime
|
| 15 |
from collections import Counter, defaultdict
|
| 16 |
from contextlib import asynccontextmanager
|
| 17 |
from typing import List, Dict, Any, Tuple, Set
|
| 18 |
|
| 19 |
from fastapi import FastAPI, Query, Form
|
|
|
|
| 20 |
from fastapi.middleware.cors import CORSMiddleware
|
| 21 |
from huggingface_hub import snapshot_download
|
|
|
|
|
|
|
| 22 |
import torch
|
| 23 |
|
| 24 |
# Hugging Face imports
|
|
|
|
| 38 |
AI_RESPONSE_CACHE = {}
|
| 39 |
MAX_AI_CACHE_SIZE = 10000
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
# --- 1. DETERMINISTIC PREPROCESSING & CONSTANTS ---
|
| 42 |
STOP_WORDS = {"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "is", "are", "by", "this", "that", "it", "what", "who", "where", "when", "why", "how"}
|
| 43 |
ADULT_KEYWORDS = {
|
|
|
|
| 73 |
if not text: return ""
|
| 74 |
text = str(text).lower()
|
| 75 |
text = re.sub(r'[\'’]', '', text)
|
|
|
|
| 76 |
text = re.sub(r'[^\w\s\-]', ' ', text)
|
| 77 |
return " ".join(text.split())
|
| 78 |
|
|
|
|
| 85 |
# --- 2. DETERMINISTIC QUERY INTENT CLASSIFIER ---
|
| 86 |
def detect_query_intent(query_tokens: List[str]) -> Dict[str, Any]:
|
| 87 |
intents = []
|
|
|
|
| 88 |
news_terms = {"latest", "news", "today", "breaking", "recent", "updates", "current", "aaj"}
|
| 89 |
if any(term in query_tokens for term in news_terms): intents.append("NEWS")
|
|
|
|
| 90 |
def_terms = {"definition", "meaning", "kya", "matlab"}
|
| 91 |
if any(term in query_tokens for term in def_terms): intents.append("DEFINITION")
|
|
|
|
| 92 |
how_terms = {"tutorial", "guide", "steps", "learn", "how", "kaise"}
|
| 93 |
if any(term in query_tokens for term in how_terms): intents.append("HOW_TO")
|
|
|
|
| 94 |
comp_terms = {"vs", "versus", "compare", "comparison", "difference", "best", "accha"}
|
| 95 |
if any(term in query_tokens for term in comp_terms): intents.append("COMPARISON")
|
|
|
|
| 96 |
local_terms = {"near", "restaurant", "restaurants", "location", "weather", "paas"}
|
| 97 |
if any(term in query_tokens for term in local_terms): intents.append("LOCAL")
|
|
|
|
| 98 |
if len(query_tokens) <= 2 and not intents: intents.append("NAVIGATIONAL")
|
|
|
|
| 99 |
return {"intent": intents[0] if intents else "GENERAL", "signals": intents}
|
| 100 |
|
| 101 |
+
# --- 3. INTERNAL RAG CHUNKING LOGIC ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
def rank_chunks_for_rag(text: str, query_tokens: List[str]) -> List[str]:
|
| 103 |
if not text: return []
|
| 104 |
chunks = text.split("\n\n")
|
| 105 |
q_set = set(query_tokens)
|
| 106 |
scored_chunks = []
|
|
|
|
| 107 |
for c in chunks:
|
| 108 |
c_toks = set(tokenize(c))
|
| 109 |
score = len(q_set.intersection(c_toks))
|
| 110 |
if score > 0: scored_chunks.append((score, c))
|
|
|
|
| 111 |
if not scored_chunks and chunks: scored_chunks = [(0, c) for c in chunks[:3]]
|
| 112 |
scored_chunks.sort(reverse=True, key=lambda x: x[0])
|
| 113 |
return [c for score, c in scored_chunks[:2]]
|
| 114 |
|
| 115 |
+
|
| 116 |
+
# --- 4. CORE DETERMINISTIC SEARCH INDEX (STREAMING ARCHITECTURE) ---
|
| 117 |
class HybridSearchIndex:
|
| 118 |
def __init__(self):
|
| 119 |
self.docs = []
|
|
|
|
| 120 |
self.total_docs = 0
|
| 121 |
+
self.total_len = 0
|
| 122 |
self.vocab = set()
|
| 123 |
self.high_freq_vocab = []
|
| 124 |
self.df = defaultdict(int)
|
| 125 |
self.avg_doc_len = 0.0
|
| 126 |
+
|
| 127 |
+
# MEMORY SAVER: C-style array index, saves >90% memory over sets
|
| 128 |
+
self.inverted_index = defaultdict(lambda: array.array('I'))
|
| 129 |
+
|
| 130 |
+
self.suggestion_counter = Counter()
|
| 131 |
self.suggestions = []
|
| 132 |
self.k1 = 1.5
|
| 133 |
self.b = 0.75
|
| 134 |
|
| 135 |
+
def add_document(self, doc_json: dict):
|
| 136 |
+
doc_id = len(self.docs)
|
| 137 |
+
|
| 138 |
+
metadata = doc_json.get("metadata") or {}
|
| 139 |
+
content_dict = doc_json.get("content") or {}
|
| 140 |
+
|
| 141 |
+
title_str = metadata.get("title") or ""
|
| 142 |
+
desc_str = metadata.get("description") or ""
|
| 143 |
+
snippet_str = content_dict.get("snippet") or ""
|
| 144 |
+
main_text = (content_dict.get("main_text") or "")[:800] # Safe crop for 16GB
|
| 145 |
+
|
| 146 |
+
headings = content_dict.get("headings") or {}
|
| 147 |
+
h1_str = " ".join(headings.get("h1") or [])
|
| 148 |
+
h2_str = " ".join(headings.get("h2") or [])
|
| 149 |
+
|
| 150 |
+
url = doc_json.get("url", "")
|
| 151 |
+
parsed_url = urllib.parse.urlparse(url)
|
| 152 |
+
|
| 153 |
+
# MEMORY SAVER: sys.intern deduplicates domain strings globally in RAM
|
| 154 |
+
domain_str = sys.intern(parsed_url.netloc.lower().replace("www.", ""))
|
| 155 |
+
|
| 156 |
+
clean_doc = {
|
| 157 |
+
"url": url,
|
| 158 |
+
"_fast_domain": domain_str,
|
| 159 |
+
"_fast_path": parsed_url.path.strip("/"),
|
| 160 |
+
"_fast_title": title_str[:150],
|
| 161 |
+
"_fast_desc": desc_str[:250],
|
| 162 |
+
"_fast_snippet": snippet_str[:300],
|
| 163 |
+
"_fast_content": main_text,
|
| 164 |
+
"_fast_headings": (h1_str + " " + h2_str)[:200]
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
title_tokens = tokenize(clean_doc["_fast_title"])
|
| 168 |
+
desc_tokens = tokenize(clean_doc["_fast_desc"])
|
| 169 |
+
domain_tokens = tokenize(clean_doc["_fast_domain"])
|
| 170 |
+
headings_tokens = tokenize(clean_doc["_fast_headings"])
|
| 171 |
+
snippet_tokens = tokenize(clean_doc["_fast_snippet"])
|
| 172 |
+
content_tokens = tokenize(clean_doc["_fast_content"])[:250]
|
| 173 |
+
|
| 174 |
+
all_tokens = title_tokens + desc_tokens + domain_tokens + headings_tokens + snippet_tokens + content_tokens
|
| 175 |
+
|
| 176 |
+
clean_doc["_token_len"] = len(all_tokens)
|
| 177 |
+
self.total_len += len(all_tokens)
|
| 178 |
+
|
| 179 |
+
combined_adult_check = f"{title_str} {desc_str} {url} {snippet_str} {main_text[:200]}".lower()
|
| 180 |
+
clean_doc["_is_adult"] = bool(ADULT_REGEX.search(combined_adult_check))
|
| 181 |
+
|
| 182 |
+
self.docs.append(clean_doc)
|
| 183 |
+
|
| 184 |
+
distinct_tokens = set(all_tokens)
|
| 185 |
+
for term in distinct_tokens:
|
| 186 |
+
self.inverted_index[term].append(doc_id)
|
| 187 |
+
self.df[term] += 1
|
| 188 |
+
self.vocab.add(term)
|
| 189 |
|
| 190 |
+
if not clean_doc["_is_adult"]:
|
| 191 |
+
clean_title = [w for w in title_tokens if w not in ADULT_KEYWORDS and len(w) > 2]
|
| 192 |
+
for w in clean_title: self.suggestion_counter[w] += 1
|
| 193 |
+
if len(domain_str) > 2 and domain_str not in ADULT_KEYWORDS: self.suggestion_counter[domain_str] += 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
+
def finalize_build(self):
|
| 196 |
self.total_docs = len(self.docs)
|
| 197 |
+
self.avg_doc_len = self.total_len / max(1, self.total_docs)
|
| 198 |
+
self.suggestions = self.suggestion_counter.most_common(20000)
|
|
|
|
| 199 |
self.high_freq_vocab = [word for word, count in Counter(self.df).most_common(15000)]
|
| 200 |
+
|
| 201 |
+
self.suggestion_counter.clear()
|
| 202 |
+
gc.collect()
|
| 203 |
+
print(f"🚀 Indexing Complete! Exactly {self.total_docs} records fully embedded inside Safe RAM Limits.")
|
| 204 |
|
| 205 |
def idf(self, term: str) -> float:
|
| 206 |
df = self.df.get(term, 0)
|
| 207 |
+
total = max(1, self.total_docs)
|
| 208 |
return math.log(1.0 + (total - df + 0.5) / (df + 0.5))
|
| 209 |
|
| 210 |
def _get_candidates(self, query_tokens: List[str]) -> Set[int]:
|
|
|
|
| 212 |
for term in query_tokens:
|
| 213 |
if term in self.inverted_index: candidates.update(self.inverted_index[term])
|
| 214 |
|
|
|
|
| 215 |
if len(candidates) < 10 and self.high_freq_vocab:
|
| 216 |
for term in query_tokens:
|
| 217 |
if term not in self.inverted_index:
|
|
|
|
| 236 |
scores = {"bm25": 0.0, "title": 0.0, "phrase": 0.0, "exact": 0.0, "url": 0.0, "domain": 0.0, "quality": 0.0, "freshness": 0.0, "proximity": 0.0, "root_domain": 0.0, "headings": 0.0, "body": 0.0}
|
| 237 |
|
| 238 |
doc_len = doc.get("_token_len", 1)
|
| 239 |
+
avg_dl = max(1.0, self.avg_doc_len)
|
| 240 |
|
| 241 |
content_lower = content.lower()
|
| 242 |
snippet_lower = snippet.lower()
|
|
|
|
| 303 |
if len(desc_toks) >= 10: q_score += 0.2
|
| 304 |
if doc.get("url", "").startswith("https"): q_score += 0.2
|
| 305 |
|
|
|
|
| 306 |
if domain.endswith(".edu") or domain.endswith(".gov"): q_score += 0.4
|
| 307 |
elif domain.endswith(".org"): q_score += 0.15
|
| 308 |
|
| 309 |
if len(title_toks) > 0 and (len(set(title_toks)) / len(title_toks)) < 0.5: q_score -= 0.3
|
| 310 |
scores["quality"] = max(0.0, min(1.0, q_score))
|
| 311 |
|
| 312 |
+
if url == "" or url == "/": scores["root_domain"] = 1.0
|
|
|
|
| 313 |
|
| 314 |
return scores
|
| 315 |
|
| 316 |
def search(self, query: str, debug: bool = False) -> Tuple[List[dict], Dict[str, Any], str]:
|
|
|
|
| 317 |
if self.total_docs == 0:
|
| 318 |
return [], {"intent": "GENERAL", "signals": []}, None
|
| 319 |
|
| 320 |
query_norm = normalize_text(query)
|
| 321 |
raw_tokens = query_norm.split()
|
| 322 |
|
|
|
|
| 323 |
corrected_tokens = []
|
| 324 |
is_corrected = False
|
| 325 |
|
|
|
|
| 328 |
if t in self.vocab or t in EXPANSION_DICT or t in ADULT_KEYWORDS:
|
| 329 |
corrected_tokens.append(t)
|
| 330 |
else:
|
|
|
|
| 331 |
matches = difflib.get_close_matches(t, self.high_freq_vocab, n=1, cutoff=0.85)
|
| 332 |
if matches:
|
| 333 |
corrected_tokens.append(matches[0])
|
|
|
|
| 345 |
if t in EXPANSION_DICT: expanded_tokens.extend(EXPANSION_DICT[t])
|
| 346 |
|
| 347 |
sorted_tokens = list(dict.fromkeys(expanded_tokens))
|
| 348 |
+
if not sorted_tokens: sorted_tokens = corrected_tokens
|
|
|
|
| 349 |
|
| 350 |
sorted_tokens.sort(key=lambda t: self.idf(t), reverse=True)
|
|
|
|
| 351 |
intent_info = detect_query_intent(sorted_tokens)
|
| 352 |
intent = intent_info["intent"]
|
| 353 |
user_wants_adult = any(aw in query_norm for aw in ADULT_KEYWORDS)
|
|
|
|
| 404 |
|
| 405 |
if final_score > 0.05:
|
| 406 |
doc_out = {
|
| 407 |
+
"doc_id": str(doc_id),
|
| 408 |
"title": doc.get("_fast_title", ""),
|
| 409 |
"description": doc.get("_fast_desc", ""),
|
| 410 |
"snippet": doc.get("_fast_snippet", ""),
|
|
|
|
| 435 |
search_index = HybridSearchIndex()
|
| 436 |
|
| 437 |
def load_and_index_dataset():
|
| 438 |
+
print("📦 Automatically Downloading and Streaming 'web-search-dataset' ...")
|
|
|
|
| 439 |
try:
|
| 440 |
dataset_path = snapshot_download(
|
| 441 |
repo_id="Nebulixlabs/web-search-dataset",
|
|
|
|
| 445 |
)
|
| 446 |
except Exception as e:
|
| 447 |
print("⚠️ Direct HF Hub fetch failed, attempting local 'data' folder mapping. Error:", e)
|
| 448 |
+
dataset_path = "."
|
| 449 |
|
|
|
|
| 450 |
data_folder = os.path.join(dataset_path, "data")
|
| 451 |
+
if not os.path.exists(data_folder): data_folder = "data"
|
|
|
|
| 452 |
|
| 453 |
if os.path.exists(data_folder):
|
| 454 |
jsonl_files = [f for f in os.listdir(data_folder) if f.endswith(".jsonl")]
|
| 455 |
+
docs_processed = 0
|
| 456 |
+
|
| 457 |
for file in jsonl_files:
|
| 458 |
file_path = os.path.join(data_folder, file)
|
| 459 |
with open(file_path, "r", encoding="utf-8") as f:
|
|
|
|
| 461 |
if not line.strip(): continue
|
| 462 |
try:
|
| 463 |
doc = json.loads(line)
|
| 464 |
+
search_index.add_document(doc)
|
| 465 |
+
docs_processed += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
|
| 467 |
+
if docs_processed % 50000 == 0:
|
| 468 |
+
gc.collect()
|
| 469 |
+
print(f"🔄 Streamed & Indexed {docs_processed} docs so far...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
except Exception:
|
| 471 |
pass
|
| 472 |
+
|
| 473 |
+
search_index.finalize_build()
|
| 474 |
|
| 475 |
# --- AI GENERATION MODULE ---
|
| 476 |
def load_ai_models():
|
|
|
|
| 497 |
|
| 498 |
try: await asyncio.wait_for(ai_semaphore.acquire(), timeout=0.1)
|
| 499 |
except asyncio.TimeoutError:
|
| 500 |
+
return "⚠️ AI Servers are fully occupied right now. Displaying your ultra-fast search results below!"
|
| 501 |
|
| 502 |
try:
|
| 503 |
def _gen():
|
| 504 |
tokenizer = ai_generator.tokenizer
|
| 505 |
try: prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 506 |
except: prompt = "\n".join([f"{m['role'].capitalize()}: {m['content']}" for m in messages]) + "\nAssistant: "
|
|
|
|
| 507 |
result = ai_generator(prompt, max_new_tokens=max_tokens, do_sample=False, truncation=True, return_full_text=False)
|
| 508 |
return result[0]['generated_text'].strip()
|
| 509 |
|
| 510 |
response = await asyncio.to_thread(_gen)
|
|
|
|
| 511 |
if len(AI_RESPONSE_CACHE) > MAX_AI_CACHE_SIZE: AI_RESPONSE_CACHE.clear()
|
| 512 |
AI_RESPONSE_CACHE[query_key] = response
|
|
|
|
| 513 |
return response
|
| 514 |
finally:
|
| 515 |
ai_semaphore.release()
|
|
|
|
| 531 |
matches = [word for word, count in search_index.suggestions if word.startswith(q)][:7]
|
| 532 |
return {"query": q, "suggestions": matches}
|
| 533 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
@app.get("/search")
|
| 535 |
async def pure_search(q: str = Query(...), content: bool = Query(False), debug: bool = Query(False)):
|
| 536 |
req_id = str(uuid.uuid4())
|
|
|
|
| 538 |
top_20 = results[:STRICT_RESULT_LIMIT]
|
| 539 |
|
| 540 |
for r in top_20:
|
| 541 |
+
if content: r["content"] = r.get("_internal_content", "")
|
|
|
|
| 542 |
r.pop("_internal_content", None)
|
| 543 |
|
| 544 |
return {
|
|
|
|
| 562 |
r.pop("_internal_content", None)
|
| 563 |
|
| 564 |
return {
|
| 565 |
+
"request_id": req_id, "query": q, "corrected_query": corrected_query,
|
| 566 |
+
"ai_response": AI_RESPONSE_CACHE[cache_key], "results_count": len(top_20),
|
| 567 |
+
"results": top_20, "sources": [], "cached": True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
}
|
| 569 |
|
| 570 |
query_tokens = tokenize(q)
|
|
|
|
| 575 |
valid_sources_count = 0
|
| 576 |
for r in top_20[:3]:
|
| 577 |
doc_content = r.get("_internal_content", "")
|
| 578 |
+
if include_content: r["content"] = doc_content
|
|
|
|
|
|
|
| 579 |
|
| 580 |
if doc_content and valid_sources_count < 3:
|
| 581 |
best_chunks = rank_chunks_for_rag(doc_content, query_tokens)
|
|
|
|
| 584 |
used_sources.append({"id": valid_sources_count+1, "title": r["title"], "url": r["url"]})
|
| 585 |
valid_sources_count += 1
|
| 586 |
|
| 587 |
+
for r in top_20: r.pop("_internal_content", None)
|
|
|
|
| 588 |
|
| 589 |
system_prompt = (
|
| 590 |
"You are a helpful AI. Answer directly using the context. "
|
|
|
|
| 608 |
ai_reply = await run_ai_task(messages, max_tokens=max_ai_tokens, query_key=cache_key)
|
| 609 |
|
| 610 |
return {
|
| 611 |
+
"request_id": req_id, "query": q, "corrected_query": corrected_query,
|
| 612 |
+
"ai_response": ai_reply, "results_count": len(top_20),
|
| 613 |
+
"results": top_20, "sources": used_sources, "cached": False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
}
|
| 615 |
|
| 616 |
@app.get("/aimode")
|