AI

Erstelle eine semantische Jobsuchmaschine mit Bright Data, LanceDB und Cohere

Erstelle eine semantische Jobsuchmaschine. Bright Datas Web Scraper liefert strukturierte LinkedIn-Jobs; Cohere bietet Embeddings für bedeutungsbasiertes Matching.
27 min lesen
Build a Semantic Job Search Engine with Bright Data, LanceDB, and Cohere

Jobbörsen suchen nur nach exakten Wörtern, sodass die passende Stelle verborgen bleibt, wenn deine Formulierung nicht mit der Stellenanzeige übereinstimmt. Semantische Suche vergleicht stattdessen die Bedeutung. Wir bauen sie von Grund auf und messen, welcher Suchmodus gewinnt, anstatt anzunehmen, dass der komplexeste die beste Wahl ist.

TL;DR

Dieser Leitfaden erstellt eine semantische Jobsuchmaschine über 200 echte LinkedIn-Stellenanzeigen mit Bright Data (Web-Scraping), Cohere (Embeddings + Reranking) und LanceDB (lokaler Vektorspeicher).

  • Keyword-Suche findet exakte Wörter. Vektorsuche findet Bedeutung. Eine Anfrage wie “Ingenieur, der an LLMs arbeitet” findet eine “GenAI Developer”-Stelle, die die Keyword-Suche übersieht.
  • Bright Datas Web Scraper API liefert strukturierte LinkedIn-Jobs als JSON für 0,0015 $ pro Datensatz, ohne HTML-Parsing oder Scraper-Wartung.
  • LanceDB läuft lokal und kombiniert Vektorsuche mit SQL-Filtern (Gehalt, Seniorität) in einer einzigen Abfrage, plus Volltextsuche und Cohere-Reranking.
  • Bei 10 Testanfragen erzielte die Vektorsuche 70 % Precision@3 gegenüber 43 % bei Keyword-Suche. Hybrid + Rerank brachte bei dieser Größenordnung keinen messbaren Vorteil, daher ist bei unter ~10.000 Zeilen die Vektorsuche allein ein vernünftiger Standard.
  • Das vollständige Projekt umfasst 9 kleine Dateien, einschließlich eines Eval-Frameworks, und der vollständige Code ist auf GitHub. Der gesamte Durchlauf kostet ~0,34 $.

Das Problem mit der Keyword-Suche

Die Keyword-Suche auf einer Jobbörse tut genau das, was du verlangst. Sie gibt Stellenanzeigen zurück, deren Titel oder Beschreibung die wörtlichen Tokens deiner Anfrage enthält. Wenn du nach “Ingenieur, der an LLMs und Prompt Engineering arbeitet” suchst, wirst du Stellen wie “GenAI Developer” verpassen, auch wenn sie perfekt passen. Lexikalische Suche findet exakte Wörter, nicht Bedeutung.

Vektorsuche vergleicht Bedeutungen. Jede Stellenbeschreibung wird in ein Embedding umgewandelt (einen hochdimensionalen Vektor, der ihren semantischen Inhalt erfasst), und dasselbe gilt für deine Anfrage. Eine Stelle, deren Vektor nah an dem deiner Anfrage liegt, ist semantisch eine gute Übereinstimmung, auch wenn sie kein einziges gemeinsames Wort enthält.

Um das in eine funktionierende Suchmaschine zu verwandeln, braucht es 3 Komponenten:

  1. Bright Data scraped 200 echte LinkedIn-Stellenanzeigen in sauberes, strukturiertes JSON.
  2. Cohere wandelt die Beschreibungen in Embeddings um und führt das Reranking der Ergebnisse durch.
  3. LanceDB speichert die Embeddings lokal und beantwortet hybride Anfragen (Vektor + Volltext) mit SQL-ähnlichen Filtern.

Der Stack auf einen Blick

Was jede Schicht leistet und warum wir sie verwenden:

Schicht Tool Warum dieses
Webdaten Bright Data Web Scraper API Vorgefertigter LinkedIn-Scraper liefert strukturiertes JSON mit Gehalt, Seniorität und Standort, ohne HTML-Parsing oder Scraper-Wartung.
Embeddings Cohere embed-english-v3.0 Asymmetrische Kodierung (unterschiedliche Eingabetypen für Dokumente vs. Anfragen). Cohere bietet auch embed-v4.0 an, das multimodal ist. Wir verwenden hier v3 wegen seines Preis-/Latenzprofils für Englisch (geplant: Re-Embedding vor End-of-Life von v3).
Reranker Cohere rerank-v3.5 Wir verwenden v3.5 wegen seines Preis-/Latenzprofils. Cohere bietet auch rerank-v4.0 an (-pro für Qualität, -fast für Latenz).
Vektorspeicher LanceDB Lokal, eingebettet, keine Server. Unterstützt hybride (Vektor + BM25) Suche und SQL-Vorfilter.
UI (optional) Streamlit Web-UI mit minimalem Code für eine Python-Daten-App.

Dieser Stack läuft in einer einzigen Python-venv auf deinem Laptop. Bright Data und Cohere sind die einzigen verwalteten Dienste.

Setup

Das vollständige, ausführbare Projekt ist auf GitHub. Klone es und installiere die Abhängigkeiten (Python 3.10 oder neuer):

git clone https://github.com/triposat/semantic-job-search.git
cd semantic-job-search
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

Kopiere die Beispiel-Env-Datei und füge deine beiden API-Schlüssel hinzu: ein Bright Data Token und einen Cohere-Schlüssel von dashboard.cohere.com (ein Trial-Key reicht für den gesamten Leitfaden):

cp .env.example .env
# then edit .env with your keys:
#   BRIGHTDATA_API_TOKEN=...
#   COHERE_API_KEY=...

Wenn beide Schlüssel vorhanden sind, führe python scrape.py aus, um die Daten abzurufen, und python index.py, um den Index zu erstellen.

Architektur

Das System besteht aus zwei Abläufen, nicht einem. Ingest erstellt den Index (einmalig oder planmäßig ausführen). Query läuft bei jeder Suche. Beide verwenden Cohere und LanceDB, aber für unterschiedliche Aufgaben.

