From 06795feed6f0af115f47b3948df3cf8d5b37a840 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 3 Dec 2025 10:39:11 +0100 Subject: [PATCH] Dateien nach "app/core" hochladen --- app/core/retriever.py | 125 ++++++++++++++++-------------------------- 1 file changed, 48 insertions(+), 77 deletions(-) diff --git a/app/core/retriever.py b/app/core/retriever.py index 38718cb..fb2487f 100644 --- a/app/core/retriever.py +++ b/app/core/retriever.py @@ -1,19 +1,3 @@ -""" -app/core/retriever.py — Semantischer/Hybrid-Retriever (WP-04 / Step 4a, Schritt 1) - -Aktueller Stand: -- Reine Chunk-Vektorsuche gegen *_chunks (Qdrant) -- Zwei Modi: - - semantic_retrieve: used_mode = "semantic" - - hybrid_retrieve: used_mode = "hybrid" (aktuell gleiche Kandidatenliste) -- Noch keine Edge-Expansion, kein retriever_weight - -Wichtige Design-Entscheidung: -- Wir importieren die benötigten Funktionen NICHT direkt, - sondern die Module (qdr, qp, ec). Dadurch funktionieren - Monkeypatches in den Tests (monkeypatch.setattr(qp, "..."), etc.). -""" - from __future__ import annotations import time @@ -27,22 +11,12 @@ import app.services.embeddings_client as ec def _get_client_and_prefix() -> Tuple[Any, str]: - """ - Liefert (QdrantClient, prefix) basierend auf QdrantConfig.from_env(). - """ cfg = qdr.QdrantConfig.from_env() client = qdr.get_client(cfg) return client, cfg.prefix def _get_query_vector(req: QueryRequest) -> List[float]: - """ - Liefert den Query-Vektor aus dem Request. - - - Falls req.query_vector gesetzt ist, wird dieser unverändert genutzt. - - Falls req.query (Text) gesetzt ist, wird ec.embed_text(req.query) aufgerufen. - - Andernfalls: ValueError. - """ if req.query_vector is not None: if not isinstance(req.query_vector, list): raise ValueError("query_vector muss eine Liste von floats sein") @@ -61,48 +35,66 @@ def _semantic_hits( top_k: int, filters: Dict | None, ): - """ - Kapselt den Aufruf von qp.search_chunks_by_vector. - - Rückgabeformat laut qdrant_points.search_chunks_by_vector: - [ - (point_id: str, score: float, payload: dict), - ... - ] - """ flt = filters or None hits = qp.search_chunks_by_vector(client, prefix, vector, top=top_k, filters=flt) return hits +def _resolve_top_k(req: QueryRequest) -> int: + if isinstance(req.top_k, int) and req.top_k > 0: + return req.top_k + s = get_settings() + return max(1, int(getattr(s, "RETRIEVER_TOP_K", 10))) + + +def _compute_total_score(semantic_score: float, payload: Dict[str, Any]) -> Tuple[float, float, float]: + """Berechnet total_score auf Basis von semantic_score und retriever_weight. + + Aktuelle Formel (Step 2): + total_score = semantic_score * max(retriever_weight, 0.0) + + retriever_weight stammt aus dem Chunk-Payload und ist bereits aus types.yaml + abgeleitet. Falls nicht gesetzt, wird 1.0 angenommen. + + edge_bonus und centrality_bonus bleiben in diesem Schritt 0.0. + """ + raw_weight = payload.get("retriever_weight", 1.0) + try: + weight = float(raw_weight) + except (TypeError, ValueError): + weight = 1.0 + if weight < 0.0: + weight = 0.0 + + edge_bonus = 0.0 + cent_bonus = 0.0 + total = float(semantic_score) * weight + edge_bonus + cent_bonus + return total, edge_bonus, cent_bonus + + def _build_hits_from_semantic( hits: List[Tuple[str, float, Dict[str, Any]]], top_k: int, used_mode: str, ) -> QueryResponse: - """ - Formt rohe Treffer in QueryResponse um. - - Aktueller Step-1-Stand: - - edge_bonus = 0.0 - - centrality_bonus = 0.0 - - total_score = semantic_score - """ + """Formt rohe Treffer in QueryResponse um und wendet das Scoring an.""" t0 = time.time() - # defensiv sortieren, auch wenn Qdrant selbst sortiert - sorted_hits = sorted(hits, key=lambda h: float(h[1]), reverse=True) - limited = sorted_hits[: max(1, top_k)] + + enriched: List[Tuple[str, float, Dict[str, Any], float, float, float]] = [] + for pid, semantic_score, payload in hits: + total, edge_bonus, cent_bonus = _compute_total_score(semantic_score, payload) + enriched.append((pid, float(semantic_score), payload, total, edge_bonus, cent_bonus)) + + # Sortierung nach total_score absteigend + enriched_sorted = sorted(enriched, key=lambda h: h[3], reverse=True) + limited = enriched_sorted[: max(1, top_k)] results: List[QueryHit] = [] - for pid, semantic_score, payload in limited: + for pid, semantic_score, payload, total, edge_bonus, cent_bonus in limited: note_id = payload.get("note_id") path = payload.get("path") section = payload.get("section_title") - edge_bonus = 0.0 - cent_bonus = 0.0 - total = float(semantic_score) + edge_bonus + cent_bonus - results.append( QueryHit( node_id=str(pid), @@ -111,8 +103,11 @@ def _build_hits_from_semantic( edge_bonus=edge_bonus, centrality_bonus=cent_bonus, total_score=total, - paths=None, # Edge-Paths kommen in einem späteren Schritt hinzu - source={"path": path, "section": section}, + paths=None, + source={ + "path": path, + "section": section, + }, ) ) @@ -120,23 +115,7 @@ def _build_hits_from_semantic( return QueryResponse(results=results, used_mode=used_mode, latency_ms=dt) -def _resolve_top_k(req: QueryRequest) -> int: - """ - Ermittelt ein sinnvolles top_k: - - - bevorzugt req.top_k, falls > 0 - - sonst Settings.RETRIEVER_TOP_K (Default 10) - """ - if isinstance(req.top_k, int) and req.top_k > 0: - return req.top_k - s = get_settings() - return max(1, int(getattr(s, "RETRIEVER_TOP_K", 10))) - - def semantic_retrieve(req: QueryRequest) -> QueryResponse: - """ - Reiner semantischer Retriever (ohne Edge-Expansion). - """ top_k = _resolve_top_k(req) vector = _get_query_vector(req) client, prefix = _get_client_and_prefix() @@ -145,14 +124,6 @@ def semantic_retrieve(req: QueryRequest) -> QueryResponse: def hybrid_retrieve(req: QueryRequest) -> QueryResponse: - """ - Hybrid-Retriever. - - Step-1-Implementierung: - - nutzt die gleiche semantische Kandidatenliste wie semantic_retrieve - - setzt lediglich used_mode = "hybrid" - - Edge-Expansion & Score-Modifikationen folgen in den nächsten Schritten. - """ top_k = _resolve_top_k(req) vector = _get_query_vector(req) client, prefix = _get_client_and_prefix()