File size: 6,170 Bytes
b30f068
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
#!/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)