Zwei-Fluss-Architekturdiagramm. INGEST (einmalig oder planmäßig): Ein 'keyword'-Pfeil führt zu Bright Data ('Jobs nach Keyword entdecken (async)'), das 'jobs (JSON)' an Cohere ('embed (document)') sendet, das 'vectors' an LanceDB ('Vektor + FTS + skalare Indizes, versioniert') weitergibt. QUERY (pro Suche, Standard-Hybridmodus): Ein 'query'-Pfeil führt zu Cohere ('embed (query)'), das einen 'query vector' an LanceDB ('Vektor + FTS-Suche + SQL-Vorfilter') sendet, das 'candidates' an Cohere ('rerank') weitergibt, das 'ranked results' zurückgibt. Cohere und LanceDB sind hervorgehoben, da sie in beiden Abläufen wiederverwendet werden; Rerank läuft nur im Hybridmodus.

Die beiden Abläufe nebeneinander. Ingest bettet Dokumente ein und speichert sie. Query bettet den Suchtext ein, führt Vektor- und Volltextsuche mit SQL-Vorfilter durch und rerankt anschließend. Cohere und LanceDB erscheinen in beiden Abläufen, übernehmen aber jeweils unterschiedliche Aufgaben – deshalb berührt Reranking nie den Ingest-Pfad.

3 Skripte führen die Pipeline aus: scrape.py, index.py, search.py. 6 weitere Hilfsskripte: lib.py (gemeinsames Such-Backend), compare.py (Modusvergleich), eval.py (Precision@3), stats.py (Datensatz-Zusammenfassung), versions.py (Snapshot-Browser) und app.py (Streamlit-UI).

LinkedIn mit Bright Data scrapen

LinkedIn ist eine wichtige Quelle für Jobdaten, aber schwer zuverlässig zu scrapen: Rate-Limits, dynamisches Markup und HTML, das sich ohne Vorwarnung ändert. Die Web Scraper API liefert sauberes, strukturiertes JSON aus vorgefertigten Endpunkten, sodass du keine Parser warten musst.

Den richtigen Endpunkt wählen

Bright Data stellt mehrere LinkedIn-Scraper bereit:

  • Personenprofile → individuelle Mitgliederprofile
  • Unternehmensinformationen → Unternehmensseiten
  • Stellenanzeigen → Collect by URL → bestimmte Job-URLs, die du bereits hast
  • Stellenanzeigen → Discover by keyword ← das wollen wir
  • Stellenanzeigen → Discover by URL → Jobs aus einer Suchergebnis-URL
  • LinkedIn-Posts und Personensuche → andere Entitätstypen

Discover by keyword ist die richtige Wahl, da wir Jobs per Suchanfrage in großen Mengen entdecken wollen. Ein einziger API-Aufruf liefert bis zu 1.000 strukturierte Stellenanzeigen pro Keyword, einschließlich Titel, Unternehmen, Standort, Senioritätsstufe, Beschäftigungsart, Gehaltsbereich (sofern angegeben) und der vollständigen Stellenbeschreibung.

Jeder Scraper hat eine eigene dataset_id. Um eine zu finden, öffne Bright Datas Scrapers Library, suche nach der Website (hier linkedin.com) und öffne sie. Wähle den Endpunkt Stellenanzeigen → Discover by keyword – seine dataset_id (gd_lpfll7v5hcqtkxl6l) und eine sofort ausführbare Anfrage erscheinen im Code-Beispiele-Panel. Ein gültiges Token ist alles, was scrape.py benötigt, um ihn aufzurufen.

Bright Data Dashboard zeigt das Web Scrapers Library-Menü, mit dem LinkedIn-Stellenanzeigen-Endpunkt 'Discover by keyword' in der linken Seitenleiste ausgewählt. Das mittlere Panel zeigt den Konfigurationsreiter mit Beispieleingaben (paris/product manager, New York/python developer). Das rechte Panel zeigt die Code-Beispiele-Ansicht mit einer authentifizierten curl-Anfrage, die `dataset_id=gd_lpfll7v5hcqtkxl6l` enthält.

Die Scraper-Seite ‘Discover by keyword’. Im Code-Beispiele-Panel rechts findest du die dataset_id.

Synchron vs. asynchron

Bright Data bietet 2 Liefermodi:

  • Synchron (POST /datasets/v3/scrape) gibt die Daten direkt zurück, am besten für sehr kleine Batches.
  • Asynchron (POST /datasets/v3/trigger) gibt eine Snapshot-ID zurück. Du pollst auf Abschluss und lädst das Ergebnis herunter – am besten für alles Größere.

In unseren Durchläufen betrug die Antwortzeit durchschnittlich ~6 Sekunden pro Eingabe. Bei 2 Keywords mit limit_per_input=100 (200 Jobs insgesamt) muss ein synchroner Aufruf die Verbindung für den gesamten Batch offen halten, was zu einem Timeout führen kann. Asynchron ist der sichere Standard.

Kosten mit Pro-Eingabe-Limits kontrollieren

Der Query-Parameter limit_per_input=N begrenzt, wie viele Ergebnisse jede Eingabesuche zurückgibt – genau der Regler, den du für planbare Ausgaben brauchst:

2 keywords × 100 jobs × $0.0015 = $0.30 per run

Erhöhe ihn für größere Durchläufe, bis zu 1.000 Jobs pro Keyword.

Der Code

Der Scraper löst einen Snapshot aus, pollt bis er bereit ist und lädt das JSON herunter. Der Kern ist unten (eine Produktionsversion würde Retry/Backoff und umfangreichere Fehlerbehandlung hinzufügen):

# scrape.py
import json, time, sys
from pathlib import Path
import requests
from lib import require_env

BD_TOKEN = require_env("BRIGHTDATA_API_TOKEN")
DATASET_ID = "gd_lpfll7v5hcqtkxl6l"  # LinkedIn jobs - discover by keyword
LIMIT_PER_INPUT = 100

SEARCHES = [
    {"location": "San Francisco", "keyword": "machine learning engineer",
     "country": "US", "time_range": "Past month", "job_type": "Full-time",
     "experience_level": "", "remote": "", "company": "", "location_radius": ""},
    {"location": "New York", "keyword": "python developer",
     "country": "US", "time_range": "Past month", "job_type": "Full-time",
     "experience_level": "", "remote": "", "company": "", "location_radius": ""},
]

API = "https://api.brightdata.com/datasets/v3"
HEADERS = {"Authorization": f"Bearer {BD_TOKEN}", "Content-Type": "application/json"}

def trigger_snapshot() -> str:
    r = requests.post(f"{API}/trigger", headers=HEADERS, json={"input": SEARCHES},
        params={"dataset_id": DATASET_ID, "type": "discover_new",
                "discover_by": "keyword", "include_errors": "true",
                "limit_per_input": str(LIMIT_PER_INPUT)})
    r.raise_for_status()
    return r.json()["snapshot_id"]

