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:
- Bright Data scraped 200 echte LinkedIn-Stellenanzeigen in sauberes, strukturiertes JSON.
- Cohere wandelt die Beschreibungen in Embeddings um und führt das Reranking der Ergebnisse durch.
- 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.

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.

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):
- Asymmetrische Kodierung. Cohere erlaubt es, die Eingabe beim Indexieren als
search_documentund beim Suchen alssearch_queryzu kennzeichnen. Das Modell kodiert jede Seite unterschiedlich, was besser funktioniert als beide gleich zu behandeln. - 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. - 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_annualprofitiert davon, weil wir Bereichsabfragen wollen (>=,BETWEEN). - BITMAP für Enums mit niedriger Kardinalität.
seniorityhat ~6 verschiedene Werte,employment_typeist fast ausschließlichFull-time, undsearch_keywordist 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 übergebenmodel_name="rerank-v3.5"explizit, weil der LanceDB-Standard älter ist.prefilter=Truewendet 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):
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:

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.pyerneut 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.

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 inscrape.pyhinzu. - 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 übergebeaccelerator="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:
list_tables()gibt keine Liste zurück. In LanceDB 0.30 gibt es einListTablesResponse-Objekt zurück, das im REPL iterierbar aussieht, aberif TABLE in db.list_tables()schlägt still fehl. Verwende stattdessentry: db.open_table(TABLE)und fange die Ausnahme ab, oder verwende.tablesauf der Antwort.table.checkout(tag)gibtNonezurück und mutiert das Table-Handle an Ort und Stelle. Es sieht wie ein Bug aus, ist aber keiner. Machet = db.open_table(...); t.checkout(tag); use(t), nichtt = db.open_table(...).checkout(tag).- Der Standard-
CohereReranker()verwendet ein altes Modell (rerank-english-v3.0in den von uns getesteten Versionen). Übergebe ein Modell explizit, entwederrerank-v3.5(was wir hier verwenden) oderrerank-v4.0-profür höhere Qualität. Der Standard warnt dich nicht. - Verwende
/trigger+ Polling, nicht/scrape, für echte Batches. Synchron (/scrape) ist für kleine Abfragen gebaut. Die Verbindung fürlimit_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. - Einige gescrapte Datensätze sind Fehlerzeilen. Von 204 Jobs hatten 4 ein gesetztes
error-Feld statt einesjob_title(zum Beispiel"Crawl aborted on job cancel"). Sie sehen oberflächlich wie normale Datensätze aus, also filtere sie inindex.pyheraus, odermerge_insertschlägt bei einer leerenjob_idfehl. - Gehälter kommen in 2 Perioden (
yrundhr), aber das Schema-Feld ist dasselbe. Ohne Normalisierung auf Jahresbasis (stündlich mit 2080 multiplizieren) übersieht ein Filter wiesalary_min_annual >= 200000stillschweigend gut bezahlte Stundenverträge und schließt unplausibel niedrige Jahresgehälter ein. argparse-Hilfszeichenketten mit rohem%schlagen in Python 3.14 fehl. Das Schreiben von--where "salary > 200000 AND location LIKE '%SF%'"in deinem Hilfstext löstValueError: badly formed help stringaus, weil argparse versucht, es zu formatieren. Escape als%%oder formuliere das Beispiel um.- Streamlit rendert Text zwischen
$-Zeichen als LaTeX-Mathematik. Ein Gehalt wie$150k–$200k, das mitst.markdownoderst.captionangezeigt wird, wird zu unlesbarer Mathematik. Escape jedes$in deinen Anzeigezeichenketten (dasapp.pyim Repository macht es mit einem einzeiligenreplace), 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 >= 200000extrahieren. - 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.pymanuell 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.