| |
| """ |
| 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 |
| _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: |
| |
| 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") |
|
|
| |
| store = QdrantVectorStore() |
| 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") |
|
|
| |
| searcher = CDMSRAGSearch() |
| searcher.vector_store = store |
| searcher.embedding_service = LocalEmbedder() |
|
|
| |
| 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}") |
|
|
| |
| 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) |
| 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) |
|
|
| |
| 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)) |
|
|
| |
| 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) |
| 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) |
|
|
| |
| 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) |
|
|