def wait_until_ready(snapshot_id: str) -> None:
    while True:
        status = requests.get(f"{API}/progress/{snapshot_id}", headers=HEADERS).json()["status"]
        if status == "ready": return
        if status == "failed": raise RuntimeError("snapshot failed")
        time.sleep(10)

def download(snapshot_id: str) -> list[dict]:
    return requests.get(f"{API}/snapshot/{snapshot_id}",
                        headers=HEADERS, params={"format": "json"}).json()

Ausführen:

$ python scrape.py
→ scraping 2 keyword searches, max 100 jobs each
  estimated max cost: $0.30 (at $0.0015/record × 200 max records)
  triggered snapshot: sd_mojicp6g39xwbwqn2
  status: ready
✓ saved 204 jobs → data/raw_jobs.json
  actual cost: $0.31

Was du zurückbekommst

Jeder Job im JSON hat 25+ Felder. Hier sind die wichtigsten:

{
  "job_posting_id": "",
  "job_title": "Associate Machine Learning Engineer",
  "company_name": "ExampleCo",
  "job_location": "San Francisco, CA",
  "job_seniority_level": "Entry level",
  "job_employment_type": "Full-time",
  "job_industries": "Software Development",
  "job_summary": "About ExampleCo. ExampleCo is the career network for the AI economy...",
  "base_salary": {
    "min_amount": 115000,
    "max_amount": 144000,
    "currency": "$",
    "payment_period": "yr"
  },
  "job_posted_date": "2026-04-25T03:41:21.072Z",
  "url": "https://www.linkedin.com/jobs/view/"
}

Das strukturierte Feld base_salary ermöglicht Gehaltsfilter-Anfragen im nächsten Schritt.

Indexieren mit Cohere und LanceDB

Wir haben 204 rohe Job-Datensätze, 4 davon sind Fehlerzeilen, die wir beim Laden herausfiltern. Jetzt machen wir die verbleibenden 200 semantisch durchsuchbar.

Warum Cohere

Wir haben Cohere den Alternativen vorgezogen (OpenAIs Embedding-Modelle, Voyage AI oder lokale Sentence-Transformer):

  1. Asymmetrische Kodierung. Cohere erlaubt es, die Eingabe beim Indexieren als search_document und beim Suchen als search_query zu kennzeichnen. Das Modell kodiert jede Seite unterschiedlich, was besser funktioniert als beide gleich zu behandeln.
  2. Deklaratives Embedding. LancDBs Registry unterstützt Cohere nativ (wie auch OpenAI und Sentence-Transformer), sodass das Embedding beim Einfügen und Abfragen ohne manuelle embed()-Aufrufe erfolgt.
  3. Die Rerank-API. Es ist ein separates Modell, das eine Anfrage plus eine Kandidatenliste entgegennimmt und die Kandidaten nach tatsächlicher Relevanz neu ordnet. Es ist die zweite Stufe, die das Ranking einer hybriden Pipeline verbessern kann, und wir fügen diese Stufe mit einem einzigen .rerank()-Aufruf hinzu.

Die LanceDB Embedding-Registry

Embeddings in LanceDB laufen über seine Embedding-Registry. Du deklarierst dein Schema einmal, und Embeddings erfolgen automatisch bei jedem Einfügen und jeder Abfrage, jeweils mit dem richtigen input_type.

# index.py
import lancedb
from lancedb.embeddings import get_registry
from lancedb.pydantic import LanceModel, Vector

cohere = get_registry().get("cohere").create(
    name="embed-english-v3.0",
    api_key=COHERE_API_KEY,
)

class Job(LanceModel):
    text: str = cohere.SourceField()              # ← was eingebettet wird
    vector: Vector(cohere.ndims()) = cohere.VectorField()  # ← gespeichertes Embedding
    job_id: str
    title: str
    company: str
    location: str
    country_code: str
    seniority: str
    employment_type: str
    job_function: str
    industry: str
    posted_date: str
    apply_url: str
    search_keyword: str
    salary_min_annual: float
    salary_max_annual: float
    salary_currency: str
    salary_display: str
    description_snippet: str

Alles nach vector ist eine einfache gespeicherte Spalte, die für Filterung und Anzeige verwendet wird.

Der Trick zur Gehaltsnormalisierung

Die meisten Jobs haben Jahresgehälter, aber einige sind stündlich angegeben. Damit salary_min_annual >= 200000 konsistent funktioniert, normalisieren wir beim Ingest:

HOURS_PER_YEAR = 2080

def _normalize_salary(base):
    if not base:
        return 0.0, 0.0, "", ""
    lo = float(base.get("min_amount") or 0)
    hi = float(base.get("max_amount") or 0)
    if (base.get("payment_period") or "").lower() == "hr":
        lo *= HOURS_PER_YEAR
        hi *= HOURS_PER_YEAR
    currency = base.get("currency") or ""
    display = f"{currency}{int(lo):,}–{currency}{int(hi):,}/yr" if (lo and hi) else ""
    return lo, hi, currency, display

Wir speichern sowohl die rohen numerischen Werte (für Filter) als auch eine lesbare Anzeigezeichenkette (für die UI).

Inkrementelle Updates mit Upserts

Beim ersten Ausführen von index.py wird die Tabelle erstellt. Jeder weitere Durchlauf ist ein Upsert mit job_id als Schlüssel:

result = (
    table.merge_insert("job_id")
         .when_matched_update_all()       # vorhandene Stellenanzeigen aktualisieren
         .when_not_matched_insert_all()   # neu entdeckte hinzufügen
         .execute(rows)
)
print(f"inserted={result.num_inserted_rows}, updated={result.num_updated_rows}")

Neue Stellenanzeigen aus einem frischen Bright Data Scrape werden eingefügt, und erneut gepostete Jobs (gleiche job_id) haben ihre Gehälter, Beschreibungen und Zeitstempel aktualisiert. Um veraltete Anzeigen vollständig zu entfernen, ergänze .when_not_matched_by_source_delete().

Der gesamte Upsert ist eine einzelne atomare Transaktion. Da Lance Daten spaltenweise mit Copy-on-Write speichert, ist die erneute Ingestion ein inkrementeller Schreibvorgang statt eines vollständigen Tabellenaufbaus.

Skalare Indizes für schnelle SQL-Filter

Wenn search.py, where "salary_min_annual >= 200000" läuft, wendet LanceDB den Filter vor dem Vektorscan an (prefilter=True). Bei 200 Zeilen ist das so oder so sofort. Bei 200.000 Zeilen müsste der Filter die gesamte Spalte durchlaufen, wenn wir LanceDB nicht mitteilen, wie er indiziert werden soll:

