From 42216865e2c5a6e2a63f18ebebc67e23d38b7f7e Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 2 Dec 2025 17:37:06 +0100 Subject: [PATCH] app/core/retriever.py aktualisiert --- app/core/retriever.py | 108 ++++++++++++++++-------------------------- 1 file changed, 41 insertions(+), 67 deletions(-) diff --git a/app/core/retriever.py b/app/core/retriever.py index 9715bfc..38718cb 100644 --- a/app/core/retriever.py +++ b/app/core/retriever.py @@ -1,28 +1,17 @@ """ -app/core/retriever.py — Semantischer/Edge-Aware/Hybrid Retriever (WP-04 / Step 4a) +app/core/retriever.py — Semantischer/Hybrid-Retriever (WP-04 / Step 4a, Schritt 1) -Zweck ------ -- Kandidatenfindung via Vektorsuche in *_chunks (Qdrant) -- perspektivisch: Edge-Expansion & Graph-Heuristiken (graph_adapter) -- kombiniertes Ranking zur Rückgabe von Top-K Treffern +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 -Dieser Stand (Step 4a – Schritt 1) implementiert zunächst: -- reine semantische Chunk-Suche (ohne Edge-Expansion) -- Hybrid-Modus als Alias der semantischen Suche -- saubere Nutzung der vorhandenen DTOs (QueryRequest/QueryResponse/QueryHit) -- kompatibles Verhalten zu den bestehenden Tests in tests/test_query_unit.py - und tests/test_query_text_embed_unit.py - -Weitere Schritte (separat umzusetzen): -- Einbezug von retriever_weight (Note-/Chunk-Metadaten) -- Edge-Expansion über mindnet_edges + graph_adapter -- ausführliche Provenienzpfade (paths) pro Treffer - -Kompatibilität --------------- -- Python 3.12+ -- qdrant-client 1.x +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 @@ -31,31 +20,28 @@ import time from typing import Any, Dict, List, Tuple from app.config import get_settings -from app.core.qdrant import QdrantConfig, get_client -from app.core.qdrant_points import search_chunks_by_vector from app.models.dto import QueryRequest, QueryResponse, QueryHit -from app.services.embeddings_client import embed_text +import app.core.qdrant as qdr +import app.core.qdrant_points as qp +import app.services.embeddings_client as ec def _get_client_and_prefix() -> Tuple[Any, str]: """ - Liefert (QdrantClient, prefix). - - QdrantConfig.from_env() ist hier die zentrale Stelle für alle - Qdrant-bezogenen ENV-Parameter (URL, API-KEY, Prefix, Dim). + Liefert (QdrantClient, prefix) basierend auf QdrantConfig.from_env(). """ - cfg = QdrantConfig.from_env() - client = get_client(cfg) + cfg = qdr.QdrantConfig.from_env() + client = qdr.get_client(cfg) return client, cfg.prefix def _get_query_vector(req: QueryRequest) -> List[float]: """ - Liefert einen Query-Vektor basierend auf QueryRequest: + 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 embed_text() aufgerufen. - - Andernfalls wird ein ValueError geworfen. + - 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): @@ -63,8 +49,7 @@ def _get_query_vector(req: QueryRequest) -> List[float]: return req.query_vector if req.query: - # Lazy-Load des Modells passiert im embeddings_client selbst. - return embed_text(req.query) + return ec.embed_text(req.query) raise ValueError("Weder query_vector noch query gesetzt – mindestens eines ist erforderlich") @@ -75,20 +60,18 @@ def _semantic_hits( vector: List[float], top_k: int, filters: Dict | None, -) -> List[Tuple[str, float, Dict[str, Any]]]: +): """ - Ruft die eigentliche Qdrant-Suche auf. + Kapselt den Aufruf von qp.search_chunks_by_vector. - Nutzt app.core.qdrant_points.search_chunks_by_vector als Single Source of Truth - für das Search-API gegen mindnet_chunks. + Rückgabeformat laut qdrant_points.search_chunks_by_vector: + [ + (point_id: str, score: float, payload: dict), + ... + ] """ flt = filters or None - hits = search_chunks_by_vector(client, prefix, vector, top=top_k, filters=flt) - # Erwartete Struktur laut bisherigen Tests: - # [ - # ("chunk:1", 0.9, {"note_id": "...", "path": "...", "section_title": "..."}), - # ... - # ] + hits = qp.search_chunks_by_vector(client, prefix, vector, top=top_k, filters=flt) return hits @@ -98,17 +81,15 @@ def _build_hits_from_semantic( used_mode: str, ) -> QueryResponse: """ - Formt rohe Qdrant-Treffer in QueryResponse um. + Formt rohe Treffer in QueryResponse um. - Aktueller Schritt: + Aktueller Step-1-Stand: - edge_bonus = 0.0 - centrality_bonus = 0.0 - total_score = semantic_score - - Sortierung: absteigend nach total_score. """ t0 = time.time() - # defensiv: sortieren, auch wenn Qdrant bereits sortiert liefert + # 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)] @@ -130,7 +111,7 @@ def _build_hits_from_semantic( edge_bonus=edge_bonus, centrality_bonus=cent_bonus, total_score=total, - paths=None, # Edge-Expansion folgt in späteren Schritten + paths=None, # Edge-Paths kommen in einem späteren Schritt hinzu source={"path": path, "section": section}, ) ) @@ -141,7 +122,10 @@ def _build_hits_from_semantic( def _resolve_top_k(req: QueryRequest) -> int: """ - Ermittelt ein sinnvolles top_k auf Basis von Request und Settings. + 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 @@ -152,17 +136,11 @@ def _resolve_top_k(req: QueryRequest) -> int: def semantic_retrieve(req: QueryRequest) -> QueryResponse: """ Reiner semantischer Retriever (ohne Edge-Expansion). - - - nutzt entweder query_vector oder embed_text(query) - - ruft search_chunks_by_vector auf - - sortiert nach semantic_score """ top_k = _resolve_top_k(req) vector = _get_query_vector(req) client, prefix = _get_client_and_prefix() hits = _semantic_hits(client, prefix, vector, top_k=top_k, filters=req.filters) - - # used_mode = "semantic" für den expliziten Semantic-Mode return _build_hits_from_semantic(hits, top_k=top_k, used_mode="semantic") @@ -170,17 +148,13 @@ def hybrid_retrieve(req: QueryRequest) -> QueryResponse: """ Hybrid-Retriever. - Aktueller Step-1-Stand: - - nutzt die gleiche reine semantische Kandidatenliste wie semantic_retrieve - - Edge-Expansion & Centrality-Bewertungen folgen in einem späteren Schritt - - used_mode wird auf "hybrid" gesetzt (Tests erwarten dies explizit) - - Damit bleiben bestehende Tests und Aufrufer kompatibel, während wir - die Edge-Logik iterativ ergänzen können. + 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() hits = _semantic_hits(client, prefix, vector, top_k=top_k, filters=req.filters) - return _build_hits_from_semantic(hits, top_k=top_k, used_mode="hybrid")