File size: 13,583 Bytes
b30f068 3197651 b30f068 9b5aa54 3197651 9b5aa54 3197651 9b5aa54 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 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 | """
CDMS RAG Search Module
Searches Qdrant vector database for CDMS label information with page citations
"""
import sys
from pathlib import Path
from typing import Dict, List, Optional
# Add project root to path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from src.rag.vector_store import QdrantVectorStore, get_shared_vector_store
from src.rag.embeddings import OpenAIEmbeddingService
from src.config.credentials import CredentialsManager
from qdrant_client.models import Filter, FieldCondition, MatchValue
class CDMSRAGSearch:
"""
RAG search for CDMS pesticide labels
Searches Qdrant vector database for relevant label information
with accurate page number tracking
Usage:
searcher = CDMSRAGSearch()
results = searcher.search("What's the application rate for Roundup?", product_name="Roundup")
"""
def __init__(self, vector_store=None):
"""Initialize RAG search components.
vector_store: optional shared QdrantVectorStore. The embedded on-disk
Qdrant permits one client per path per process, so online mode passes a
single store shared with the DocumentLoader instead of opening a second.
"""
# Reuse the provided store, else the process-wide singleton (never open a
# second client to the embedded store — see get_shared_vector_store).
if vector_store is not None:
self.vector_store = vector_store
else:
try:
self.vector_store = get_shared_vector_store()
except Exception as e:
print(f"⚠️ Warning: Could not initialize Qdrant: {e}")
self.vector_store = None
# Initialize embedding service
try:
creds = CredentialsManager()
openai_key = creds.get_api_key("openai")
self.embedding_service = OpenAIEmbeddingService(api_key=openai_key)
except Exception as e:
print(f"⚠️ Warning: Could not initialize OpenAI embeddings: {e}")
self.embedding_service = None
def search(
self,
query: str,
product_name: Optional[str] = None,
limit: int = 5,
score_threshold: float = 0.4
) -> List[Dict]:
"""
Search CDMS documents in Qdrant
Args:
query: User's question or search query
product_name: Optional product name to filter results (e.g., "Roundup")
limit: Maximum number of results to return
score_threshold: Minimum similarity score (0-1)
Returns:
List of dicts with:
{
"content": str (chunk text),
"page_number": int (exact page number),
"source_file": str (PDF filename),
"score": float (similarity score 0-1),
"document_id": str,
"chunk_index": int
}
"""
if not self.vector_store or not self.embedding_service:
return []
# Abstention gate: if the caller asked for a specific product that we
# have not indexed (or whose text failed to extract), return nothing so
# the response layer can say "not found" instead of the vector search
# falling back to the product that dominates the index.
if product_name:
try:
from src.cdms.product_catalog import get_catalog
if not get_catalog().is_available(product_name):
print(f"ℹ️ '{product_name}' is not in the indexed catalog - abstaining.")
return []
except Exception as e:
print(f"⚠️ Catalog availability check skipped: {e}")
try:
# Generate query embedding
query_embedding = self.embedding_service.generate_embedding(query)
if not query_embedding:
print("⚠️ Warning: Failed to generate query embedding")
return []
# Over-fetch so we have room to diversify (general query) or keep the
# best chunks within a product (scoped query).
search_limit = limit * 3
if product_name:
# Scope retrieval to the requested product AT the vector level.
# This is the robust fix for the ISA "wrong herbicide" bug: a
# minority product (e.g. boron = ~2% of the index) is no longer
# crowded out of a global, Roundup-dominated top-k before the
# Python post-filter ever runs.
from src.cdms.product_catalog import normalize_filename
canonical = normalize_filename(product_name)
# The Qdrant filter already guarantees we only see the requested
# product's chunks, so the 0.4 wrong-product gate no longer applies
# here. Use a low threshold so an explicitly-requested, indexed
# product still returns its label even when a generic query matches
# its (e.g. SDS) text only weakly -- Actagro's best chunk is ~0.34,
# which the 0.4 gate wrongly turned into a "0 labels" abstention.
product_threshold = min(score_threshold, 0.2)
product_filter = Filter(
must=[FieldCondition(key="product", match=MatchValue(value=canonical))]
)
results = self.vector_store.search_documents(
query_embedding=query_embedding,
limit=search_limit,
score_threshold=product_threshold,
query_filter=product_filter,
)
if results:
results = results[:limit]
else:
# Fallback for indexes built before the `product` payload field
# existed (or a filter miss): global search + substring
# post-filter — the previous behavior. Abstention has already
# guaranteed the product is in the catalog.
global_results = self.vector_store.search_documents(
query_embedding=query_embedding,
limit=search_limit,
score_threshold=product_threshold,
)
product_lower = product_name.lower()
results = [
r for r in global_results
if product_lower in r.get("source_file", "").lower()
or product_lower in r.get("metadata", {}).get("document_name", "").lower()
][:limit]
else:
# No product filter (general query): diversify so the product
# that dominates the index doesn't monopolise every slot.
results = self.vector_store.search_documents(
query_embedding=query_embedding,
limit=search_limit,
score_threshold=score_threshold,
)
from src.cdms.product_catalog import diversify_by_product
results = diversify_by_product(results, limit=limit, max_per_product=2)
# Format results with page numbers and PDF URLs
formatted_results = []
for result in results:
# PHASE 1 FIX: Extract pdf_url from result with multiple fallback strategies
metadata = result.get("metadata", {})
# Strategy 1: Direct pdf_url field (preferred - from vector_store extraction)
pdf_url = result.get("pdf_url", "")
# Strategy 2: From metadata dict
if not pdf_url:
pdf_url = metadata.get("pdf_url", "")
# Strategy 3: From payload directly (if metadata is the payload)
if not pdf_url and isinstance(metadata, dict):
pdf_url = metadata.get("pdf_url", "")
# Also extract url_hash for matching
url_hash = result.get("url_hash", "") or metadata.get("url_hash", "")
# PHASE 2 FIX: Extract page_number with multiple fallback strategies
page_number = result.get("page_number", 0)
# Strategy 1: Direct page_number field (preferred - from vector_store extraction)
if page_number <= 0:
# Strategy 2: From metadata dict
page_number = metadata.get("page_number", 0)
# Strategy 3: Validate and fix if still invalid
if page_number <= 0:
# Fallback: Estimate page number based on chunk_index
chunk_index = metadata.get("chunk_index", 0)
if chunk_index > 0:
# Rough estimate: 3 chunks per page
page_number = (chunk_index // 3) + 1
else:
# Last resort: use page 1
page_number = 1
print(f"⚠️ Warning: Invalid or missing page_number for chunk, using estimated value: {page_number}")
formatted_results.append({
"content": result.get("content", ""),
"page_number": page_number, # PHASE 2 FIX: Validated page number
"source_file": result.get("source_file", "Unknown"),
"score": result.get("score", 0.0),
"document_id": result.get("document_id", ""),
"chunk_index": metadata.get("chunk_index", 0),
"document_name": metadata.get("document_name", ""),
"pdf_url": pdf_url, # PHASE 1 FIX: Include PDF URL from metadata
"url_hash": url_hash # PHASE 1 FIX: Include URL hash for matching
})
return formatted_results
except Exception as e:
print(f"⚠️ Warning: RAG search failed: {e}")
import traceback
traceback.print_exc()
return []
def search_by_product(
self,
product_name: str,
query: Optional[str] = None,
limit: int = 5
) -> List[Dict]:
"""
Search for specific product information
Args:
product_name: Product name (e.g., "Roundup")
query: Optional specific question (if None, returns general product info)
limit: Maximum results
Returns:
List of relevant chunks with page numbers
"""
if query:
return self.search(query=query, product_name=product_name, limit=limit)
else:
# General product search
return self.search(
query=f"{product_name} pesticide label information",
product_name=product_name,
limit=limit
)
def get_collection_stats(self) -> Dict:
"""
Get statistics about the CDMS documents collection
Returns:
Dict with collection information
"""
if not self.vector_store:
return {"error": "Vector store not initialized"}
try:
info = self.vector_store.get_collection_info()
return info
except Exception as e:
return {"error": str(e)}
# Test function
if __name__ == "__main__":
print("=" * 80)
print("Testing CDMS RAG Search")
print("=" * 80)
try:
searcher = CDMSRAGSearch()
# Check collection stats
print("\n📊 Collection Stats:")
stats = searcher.get_collection_stats()
if "error" not in stats:
if "cdms_documents" in stats:
doc_info = stats["cdms_documents"]
print(f" Documents in Qdrant: {doc_info.get('points_count', 0)}")
print(f" Vectors: {doc_info.get('vectors_count', 0)}")
else:
print(" ⚠️ No documents indexed yet")
else:
print(f" ⚠️ {stats['error']}")
# Test search (if documents exist)
if stats.get("cdms_documents", {}).get("points_count", 0) > 0:
print("\n🔍 Testing search: 'application rate'")
results = searcher.search("application rate", limit=3)
if results:
print(f" ✅ Found {len(results)} result(s)")
for i, result in enumerate(results, 1):
print(f"\n {i}. Score: {result['score']:.3f}")
print(f" Page: {result['page_number']}")
print(f" File: {result['source_file']}")
print(f" Content: {result['content'][:100]}...")
else:
print(" ⚠️ No results found")
else:
print("\n💡 To test search:")
print(" 1. Download PDFs (Phase 1)")
print(" 2. Process and index PDFs in Qdrant")
print(" 3. Run this test again")
print("\n" + "=" * 80)
except Exception as e:
print(f"❌ Error: {e}")
import traceback
traceback.print_exc()
|