table.create_scalar_index("salary_min_annual", index_type="BTREE",  replace=True)
table.create_scalar_index("seniority",         index_type="BITMAP", replace=True)
table.create_scalar_index("search_keyword",    index_type="BITMAP", replace=True)
table.create_scalar_index("employment_type",   index_type="BITMAP", replace=True)

2 Indextypen decken unsere Anforderungen ab:

  • BTREE für sortierbare Spalten mit höherer Kardinalität. salary_min_annual profitiert davon, weil wir Bereichsabfragen wollen (>=, BETWEEN).
  • BITMAP für Enums mit niedriger Kardinalität. seniority hat ~6 verschiedene Werte, employment_type ist fast ausschließlich Full-time, und search_keyword ist einer unserer 2 Scrape-Eingaben. Jeder eindeutige Wert erhält seine eigene Bitmap. Ein =-Filter wird zu einem einzigen bitweisen AND.

Beide laufen mit replace=True, sodass das erneute Ausführen von index.py sie idempotent neu erstellt. Nach dem Aufruf meldet table.list_indices() alle 5 (die 4 skalaren + den FTS-Index):

text_idx               type=FTS      columns=['text']
salary_min_annual_idx  type=BTree    columns=['salary_min_annual']
seniority_idx          type=Bitmap   columns=['seniority']
search_keyword_idx     type=Bitmap   columns=['search_keyword']
employment_type_idx    type=Bitmap   columns=['employment_type']

Die indizierten Daten inspizieren

Nach dem Ausführen von python index.py fasst unser Begleitskript stats.py zusammen, was in der Datenbank ist:

$ python stats.py

📊 LanceDB · table 'jobs'  ·  200 rows

by source keyword
  machine learning engineer  ████████████████████ 100
  python developer           ████████████████████ 100

by seniority
  Mid-Senior level  ████████████████████ 99
  Entry level       ████████████ 62
  Not Applicable    ████ 20
  Internship        ██ 14
  Associate          4
  Director           1

salary coverage: 43/200 jobs (22%)
  min  $   65,000
  med  $  150,000
  max  $1,000,000

  highest-paying jobs:
    • Quantitative Developer (Python)                  Fintal Partners       $400,000–$1,000,000/yr
    • Machine Learning Engineer                        Mercor                $130,000–$500,000/yr
    • Data Scientist                                   Triumph               $200,000–$400,000/yr
    • Senior Python Developer (Middle Office Tech)     Quantitative Systems  $200,000–$400,000/yr
    • ML Engineer (Infra & Distributed training)       techire ai            $250,000–$400,000/yr

top hiring companies (top 10)
  Turing          ████████████████████ 7
  Handshake       █████████████████ 6
  OpenAI          █████████████████ 6
  Meta            █████████████████ 6
  Jack & Jill     ██████████████ 5
  DataAnnotation  ██████████████ 5
  Catalyst Labs   ███████████ 4
  Notion          ███████████ 4
  LangChain       ███████████ 4
  Uber            ████████ 3

Hybridsuche mit Reranking ausführen

LanceDB unterstützt 3 Suchmodi, und unser lib.py stellt alle 3 hinter einer einzigen Funktion bereit:

# lib.py
from lancedb.rerankers import CohereReranker

reranker = CohereReranker(model_name="rerank-v3.5")  # pinned; Coheres neueres Modell ist rerank-v4.0

def search(query: str, mode: str = "hybrid", limit: int = 10, where: str | None = None):
    table = _table()
    if mode == "vector":
        q = table.search(query, query_type="vector")
    elif mode == "keyword":
        q = table.search(query, query_type="fts")
    elif mode == "hybrid":
        q = table.search(query, query_type="hybrid").rerank(reranker=reranker)
    if where:
        q = q.where(where, prefilter=True)
    return q.limit(limit).to_pandas()

Drei Aspekte von search() sind es wert, erklärt zu werden:

  • query_type="hybrid" kombiniert Vektorähnlichkeit und BM25-Scores aus dem Volltextindex, den wir zur Indexierungszeit erstellt haben (LancDBs natives FTS). Die Vereinigung der Kandidaten wird dann neu gerankt.
  • .rerank(reranker) sendet die Kandidatenliste an Coheres Rerank-API und gibt deren Reihenfolge zurück. Wir übergeben model_name="rerank-v3.5" explizit, weil der LanceDB-Standard älter ist.
  • prefilter=True wendet die SQL-WHERE-Klausel vor dem Vektorscan an, nicht danach. Das ist schneller (kleinerer Suchraum) und genauer (du verlierst keine Ergebnisse durch Abschneiden).

Eine echte Anfrage

Hier sind die Top-2-Ergebnisse für eine Anfrage, die wenig wörtliche Übereinstimmung mit einem Job-Titel im Datensatz hat:

$ python search.py "deep learning model training with GPUs"

  ▸ Training: ML Framework Engineer  ·  score 0.275
    OpenAI — San Francisco, CA
    Entry level · Full-time · 2026-04-22
    "About The Team Training Runtime designs the core distributed
     machine-learning training runtime that powers everything from early
     research experiments to frontier-scale model runs..."

  ▸ Machine Learning Engineer  ·  score 0.138
    Skild AI — San Mateo, CA
    Entry level · Full-time · 2026-04-15
    "Company Overview At Skild AI, we are building the world's first
     general purpose robotic intelligence that is robust and adapts to
     unseen scenarios without failing. We believe massive scale through
     data-driven machine learning..."

Keiner der Job-Titel enthält “GPUs”, aber beide Beschreibungen handeln von verteiltem ML-Training – genau das, wonach die Anfrage fragt. Reine Keyword-Suche hätte wahrscheinlich beide übersehen.

Jeder Modus gibt eine andere Art von Score zurück. Vektormodus gibt Kosinus-Distanz zurück (niedriger = näher), Hybrid+Rerank gibt Coheres Relevanzscore zurück (0 bis 1, höher = besser), und Keyword-Modus gibt rohen BM25 zurück (unbegrenzt, höher = mehr Keyword-Überlappung). Die Zahlen sind nicht modusübergreifend vergleichbar, nur innerhalb eines einzelnen Modus.

Semantik mit harten Einschränkungen kombinieren

Semantische Ähnlichkeit und SQL-Filter lassen sich in LanceDB in einer einzigen Anfrage kombinieren:

