secureshield-backend / utils /vector_store.py
Sharma7's picture
perf(backend): Skip IRDAI knowledge re-indexing if already present in DB
8d4d09c
Raw
History Blame Contribute Delete
12.1 kB
"""
Vector Store utility for PolicyEye.
Uses Supabase PostgreSQL with pgvector extension for semantic search
over IRDAI regulations and policy knowledge base.
This replaces the keyword-based irdai_regulation_lookup with true
semantic (meaning-based) search. For example:
- Query: "how long before diabetes is covered?"
- Matches: "pre_existing_disease_waiting_period" (48 months max)
Embedding model: Uses a lightweight local model via sentence-transformers
so there are no API costs for embeddings.
"""
import os
import json
import logging
import hashlib
from pathlib import Path
from typing import List
logger = logging.getLogger(__name__)
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
# Path to the IRDAI knowledge base
_KNOWLEDGE_DIR = Path(__file__).parent.parent / "knowledge"
# Embedding model — small, fast, free (runs locally, no API cost)
_EMBED_MODEL_NAME = "all-MiniLM-L6-v2"
_embedder = None
def _get_embedder():
"""Lazy-load the sentence-transformers model."""
global _embedder
if _embedder is None:
try:
from sentence_transformers import SentenceTransformer
_embedder = SentenceTransformer(_EMBED_MODEL_NAME)
logger.info(f"[VectorStore] Loaded embedding model: {_EMBED_MODEL_NAME}")
except ImportError:
logger.warning(
"[VectorStore] sentence-transformers not installed. "
"Falling back to keyword search."
)
return None
return _embedder
def _get_client():
"""Lazy-init Supabase client."""
from supabase import create_client
if not SUPABASE_URL or not SUPABASE_KEY:
raise RuntimeError("SUPABASE_URL and SUPABASE_KEY must be set")
return create_client(SUPABASE_URL, SUPABASE_KEY)
def _embed(text: str) -> list:
"""Generate an embedding vector for a text string."""
model = _get_embedder()
if model is None:
return []
vec = model.encode(text).tolist()
return vec
def create_vector_table():
"""
Create the irdai_knowledge table with pgvector column in Supabase.
Call this once during setup.
"""
client = _get_client()
sql = """
CREATE TABLE IF NOT EXISTS irdai_knowledge (
id SERIAL PRIMARY KEY,
chunk_id TEXT UNIQUE NOT NULL,
category TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(384),
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create an index for fast similarity search
CREATE INDEX IF NOT EXISTS idx_irdai_knowledge_embedding
ON irdai_knowledge
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 10);
"""
client.postgrest.rpc("exec_sql", {"query": sql}).execute()
logger.info("[VectorStore] Created irdai_knowledge table with pgvector index")
def _chunk_knowledge_base() -> list[dict]:
"""
Break the IRDAI knowledge base JSON into searchable chunks.
Each chunk gets a unique ID, category, title, and content string.
"""
with open(_KNOWLEDGE_DIR / "irdai_rules.json", "r") as f:
kb = json.load(f)
chunks = []
# Standard definitions
for term, definition in kb.get("standard_definitions", {}).items():
title = term.replace("_", " ").title()
chunks.append({
"chunk_id": f"def_{term}",
"category": "standard_definition",
"title": title,
"content": f"{title}: {definition}",
"metadata": {"term": term},
})
# Mandated limits
for key, data in kb.get("mandated_limits", {}).items():
title = key.replace("_", " ").title()
content_parts = [f"{title}: {data.get('description', '')}"]
if "maximum_days" in data:
content_parts.append(f"Maximum: {data['maximum_days']} days")
if "maximum_months" in data:
content_parts.append(f"Maximum: {data['maximum_months']} months")
if "years" in data:
content_parts.append(f"Period: {data['years']} years")
if "regulation" in data:
content_parts.append(f"Regulation: {data['regulation']}")
if "common_diseases" in data:
content_parts.append(
f"Common diseases: {', '.join(data['common_diseases'])}"
)
chunks.append({
"chunk_id": f"limit_{key}",
"category": "mandated_limit",
"title": title,
"content": " | ".join(content_parts),
"metadata": data,
})
# Standard exclusions (permanent)
for i, excl in enumerate(kb.get("standard_exclusions", {}).get("permanent", [])):
name = excl["name"]
content = f"Permanent Exclusion: {name}"
if "exception" in excl:
content += f" (Exception: {excl['exception']})"
chunks.append({
"chunk_id": f"excl_perm_{i}",
"category": "exclusion_permanent",
"title": name,
"content": content,
"metadata": excl,
})
# Standard exclusions (conditional)
for i, excl in enumerate(kb.get("standard_exclusions", {}).get("conditional", [])):
name = excl["name"]
content = f"Conditional Exclusion: {name}"
if "note" in excl:
content += f" ({excl['note']})"
if "typical_waiting_months" in excl:
content += f" | Typical waiting: {excl['typical_waiting_months']} months"
chunks.append({
"chunk_id": f"excl_cond_{i}",
"category": "exclusion_conditional",
"title": name,
"content": content,
"metadata": excl,
})
# Room rent guidelines
for i, structure in enumerate(
kb.get("room_rent_guidelines", {}).get("common_structures", [])
):
content = f"Room Rent: {structure['description']}"
if "typical_value" in structure:
content += f" (Typical: {structure['typical_value']}%)"
chunks.append({
"chunk_id": f"room_rent_{i}",
"category": "room_rent",
"title": f"Room Rent — {structure['type'].replace('_', ' ').title()}",
"content": content,
"metadata": structure,
})
# Copay guidelines
for i, copay_type in enumerate(
kb.get("copay_guidelines", {}).get("types", [])
):
content = f"Co-payment: {copay_type.get('description', copay_type.get('type', 'N/A'))}"
chunks.append({
"chunk_id": f"copay_{i}",
"category": "copay",
"title": f"Copay — {copay_type['type'].replace('_', ' ').title()}",
"content": content,
"metadata": copay_type,
})
# Compliance guardrails
for key, value in kb.get("compliance_guardrails", {}).items():
title = key.replace("_", " ").title()
chunks.append({
"chunk_id": f"guardrail_{key}",
"category": "compliance_guardrail",
"title": title,
"content": f"Compliance: {title}{value}",
"metadata": {"key": key, "value": value},
})
logger.info(f"[VectorStore] Chunked IRDAI knowledge base into {len(chunks)} chunks")
return chunks
def index_irdai_knowledge(force_reindex=False):
"""
Embed and store all IRDAI knowledge chunks into Supabase pgvector.
This is an idempotent operation — chunks with existing IDs are skipped.
"""
client = _get_client()
# Fast path: Skip if already indexed
if not force_reindex:
try:
res = client.table("irdai_knowledge").select("id", count="exact").limit(1).execute()
if res.count and res.count > 0:
logger.info(f"[VectorStore] Knowledge base already indexed ({res.count} items). Skipping re-index.")
return 0
except Exception as e:
logger.warning(f"[VectorStore] Could not check existing rows: {e}")
embedder = _get_embedder()
if embedder is None:
logger.warning("[VectorStore] No embedder available, skipping indexing")
return 0
chunks = _chunk_knowledge_base()
indexed = 0
for chunk in chunks:
embedding = _embed(chunk["content"])
if not embedding:
continue
# Upsert: insert or skip if chunk_id exists
try:
client.table("irdai_knowledge").upsert(
{
"chunk_id": chunk["chunk_id"],
"category": chunk["category"],
"title": chunk["title"],
"content": chunk["content"],
"embedding": embedding,
"metadata": json.dumps(chunk["metadata"]),
},
on_conflict="chunk_id",
).execute()
indexed += 1
except Exception as e:
logger.warning(f"[VectorStore] Failed to index {chunk['chunk_id']}: {e}")
logger.info(f"[VectorStore] Indexed {indexed}/{len(chunks)} IRDAI knowledge chunks")
return indexed
def semantic_search(query: str, top_k: int = 5) -> list[dict]:
"""
Perform semantic similarity search over the IRDAI knowledge base.
Args:
query: Natural language question (e.g., "is diabetes covered?")
top_k: Number of results to return
Returns:
List of matching chunks with similarity scores
"""
embedding = _embed(query)
if not embedding:
# Fallback to basic text search
return _fallback_text_search(query, top_k)
client = _get_client()
# Use Supabase RPC for vector similarity search
try:
result = client.rpc(
"search_irdai_knowledge",
{
"query_embedding": embedding,
"match_threshold": 0.3,
"match_count": top_k,
},
).execute()
matches = []
for row in result.data or []:
matches.append({
"chunk_id": row.get("chunk_id"),
"category": row.get("category"),
"title": row.get("title"),
"content": row.get("content"),
"similarity": round(row.get("similarity", 0), 4),
"metadata": row.get("metadata", {}),
})
logger.info(
f"[VectorStore] Semantic search for '{query[:50]}...' "
f"→ {len(matches)} results"
)
return matches
except Exception as e:
logger.warning(f"[VectorStore] Semantic search failed: {e}, using fallback")
return _fallback_text_search(query, top_k)
def _fallback_text_search(query: str, top_k: int = 5) -> list[dict]:
"""Fallback: simple keyword search over local JSON if pgvector unavailable."""
chunks = _chunk_knowledge_base()
query_lower = query.lower()
scored = []
for chunk in chunks:
content_lower = chunk["content"].lower()
# Simple keyword overlap score
words = query_lower.split()
score = sum(1 for w in words if w in content_lower) / max(len(words), 1)
if score > 0:
scored.append({**chunk, "similarity": round(score, 4)})
scored.sort(key=lambda x: x["similarity"], reverse=True)
return scored[:top_k]
# SQL function that needs to be created in Supabase for semantic search
SEARCH_FUNCTION_SQL = """
CREATE OR REPLACE FUNCTION search_irdai_knowledge(
query_embedding vector(384),
match_threshold float DEFAULT 0.3,
match_count int DEFAULT 5
)
RETURNS TABLE (
chunk_id text,
category text,
title text,
content text,
similarity float,
metadata jsonb
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
ik.chunk_id,
ik.category,
ik.title,
ik.content,
1 - (ik.embedding <=> query_embedding) AS similarity,
ik.metadata::jsonb
FROM irdai_knowledge ik
WHERE 1 - (ik.embedding <=> query_embedding) > match_threshold
ORDER BY ik.embedding <=> query_embedding
LIMIT match_count;
END;
$$;
"""