Performance-Audit a-icking Search-Pipeline

TL;DR

Der BENCHMARK.md-Breakdown ist falsch geframed. Die 12s sind nicht “5–6s Polling-Overhead”, sondern eine Mischung aus Retry-After: 5 (zwingt Client zu 5s Wartezeit), Pipeline-Lambda-Cold-Start (512 MB, NICHT provisioned), Polling-Round-Trips und SQS-Hop. Bei voll warmem System schafft die Pipeline 1.5–2.5s. Architektur ist für ~1s-Latenz übertrieben und sabotiert sich selbst.

1. Latenz-Budget bei “kalt” (12s realistisch)

PhaseGeschätztWo
API-Gateway → API-Handler-Lambda (128 MB, kalt)200–400 mslambdas/api_handler/handler.py:1
SQS-Hop (send + EventSourceMapping-Poll)300–800 mssearchpipeline/main.tf:72 batch_size=1, kein Long-Poll-Tuning
Search-Pipeline-Lambda Cold-Start (Container 512 MB)2.5–4.0 ssearchpipeline/main.tf:35 — 512 MB deutlich zu wenig für Container-Image
_get_lambda_client() lazy init + erster boto3-Call SigV4200–400 mslambda_embedding.py:36-47
Embedding-Lambda warm (Provisioned, ~2 GB BGE-M3 schon geladen)80–150 msmodels/embedding/app.py:15-28
pgvector HNSW Query (RDS, Pool kalt beim ersten Hit)200–800 mspg_client.py:117-168
Rerank-Lambda warm (Provisioned, Reranker schon geladen)400–900 msmodels/reranking/app.py:30-35 (BGE-reranker-base CPU, 20 Kandidaten)
Retry-After: 5 Client wartet 5s vor erstem Poll5.000 msapi_handler/handler.py:79
Polling-Overhead (alle 2 s)0–2 sClient-seitig

Summe Cold-Path: ~10–18 s — passt zu den 12 s. Warm-Path ohne Polling: ~1.5–2.5 s.

Erste Quick-Wins schon vor Refactor:

  • Retry-After: 1 statt 5 → spart 4s sofort
  • Pipeline-Lambda 2048 MB statt 512 → spart 1-2s Cold-Start + 200-400ms Steady-State
  • Models eager im Init laden statt lazy → spart 2-4s bei erstem echten Request (gratis, Init-Phase wird nicht gebillt)

2. Top-5 Hotspots

  1. searchpipeline/main.tf:35 — Search-Pipeline-Lambda hat 512 MB. Container-Image-Lambda mit boto3 + psycopg + asyncio braucht 1024–2048 MB damit Init <1 s ist. AWS-Lambda-CPU skaliert linear mit Memory bis ~1.8 GB → mit 512 MB hat die Function ~0.3 vCPU. Gewinn: 1–2 s Cold-Start, 200–400 ms Steady-State.

  2. models/embedding/app.py:15-28 und models/reranking/app.py:30-35 — Lazy Model Load. Das BGE-M3-Model wird erst beim ersten Request geladen, nicht in der Init-Phase. Bei Provisioned Concurrency egal (warm), aber wenn man PC abschaltet relevant: Cold-Start zählt damit voll im User-Path. Fix: _get_model() in Modul-Top-Level aufrufen (Lambda gibt 10 s Init-Phase außerhalb der Billing). Gewinn: 2–4 s beim ersten echten Request nach PC-Aus.

  3. api_handler/handler.py:75-83 — SQS+Polling für 10s-Workload. Komplette SQS+Jobs-Tabelle+Polling-Maschinerie für eine Operation die in 30s-API-Gateway-Timeout passt. Jeder Hop addiert 200–800 ms. Gewinn: 1–2 s wenn synchron, plus Wegfall des Retry-After: 5-Lags clientseitig.

  4. cli.py:413asyncio.run() pro Request. Im Lambda-Handler wird in lambda_handler.py:171 ein ThreadPoolExecutor mit max_workers=5 aufgemacht und jeder Worker macht in cli.py:413 ein eigenes asyncio.run() mit eigener aioboto3-Session. Bei Single-Query wird Batch-Code gar nicht genutzt — pipeline.run() (Zeile 155) geht den synchronen Pfad und ruft rerank_documents sync auf. Async-Overhead ohne Async-Nutzen. Jeder Thread öffnet eine eigene aioboto3-Session → 50–150 ms Setup pro Query. Gewinn: 100–500 ms pro Query.

  5. pg_client.py:124,138,161 — SQL-Injection durch f-String + Vector im Klartext. Der Vector mit 1024 Floats wird als String in den Query injected (vector_str = f"[{','.join(...)}]" → ~10–20 KB Query-Text). Damit wird Postgres’ Prepared-Statement-Cache useless, jede Query ist ein neuer Parse+Plan. Fix: cursor.execute(sql, (vector_param,)) mit psycopg-Adapter. Außerdem: SET search_path in eigener Cursor-Roundtrip (pg_client.py:122) ist ein extra Round-Trip pro Request. Gewinn: 30–100 ms pro Query plus saubere Security.

