smart-agro-api / rag_engine.py
harsh0o23's picture
Deploy Axiom V2 Backend: 476M SFT Foundation Model, Vector RAG & Search
a1e58cd verified
Raw History Blame Contribute Delete
12.1 kB
import os
import io
import json
import re
import torch
import httpx
from bs4 import BeautifulSoup
from typing import List, Dict, Any, Optional
from sentence_transformers import SentenceTransformer
from ddgs import DDGS
import pypdf
KNOWLEDGE_DIR = os.path.join(os.path.dirname(__file__), "knowledge_base")
os.makedirs(KNOWLEDGE_DIR, exist_ok=True)
INDEX_PATH = os.path.join(KNOWLEDGE_DIR, "index.pt")
METADATA_PATH = os.path.join(KNOWLEDGE_DIR, "metadata.json")
class RAGEngine:
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
self.device = "mps" if torch.backends.mps.is_available() else "cuda" if torch.cuda.is_available() else "cpu"
print(f"⚡ Initializing RAG Embedding Engine on {self.device}...")
self.encoder = SentenceTransformer(model_name, device=self.device)
self.chunks: List[Dict[str, Any]] = [] # [{ "text": str, "source": str, "chunk_id": int }]
self.embeddings: Optional[torch.Tensor] = None # Tensor [N, 384]
self.load_index()
def load_index(self):
if os.path.exists(INDEX_PATH) and os.path.exists(METADATA_PATH):
try:
self.embeddings = torch.load(INDEX_PATH, map_location=self.device, weights_only=True)
with open(METADATA_PATH, "r", encoding="utf-8") as f:
self.chunks = json.load(f)
print(f"📚 Loaded {len(self.chunks)} document chunks from disk cache.")
except Exception as e:
print(f"⚠️ Failed to load RAG cache: {e}")
self.chunks = []
self.embeddings = None
def save_index(self):
try:
if self.embeddings is not None and len(self.chunks) > 0:
torch.save(self.embeddings, INDEX_PATH)
with open(METADATA_PATH, "w", encoding="utf-8") as f:
json.dump(self.chunks, f, indent=2)
print(f"💾 Saved {len(self.chunks)} chunks to knowledge base.")
else:
if os.path.exists(INDEX_PATH): os.remove(INDEX_PATH)
if os.path.exists(METADATA_PATH): os.remove(METADATA_PATH)
except Exception as e:
print(f"⚠️ Failed to save RAG cache: {e}")
def chunk_text(self, text: str, source: str, chunk_size: int = 500, overlap: int = 80) -> List[Dict[str, Any]]:
cleaned = re.sub(r'\s+', ' ', text).strip()
if not cleaned:
return []
chunks = []
start = 0
chunk_id = 0
text_len = len(cleaned)
while start < text_len:
end = min(start + chunk_size, text_len)
# Try to break at nearest space or punctuation if not at the end
if end < text_len:
last_space = cleaned.rfind(' ', start, end)
if last_space > start + (chunk_size // 2):
end = last_space
chunk_slice = cleaned[start:end].strip()
if len(chunk_slice) > 20:
chunks.append({
"text": chunk_slice,
"source": source,
"chunk_id": chunk_id
})
chunk_id += 1
start = end - overlap if end < text_len else text_len
return chunks
def ingest_file(self, filename: str, file_bytes: bytes) -> Dict[str, Any]:
extracted_text = ""
ext = os.path.splitext(filename)[1].lower()
if ext == ".pdf":
try:
reader = pypdf.PdfReader(io.BytesIO(file_bytes))
pages_text = []
for i, page in enumerate(reader.pages):
t = page.extract_text()
if t:
pages_text.append(f"[Page {i+1}] {t}")
extracted_text = "\n".join(pages_text)
except Exception as e:
raise ValueError(f"Failed to extract text from PDF '{filename}': {e}")
else:
# Plaintext, Markdown, Python, Code, JSON, CSV
try:
extracted_text = file_bytes.decode("utf-8")
except UnicodeDecodeError:
extracted_text = file_bytes.decode("latin-1", errors="ignore")
if not extracted_text.strip():
raise ValueError(f"No extractable text found in '{filename}'")
# Remove existing chunks for this file if re-uploading
self.delete_document(filename, auto_save=False)
new_chunks = self.chunk_text(extracted_text, source=filename)
if not new_chunks:
raise ValueError("Document was too short to create meaningful chunks.")
texts = [c["text"] for c in new_chunks]
new_embeddings = self.encoder.encode(texts, batch_size=32, convert_to_tensor=True, show_progress_bar=False).to(self.device)
# Normalize for cosine similarity via dot product
new_embeddings = torch.nn.functional.normalize(new_embeddings, p=2, dim=1)
if self.embeddings is None or len(self.chunks) == 0:
self.embeddings = new_embeddings
self.chunks = new_chunks
else:
self.embeddings = torch.cat([self.embeddings, new_embeddings], dim=0)
self.chunks.extend(new_chunks)
self.save_index()
return {
"filename": filename,
"chunks_added": len(new_chunks),
"total_chunks": len(self.chunks),
"total_documents": len(self.list_documents())
}
def search(self, query: str, top_k: int = 3, min_similarity: float = 0.20) -> List[Dict[str, Any]]:
if self.embeddings is None or len(self.chunks) == 0:
return []
query_emb = self.encoder.encode([query], convert_to_tensor=True, show_progress_bar=False).to(self.device)
query_emb = torch.nn.functional.normalize(query_emb, p=2, dim=1)
# Dot product of normalized vectors = Cosine Similarity
scores = torch.mm(query_emb, self.embeddings.T).squeeze(0) # [N]
top_k_val = min(top_k, len(self.chunks))
top_scores, top_indices = torch.topk(scores, k=top_k_val)
results = []
for score, idx in zip(top_scores.tolist(), top_indices.tolist()):
if score >= min_similarity:
chunk = dict(self.chunks[idx])
chunk["score"] = round(float(score), 4)
results.append(chunk)
return results
def list_documents(self) -> List[Dict[str, Any]]:
docs: Dict[str, int] = {}
for c in self.chunks:
src = c.get("source", "Unknown")
docs[src] = docs.get(src, 0) + 1
return [{"filename": name, "chunks": count} for name, count in docs.items()]
def delete_document(self, filename: str, auto_save: bool = True) -> int:
if not self.chunks:
return 0
keep_indices = [i for i, c in enumerate(self.chunks) if c.get("source") != filename]
removed_count = len(self.chunks) - len(keep_indices)
if removed_count > 0:
self.chunks = [self.chunks[i] for i in keep_indices]
if self.chunks and self.embeddings is not None:
self.embeddings = self.embeddings[keep_indices]
else:
self.embeddings = None
if auto_save:
self.save_index()
return removed_count
def clear(self):
self.chunks = []
self.embeddings = None
self.save_index()
class WebSearchEngine:
def __init__(self, rag_encoder: Optional[SentenceTransformer] = None):
self.rag_encoder = rag_encoder
self.client = httpx.Client(
headers={
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
},
follow_redirects=True,
timeout=4.0
)
def scrape_url_paragraphs(self, url: str) -> List[str]:
try:
resp = self.client.get(url)
if resp.status_code != 200:
return []
soup = BeautifulSoup(resp.text, "html.parser")
# Strip noise
for tag in soup(["script", "style", "nav", "footer", "header", "noscript", "aside", "form"]):
tag.decompose()
paragraphs = []
for p in soup.find_all(["p", "h1", "h2", "h3", "li"]):
txt = re.sub(r'\s+', ' ', p.get_text()).strip()
if len(txt) > 40 and not any(skip in txt.lower() for skip in ["cookie", "privacy policy", "all rights reserved", "subscribe"]):
paragraphs.append(txt)
return paragraphs[:25] # top 25 candidate paragraphs
except Exception:
return []
def search_wikipedia_fallback(self, query: str, max_results: int = 2) -> List[Dict[str, Any]]:
try:
import urllib.parse
encoded_query = urllib.parse.quote_plus(query)
search_url = f"https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={encoded_query}&utf8=&format=json"
resp = self.client.get(search_url, timeout=3.0)
if resp.status_code == 200:
data = resp.json()
items = data.get("query", {}).get("search", [])
results = []
for item in items[:max_results]:
title = item.get("title", "")
clean_snippet = BeautifulSoup(item.get("snippet", ""), "html.parser").get_text()
results.append({
"title": f"Wikipedia: {title}",
"url": f"https://en.wikipedia.org/wiki/{title.replace(' ', '_')}",
"snippet": clean_snippet,
"body": clean_snippet
})
return results
except Exception:
pass
return []
def search_and_deep_scrape(self, query: str, max_results: int = 3) -> Dict[str, Any]:
raw_results = []
try:
ddgs_res = DDGS().text(query, max_results=max_results)
if ddgs_res:
for r in ddgs_res:
raw_results.append({
"title": r.get("title", "Web Page"),
"url": r.get("href", ""),
"snippet": r.get("body", ""),
"body": r.get("body", "")
})
except Exception as e:
print(f"⚠️ DDGS primary search notice: {e}")
# If primary search produced nothing, invoke Wikipedia fallback
if not raw_results:
raw_results = self.search_wikipedia_fallback(query, max_results=max_results)
if not raw_results:
return {"context": "", "sources": []}
sources = []
detailed_context_blocks = []
for i, item in enumerate(raw_results[:max_results]):
title = item["title"]
url = item["url"]
snippet = item["snippet"]
# Record source metadata for frontend citations
sources.append({
"id": i + 1,
"title": title,
"url": url,
"snippet": snippet[:180] + ("..." if len(snippet) > 180 else "")
})
# Deep scrape top 2 web pages if valid URL
page_text = ""
if url and url.startswith("http") and i < 2:
paragraphs = self.scrape_url_paragraphs(url)
if paragraphs:
# Select top most informative paragraphs
page_text = " ".join(paragraphs[:3])
block_content = page_text if page_text and len(page_text) > len(snippet) else snippet
clean_slice = re.sub(r'\s+', ' ', block_content).strip()[:350]
if clean_slice:
detailed_context_blocks.append(f"- {clean_slice}")
combined_context = "\n".join(detailed_context_blocks)
return {
"context": combined_context,
"sources": sources
}