$ python search.py "fintech python role with equity" \
    --where "salary_min_annual >= 250000"

  ▸ Quantitative Developer (Python)  ·  score 0.374
    Fintal Partners — New York, United States
    Mid-Senior level · Full-time · $400,000–$1,000,000/yr · 2026-04-22

  ▸ Senior Software Engineer (Python)  ·  score 0.272
    Fintal Partners — New York, NY
    Mid-Senior level · Full-time · $250,000–$400,000/yr · 2026-04-23

Der Vektor-Teil findet den beschreibenden Teil (‘fintech python with equity’). Der SQL-Filter setzt die numerische Bedingung durch (>= $250k). Beide Ergebnisse sind Fintal Partners-Stellen im richtigen Gehaltsbereich.

Dasselbe Hybrid + Filter-Muster läuft in der Streamlit-UI, bei einem späteren Scrape (die Live-Anzeigen unterscheiden sich vom CLI-Durchlauf oben):

Streamlit-Suchoberfläche mit der Anfrage 'fintech python role with equity' und dem Mindestgehalt-Schieberegler in der Seitenleiste auf 250.000 $ gesetzt. Die Ergebnis-Kopfzeile lautet '3 results · mode: hybrid · filter: salary_min_annual >= 250000′. Die oberste Ergebniskarte zeigt Senior Software Engineer (Python) bei Fintal Partners in New York, NY, mit Badges für Mid-Senior level, Full-time und einem grünen Gehaltsbadge mit $300.000–$500.000/Jahr, Relevanzscore 0,272 und einem Beschreibungsausschnitt über ein quantitatives Handelsunternehmen. Ein zweites Ergebnis, Data Scientist bei OpenArt AI in San Francisco, Score 0,165, beginnt darunter.”/></figure>
<p class=Die Streamlit-App führt eine Hybridsuche aus, mit gesetztem Gehaltsschieberegler, bereitgestellt von app.py. Der Schieberegler erzeugt den salary_min_annual >= 250000-Vorfilter, der im grün-auf-schwarz-Filter-Banner angezeigt wird.

Wo Keyword-, Vektor- und Hybridsuche abweichen

compare.py führt dieselbe Anfrage durch alle 3 Modi und gibt einen Nebeneinander-Bericht aus:

$ python compare.py "engineer working on LLMs and prompt engineering" --top 3

══════════════════════════════════════════════════════════════════════════
  query: engineer working on LLMs and prompt engineering
══════════════════════════════════════════════════════════════════════════

  ── keyword (BM25) ───────────────────────────────────────────────────────
  1. AI/ML Engineer                                          — Careerswift
  2. AI/ML Engineer                                          — Careerswift
  3. Applied AI Engineer                                     — Serval

  ── vector (Cohere) ──────────────────────────────────────────────────────
  1. Senior Software Engineer (Prompt Engineer Python/GenAI)        — Genpact
  2. 15+ Years exp/ Need f2f/ AI/ML Engineer or Python AI Engi...   — Jobs via Dice
  3. ML Engineer (Infra & Distributed training)                     — techire ai

  ── hybrid + rerank ──────────────────────────────────────────────────────
  1. Applied AI Engineer                                     — Serval
  2. Senior Software Engineer (Prompt Engineer Python/GenAI) — Genpact
  3. AI/ML Engineer                                          — Careerswift

  overlap: keyword∩vector=0/3 · hybrid∩vector=1/3 · hybrid∩keyword=2/3

In der Überlappungszeile haben Keyword- und Vektorsuche 0 der gleichen Jobs in den Top 3 gefunden. Sie durchsuchen unterschiedliche konzeptuelle Räume.

  • Keyword (BM25) findet Stellenanzeigen, in denen die wörtlichen Tokens “LLMs” und “prompt” am häufigsten vorkommen. Es liefert generische KI/ML-Titel.
  • Vektor (Cohere) findet die Senior Software Engineer (Prompt Engineer Python/GenAI)-Stelle auf Platz 1, obwohl die Benutzeranfrage “prompt engineering” (Gerundium) und der Titel “Prompt Engineer” (Substantiv) verwendet. Es liefert auch eine LLM-fokussierte Stelle von Jobs via Dice, die semantisch stark passt, aber lexikalisch weit von der Anfrage entfernt ist.
  • Hybrid + Rerank nimmt die Vereinigung, dedupliziert und führt sie durch Cohere Rerank. Die Serval Applied AI Engineer-Stelle ($200k bis $325k) rückt auf Platz 1. Ihre Beschreibung ist dicht mit Prompt-Engineering- und LLM-Agent-Arbeit, aber weder ihr Titel noch ihre BM25-gewichteten Top-Terme hätten die Stelle so hoch eingestuft.

Für diese spezifische Anfrage haben Vektor- und Hybridsuche besser abgeschnitten als Keyword-Suche. Rohe Token-Überlappung hat die Genpact- und Serval-Ergebnisse unter ihrem semantischen Relevanzrang eingestuft. Aber eine einzelne Anfrage ist eine Anekdote, kein Beweis. Ob dieses Muster allgemein gilt, ist eine Frage, die nur eine echte Evaluation beantworten kann.

Qualität mit Precision@3 messen

Um das richtig zu messen, bewertet eval.py 10 handgeschriebene Anfragen gegen alle 3 Modi und berechnet Precision@3 – den Anteil der Top-3-Ergebnisse, der einem transparenten Ground-Truth-Prädikat entspricht.

Die Ground Truth für jede Anfrage ist ein Python-Prädikat, keine Zahl aus dem Nichts, sodass ein Leser selbst entscheiden kann, ob er Ergebnisse genauso bewertet.

Für “machine learning engineer at OpenAI” zählt ein Ergebnis als relevant, nur wenn sein company-Feld “OpenAI” enthält. Für “quantitative developer at trading firm” ist die Regel breiter. Ein Ergebnis zählt, wenn der Titel “Quant” oder “Trading” enthält oder das Unternehmen ein bekanntes Handelsunternehmen ist (Fintal Partners, DRW, Hudson River Trading, Tower Research, Mondrian Alpha). Diese Prädikate sind auf den Beispiel-Datensatz abgestimmt, sodass deine Scores bei neuen Jobs abweichen werden. Passe sie an deine eigenen Daten an. Der Abstand zwischen den Modi bleibt auch dann bestehen, wenn die genauen Prozentzahlen nicht übereinstimmen.

Ausführen:

$ python eval.py

precision@3 per query (hits/3)
────────────────────────────────────────────────────────────────────────
  query                                          keyword    vector     hybrid
