RAG4Vrikshayurveda / rag_pipeline.py
Viraj77's picture
Initial commit: RAG for Vrikshayurveda with forward+backward chaining inference engine
0ee953b
Raw
History Blame Contribute Delete
14.5 kB
"""
JAIM - RAG Pipeline (v3)
Core Retrieval-Augmented Generation engine with:
- BAAI/bge-large-en-v1.5 embeddings (1024 dims)
- Query Expansion via Groq (3 rephrasings + original = 4 queries)
- Deduplication by parent entry ID β†’ single best match to LLM
- Forward + Backward chaining inference engine
Uses `requests` for HTTP calls for Python 3.14 compatibility.
"""
import os
import json
import time
import requests
from dotenv import load_dotenv
from sentence_transformers import SentenceTransformer
from inference.engine import VrikshayurvedaInferenceEngine
load_dotenv()
# ─── Config ──────────────────────────────────────────────────────────────────
PINECONE_API_KEY = os.getenv("PINECONE_API_KEY")
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "jaim3")
EMBEDDING_MODEL = "BAAI/bge-large-en-v1.5"
GROQ_MODEL = "llama-3.3-70b-versatile"
# BGE spec: queries must be prefixed with this instruction
QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
TOP_K = 3
class JAIMPipeline:
"""
JAIM RAG Pipeline v3:
1. Expands user query into 4 variants via Groq
2. Encodes all variants using BGE-large-en-v1.5 (with query prefix)
3. Retrieves top-K from Pinecone for each variant
4. Deduplicates by parent_id, keeps highest score per entry
5. Runs forward + backward chaining inference engine
6. Sends the best match's context + inference grounding to Groq LLM
"""
def __init__(self):
print("πŸ”„ Initializing JAIM Pipeline...")
print(" πŸ“¦ Loading BGE-large-en-v1.5 embedding model...")
self.embed_model = SentenceTransformer(EMBEDDING_MODEL)
print(" 🌲 Connecting to Pinecone...")
self.pinecone_host = self._get_pinecone_host()
print(f" Index host: {self.pinecone_host}")
print(" πŸ€– Groq (Llama 3.3 70B) configured")
print(" 🧠 Initializing forward/backward chaining inference engine...")
self.inference_engine = VrikshayurvedaInferenceEngine()
print("βœ… JAIM Pipeline ready!\n")
# ─── Pinecone Host Discovery ──────────────────────────────────────────────
def _get_pinecone_host(self) -> str:
"""Get the Pinecone index host URL via the control plane API."""
url = f"https://api.pinecone.io/indexes/{INDEX_NAME}"
headers = {"Api-Key": PINECONE_API_KEY}
resp = requests.get(url, headers=headers, timeout=15)
resp.raise_for_status()
return resp.json()["host"]
# ─── Query Expansion via Groq ─────────────────────────────────────────────
def expand_query(self, query: str) -> list[str]:
"""
Use Groq to generate 3 rephrasings of the user query.
Returns [original, rephrasing1, rephrasing2, rephrasing3].
"""
url = "https://api.groq.com/openai/v1/chat/completions"
headers = {
"Authorization": f"Bearer {GROQ_API_KEY}",
"Content-Type": "application/json",
}
prompt = f"""Generate exactly 3 different rephrasings of the following plant symptom query.
Each rephrasing should use different vocabulary while preserving the meaning.
Return ONLY a valid JSON array of 3 strings. No extra text, no markdown.
Original query: "{query}"
"""
payload = {
"model": GROQ_MODEL,
"messages": [
{"role": "system", "content": "You rephrase queries. Return only valid JSON arrays."},
{"role": "user", "content": prompt},
],
"temperature": 0.7,
"max_tokens": 256,
}
for attempt in range(3):
resp = requests.post(url, json=payload, headers=headers, timeout=15)
if resp.status_code == 429:
time.sleep(2 ** (attempt + 1))
continue
resp.raise_for_status()
break
else:
print(" ⚠️ Query expansion failed (rate limited), using original only")
return [query]
content = resp.json()["choices"][0]["message"]["content"].strip()
if content.startswith("```"):
content = content.split("\n", 1)[1].rsplit("```", 1)[0].strip()
try:
rephrasings = json.loads(content)
return [query] + rephrasings[:3]
except json.JSONDecodeError:
print(" ⚠️ Query expansion parse error, using original only")
return [query]
# ─── Pinecone Retrieval ───────────────────────────────────────────────────
def retrieve_single(self, query_embedding: list, top_k: int = TOP_K) -> list[dict]:
"""Query Pinecone with a single embedding vector."""
url = f"https://{self.pinecone_host}/query"
headers = {
"Api-Key": PINECONE_API_KEY,
"Content-Type": "application/json",
}
payload = {
"vector": query_embedding,
"topK": top_k,
"includeMetadata": True,
}
resp = requests.post(url, json=payload, headers=headers, timeout=15)
resp.raise_for_status()
matches = []
for match in resp.json().get("matches", []):
matches.append({
"id": match["id"],
"score": round(match["score"], 4),
"metadata": match.get("metadata", {})
})
return matches
def retrieve_with_variants(self, variants: list[str], top_k: int = TOP_K) -> list[dict]:
"""
Retrieval pipeline using pre-expanded query variants:
1. Embed each variant with BGE-large (query prefix applied)
2. Retrieve top-K from Pinecone for each variant
3. Deduplicate by parent_id, keep highest score per entry
"""
print(f" πŸ”„ Retrieving with {len(variants)} query variants")
all_matches = []
for variant in variants:
prefixed = f"{QUERY_PREFIX}{variant}"
embedding = self.embed_model.encode(prefixed).tolist()
matches = self.retrieve_single(embedding, top_k)
all_matches.extend(matches)
# Deduplicate by parent_id, keep highest score per entry
best_by_parent = {}
for match in all_matches:
parent = match["metadata"].get("parent_id", match["id"])
if parent not in best_by_parent or match["score"] > best_by_parent[parent]["score"]:
best_by_parent[parent] = match
# Sort by score descending
deduped = sorted(best_by_parent.values(), key=lambda x: x["score"], reverse=True)
return deduped
# ─── Context Building ─────────────────────────────────────────────────────
def build_context(self, matches: list[dict]) -> str:
"""Build context from the single highest-scoring deduplicated match."""
if not matches:
return "No relevant entries found in the Vrikshayurveda database."
# Use only the single highest-scoring match
match = matches[0]
meta = match["metadata"]
possible_causes = meta.get("possible_causes", "N/A")
if isinstance(possible_causes, list):
possible_causes = ", ".join(possible_causes)
return (
f"--- Best Match (Relevance: {match['score']}) ---\n"
f"Disorder Type: {meta.get('disorder', 'N/A')}\n"
f"Cause (Dosha): {meta.get('cause_given', 'N/A')}\n"
f"Symptoms: {meta.get('symptoms', 'N/A')}\n"
f"Cause Elaborated: {meta.get('cause_elaborated', 'N/A')}\n"
f"Possible Causes: {possible_causes}\n"
f"Ayurvedic Treatment: {meta.get('treatment_material', 'N/A')}\n"
)
# ─── LLM Response Generation ──────────────────────────────────────────────
def _format_inference_context(self, inference_result) -> str:
"""Convert chaining inference output into compact prompt context."""
if inference_result is None:
return "No deterministic diagnosis available."
if hasattr(inference_result, 'llm_context'):
return inference_result.llm_context
return "No inference context available."
def generate_response(self, query: str, context: str, inference_result=None) -> str:
"""Use Groq REST API (Llama 3.3 70B) to generate a response."""
system_prompt = """You are JAIM (Jnana AI for Marga β€” Knowledge AI for the Path), an expert Ayurvedic plant care assistant powered by the ancient Vrikshayurveda (Science of Plant Life).
You help farmers, gardeners, and plant enthusiasts diagnose plant disorders and recommend traditional Ayurvedic treatments based on the Vrikshayurveda text by Surapala.
IMPORTANT RULES:
1. Base your response PRIMARILY on the retrieved Vrikshayurveda knowledge provided.
2. The Inference Engine Analysis provides a formal reasoning chain β€” use it to validate and strengthen your response.
3. Provide the specific Ayurvedic treatment from the retrieved data based on the matching symptoms.
4. You may add brief modern context to help the user understand, but always prioritize the traditional treatment.
5. Be clear, structured, and helpful.
6. If the query doesn't match any known disorder well, say so honestly."""
llm_grounding = self._format_inference_context(inference_result)
user_message = f"""═══════════════════════════════════════
[Inference Engine Analysis]
═══════════════════════════════════════
{llm_grounding}
═══════════════════════════════════════
[Retrieved Vrikshayurveda Passages]
═══════════════════════════════════════
{context}
═══════════════════════════════════════
[User Question]
═══════════════════════════════════════
{query}
Please provide a comprehensive, well-structured response:"""
url = "https://api.groq.com/openai/v1/chat/completions"
headers = {
"Authorization": f"Bearer {GROQ_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": GROQ_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"temperature": 0.7,
"max_tokens": 2048,
}
# Retry with backoff for rate limits
for attempt in range(3):
resp = requests.post(url, json=payload, headers=headers, timeout=30)
if resp.status_code == 429:
wait_time = 2 ** (attempt + 1)
print(f" ⏳ Rate limited, retrying in {wait_time}s...")
time.sleep(wait_time)
continue
resp.raise_for_status()
break
else:
return "⚠️ Rate limit exceeded. Please try again in a minute."
result = resp.json()
try:
return result["choices"][0]["message"]["content"]
except (KeyError, IndexError):
return f"Error parsing response: {json.dumps(result, indent=2)}"
# ─── Full RAG Pipeline ────────────────────────────────────────────────────
def query(self, user_query: str, top_k: int = TOP_K) -> dict:
"""
Full RAG pipeline:
expand β†’ retrieve β†’ infer (chaining) β†’ build context β†’ generate response.
"""
# Step 1: Expand query into variants
expanded_queries = self.expand_query(user_query)
print(f" πŸ”„ Query expanded into {len(expanded_queries)} variants")
# Step 2: Retrieve with pre-expanded variants
matches = self.retrieve_with_variants(expanded_queries, top_k)
# Step 3: Extract symptom text from retrieved matches for inference
retrieved_chunks = [
m["metadata"].get("symptoms", "")
for m in matches
if m.get("metadata", {}).get("symptoms")
]
# Step 4: Run forward + backward chaining inference engine
diagnosis_result = self.inference_engine.diagnose(
user_query=user_query,
expanded_queries=expanded_queries,
retrieved_chunks=retrieved_chunks,
)
# Step 5: Build context and generate LLM response
context = self.build_context(matches)
response = self.generate_response(
user_query, context, inference_result=diagnosis_result
)
return {
"query": user_query,
"response": response,
"diagnosis": diagnosis_result,
"sources": matches[:top_k],
"num_sources": len(matches)
}
# ─── Quick Test ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
pipeline = JAIMPipeline()
test_query = "My tree trunk is bent and the fruits are hard and not juicy"
print(f"πŸ” Query: {test_query}\n")
result = pipeline.query(test_query)
print("=" * 60)
print("πŸ“‹ JAIM RESPONSE:")
print("=" * 60)
print(result["response"])
print("\n" + "=" * 60)
print(f"πŸ“Š Sources used: {result['num_sources']}")
for src in result["sources"]:
print(f" β€’ {src['id']} (score: {src['score']})")