3. Quality-Probleme

  • Embedding-Spalte uneinheitlich. Index existiert auf kurztext_embedding, langtext_embedding, suchwort_embedding. Suche nutzt nur langtext_embedding. Konsistent mit Reranking-Doc — aber suchwort_embedding und kurztext_embedding sind tote Indizes die nur Storage+Insert-Last erzeugen. Entweder Hybrid-Multi-Index oder weg.
  • Hybrid-Search-Implementierung in pg_client.py:339-410 solide aufgebaut (Semantic-Candidates → Phrase + OR-Match, Score-Normalisierung über Window-Function). Aber: candidate_pool = top_k * 3 zieht nur 60 Kandidaten — bei m=32 ist das eng. Wenn das exakte Keyword-Match nicht in den Top-60-Semantic-Hits ist, findet’s hybrid nie. Für “DN 100 vs DN 150”-Problematik unzureichend.
  • HNSW-ef_search wird nirgends gesetzt. 006_tune_hnsw_indexes.sql tunt m/ef_construction, aber ef_search (Query-Time-Recall) bleibt auf Default 40. Für Recall sinnvoll: SET hnsw.ef_search = 100 per Session.
  • Fine-Tuned-Model: commit 1669bb5 deployt fine-tuned model, aber models/embedding/app.py:11 zeigt hardcoded MODEL_DIR=/opt/models/bge-m3 und MODEL_ID=BAAI/bge-m3. Wenn das Fine-Tuned-Model wirklich live ist, ist es nur über CodeBuild-Bake-Step ins Image gekommen — die env-Variable lügt. Prüfen: ist das deployte Image-Tag wirklich der fine-tuned? Embedding-Dimension vector(1024) passt zu BGE-M3 — aber Katalog-Embeddings müssen mit demselben Model gemacht worden sein wie Query-Embeddings, sonst silent Recall-Verlust.

4. Architektur-Kritik

SQS+Jobs+Polling rechtfertigt sich nur wenn (a) Operation >30s dauert, (b) Burst-Smoothing nötig ist, oder (c) Retry-Semantik garantiert sein muss. Hier ist nichts davon der Fall. Eine 10s-Pipeline (warm: 2s) passt in API-Gateway-29s-Timeout. Polling verschlechtert Wahrnehmungs-Latenz um 2–7 s und addiert vier Komponenten (SQS, Jobs-Tabelle, Status-Lambda, Polling-Client-Logik) die alle Fehlerquellen sind.

Die zwei separaten Modell-Lambdas (Embedding + Rerank) sind eine Anti-Optimierung: Beide laden PyTorch+SentenceTransformers (~500 MB Boot-Footprint), beide haben getrennte Cold-Starts. Wenn Embedding und Rerank in einem Container liefen, wäre der Cold-Start nur einmal zu zahlen (~3-4s statt ~6-7s) und der Lambda-Invoke-Hop entfällt — 100–300 ms pro Call. Memory: 6 GB → ~8 GB. Bei warmem Path null Nachteil.

5. Targets

VarianteSteady-State p50Cold-Start p99Aufwand
(a) Quick-Tuning (Pipeline-Mem auf 2048, Models eager-load, ef_search=100, parametrisierte SQL, Retry-After: 1 statt 5, optional Provisioned-Concurrency-Tuning)1.2–2.0 s~6 s1–2 Tage
(b) Moderater Refactor (sync API-Path, SQS nur für Batch >5 Queries, kombinierte Embed+Rerank-Lambda, aioboto3-Session pro Container, HNSW ef_search-Tuning, Multi-Field-Embedding falls Recall-Test es bringt)500–900 ms~3 s1 Woche
(c) Neubau (Fargate-Service mit beiden Modellen in-process, Postgres-Connection-Pool warm, gRPC oder direkt-HTTP, kein API-Gateway-Overhead, Embedding+Search+Rerank in einem Function-Call)200–400 msnie kalt2–3 Wochen

Empfehlung: Variante (a) bringt schon Faktor 5–10. Vor Refactor erst messen ob (a) reicht.

Relevante Files

  • ~/source/a-icking/BENCHMARK.md
  • ~/source/a-icking/search_pipeline/cli.py
  • ~/source/a-icking/search_pipeline/lambda_handler.py
  • ~/source/a-icking/search_pipeline/lambda_embedding.py
  • ~/source/a-icking/search_pipeline/pg_client.py
  • ~/source/a-icking/models/embedding/app.py
  • ~/source/a-icking/models/reranking/app.py
  • ~/source/a-icking/lambdas/api_handler/handler.py
  • ~/source/a-icking/terraform/modules/searchpipeline/main.tf
  • ~/source/a-icking/terraform/stacks/app/variables.tf
  • ~/source/a-icking/terraform/modules/lambda-warmer/main.tf
  • ~/source/a-icking/db/migrations/006_tune_hnsw_indexes.sql