────────────────────────────────────────────────────────────────────────
  machine learning engineer at OpenAI            1.00 (3/3)  1.00 (3/3)  1.00 (3/3)
  founding engineer at AI startup with equity    0.33 (1/3)  0.67 (2/3)  0.67 (2/3)
  prompt engineer working with LLMs              0.00 (0/3)  0.67 (2/3)  0.33 (1/3)
  quantitative developer at trading firm         0.67 (2/3)  1.00 (3/3)  1.00 (3/3)
  computer vision and robotics engineer          1.00 (3/3)  0.67 (2/3)  1.00 (3/3)
  data scientist role                            0.67 (2/3)  1.00 (3/3)  1.00 (3/3)
  distributed training infrastructure for ML     0.33 (1/3)  0.67 (2/3)  0.67 (2/3)
  backend engineer at AI company                 0.33 (1/3)  0.33 (1/3)  0.33 (1/3)
  python developer at fintech                    0.00 (0/3)  0.67 (2/3)  0.33 (1/3)
  high-paying machine learning role with equity  0.00 (0/3)  0.33 (1/3)  0.33 (1/3)
────────────────────────────────────────────────────────────────────────
  AVERAGE (10 queries)                           0.433       0.700       0.667

Dieselben Zahlen als Diagramm:

Balkendiagramm von Precision@3 über 10 Testanfragen: Keyword 43 %, Vektor 70 %, Hybrid + Rerank 67 %.

Precision@3 gemittelt über die 10 Eval-Anfragen. Vektor schneidet deutlich besser ab als Keyword, und Hybrid liegt nur wenige Punkte hinter Vektor.

Was die Zahlen sagen

Aus der Tabelle:

  • Vektorsuche schnitt deutlich besser ab als Keyword-Suche mit 70 % vs. 43 % durchschnittlicher Precision@3. Alle 3 Anfragen, bei denen Keyword 0 erzielte (“prompt engineer”, “python developer at fintech”, “high-paying ML with equity”), hatten bei Vektor mindestens 1 relevanten Treffer.
  • Hybrid + Rerank hat Vektor bei dieser Größenordnung nicht übertroffen. Der Abstand von 67 % vs. 70 % liegt im Rauschbereich: Der Reranker fügt einen Cohere-Aufruf pro Anfrage hinzu, und der FTS-Teil liefert ihm lexikalische Näherungstreffer, die er dann herausfiltern muss.
  • Kein Modus wird strikt dominiert. “Computer vision and robotics” ist die einzige Anfrage, bei der Keyword (1,00) besser abschneidet als Vektor (0,67), weil die relevanten Unternehmen alle wörtliche Robotik-Begriffe in ihren Beschreibungen enthalten.

Wann Hybrid + Rerank aktivieren

Das hängt von einigen Faktoren ab:

  • Größe des Kandidaten-Pools. Bei einigen hundert Zeilen reicht Vektor allein meist aus. Das zweistufige Retrieval von Hybrid braucht einen größeren Pool (10k+), bevor der Rerank-Schritt seinen Aufwand rechtfertigt.
  • Anfragetyp. Anfragen mit sowohl semantischer Absicht als auch markanten Keywords (ein Markenname, eine spezifische Technologie) profitieren von Hybrid. Rein semantische Anfragen meist nicht.
  • Reranker-Qualität. Coheres rerank-v3.5 hat in unserer Evaluation gut abgeschnitten. Wenn du einen anderen Reranker verwendest, führe eval.py erneut aus, bevor du ihm vertraust, da ein schwächerer Reranker gute Vektorergebnisse bei einem kleinen Kandidaten-Pool nach unten verschieben kann.

Führe eval.py auf deinen eigenen Daten aus, um zu entscheiden. Eine Anfrage hinzuzufügen ist eine Zeichenkette plus ein Ground-Truth-Prädikat.

Hinweis: Die Hybrid-Evaluation läuft problemlos mit einem kostenlosen Cohere-Key. Das Trial-Rate-Limit sorgt dafür, dass sie zurückfällt und in ~90 Sekunden statt ~15 Sekunden fertig wird.

Eine Web-UI mit Streamlit hinzufügen

Streamlit verwandelt dasselbe Such-Backend in eine klickbare Web-App. Der Such- und Render-Kern ist unten:

# app.py
import streamlit as st
from lib import search

mode = st.sidebar.radio("Mode", ["hybrid", "vector", "keyword"])
seniority = st.sidebar.selectbox("Seniority", ["any", "Entry level", "Associate", "Mid-Senior level", "Director", "Internship", "Not Applicable"])
min_salary = st.sidebar.slider("Min salary ($/yr)", 0, 500_000, 0, step=10_000)

query = st.text_input("Search jobs", placeholder="e.g. remote ML engineer...")

if query:
    where_clauses = []
    if seniority != "any":
        where_clauses.append(f"seniority = '{seniority}'")
    if min_salary > 0:
        where_clauses.append(f"salary_min_annual >= {min_salary}")
    where = " AND ".join(where_clauses) or None

    df = search(query, mode=mode, where=where, limit=10)
    for _, row in df.iterrows():
        with st.container(border=True):
            st.markdown(f"### [{row['title']}]({row['apply_url']})")
            st.markdown(f"**{row['company']}** — {row['location']}")
            st.caption(row["description_snippet"] + "…")

Ausführen:

streamlit run app.py

Du erhältst eine vollständige Suchseite unter localhost:8501 mit einem Suchfeld, Modus-Umschalter, Seitenleistenfiltern für Seniorität, Quell-Keyword und Gehalt sowie Ergebniskarten mit Badges, Scores und Snippet-Vorschauen.

Streamlit-Suchoberfläche für die Anfrage 'founding ML engineer at AI startup with computer vision' mit einer Hybrid-Ergebnisliste. Die Seitenleiste enthält Filter für Suchmodus, Seniorität, Quell-Suchanfrage-Keyword, Mindestgehalt und Anzahl der Ergebnisse. Die oberste Ergebniskarte ist 'Founding ML Engineer | Frontier Medical AI | $150k–$200k | SF' von CoffeeSpace in der San Francisco Bay Area, mit Badges für Mid-Senior level, Full-time, einem Cohere-Relevanzscore von 0,720 und einem Beschreibungsausschnitt. Ein zweites Ergebnis, 'AI/ML Engineer - AI Design Software Leader' mit Score 0,711, beginnt darunter.

Die Streamlit-App führt eine Hybridsuche aus. Das Score-Badge auf jeder Karte ist Coheres Relevanzscore, und der Ausschnitt unter den Badges zeigt, warum jedes Ergebnis in die Top 3 gelangt ist.

Kostenlose Zeitreise mit LanceDB

