agAdvisor / test_rag_integration.py
tirtho149's picture
Deploy AgAdvisor
b30f068 verified
Raw
History Blame Contribute Delete
6.17 kB
#!/usr/bin/env python3
"""
End-to-end RAG integration test (NO OpenAI key required).
Drives the REAL src/cdms/rag_search.py pipeline over the REAL 2,109 chunks from
data/cdms_metadata.db, using an in-memory Qdrant and a local hashing embedder in
place of OpenAI. Demonstrates the before/after for the ISA-reported bug:
* OLD path (raw top-k, threshold 0.3, no diversity) -> results dominated by the
product that owns most of the index (Roundup = 71% of chunks).
* NEW path (rag_search.search: threshold 0.4 + product diversity + abstention)
-> varied products, and an honest "no results" for products we don't have.
Requires: qdrant-client, numpy (pip install qdrant-client numpy)
Run: python test_rag_integration.py
"""
import hashlib
import re
import sqlite3
import sys
from collections import Counter
import numpy as np
sys.path.insert(0, ".")
from src.rag.vector_store import QdrantVectorStore
from src.cdms.rag_search import CDMSRAGSearch
from src.cdms.product_catalog import normalize_filename
DIM = 1536 # match QdrantVectorStore's configured vector size
_TOKEN = re.compile(r"[a-z0-9]+")
def embed(text: str):
"""Deterministic lexical embedding (hashed bag-of-words, L2-normalized).
Not semantic, but faithful for this test: it reproduces the index-dominance
effect (more Roundup chunks -> Roundup fills the top-k) so we can show the
fix changing the outcome. COSINE distance in Qdrant matches the normalization.
"""
v = np.zeros(DIM, dtype=np.float32)
for tok in _TOKEN.findall(text.lower()):
idx = int(hashlib.md5(tok.encode()).hexdigest(), 16) % DIM
v[idx] += 1.0
n = np.linalg.norm(v)
if n > 0:
v /= n
return v.tolist()
class LocalEmbedder:
"""Drop-in stand-in for OpenAIEmbeddingService."""
def generate_embedding(self, text: str):
return embed(text)
def products_of(results):
return dict(Counter(normalize_filename(r.get("source_file", "")) for r in results))
def main() -> bool:
# 1) Load real chunks from the shipped DB.
conn = sqlite3.connect("data/cdms_metadata.db")
rows = conn.execute(
"""SELECT dc.id, dc.content, dc.page_number, dc.document_id, d.filename
FROM document_chunks dc JOIN documents d ON dc.document_id = d.id"""
).fetchall()
print(f"Loaded {len(rows)} real chunks from data/cdms_metadata.db")
# 2) Build an in-memory Qdrant and index every chunk with local embeddings.
store = QdrantVectorStore() # Docker unavailable -> in-memory mode
for cid, content, page, docid, filename in rows:
if not content:
continue
payload = {
"content": content,
"source_file": filename,
"page_number": page or 0,
"document_id": docid,
}
store.add_document_chunk(str(cid), embed(content), payload)
print("Indexed chunks into in-memory Qdrant.\n")
# 3) Wire the REAL search pipeline to the local store + embedder.
searcher = CDMSRAGSearch()
searcher.vector_store = store
searcher.embedding_service = LocalEmbedder()
# Helper: OLD behavior = raw top-5 at the old 0.3 threshold, no diversity.
def old_top5(query):
return store.search_documents(embed(query), limit=5, score_threshold=0.05)
passed, failed = 0, []
def check(name, cond, detail=""):
nonlocal passed
if cond:
passed += 1
print(f" ✅ {name} {detail}")
else:
failed.append(name)
print(f" ❌ {name} {detail}")
# --- Case 1: general query should not be monopolized by one product -----
print("=" * 70)
print("CASE 1: general query — dominance vs diversity")
print("=" * 70)
q1 = "What is the application rate and mixing instructions?"
old = old_top5(q1)
new = searcher.search(q1, score_threshold=0.05) # low thr = lexical scale
old_p, new_p = products_of(old), products_of(new)
print(f" query: {q1!r}")
print(f" OLD top-5 products: {old_p}")
print(f" NEW top-5 products: {new_p}")
check("NEW caps any single product at <=2", max(new_p.values()) <= 2 if new_p else False)
check("NEW returns >=2 distinct products", len(new_p) >= 2 if new_p else False)
# --- Case 2: specific product we HAVE resolves to that product ----------
print("\n" + "=" * 70)
print("CASE 2: specific indexed product (Sevin)")
print("=" * 70)
q2 = "Is Sevin safe to use on vegetables?"
res2 = searcher.search(q2, product_name="sevin", score_threshold=0.0)
p2 = products_of(res2)
print(f" query: {q2!r} -> products: {p2}")
check("returns only Sevin chunks", set(p2.keys()) <= {"sevin"} and bool(p2))
# --- Case 3: product we DON'T have -> abstain (the core bug) ------------
print("\n" + "=" * 70)
print("CASE 3: un-indexed product (Trust) — must ABSTAIN, not substitute")
print("=" * 70)
q3 = "What is the application rate for Trust herbicide?"
old3 = old_top5(q3) # OLD global search: no abstention, no product filter
res3 = searcher.search(q3, product_name="Trust", score_threshold=0.0)
print(f" query: {q3!r}")
print(f" OLD path would answer from: {products_of(old3)} <-- THE BUG (wrong products)")
print(f" NEW path returns: {len(res3)} results (abstains)")
check("OLD path substitutes a different product", len(old3) > 0 and "trust" not in products_of(old3))
check("abstains on un-indexed product", len(res3) == 0)
# --- Case 4: 0-chunk product (ACQUIT) also abstains --------------------
print("\n" + "=" * 70)
print("CASE 4: present-but-unprocessed product (ACQUIT, 0 chunks)")
print("=" * 70)
res4 = searcher.search("ACQUIT label safety", product_name="ACQUIT", score_threshold=0.0)
print(f" -> {len(res4)} results (expected 0)")
check("abstains on 0-chunk product", len(res4) == 0)
print("\n" + "=" * 70)
print(f"INTEGRATION RESULT: {passed} passed, {len(failed)} failed")
if failed:
print("FAILED:", ", ".join(failed))
print("=" * 70)
return not failed
if __name__ == "__main__":
sys.exit(0 if main() else 1)