gsearch-api / structured_search.py
tanmay-bm's picture
fix: taluka entity causing district filter to reject correct college rows
a0d142e
Raw
History Blame Contribute Delete
13.6 kB
"""
GCAS Search Engine – Structured In-Memory Lookup
=================================================
Replaces FAISS for ~90% of queries by scanning the in-memory data_store
with the structured filters extracted in query_planner.QueryPlan.
Why this is better than FAISS for this dataset
-----------------------------------------------
β€’ The data is a structured Excel DB β€” not free text. Every query in the
sample taxonomy is a WHERE-clause lookup (college + intent + filters).
β€’ FAISS uses semantic embeddings which are dominated by "for girls / mahila"
style phrases, causing wrong colleges to rank first.
β€’ A Python list scan of 44,500 rows completes in < 80 ms β€” fast enough for
a voice agent with no embedding roundtrip.
Lookup strategy
---------------
1. If college_name is resolved β†’ hard-match on CollegeName.
2. Else if district / university / program β†’ soft scan with those filters.
3. Sort/rank results:
- college-specific: order by program name (alphabetical, fees asc for fee intent)
- list queries: Government > Grant-in-Aid > Self-Finance, then alphabetical
4. Returns list of {table, row_index, score, data} dicts β€” same shape as
indexer.search() so the rest of the pipeline is unchanged.
"""
from __future__ import annotations
import logging
import re
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Column name normalisers (handles naming inconsistencies across tables)
# ---------------------------------------------------------------------------
def _get(row: Dict, *keys: str, default: str = "") -> str:
for k in keys:
v = row.get(k)
if v is not None:
s = str(v).strip()
if s and s.lower() not in ("nan", "none", "nat"):
return s
return default
def _college_name(row: Dict) -> str:
return _get(row, "CollegeName", "College")
def _district(row: Dict) -> str:
return _get(row, "CollegeDistrict", "DistrictName")
def _university(row: Dict) -> str:
return _get(row, "UniversityName", "University")
def _program(row: Dict) -> str:
return _get(row, "AdmissionName", "Program")
def _college_type(row: Dict) -> str:
# CollegeMaster uses InstituteTypeForCollegeName
# IntakeMaster uses CollegeProgramTypeName (Grant in Aid - Regular / Self Finance / Government)
return _get(row, "InstituteTypeForCollegeName", "CollegeProgramTypeName")
def _medium(row: Dict) -> str:
return _get(row, "MediumName", "Medium")
def _education_mode(row: Dict) -> str:
return _get(row, "EducationMode")
# ---------------------------------------------------------------------------
# College-name matching
# ---------------------------------------------------------------------------
def _college_matches(row: Dict, college_name: str) -> bool:
"""
Match a resolved canonical college name against a data-store row.
Handles:
- exact match
- row name starts with / contains the canonical name
- canonical name is a prefix of the row name (e.g. row has suffix like
"B.COM. (ENGLISH MEDIUM) ADDITIONAL DIVISION SELF FINANCE (FOR WOMEN'S)")
"""
cn = college_name.strip().lower()
rn = _college_name(row).strip().lower()
if not rn:
return False
return (
rn == cn
or rn.startswith(cn)
or cn.startswith(rn)
or cn in rn
)
# ---------------------------------------------------------------------------
# College-type matching (handles "Grant in Aid - Regular" β†’ "Grant in Aid")
# ---------------------------------------------------------------------------
_COLLEGE_TYPE_NORM: Dict[str, str] = {
"government": "government",
"grant in aid": "grant",
"grant-in-aid": "grant",
"aided": "grant",
"self finance": "self",
"self-finance": "self",
"self financing": "self",
"private": "self",
"university department": "university",
"constituent": "constituent",
"diet": "diet",
}
def _type_matches(row: Dict, college_type: str) -> bool:
ct_raw = _college_type(row).lower()
wanted = college_type.lower()
# normalise both sides
ct_norm = next((v for k, v in _COLLEGE_TYPE_NORM.items() if k in ct_raw), ct_raw)
want_norm = next((v for k, v in _COLLEGE_TYPE_NORM.items() if k in wanted), wanted)
return want_norm in ct_norm or ct_norm in want_norm
# ---------------------------------------------------------------------------
# Hostel type matching
# ---------------------------------------------------------------------------
def _hostel_matches(row: Dict, hostel_needed: str) -> bool:
if hostel_needed == "girls":
return _get(row, "GirlsHostel").lower() == "yes"
if hostel_needed == "boys":
return _get(row, "BoysHostel").lower() == "yes"
# "any" β€” at least one hostel exists
return (
_get(row, "GirlsHostel").lower() == "yes"
or _get(row, "BoysHostel").lower() == "yes"
)
# ---------------------------------------------------------------------------
# NAAC filter
# ---------------------------------------------------------------------------
def _naac_matches(row: Dict, query: str) -> bool:
is_naac = _get(row, "IsNAAC").lower()
if is_naac != "yes":
return False
# Optional: grade filter (e.g. "A+ grade")
grade_m = re.search(r"\bnaac\s+(a\+?|b\+?|c)\b", query, re.IGNORECASE)
if grade_m:
wanted_grade = grade_m.group(1).upper()
actual_grade = _get(row, "NAACGrade").upper()
return actual_grade.startswith(wanted_grade)
return True
# ---------------------------------------------------------------------------
# Row matcher β€” applies all plan filters
# ---------------------------------------------------------------------------
def _row_matches(
row: Dict,
college_name: Optional[str],
district: Optional[str],
university: Optional[str],
program_pattern: Optional[str],
gender: Optional[str],
medium: Optional[str],
college_type: Optional[str],
education_mode: Optional[str],
hostel_needed: Optional[str],
intent: str,
normalized_query: str,
) -> bool:
# ── College name (hard match when resolved) ───────────────────────────
if college_name:
if not _college_matches(row, college_name):
return False
# ── District ──────────────────────────────────────────────────────────
# Skip district filter when college_name is already resolved β€” the college
# name uniquely identifies the college and is more specific than district.
# (Filtering by district would incorrectly drop colleges in a taluka whose
# name matches the query but whose CollegeDistrict differs from the taluka.)
if district and not college_name:
dist = _district(row).lower()
if not dist:
return False
if district.lower() not in dist and dist not in district.lower():
return False
# ── University ────────────────────────────────────────────────────────
if university:
uni = _university(row).lower()
if university.lower() not in uni:
return False
# ── Program ───────────────────────────────────────────────────────────
if program_pattern:
prog = _program(row).lower()
if not prog:
return False
if program_pattern.lower() not in prog:
return False
# ── College type ──────────────────────────────────────────────────────
if college_type:
if not _type_matches(row, college_type):
return False
# ── Medium ────────────────────────────────────────────────────────────
if medium:
med = _medium(row).lower()
if medium.lower() not in med:
return False
# ── Education mode (Girls-Only / Boys-Only) ───────────────────────────
if education_mode:
em = _education_mode(row).lower()
if education_mode.lower() not in em:
return False
# ── Hostel availability ───────────────────────────────────────────────
if hostel_needed:
if not _hostel_matches(row, hostel_needed):
return False
# ── NAAC filter ───────────────────────────────────────────────────────
if intent == "naac":
if not _naac_matches(row, normalized_query):
return False
return True
# ---------------------------------------------------------------------------
# Scoring / ranking
# ---------------------------------------------------------------------------
_TYPE_RANK = {
"government": 0,
"grant": 1,
"constituent": 2,
"university": 3,
"diet": 4,
"self": 5,
}
def _rank_score(row: Dict, intent: str, gender: Optional[str]) -> float:
"""
A deterministic score in [0, 1] for sorting list-query results.
Higher = ranked first.
"""
ct_raw = _college_type(row).lower()
ct_norm = next((v for k, v in _COLLEGE_TYPE_NORM.items() if k in ct_raw), "self")
base = 1.0 - (_TYPE_RANK.get(ct_norm, 5) / 10.0)
# For fee queries: prefer rows that have non-zero female fee when gender=female
if intent == "fees" and gender == "female":
fee_str = _get(row,
"Programme Fees for Female Student (Annual/ Two Semesters",
"fee_girls")
try:
if float(fee_str) > 0:
base += 0.05
except (ValueError, TypeError):
pass
return min(base, 0.99)
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def lookup(
plan: Any, # QueryPlan (imported lazily to avoid circular imports)
data_store: Dict[str, List[Dict]],
max_rows: int = 200,
) -> List[Dict[str, Any]]:
"""
Scan data_store with structured filters from QueryPlan.
Returns a list of candidate dicts:
{table, row_index, score, data}
Same shape as indexer.search() so the rest of the pipeline works unchanged.
Returns [] when no entities are specified (caller should fall back to FAISS).
"""
# Guard: don't run a full-table scan with zero filters (return to FAISS)
has_filter = any([
plan.college_name,
plan.district,
plan.university,
plan.program_pattern,
plan.college_type,
plan.education_mode,
plan.hostel_needed,
(plan.intent == "naac"),
])
if not has_filter:
logger.debug("[structured] No filters β€” deferring to FAISS.")
return []
tables = plan.preferred_tables or list(data_store.keys())
results: List[Dict[str, Any]] = []
for table_name in tables:
rows = data_store.get(table_name, [])
for idx, row in enumerate(rows):
if _row_matches(
row = row,
college_name = plan.college_name,
district = plan.district,
university = plan.university,
program_pattern = plan.program_pattern,
gender = plan.gender,
medium = plan.medium,
college_type = plan.college_type,
education_mode = plan.education_mode,
hostel_needed = plan.hostel_needed,
intent = plan.intent,
normalized_query = plan.corrected_query,
):
score = _rank_score(row, plan.intent, plan.gender)
results.append({
"table": table_name,
"row_index": idx,
"score": score,
"data": row,
})
if len(results) >= max_rows:
break
if len(results) >= max_rows:
break
if results:
# Sort: highest score first, then college name alphabetically
results.sort(key=lambda c: (-c["score"], _college_name(c["data"]).lower()))
logger.info(
"[structured] Found %d rows | college=%r district=%r uni=%r prog=%r "
"gender=%s intent=%s",
len(results), plan.college_name, plan.district,
plan.university, plan.program_pattern, plan.gender, plan.intent,
)
else:
logger.info(
"[structured] 0 rows matched β€” will fall back to FAISS | "
"college=%r district=%r uni=%r prog=%r",
plan.college_name, plan.district, plan.university, plan.program_pattern,
)
return results