Das deckt Suche und UI ab. LanceDB hat noch eine weitere Funktion, die es wert ist, gezeigt zu werden. Jeder Schreibvorgang in LanceDB erstellt automatisch eine neue Version, ohne zusätzliche Kosten oder Infrastruktur. So funktioniert das zugrunde liegende Lance-Spaltenformat. Damit eine Version später leicht zu finden ist, taggt index.py sie nach jedem Ingest:

table.tags.create(f"ingest-{datetime.now():%Y-%m-%d-%H%M}", table.version)

Unser Begleitskript versions.py ermöglicht es dir dann, historische Snapshots zu durchsuchen und zu öffnen. Nach einmaligem Ausführen von python index.py siehst du 1 Tag. Nach einem zweiten Ingest (z. B. eine Woche später erneut scrapen) siehst du 2:

$ python versions.py

📊 table 'jobs'  ·  current version: 13  ·  200 rows

🏷  tags (2):
  • ingest-2026-05-20-0905           → version 7
  • ingest-2026-05-20-0906           → version 13  ← current

  travel back with: `python versions.py --tag `

$ python versions.py --tag ingest-2026-05-20-0905

📌 snapshot 'ingest-2026-05-20-0905'  ·  version 7  ·  200 rows
  • Associate Machine Learning Engineer  — Handshake
  • Machine Learning Engineer            — RZR
  • Machine Learning Engineer            — ChatGPT Jobs

Zeitreise ist ein einziger table.checkout(tag_or_version)-Aufruf. Für ein Jobsuch-Produkt beantwortet es Fragen wie “Welche Stellen wurden letztes Quartal gepostet?” oder “Verschiebt sich die Gehaltsverteilung im Laufe der Zeit?” ohne eine separate Zeitreihendatenbank. Das ist einer der Gründe, warum wir hier LanceDB gewählt haben.

Kosten und Skalierung

Für die Demo (200 Jobs, ~5 Beispielanfragen):

Posten Kosten
Bright Data Scrape (204 Datensätze @ $0,0015/Datensatz) $0,31
Cohere Embeddings (~228k Token gesamt @ $0,10/1M) ~$0,02
Cohere Rerank (~$0,002/Anfrage, Rerank v3.5 bei $2/1k Suchen) ~$0,01 für 5 Anfragen
LanceDB kostenlos

Die End-to-End-Demo kostet insgesamt ~$0,34. Diese Preise stammen aus einem Durchlauf von 2026, also überprüfe die aktuellen Tarife der Anbieter.

Skalieren

Die lokale Demo verarbeitet 200 Jobs. Einige Hebel decken den Weg von hier zu einem produktionsreifen Datensatz ab:

  • Mehr Jobs. Ändere LIMIT_PER_INPUT (max. 1.000 pro Keyword) oder füge mehr Keyword-Suchen hinzu. 10.000 Jobs kosten ~$15 an Bright Data-Credits.
  • Mehr Keywords / Standorte. Füge Einträge zur SEARCHES-Liste in scrape.py hinzu.
  • Geplante Aktualisierung. Der von uns erstellte merge_insert-Upsert bedeutet, dass ein erneutes Ausführen der Pipeline das Geänderte aktualisiert. Bright Data unterstützt geplante Sammlung und Lieferung vom Dashboard aus. Kombiniere das mit dem Upsert und du hast einen sich selbst aktualisierenden Datensatz.
  • Vektorindex. Ab ~10k Zeilen tausche Brute-Force-Suche gegen einen HNSW- oder IVF_PQ-Index via table.create_index(vector_column_name="vector"). Er wird standardmäßig auf der CPU erstellt. Für einen GPU-Build übergebe accelerator="cuda" (oder "mps" auf Apple Silicon) mit PyTorch>2.0. Automatisches GPU-Indexing ist derzeit ein LanceDB Enterprise-Feature.
  • Produktionsvektorspeicher. LanceDB OSS skaliert auf Millionen von Vektoren auf einem einzelnen Knoten. Jenseits von Hunderten von Millionen Vektoren oder Terabytes an Daten fügen LanceDB Cloud und Enterprise verteiltes Indexing und Abfrageausführung hinzu (ihre Dokumentation zielt auf ~10 bis 50 Mrd. Zeilen / ~10 bis 30 TB ab).

Vor diesen Skalierungsmaßnahmen hat die Demo selbst jedoch scharfe Kanten.

8 Bugs und Fallstricke, auf die wir gestoßen sind

Falls es dir die Stunden erspart, die sie uns gekostet haben:

  1. list_tables() gibt keine Liste zurück. In LanceDB 0.30 gibt es ein ListTablesResponse-Objekt zurück, das im REPL iterierbar aussieht, aber if TABLE in db.list_tables() schlägt still fehl. Verwende stattdessen try: db.open_table(TABLE) und fange die Ausnahme ab, oder verwende .tables auf der Antwort.
  2. table.checkout(tag) gibt None zurück und mutiert das Table-Handle an Ort und Stelle. Es sieht wie ein Bug aus, ist aber keiner. Mache t = db.open_table(...); t.checkout(tag); use(t), nicht t = db.open_table(...).checkout(tag).
  3. Der Standard-CohereReranker() verwendet ein altes Modell (rerank-english-v3.0 in den von uns getesteten Versionen). Übergebe ein Modell explizit, entweder rerank-v3.5 (was wir hier verwenden) oder rerank-v4.0-pro für höhere Qualität. Der Standard warnt dich nicht.
  4. Verwende /trigger + Polling, nicht /scrape, für echte Batches. Synchron (/scrape) ist für kleine Abfragen gebaut. Die Verbindung für limit_per_input=100 × 2 Keywords (~200 Jobs) offen zu halten, kann zu einem Timeout führen, also verwende /trigger + Polling für alles über ~50 Datensätze.
  5. Einige gescrapte Datensätze sind Fehlerzeilen. Von 204 Jobs hatten 4 ein gesetztes error-Feld statt eines job_title (zum Beispiel "Crawl aborted on job cancel"). Sie sehen oberflächlich wie normale Datensätze aus, also filtere sie in index.py heraus, oder merge_insert schlägt bei einer leeren job_id fehl.
  6. Gehälter kommen in 2 Perioden (yr und hr), aber das Schema-Feld ist dasselbe. Ohne Normalisierung auf Jahresbasis (stündlich mit 2080 multiplizieren) übersieht ein Filter wie salary_min_annual >= 200000 stillschweigend gut bezahlte Stundenverträge und schließt unplausibel niedrige Jahresgehälter ein.
  7. argparse-Hilfszeichenketten mit rohem % schlagen in Python 3.14 fehl. Das Schreiben von --where "salary > 200000 AND location LIKE '%SF%'" in deinem Hilfstext löst ValueError: badly formed help string aus, weil argparse versucht, es zu formatieren. Escape als %% oder formuliere das Beispiel um.
  8. Streamlit rendert Text zwischen $-Zeichen als LaTeX-Mathematik. Ein Gehalt wie $150k–$200k, das mit st.markdown oder st.caption angezeigt wird, wird zu unlesbarer Mathematik. Escape jedes $ in deinen Anzeigezeichenketten (das app.py im Repository macht es mit einem einzeiligen replace), oder die Gehaltsbadges werden zu Kauderwelsch.

