LawAgent-backend / src /retriever.py
hllerdgn's picture
fix: retriever pickle cache path ve BM25Plus modül hatası düzeltildi
d904aaf
Raw
History Blame Contribute Delete
15.5 kB
import math
import os
import sys
import pickle
import re
import logging
import atexit
import warnings
import copyreg
from collections import defaultdict
from typing import Any, Dict, List, Optional, Tuple
# Bu dosyanın bulunduğu dizin (src/) — CWD'den bağımsız
_SRC_DIR = os.path.dirname(os.path.abspath(__file__))
# services/ klasörü BACKEND/ kökünde — hangi dizinden çalıştırılırsa çalıştırılsın bulsun
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
from qdrant_client.http import models as qm
from embedder import MursitEmbedder, COLLECTION_NAME
from services.qdrant_client import get_qdrant_client
warnings.filterwarnings("ignore", category=UserWarning)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("LawAgent.Retriever")
# ═══════════════════════════════════════════════════════════════════════════════
# AYARLAR
# ═══════════════════════════════════════════════════════════════════════════════
class CFG:
TOP_K_DENSE: int = 200
TOP_K_BM25: int = 200
FINAL_K: int = 15
MAX_SAME_ARTICLE: int = 2
ALPHA_DEFAULT: float = 0.72
ALPHA_EXACT: float = 0.45
ALPHA_SEMANTIC: float = 0.80
BOOST_MADDE: float = 35.0
BOOST_ICTIHAT: float = 6.0
BOOST_KANUN: float = 2.5
CACHE_PATH: str = os.path.join(_SRC_DIR, "data", "retriever_cache.pkl")
BOLUM_ANAHTARLARI = ("GEREKÇE", "HÜKÜM", "KARAR", "UYUŞMAZLIK", "SONUÇ")
# ═══════════════════════════════════════════════════════════════════════════════
# BM25PLUS SINIFI
# ═══════════════════════════════════════════════════════════════════════════════
class BM25Plus:
def __init__(self, k1: float = 1.6, b: float = 0.68, delta: float = 1.0):
self.k1 = k1
self.b = b
self.delta = delta
self.n = 0
self.avgdl = 0.0
self.idf: Dict[str, float] = {}
self.tf: List[Dict[str, int]] = []
self.dl: List[int] = []
def _tokenize(self, text: str) -> List[str]:
text = re.sub(r"[^\w\s]", " ", text.lower())
return [t for t in text.split() if len(t) > 1]
def index(self, docs: List[str]) -> None:
self.n = len(docs)
toks = [self._tokenize(d) for d in docs]
self.dl = [len(t) for t in toks]
self.avgdl = sum(self.dl) / max(self.n, 1)
df = defaultdict(int)
for t in toks:
for term in set(t):
df[term] += 1
self.idf = {
term: math.log((self.n - f + 0.5) / (f + 0.5) + 1) for term, f in df.items()
}
self.tf = [
dict(defaultdict(int, {term: t.count(term) for term in t})) for t in toks
]
log.info(f"[BM25+] {self.n} doküman indexlendi.")
def score(self, query: str, n: int = 300) -> List[Tuple[int, float]]:
q_toks = self._tokenize(query)
if not q_toks:
return []
scores = []
for i in range(self.n):
dl, tf, skor = self.dl[i], self.tf[i], 0.0
for t in q_toks:
if t not in self.idf or tf.get(t, 0) == 0:
continue
f = tf[t]
num = f * (self.k1 + 1)
den = f + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
skor += self.idf[t] * ((num / den) + self.delta)
if skor > 0:
scores.append((i, skor))
scores.sort(key=lambda x: -x[1])
return scores[:n]
# ═══════════════════════════════════════════════════════════════════════════════
# FUSION VE YARDIMCILAR
# ═══════════════════════════════════════════════════════════════════════════════
def _minmax_normalize(vals: List[float]) -> List[float]:
if not vals:
return vals
mn, mx = min(vals), max(vals)
rng = mx - mn
return [(v - mn) / rng if rng > 0 else 1.0 for v in vals]
def hybrid_fuse(
dense_hits: List[Dict], bm25_hits: List[Dict], alpha: float
) -> List[Dict]:
d_norms = _minmax_normalize([h["dense_score"] for h in dense_hits])
dense_map = {
h["chunk_id"]: {**h, "dense_norm": dn} for h, dn in zip(dense_hits, d_norms)
}
b_norms = _minmax_normalize([h["bm25_score"] for h in bm25_hits])
bm25_map = {
h["chunk_id"]: {**h, "bm25_norm": bn} for h, bn in zip(bm25_hits, b_norms)
}
all_cids = set(dense_map) | set(bm25_map)
fused = []
for cid in all_cids:
d, b = dense_map.get(cid), bm25_map.get(cid)
base = d if d else b
d_n, b_n = (d["dense_norm"] if d else 0.0), (b["bm25_norm"] if b else 0.0)
fused.append(
{
**base,
"skor": round(alpha * d_n + (1 - alpha) * b_n, 6),
"dense_score": round(d["dense_score"] if d else 0.0, 4),
"bm25_score": round(b["bm25_score"] if b else 0.0, 4),
}
)
fused.sort(key=lambda x: -x["skor"])
return fused
def detect_source_intent(query: str) -> str:
q = query.lower()
ictihat_keywords = ["karar", "içtihat", "yargıtay", "emsal", "görüş", "daire"]
return "yargitay" if any(kw in q for kw in ictihat_keywords) else "hybrid"
def normalize_article(x: Any) -> str:
if x is None:
return ""
found = re.search(r"(\d+)", str(x))
return found.group(1) if found else ""
_ESANLAMLILAR = {
# TBK Eklemeleri
"gabin": "aşırı oransızlık sömürme sarsılma orantısız 28",
"ikrah": "korkutma tehdit zorlama 37 38 39",
"müteselsil": "zincirleme ortaklaşa dayanışma 161 162",
# TKHK Eklemeleri
"aidat": "üyelik ücreti yıllık ücret kart çıkarma bedeli",
"promosyon": "hediye kültürel ürün süreli yayın 21",
# TTK (En çok hata burada)
"unvan": "ticaret unvanı tecavüz koruma marka 52",
"müdür": "limited şirket müdür sorumluluk temsil 623 630 644",
"bono": "emre yazılı senet kambiyo senedi zamanaşımı temel ilişki 732",
"çek": "karşılıksızdır ibraz şerh arkasına vurulan 796 814",
"rüçhan": "yeni pay alma öncelik sermaye artırımı 461",
"defter": "ticari defter tasdik delil niteliği 64 222",
}
def expand_query(query: str) -> str:
q = query.lower()
ekler = []
for anahtar, deger in _ESANLAMLILAR.items():
if anahtar in q:
ekler.append(deger)
return query + " " + " ".join(ekler) if ekler else query
def detect_kanun(query: str) -> Optional[str]:
q = query.lower()
mapping = {
"tbk": "TBK",
"ttk": "TTK",
"tkhk": "TKHK",
"borçlar": "TBK",
"ticaret": "TTK",
"tüketici": "TKHK",
}
for k, v in mapping.items():
if k in q:
return v
return None
def extract_madde(query: str) -> Optional[str]:
# "TBK 117", "m.117", "madde 117" gibi tüm varyasyonları yakalar
found = re.search(r"(?:tbk|ttk|tkhk|madde|m\.|md\.)?\s*(\d+)", query.lower())
if found:
return found.group(1)
return None
# ═══════════════════════════════════════════════════════════════════════════════
# LEGAL RETRIEVER SINIFI
# ═══════════════════════════════════════════════════════════════════════════════
class LegalRetriever:
def __init__(self, quantize: bool = False, reindex: bool = False):
self.cfg = CFG()
self.embedder = MursitEmbedder(quantize=quantize)
self.qdrant = get_qdrant_client()
self.bm25 = BM25Plus()
self.corpus: List[Dict] = []
atexit.register(self._kapat)
self._load(reindex)
def _kapat(self):
try:
self.qdrant.close()
except:
pass
def _load(self, reindex: bool) -> None:
if not reindex and os.path.exists(self.cfg.CACHE_PATH):
# Pickle __main__.BM25Plus → retriever.BM25Plus yönlendirmesi
# (retriever.py doğrudan çalıştırılıp cache kaydedilmişse gerekli)
import sys as _sys
_this_module = _sys.modules.get(__name__) or _sys.modules.get("retriever")
if _this_module and not hasattr(_sys.modules.get("__main__"), "BM25Plus"):
import types
_fake = types.ModuleType("__main__")
_fake.BM25Plus = BM25Plus
_sys.modules.setdefault("__main__", _fake)
_sys.modules["__main__"].BM25Plus = BM25Plus
try:
with open(self.cfg.CACHE_PATH, "rb") as f:
data = pickle.load(f)
self.corpus, self.bm25 = data["corpus"], data["bm25"]
log.info(f"Cache yuklendi: {len(self.corpus)} chunk.")
return
except (AttributeError, ModuleNotFoundError) as e:
log.warning(f"Cache yuklenemedi ({e}), Qdrant'tan yeniden olusturuluyor...")
# Bozuk cache'i sil, aşağıda yeniden oluşturulacak
try:
os.remove(self.cfg.CACHE_PATH)
except OSError:
pass
log.info("Corpus Qdrant'tan çekiliyor (Scroll)...")
offset = None
while True:
res, offset = self.qdrant.scroll(
COLLECTION_NAME, limit=500, offset=offset, with_payload=True
)
for p in res:
pay = p.payload
self.corpus.append(
{
"chunk_id": str(p.id),
"text": pay.get("text", ""),
"law": pay.get("law", ""),
"article_no": normalize_article(pay.get("article_no")),
"source": pay.get("source", ""),
"decision_id": pay.get("decision_id", ""),
}
)
if offset is None:
break
self.bm25.index([c["text"] for c in self.corpus])
os.makedirs(os.path.dirname(self.cfg.CACHE_PATH), exist_ok=True)
with open(self.cfg.CACHE_PATH, "wb") as f:
pickle.dump({"corpus": self.corpus, "bm25": self.bm25}, f)
def retrieve(self, query: str, k: int = None) -> List[Dict]:
k = k or self.cfg.FINAL_K
# Sorgu Genişletme (Sadece BM25 için değil, tüm süreci besler)
expanded_q = expand_query(query)
kanun, madde, source_intent = (
detect_kanun(query),
extract_madde(query),
detect_source_intent(query),
)
# --- DİNAMİK ALPHA (GÜNCELLENDİ) ---
query_len = len(query.split())
if madde:
alpha = self.cfg.ALPHA_EXACT
elif query_len > 10: # Uzun cümlelerde Mürşit'e daha çok güven
alpha = self.cfg.ALPHA_SEMANTIC
else:
alpha = self.cfg.ALPHA_DEFAULT
# 1. Dense Search (Orijinal query ile anlamsal arama)
vec = self.embedder.encode_single(query)
dense_results = self.qdrant.query_points(
COLLECTION_NAME, query=vec, limit=self.cfg.TOP_K_DENSE, with_payload=True
).points
dense_hits = [{**p.payload, "dense_score": p.score} for p in dense_results]
# 2. BM25 Search (Genişletilmiş query ile anahtar kelime araması)
bm25_hits_raw = self.bm25.score(expanded_q, n=self.cfg.TOP_K_BM25)
bm25_hits = [{**self.corpus[idx], "bm25_score": s} for idx, s in bm25_hits_raw]
# 3. Hybrid Fusion
fused = hybrid_fuse(dense_hits, bm25_hits, alpha)
# 4. Boosting
for c in fused:
# article_no'yu her ihtimale karşı tekrar temizleyip string yapıyoruz
res_art = normalize_article(c.get("article_no", ""))
# Madde numarası tam eşleşiyorsa
if madde and str(res_art) == str(madde):
# DEBUG: Gerçekten buraya giriyor mu görmek için terminale yazdırabilirsin
# print(f"DEBUG: {madde} maddesi yakalandı, boost uygulanıyor.")
c["skor"] *= self.cfg.BOOST_MADDE
# Diğer boostlar...
if source_intent == "yargitay" and c.get("source") == "yargitay":
c["skor"] *= self.cfg.BOOST_ICTIHAT
if kanun and str(c.get("law", "")).upper() == str(kanun).upper():
c["skor"] *= self.cfg.BOOST_KANUN
# Skorlara göre tekrar sırala
fused.sort(key=lambda x: -x["skor"])
return self._filter_results(fused, k)
def _filter_results(self, results: List[Dict], k: int) -> List[Dict]:
seen_art = defaultdict(int) # mevzuat maddeleri için
seen_dec = defaultdict(int) # içtihat kararları için
filtered = []
for r in results:
if r["source"] == "mevzuat":
key = f"{r['law']}_{r['article_no']}"
# Aynı maddeden en fazla 1 kez al (config'te MAX_SAME_ARTICLE=1 olmalı)
if seen_art[key] >= self.cfg.MAX_SAME_ARTICLE:
continue
seen_art[key] += 1
elif r["source"] == "yargitay":
key = r["decision_id"]
# Aynı karardan sadece 1 kez al (diversity artırıldı)
if seen_dec[key] >= 1:
continue
seen_dec[key] += 1
filtered.append(r)
if len(filtered) >= k:
break
return filtered
if __name__ == "__main__":
r = LegalRetriever()
# İçtihat beklediğimiz bir sorgu
test_sorgu = "Kira bedeli ödenmezse tahliye süreci ve Yargıtay kararları"
print(f"\n🔍 TEST SORGUSU: {test_sorgu}")
print("-" * 80)
sonuclar = r.retrieve(test_sorgu, k=5)
for i, s in enumerate(sonuclar, 1):
kaynak = s.get("source", "Bilinmiyor").upper()
madde = s.get("article_no", "-")
karar_id = s.get("decision_id", "-")
skor = s.get("skor", 0)
print(f"{i}. [{kaynak}] | Skor: {skor:.4f}")
if kaynak == "YARGITAY":
print(f" ⚖️ KARAR ID: {karar_id}")
else:
print(f" 📜 MADDE: {s.get('law')} m.{madde}")
print(f" 📝 METİN: {s.get('text')[:150]}...")
print("-" * 80)