Files
pi-qmem/gateway/store.py
T
enne2 20766de540 feat(gateway): stadio rerank con catena di fallback resiliente (frigate→brain)
- gateway/rerank.py: catena da RERANK_CHAIN (JSON, per-nodo key+timeout),
  cooldown 60s sui nodi falliti, score sigmoide [0,1], degrada con grazia
  all'ordine di fusione se tutti i nodi sono giù
- routes: /v1/memories:search applica il rerank post-fusione (fetch esteso a
  RERANK_CANDIDATES), risposta con rerank{used,backend,took_ms}, flag
  per-query rerank=false; /v1/version espone lo stato rerank
- store: search() accetta limit esteso; models: SearchIn.rerank
- metrics: qmem_rerank_calls_total + durata per backend
- test: 10 nuovi (fallback, cooldown, degradazione, integrazione) — 46 pass
2026-09-08 12:10:27 +02:00

96 lines
3.7 KiB
Python

"""Operazioni Qdrant condivise dalle route."""
from __future__ import annotations
import hashlib
from typing import Any, Optional
from qdrant_client.http import models as qm
from config import SPARSE_VECTOR_NAME
from models import SearchIn
def search_filter(body: SearchIn) -> qm.Filter | None:
must: list[Any] = []
for key in ("kind", "project_id", "scope", "parent_id", "level", "topic"):
value = getattr(body, key)
if value:
must.append(qm.FieldCondition(key=key, match=qm.MatchValue(value=value)))
if not body.include_superseded:
must.append(qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by")))
must_not: list[Any] = []
if not body.include_private:
# default: esclude i record riservati (private=true) dalle ricerche standard
must_not.append(qm.FieldCondition(key="private", match=qm.MatchValue(value=True)))
if must or must_not:
return qm.Filter(must=must or None, must_not=must_not or None)
return None
def search(qdrant: Any, collection: str, body: SearchIn, vector: list[float], sparse: Any, limit: Optional[int] = None) -> list[Any]:
"""Ricerca ibrida o densa. Con reranking attivo limit > top_k per dare candidati extra allo stadio di rerank."""
eff_limit = limit if limit is not None else body.top_k
qfilter = search_filter(body)
if body.hybrid and sparse is not None:
return qdrant.query_points(
collection_name=collection,
prefetch=[
qm.Prefetch(query=vector, using="", limit=max(body.top_k * 4, eff_limit), score_threshold=body.min_score),
qm.Prefetch(query=sparse, using=SPARSE_VECTOR_NAME, limit=max(body.top_k * 4, eff_limit)),
],
query=qm.FusionQuery(fusion=qm.Fusion.RRF),
query_filter=qfilter,
limit=eff_limit,
with_payload=True,
).points
return qdrant.query_points(
collection_name=collection,
query=vector,
query_filter=qfilter,
limit=eff_limit,
score_threshold=body.min_score,
with_payload=True,
).points
def format_results(hits: list[Any]) -> list[dict]:
return [
{
"memory_id": h.id,
"score": round(h.score, 4),
"text": h.payload.get("text"),
"kind": h.payload.get("kind"),
"agent_id": h.payload.get("agent_id"),
"scope": h.payload.get("scope"),
"project_id": h.payload.get("project_id"),
"confidence": h.payload.get("confidence"),
"created_at": h.payload.get("created_at"),
"source": h.payload.get("source"),
"supersedes_id": h.payload.get("supersedes_id"),
"superseded_by": h.payload.get("superseded_by"),
"supersede_reason": h.payload.get("supersede_reason"),
"parent_id": h.payload.get("parent_id"),
"level": h.payload.get("level"),
"topic": h.payload.get("topic"),
"private": h.payload.get("private", False),
"links": h.payload.get("links"),
}
for h in hits
]
def reparent_active_children(qdrant: Any, collection: str, old_id: str, new_id: str) -> int:
children, _ = qdrant.scroll(
collection_name=collection,
scroll_filter=qm.Filter(must=[
qm.FieldCondition(key="parent_id", match=qm.MatchValue(value=old_id)),
qm.IsEmptyCondition(is_empty=qm.PayloadField(key="superseded_by")),
]),
limit=1000,
with_payload=False,
)
if not children:
return 0
qdrant.set_payload(collection_name=collection, payload={"parent_id": new_id}, points=[p.id for p in children])
return len(children)