Was du als Nächstes bauen kannst

Das Muster, Bright Data ⟶ Embeddings ⟶ Vektor-DB ⟶ Hybridsuche, lässt sich auf fast jede Domäne übertragen:

Domäne Bright Data Produkt Was du abfragen würdest
Agentischer Webzugriff The Web MCP (kostenlose Stufe derzeit 5.000 Anfragen/Monat) “Einem KI-Agenten Live-Such- und Scrape-Tools geben und seine Antworten gegen einen LanceDB-gestützten Cache vergangener Ergebnisse verankern”
Ganzseitige Korpora Crawl API “Eine gesamte Dokumentationsseite oder Wissensbasis für hybrides Retrieval indexieren”
E-Commerce Web Scraper API (Amazon-Produkte) “Bequeme Laufschuhe unter $100 mit 4+ Sternen”
Immobilien Web Scraper API (Zillow / Redfin) “Ruhiges Familienhaus in der Nähe guter Schulen, 3+ Schlafzimmer”
News Intelligence SERP-API + Web Unlocker “KI-Sicherheitsartikel aus dieser Woche, nach Relevanz für Alignment gerankt”
Vertriebsprospektierung LinkedIn-Unternehmensinformationen “Series-A-Startups im Bereich Healthcare-KI in Europa”
Restaurants Yelp-Datensatz “Gemütliches italienisches Restaurant mit Außenbereich”

Einige natürliche Erweiterungen dieses konkreten Projekts:

  • Multimodale Suche. Wechsle zu Cohere embed-v4.0 (nativ multimodal) und bette Unternehmenslogos neben Stellenbeschreibungen ein.
  • KI-extrahierte Filter. Lass den Benutzer “Remote-ML-Jobs mit $200k+” eingeben und eine KI automatisch remote=true, salary_min_annual >= 200000 extrahieren.
  • Gespeicherte Suchen mit E-Mail-Benachrichtigungen. Eine Anfrage gegen den neuesten Scrape erneut ausführen und bei neuen Treffern benachrichtigen.
  • Lebenslauf-Matching. Einen Lebenslauf einbetten und Jobs nach Ähnlichkeit mit dem Kandidaten durchsuchen. Bright Datas LinkedIn-Jobsuche-KI-Assistent ist ein ausführlicheres Beispiel.
  • Ein selbstwartender Scraper. Gib einem Agenten Zugang zu Bright Datas MCP, und er kann die Seite inspizieren, den Scraper schreiben und einen Fix versuchen, wenn sich das Layout ändert – anstatt scrape.py manuell zu patchen. Bright Datas Scraper Studio verpackt dies als verwaltetes Produkt und verwandelt einen Klartext-Prompt in einen selbstheilenden Scraper.

Nächste Schritte

Keyword-Suche hat die richtigen Stellen verpasst, und Vektorsuche hat sie gefunden, auch wenn die Titel nie mit der Anfrage übereinstimmten. In der Evaluation erzielte Vektor 70 % Precision@3 gegenüber 43 % bei Keyword, wobei Hybrid bei dieser Größenordnung keinen Vorteil brachte.

Das vollständige Projekt auf GitHub umfasst 9 kleine Dateien. Um es auf deinen eigenen Daten zu verwenden, führe zuerst python eval.py aus, weil der beste Modus von den Daten abhängt, nicht davon, welcher der komplexeste ist. Entscheide dann einen Aktualisierungsrhythmus, bei dem merge_insert-Upserts nur das Geänderte aktualisieren und versions.py jeden Ingest als Snapshot speichert. Und bevor etwas davon in Produktion geht, plane eine Schlüsselrotationsroutine, denn sowohl BD- als auch Cohere-Schlüssel kommen in .env.

Dasselbe Muster funktioniert für alles, was Bright Data scrapen kann, nicht nur Jobs. Von dort aus hast du eine semantische Suchmaschine, die du für jeden gescrapten Datensatz wiederverwenden kannst.

FAQ

Kann ich das auch für andere Websites als LinkedIn verwenden?

Ja. Bright Datas Web Scrapers Library umfasst Hunderte von Websites (Amazon, Zillow, Yelp und mehr), jede mit ihrer eigenen dataset_id. Tausche die DATASET_ID in scrape.py und das to_row()-Mapping in index.py für die neue JSON-Form aus. Die Such- und Indexierungslogik ist datenunabhängig und lässt sich übertragen.

Benötige ich einen kostenpflichtigen Cohere-Account dafür?

Nein, ein Trial-Key reicht für die gesamte Demo. Coheres Trial-Rerank-Endpunkt ist derzeit auf 10 Aufrufe/min begrenzt, sodass eval.py einen 429 erhält und automatisch zurückfällt (~90 Sekunden statt ~15 Sekunden). Web-Scraping, Indexierung und Ad-hoc-Suche bleiben gut unter den Limits. Upgrade nur, wenn du häufig an der Evaluation iterierst.

Warum LanceDB und nicht Pinecone, Weaviate oder pgvector?

LanceDB ist eine eingebettete Bibliothek ohne Server, ohne separate Datenbank und ohne verwaltete Dienstkosten. Es unterstützt hybride Suche und Cohere-Reranking nativ, und jeder Schreibvorgang ist ein Versions-Snapshot. Für eine Single-Machine-Pipeline ohne Ops ist das der geringste Aufwand. Die anderen sind leistungsfähig, fügen aber mehr Infrastruktur hinzu.

Wie oft sollte ich den Scraper neu ausführen?

Einmal täglich passt für eine aktive Jobbörse. Bright Data kann geplante Sammlung vom Dashboard aus ausführen, und der merge_insert-Upsert dedupliziert auf LanceDB-Seite, sodass erneute Durchläufe günstig sind. Anzeigen, die älter als ~30 Tage sind, sind meist geschlossen, sodass alte Snapshots historisch werden und versions.py sie abfragbar hält.