GST_RAG_BACKEND / app /retrieval /retrieval_memory.py
Samaksh25's picture
fix(retrieval): CrossEncoder ms-marco + PRF + synonym expansion
6733714
Raw
History Blame Contribute Delete
9.25 kB
"""
Retrieval Memory (Priority 9)
================================
Logs every query + retrieval outcome to an append-only JSONL file.
Provides a mining function that surfaces "if query mentions X, always retrieve Y" patterns
so they can be fed back into the authority registry.
Two components:
1. RetrievalLogger β€” appends one JSON line per query (zero latency impact;
writes are fire-and-forget via a background thread).
2. mine_retrieval_patterns() β€” offline analysis script that reads the log
and produces authority_registry ADDITIONS for topics that are consistently
missing an authority that always appears when manually inspected.
Usage in retriever.py:
from app.retrieval.retrieval_memory import RetrievalLogger
_mem_logger = RetrievalLogger()
# After coverage check, inside search() or supplement_and_rerank():
_mem_logger.log(
query = query,
topic = _taxonomy.get("topics", []),
retrieved = [c.get("rel_path", "") for c in final_chunks[:15]],
coverage_pct = _coverage_result.get("coverage_pct", 0),
missing = _coverage_result.get("missing", []),
)
"""
from __future__ import annotations
import json
import logging
import os
import threading
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── Log file location ─────────────────────────────────────────────────────────
_DEFAULT_LOG = Path(__file__).resolve().parent.parent.parent / "data" / "retrieval_memory.jsonl"
class RetrievalLogger:
"""
Append-only JSONL logger for retrieval outcomes.
All writes happen on a daemon worker thread β€” zero latency impact on the
main retrieval path.
Log entry schema (one JSON object per line):
{
"ts": "ISO-8601 UTC timestamp",
"query": "raw query string",
"topics": ["cross_charge", ...], # taxonomy topic matches
"retrieved": ["circulars/199.../...", ...], # top-15 rel_paths
"coverage_pct": 85, # mandatory coverage %
"missing": ["CGST_SEC_25", ...], # authorities NOT found
"duration_ms": 142 # retrieval wall time (optional)
}
"""
def __init__(self, log_path: Path | str | None = None):
self._path = Path(log_path) if log_path else _DEFAULT_LOG
self._path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._queue = []
self._worker = threading.Thread(target=self._flush_loop, daemon=True)
self._worker.start()
logger.info(f"RetrievalLogger: logging to {self._path}")
def log(
self,
query: str,
topics: list[str],
retrieved: list[str],
coverage_pct: int,
missing: list[str],
duration_ms: int = 0,
) -> None:
"""Enqueues a log entry (non-blocking)."""
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"query": query,
"topics": topics,
"retrieved": retrieved[:20], # cap to avoid bloat
"coverage_pct": coverage_pct,
"missing": missing,
"duration_ms": duration_ms,
}
with self._lock:
self._queue.append(entry)
def _flush_loop(self) -> None:
"""Worker: drains the queue every 5 seconds."""
import time
while True:
time.sleep(5)
self._drain()
def _drain(self) -> None:
with self._lock:
batch, self._queue = self._queue, []
if not batch:
return
try:
with open(self._path, "a", encoding="utf-8") as fh:
for entry in batch:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception as exc:
logger.warning(f"RetrievalLogger flush failed: {exc}")
# ── Offline pattern miner ─────────────────────────────────────────────────────
def mine_retrieval_patterns(
log_path: Path | str | None = None,
min_occurrences: int = 5,
min_miss_rate: float = 0.3,
output_path: Path | str | None = None,
) -> dict[str, Any]:
"""
Analyses the retrieval log to find patterns like:
"For topic X, authority Y is missing in 40% of queries β†’ add Y to registry"
Args:
log_path: Path to retrieval_memory.jsonl (default: data/retrieval_memory.jsonl)
min_occurrences: Minimum times a topic must appear to be included in report
min_miss_rate: Fraction of queries where an authority is missing to flag it
output_path: If set, writes JSON report to this path
Returns:
{
"topic_stats": {
"cross_charge": {
"count": 42,
"avg_coverage_pct": 67,
"frequent_missing": [
{"authority": "CGST_SEC_25", "miss_rate": 0.52, "count": 22}
]
}
},
"registry_additions": {
"cross_charge": {
"sections": ["CGST_SEC_25"], # should add to registry
"circulars": ["CIRCULAR_199"],
}
}
}
After running this, review the `registry_additions` section and apply them
to data/authority_registry.json. The legal team can also run this manually
after observing patterns in production.
"""
_log_path = Path(log_path) if log_path else _DEFAULT_LOG
if not _log_path.exists():
return {"error": f"Log file not found: {_log_path}", "topic_stats": {}, "registry_additions": {}}
# Load log
entries = []
with open(_log_path, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line:
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
if not entries:
return {"error": "Log is empty", "topic_stats": {}, "registry_additions": {}}
# Aggregate per topic
topic_counts: dict[str, int] = defaultdict(int)
topic_cov_sum: dict[str, int] = defaultdict(int)
topic_miss: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for entry in entries:
for topic in entry.get("topics", ["unknown"]):
topic_counts[topic] += 1
topic_cov_sum[topic] += entry.get("coverage_pct", 100)
for miss in entry.get("missing", []):
topic_miss[topic][miss] += 1
# Build stats
topic_stats: dict[str, Any] = {}
registry_additions: dict[str, Any] = {}
for topic, count in sorted(topic_counts.items(), key=lambda x: -x[1]):
if count < min_occurrences:
continue
avg_cov = round(topic_cov_sum[topic] / count)
miss_items = topic_miss.get(topic, {})
frequent_missing = [
{
"authority": auth,
"miss_rate": round(n / count, 2),
"count": n,
}
for auth, n in sorted(miss_items.items(), key=lambda x: -x[1])
if n / count >= min_miss_rate
]
topic_stats[topic] = {
"count": count,
"avg_coverage_pct": avg_cov,
"frequent_missing": frequent_missing,
}
if frequent_missing:
additions: dict[str, list[str]] = {"sections": [], "rules": [], "circulars": []}
for item in frequent_missing:
auth = item["authority"]
if "_SEC_" in auth:
additions["sections"].append(auth)
elif "_RUL_" in auth:
additions["rules"].append(auth)
elif auth.startswith("CIRCULAR_"):
additions["circulars"].append(auth)
if any(additions.values()):
registry_additions[topic] = additions
result = {
"analysed": len(entries),
"first_entry": entries[0]["ts"] if entries else None,
"last_entry": entries[-1]["ts"] if entries else None,
"topic_stats": topic_stats,
"registry_additions": registry_additions,
}
if output_path:
with open(Path(output_path), "w", encoding="utf-8") as fh:
json.dump(result, fh, indent=2, ensure_ascii=False)
logger.info(f"Pattern mining report written to {output_path}")
return result
if __name__ == "__main__":
"""
Quick CLI:
python -m app.retrieval.retrieval_memory
"""
import sys
_out = sys.argv[1] if len(sys.argv) > 1 else None
report = mine_retrieval_patterns(output_path=_out)
print(json.dumps(report, indent=2, ensure_ascii=False))