AI

Construire un moteur de recherche d’emploi sémantique avec Bright Data, LanceDB et Cohere

Construisez un moteur de recherche d’emploi sémantique. Le Web Scraper de Bright Data retourne des offres LinkedIn structurées ; Cohere fournit des embeddings pour la correspondance basée sur le sens.
32 min de lecture
Build a Semantic Job Search Engine with Bright Data, LanceDB, and Cohere

Les sites d’offres d’emploi ne recherchent que des mots exacts, ce qui fait que le bon poste reste caché quand vos termes ne correspondent pas à l’annonce. La recherche sémantique s’appuie sur le sens. Nous la construisons de bout en bout, puis mesurons quel mode de recherche l’emporte au lieu de supposer que le plus complexe gagne.

TL;DR

Ce guide construit un moteur de recherche d’emploi sémantique sur 200 offres LinkedIn réelles en utilisant Bright Data (scraping), Cohere (embeddings + rerank) et LanceDB (stockage vectoriel local).

  • La recherche par mots-clés correspond à des mots exacts. La recherche vectorielle correspond au sens. Une requête comme “ingénieur travaillant sur des LLMs” trouve un poste de “Développeur GenAI” que la recherche par mots-clés manque.
  • L’API Web Scraper de Bright Data retourne des offres LinkedIn structurées en JSON à 0,0015 $ par enregistrement, sans analyse HTML ni maintenance de Scraper.
  • LanceDB fonctionne localement et combine la recherche vectorielle avec des filtres SQL (salaire, ancienneté) en 1 requête, plus la recherche en texte intégral et le reranking Cohere.
  • Sur 10 requêtes de test, la recherche vectorielle a obtenu 70% de precision@3 contre 43% pour les mots-clés. Hybrid + rerank n’a apporté aucun gain mesurable à cette échelle, donc en dessous de ~10k lignes, le vecteur seul est une valeur par défaut raisonnable.
  • Le projet complet comprend 9 petits fichiers, dont un harnais d’évaluation, et le code complet est sur GitHub. L’exécution complète coûte environ 0,34 $.

Le problème avec la recherche par mots-clés

La recherche par mots-clés sur un site d’offres d’emploi fait exactement ce que vous demandez. Elle retourne les annonces dont le titre ou la description contient les tokens littéraux de votre requête. Cherchez “ingénieur travaillant sur des LLMs et du prompt engineering” et vous manquerez des postes comme “Développeur GenAI” même quand ils correspondent parfaitement. La recherche lexicale correspond aux mots exacts, pas au sens.

La recherche vectorielle s’appuie sur le sens. Chaque description de poste est convertie en embedding (un vecteur de haute dimension capturant son contenu sémantique), tout comme votre requête. Un poste dont le vecteur est proche de celui de votre requête est une bonne correspondance de sens, même quand il ne partage aucun des mêmes mots.

Transformer cela en moteur de recherche fonctionnel nécessite 3 éléments :

  1. Bright Data scrape 200 vraies offres d’emploi LinkedIn en JSON structuré propre.
  2. Cohere transforme les descriptions en embeddings et rerankeles résultats finaux.
  3. LanceDB stocke les embeddings localement et sert des requêtes hybrides (vecteur + texte intégral) avec des filtres de type SQL.

La stack en un coup d’œil

Ce que fait chaque couche, et pourquoi nous l’utilisons :

Couche Outil Pourquoi celui-ci
Données web Bright Data API Web Scraper Le Scraper LinkedIn pré-construit retourne du JSON structuré avec salaire, ancienneté et localisation, sans analyse HTML ni maintenance de Scraper.
Embeddings Cohere embed-english-v3.0 Encodage asymétrique (types d’entrée différents pour les documents vs les requêtes). Cohere propose aussi embed-v4.0, multimodal. Nous utilisons v3 pour son profil prix/latence en anglais uniquement (prévoir de ré-embedder avant la fin de vie de v3).
Reranker Cohere rerank-v3.5 Nous épinglons v3.5 pour son profil prix/latence. Cohere propose aussi rerank-v4.0 (-pro pour la qualité, -fast pour la latence).
Stockage vectoriel LanceDB Local, embarqué, sans serveur. Il supporte la recherche hybride (vecteur + BM25) et les préfiltres SQL.
UI (optionnel) Streamlit Interface web à code minimal pour une application Python de données.

Cette stack fonctionne depuis un seul venv Python sur votre ordinateur portable. Bright Data et Cohere sont les seuls services gérés impliqués.

Configuration

Le projet complet et exécutable est sur GitHub. Clonez-le et installez les dépendances (Python 3.10 ou plus récent) :

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

Copiez le fichier env d’exemple et ajoutez vos deux clés API, un token Bright Data et une clé Cohere depuis dashboard.cohere.com (une clé d’essai fonctionne pour tout le guide) :

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

Avec les deux clés en place, exécutez python scrape.py pour récupérer les données et python index.py pour construire l’index.

Architecture

Le système comporte deux flux, pas un. L’ingestion construit l’index (exécutée une fois, ou selon un calendrier). La requête s’exécute à chaque recherche. Les deux utilisent Cohere et LanceDB, mais pour des tâches différentes.

Diagramme d'architecture à deux flux. INGESTION (exécutée une fois ou selon un calendrier) : une flèche 'mot-clé' entre dans Bright Data ('Découvrir des emplois par mot-clé (async)'), qui envoie des 'emplois (JSON)' à Cohere ('embed (document)'), qui envoie des 'vecteurs' à LanceDB ('index vecteur + FTS + scalaire, versionné'). REQUÊTE (par recherche, mode hybride par défaut) : une flèche 'requête' entre dans Cohere ('embed (query)'), qui envoie un 'vecteur de requête' à LanceDB ('recherche vecteur + FTS + préfiltre SQL'), qui envoie des 'candidats' à Cohere ('rerank'), qui retourne des 'résultats classés'. Cohere et LanceDB sont colorés pour montrer qu'ils sont réutilisés dans les deux flux, et le rerank ne s'exécute qu'en mode hybride.

Les deux flux côte à côte. L’ingestion embed les documents et les stocke. La requête embed le texte de recherche, exécute une recherche vecteur + texte intégral avec un préfiltre SQL, puis rerankeles résultats. Cohere et LanceDB apparaissent dans les deux flux mais font un travail différent dans chacun, c’est pourquoi le rerank ne touche jamais le chemin d’ingestion.

3 scripts exécutent le pipeline : scrape.py, index.py, search.py. 6 autres helpers : lib.py (backend de recherche partagé), compare.py (comparaison de modes), eval.py (precision@3), stats.py (résumé du jeu de données), versions.py (navigateur de snapshots) et app.py (UI Streamlit).

Scraper LinkedIn avec Bright Data

LinkedIn est une source majeure de données d’emploi, mais il est difficile à scraper de manière fiable : limites de débit, balisage dynamique et HTML qui change sans préavis. L’API Web Scraper retourne du JSON structuré propre depuis des endpoints pré-construits, vous n’avez donc pas à maintenir des parseurs.

Choisir le bon endpoint

Bright Data expose plusieurs Scrapers LinkedIn :

  • Profils de personnes → profils de membres individuels
  • Informations sur les entreprises → pages d’entreprises
  • Offres d’emploi → Collecter par URL → URLs d’emplois spécifiques que vous avez déjà
  • Offres d’emploi → Découvrir par mot-clé ← c’est ce que nous voulons
  • Offres d’emploi → Découvrir par URL → emplois depuis une URL de résultats de recherche
  • Publications LinkedIn et Recherche de personnes → autres types d’entités

Découvrir par mot-clé est le bon choix car nous voulons une découverte d’emplois en masse depuis une requête de recherche. Un seul appel API retourne jusqu’à 1 000 offres d’emploi structurées par mot-clé, incluant le titre, l’entreprise, la localisation, le niveau d’ancienneté, le type d’emploi, la fourchette salariale si indiquée, et la description complète du poste.

Chaque Scraper a son propre dataset_id. Pour en trouver un, ouvrez la Bibliothèque de Scrapers de Bright Data, recherchez le site (ici, linkedin.com) et ouvrez-le. Choisissez l’endpoint Offres d’emploi → Découvrir par mot-clé, et son dataset_id (gd_lpfll7v5hcqtkxl6l) ainsi qu’une requête prête à l’emploi apparaissent dans le panneau Exemples de code. Un token valide est tout ce dont scrape.py a besoin pour l’appeler.

Tableau de bord Bright Data montrant le menu de la Bibliothèque de Web Scrapers, avec l'endpoint LinkedIn offres d'emploi → 'Découvrir par mot-clé' sélectionné dans la barre latérale gauche. Le panneau central montre l'onglet Configuration avec des exemples d'entrées (paris/chef de produit, New York/développeur python). Le panneau droit montre la vue Exemples de code avec une requête curl authentifiée contenant `dataset_id=gd_lpfll7v5hcqtkxl6l`.

La page du Scraper ‘Découvrir par mot-clé’. Le panneau Exemples de code à droite est là où vous trouverez le dataset_id.

Synchrone vs asynchrone

Bright Data propose 2 modes de livraison :

  • Synchrone (POST /datasets/v3/scrape) retourne les données en ligne, idéal pour les très petits lots.
  • Asynchrone (POST /datasets/v3/trigger) retourne un ID de snapshot. Vous interrogez la complétion et téléchargez le résultat, idéal pour tout ce qui est plus grand.

Dans nos exécutions, le temps de réponse était en moyenne de ~6 secondes par entrée. Pour 2 mots-clés avec limit_per_input=100 (200 emplois au total), un appel synchrone doit maintenir la connexion ouverte pendant tout le lot, ce qui risque de dépasser le délai. L’asynchrone est la valeur par défaut sûre.

Contrôler le coût avec des limites par entrée

Le paramètre de requête limit_per_input=N plafonne le nombre de résultats que chaque recherche d’entrée retourne, ce qui est exactement le levier que vous voulez pour des dépenses prévisibles :

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

Augmentez-le pour des exécutions plus importantes, jusqu’à 1 000 emplois par mot-clé.

Le code

Le Scraper déclenche un snapshot, interroge jusqu’à ce qu’il soit prêt, et télécharge le JSON. L’essentiel est ci-dessous (une version de production ajouterait des tentatives/backoff et une gestion d’erreurs plus riche) :

# 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()

Exécution :

$ 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

Ce que vous récupérez

Chaque emploi dans le JSON comporte plus de 25 champs. Voici les plus importants :

{
  "job_posting_id": "<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/<id>"
}

Le champ structuré base_salary est ce qui rend possible les requêtes de filtre salarial à l’étape suivante.

Indexer avec Cohere et LanceDB

Nous avons 204 enregistrements d’emplois bruts, dont 4 sont des lignes d’erreur que nous filtrons au chargement. Nous rendons maintenant les 200 restants sémantiquement interrogeables.

Pourquoi Cohere

Nous avons choisi Cohere plutôt que les alternatives (modèles d’embedding d’OpenAI, Voyage AI ou sentence-transformers locaux) :

  1. Encodage asymétrique. Cohere vous permet de taguer l’entrée comme search_document lors de l’indexation ou search_query lors de la recherche. Le modèle encode chaque côté différemment, ce qui fonctionne mieux que de traiter les deux de la même façon.
  2. Embedding déclaratif. Le registre de LanceDB supporte Cohere nativement (comme OpenAI et sentence-transformers), donc l’embedding se produit lors de l’insertion et de la requête sans appels embed() manuels.
  3. L’API Rerank. C’est un modèle séparé qui prend une requête plus une liste de candidats et réordonne les candidats par pertinence réelle. C’est la deuxième étape qui peut affiner le classement d’un pipeline hybride, et nous ajoutons cette étape avec un seul appel .rerank().

Le registre d’embedding LanceDB

Les embeddings dans LanceDB passent par son registre d’embedding. Vous déclarez votre schéma une fois, et les embeddings se produisent automatiquement à chaque insertion et chaque requête, chacun avec le bon 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()              # ← what to embed
    vector: Vector(cohere.ndims()) = cohere.VectorField()  # ← stored 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

Tout ce qui suit vector est une colonne stockée ordinaire, utilisée pour le filtrage et l’affichage.

L’astuce de normalisation salariale

La plupart des emplois ont des salaires indiqués par année, mais quelques-uns sont par heure. Pour que salary_min_annual >= 200000 fonctionne de manière cohérente, nous normalisons lors de l’ingestion :

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

Nous stockons à la fois les valeurs numériques brutes (pour les filtres) et une chaîne d’affichage lisible par l’humain (pour l’interface).

Mises à jour incrémentielles avec des upserts

La première fois qu’index.py s’exécute, il crée la table. Chaque exécution suivante est un upsert basé sur job_id :

result = (
    table.merge_insert("job_id")
         .when_matched_update_all()       # refresh existing job postings
         .when_not_matched_insert_all()   # add newly-discovered ones
         .execute(rows)
)
print(f"inserted={result.num_inserted_rows}, updated={result.num_updated_rows}")

Les nouvelles offres d’emploi provenant d’un nouveau scraping Bright Data sont insérées, et les offres re-postées (même job_id) ont leurs salaires, descriptions et horodatages actualisés. Pour supprimer entièrement les offres obsolètes, enchaînez .when_not_matched_by_source_delete().

L’ensemble de l’upsert est une transaction atomique unique. Comme Lance stocke les données en colonnes avec copie sur écriture, la ré-ingestion est une écriture incrémentielle plutôt qu’une reconstruction complète de la table.

Index scalaires pour des filtres SQL rapides

Quand search.py, where "salary_min_annual >= 200000" s’exécute, LanceDB applique le filtre avant le scan vectoriel (prefilter=True). À 200 lignes, c’est instantané dans tous les cas. À 200 000 lignes, le filtre parcourrait toute la colonne à moins que nous indiquions à LanceDB comment l’indexer :

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 types d’index couvrent nos besoins :

  • BTREE pour les colonnes triables à cardinalité élevée. salary_min_annual en bénéficie car nous voulons des requêtes de plage (>=, BETWEEN).
  • BITMAP pour les enums à faible cardinalité. seniority a ~6 valeurs distinctes, employment_type est presque tout Full-time, et search_keyword est l’une de nos 2 entrées de scraping. Chaque valeur distincte obtient son propre bitmap. Un filtre = devient un simple AND bit à bit.

Les deux s’exécutent avec replace=True, donc réexécuter index.py les reconstruit de manière idempotente. Après l’appel, table.list_indices() rapporte les 5 (les 4 scalaires + l’index FTS) :

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']

Inspecter les données indexées

Après avoir exécuté python index.py, notre script compagnon stats.py résume ce qui est dans la base de données :

$ 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

Exécuter la recherche hybride avec reranking

LanceDB supporte 3 modes de recherche, et notre lib.py expose les 3 derrière une seule fonction :

# lib.py
from lancedb.rerankers import CohereReranker

reranker = CohereReranker(model_name="rerank-v3.5")  # pinned; Cohere's newer model is 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()

Trois éléments de search() méritent une explication :

  • query_type="hybrid" combine la similarité vectorielle et les scores BM25 de l’index de texte intégral que nous avons construit lors de l’indexation (FTS natif de LanceDB). L’union des candidats est ensuite rerankée.
  • .rerank(reranker) envoie la liste de candidats à l’API Rerank de Cohere et retourne son classement. Nous passons model_name="rerank-v3.5" explicitement car le défaut de LanceDB est plus ancien.
  • prefilter=True applique la clause SQL WHERE avant le scan vectoriel, pas après. C’est plus rapide (espace de recherche plus petit) et plus précis (vous ne perdez pas de résultats par troncature).

Une vraie requête

Voici les 2 premiers résultats pour une requête qui ne partage pas beaucoup de mots littéraux avec un titre d’emploi dans le jeu de données :

$ 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..."

Aucun titre de poste ne contient “GPUs”, mais les deux descriptions portent sur l’entraînement ML distribué, ce qui est l’objet de la requête. Une recherche par mots-clés pure manquerait probablement les deux.

Chaque mode retourne un type de score différent. Le mode vectoriel retourne la distance cosinus (plus bas = plus proche), hybrid+rerank retourne le score de pertinence de Cohere (0 à 1, plus haut = meilleur), et le mode par mots-clés retourne le BM25 brut (non borné, plus haut = plus de chevauchement de mots-clés). Les chiffres ne sont pas comparables d’un mode à l’autre, seulement au sein d’un seul mode.

Combiner la sémantique avec des contraintes strictes

La similarité sémantique et les filtres SQL se combinent en une seule requête dans LanceDB :

$ 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

La partie vectorielle correspond à la partie descriptive (“fintech python avec equity”). Le filtre SQL applique la contrainte numérique (>= 250k$). Les deux résultats sont des postes chez Fintal Partners dans la bonne fourchette salariale.

Le même pattern hybride + filtre s’exécute dans l’interface Streamlit, sur un scraping ultérieur (les annonces en direct diffèrent de l’exécution CLI ci-dessus) :

Interface de recherche Streamlit avec la requête 'fintech python role with equity' et le curseur Salaire min dans la barre latérale réglé à 250 000 $. L'en-tête des résultats indique '3 résultats · mode : hybrid · filtre : salary_min_annual >= 250000′. La carte du premier résultat montre Senior Software Engineer (Python) chez Fintal Partners à New York, NY, avec des badges Mid-Senior level, Full-time, et un badge vert de salaire indiquant $300 000,$500 000/an, score de pertinence 0.272, et un extrait sur une société de trading quantitatif. Un deuxième résultat, Data Scientist chez OpenArt AI à San Francisco, score 0.165, commence en dessous.”/></figure>
<p class=L’application Streamlit exécutant une recherche hybride avec le curseur de salaire activé, servie depuis app.py. Le curseur produit le préfiltre salary_min_annual >= 250000 affiché dans la bannière de filtre vert sur noir.

Là où les mots-clés, le vecteur et l’hybride divergent

compare.py exécute la même requête à travers les 3 modes et imprime un rapport côte à côte :

$ 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

Dans la ligne de chevauchement, les mots-clés et le vecteur n’ont trouvé 0 emploi identique dans le top 3. Ils cherchent dans des espaces conceptuels différents.

  • Les mots-clés (BM25) trouvent les annonces où les tokens littéraux “LLMs” et “prompt” apparaissent le plus fréquemment. Il retourne des titres génériques AI/ML.
  • Le vecteur (Cohere) trouve l’annonce Senior Software Engineer (Prompt Engineer Python/GenAI) en #1, même si la requête utilisateur disait “prompt engineering” (gérondif) et le titre dit “Prompt Engineer” (nom). Il retourne aussi une annonce axée LLM de Jobs via Dice qui est une forte correspondance sémantique mais lexicalement distante de la requête.
  • Hybrid + rerank prend l’union, déduplique et l’exécute à travers Cohere Rerank. Le poste Applied AI Engineer de Serval (200k à 325k$) monte en #1. Sa description est dense en travail de prompt engineering et d’agents LLM, mais ni son titre ni ses termes BM25 les plus pondérés n’auraient classé le poste aussi haut.

Pour cette requête spécifique, le vecteur et l’hybride ont tous deux mieux performé que les mots-clés. Le chevauchement brut de tokens a classé les résultats Genpact et Serval en dessous de ce que leur pertinence sémantique leur attribuait. Mais une seule requête est une anecdote, pas une preuve. Si ce schéma se maintient en général est une question à laquelle seule une vraie évaluation peut répondre.

Mesurer la qualité avec precision@3

Pour mesurer cela correctement, eval.py évalue 10 requêtes écrites à la main contre les 3 modes et calcule la precision@3, la fraction des 3 premiers résultats qui correspond à un prédicat de vérité terrain transparent.

La vérité terrain pour chaque requête est un prédicat Python, pas un nombre magique, afin qu’un lecteur puisse décider s’il noterait les résultats de la même façon.

Pour “machine learning engineer at OpenAI”, un résultat ne compte comme pertinent que si son champ company contient “OpenAI”. Pour “quantitative developer at trading firm”, la règle est plus large. Un résultat compte si le titre contient “Quant” ou “Trading”, ou si l’entreprise est une société de trading connue (Fintal Partners, DRW, Hudson River Trading, Tower Research, Mondrian Alpha). Ces prédicats sont ajustés au jeu de données d’exemple, donc vos scores varieront sur de nouveaux emplois. Ajustez-les à vos propres données. L’écart entre les modes persiste même quand les pourcentages exacts ne le font pas.

Exécution :

$ 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

Les mêmes chiffres sous forme de graphique :

Graphique à barres de precision@3 sur 10 requêtes de test : mots-clés 43%, vecteur 70%, hybrid + rerank 67%.

Precision@3 moyennée sur les 10 requêtes d’évaluation. Le vecteur score bien au-dessus des mots-clés, et l’hybride est à quelques points du vecteur.

Ce que disent les chiffres

D’après le tableau :

  • La recherche vectorielle a bien scoré au-dessus de la recherche par mots-clés avec 70% vs 43% de precision@3 moyenne. Les 3 requêtes où les mots-clés ont scoré 0 (“prompt engineer”, “python developer at fintech”, “high-paying ML with equity”) avaient au moins 1 résultat pertinent sous le vecteur.
  • Hybrid + rerank n’a pas battu le vecteur à cette échelle. L’écart de 67% vs 70% est dans le bruit : le reranker ajoute un appel Cohere par requête, et la partie FTS lui envoie des quasi-correspondances lexicales qu’il doit ensuite filtrer.
  • Aucun mode n’est strictement dominé. “Computer vision and robotics” est la seule requête où les mots-clés (1.00) scorent au-dessus du vecteur (0.67), car les entreprises pertinentes contiennent toutes des termes robotiques littéraux dans leurs descriptions.

Quand activer hybrid + rerank

Cela dépend de quelques facteurs :

  • Taille du pool de candidats. À quelques centaines de lignes, le vecteur seul suffit généralement. La récupération en 2 étapes de l’hybride nécessite un pool plus grand (10k+) avant que l’étape de rerank vaille son coût.
  • Type de requête. Les requêtes avec à la fois une intention sémantique et des mots-clés distinctifs (un nom de marque, une technologie spécifique) bénéficient de l’hybride. Les requêtes purement sémantiques généralement pas.
  • Qualité du reranker. Le rerank-v3.5 de Cohere a bien performé dans notre évaluation. Si vous remplacez par un autre reranker, réexécutez eval.py avant de lui faire confiance, car un reranker plus faible peut réordonner de bons résultats vectoriels vers le bas sur un petit pool de candidats.

Exécutez eval.py sur vos propres données pour décider. Ajouter une requête est une chaîne plus un prédicat de vérité terrain.

Note : l’évaluation hybride fonctionne bien sur une clé Cohere gratuite. La limite de débit d’essai la fait reculer et terminer en ~90s au lieu de ~15s.

Ajouter une interface web avec Streamlit

Streamlit transforme le même backend de recherche en une application web cliquable. Le cœur de recherche et rendu est ci-dessous :

# 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"] + "…")

Exécution :

streamlit run app.py

Vous obtenez une page de recherche complète sur localhost:8501 avec une boîte de recherche, un sélecteur de mode, des filtres de barre latérale pour l’ancienneté, le mot-clé source et le salaire, plus des cartes de résultats avec des badges, des scores et des aperçus d’extraits.

Interface de recherche Streamlit pour la requête 'founding ML engineer at AI startup with computer vision' montrant une liste de résultats hybrides. La barre latérale contient des filtres pour le mode de recherche, l'ancienneté, le mot-clé de recherche source, le salaire minimum et le nombre de résultats. La carte du premier résultat est 'Founding ML Engineer | Frontier Medical AI | $150k,$200k | SF' de CoffeeSpace dans la région de San Francisco Bay Area, avec des badges Mid-Senior level + Full-time, un score de pertinence Cohere de 0.720, et un extrait de description. Un deuxième résultat, 'AI/ML Engineer - AI Design Software Leader' avec un score de 0.711, commence en dessous.

L’application Streamlit exécutant une recherche hybride. Le badge de score sur chaque carte est le score de pertinence de Cohere, et l’extrait sous les badges montre pourquoi chaque résultat s’est retrouvé dans le top 3.

Voyage dans le temps gratuit avec LanceDB

Cela couvre la recherche et l’interface. LanceDB a une autre fonctionnalité qui mérite d’être montrée. Chaque écriture dans LanceDB crée automatiquement une nouvelle version, sans coût supplémentaire ni infrastructure. C’est ainsi que fonctionne le format colonnaire Lance sous-jacent. Pour rendre une version facile à retrouver plus tard, index.py la tague après chaque ingestion :

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

Notre script compagnon versions.py vous permet ensuite de parcourir et d’ouvrir des snapshots historiques. Après avoir exécuté python index.py une fois, vous verrez 1 tag. Après une deuxième ingestion (par exemple, un nouveau scraping une semaine plus tard), vous en verrez 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 <name>`

$ 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

Le voyage dans le temps est un seul appel table.checkout(tag_or_version). Pour un produit de recherche d’emploi, cela répond à des questions comme “quels postes étaient publiés le trimestre dernier ?” ou “la distribution salariale évolue-t-elle dans le temps ?” sans base de données de séries temporelles séparée. C’est l’une des raisons pour lesquelles nous avons choisi LanceDB ici.

Coût et échelle

Pour la démo (200 emplois, ~5 exemples de requêtes) :

Élément Coût
Scraping Bright Data (204 enregistrements à 0,0015 $/enregistrement) 0,31 $
Embeddings Cohere (~228k tokens au total à 0,10 $/1M) ~0,02 $
Rerank Cohere (~0,002 $/requête, Rerank v3.5 à 2 $ / 1k recherches) ~0,01 $ pour 5 requêtes
LanceDB gratuit

La démo de bout en bout coûte ~0,34 $ au total. Ces prix sont issus d’une exécution en 2026, vérifiez donc les tarifs actuels des fournisseurs.

Passer à l’échelle

La démo locale gère 200 emplois. Quelques leviers couvrent le chemin d’ici à un jeu de données à l’échelle de production :

  • Plus d’emplois. Changez LIMIT_PER_INPUT (max 1 000 par mot-clé) ou ajoutez plus de recherches par mots-clés. 10 000 emplois coûtent ~15 $ en crédits Bright Data.
  • Plus de mots-clés / localisations. Ajoutez des entrées à la liste SEARCHES dans scrape.py.
  • Actualisation planifiée. L’upsert merge_insert que nous avons construit signifie que réexécuter le pipeline actualise ce qui a changé. Bright Data supporte la collecte et la livraison planifiées depuis le tableau de bord. Associez cela à l’upsert et vous avez un jeu de données auto-actualisé.
  • Index vectoriel. Au-delà de ~10k lignes, remplacez la recherche par force brute par un index HNSW ou IVF_PQ via table.create_index(vector_column_name="vector"). Il se construit sur CPU par défaut. Pour une construction GPU, passez accelerator="cuda" (ou "mps" sur Apple Silicon) avec PyTorch>2.0. L’indexation GPU automatique est actuellement une fonctionnalité LanceDB Enterprise.
  • Stockage vectoriel de production. LanceDB OSS passe à des millions de vecteurs sur un seul nœud. Au-delà de centaines de millions de vecteurs ou de téraoctets de données, LanceDB Cloud et Enterprise ajoutent l’indexation distribuée et l’exécution de requêtes (leur documentation cible ~10 à 50 milliards de lignes / ~10 à 30 To).

Avant ces mesures de mise à l’échelle, cependant, la démo elle-même a des points faibles.

8 bugs et pièges que nous avons rencontrés

Au cas où cela vous ferait économiser les heures qu’ils nous ont coûtées :

  1. list_tables() ne retourne pas une liste. Dans LanceDB 0.30, il retourne un objet ListTablesResponse qui semble itérable dans le REPL mais if TABLE in db.list_tables() échoue silencieusement. Utilisez try: db.open_table(TABLE) et capturez l’exception à la place, ou .tables sur la réponse.
  2. table.checkout(tag) retourne None et mute le handle de table en place. Cela ressemble à un bug, mais ce n’en est pas un. Faites t = db.open_table(...); t.checkout(tag); use(t), pas t = db.open_table(...).checkout(tag).
  3. Le CohereReranker() par défaut utilise un ancien modèle (rerank-english-v3.0 dans les versions que nous avons testées). Passez un modèle explicitement, soit rerank-v3.5 (ce que nous épinglons ici) soit rerank-v4.0-pro pour une meilleure qualité. Le défaut ne vous avertit pas.
  4. Utilisez /trigger + polling, pas /scrape, pour les vrais lots. Sync (/scrape) est conçu pour les petites extractions. Maintenir la connexion ouverte pour limit_per_input=100 × 2 mots-clés (~200 emplois) peut dépasser le délai, donc utilisez /trigger + polling pour tout ce qui dépasse ~50 enregistrements.
  5. Quelques enregistrements scrapés sont des lignes d’erreur. Sur 204 emplois, 4 avaient un champ error défini au lieu d’un job_title (par exemple, "Crawl aborted on job cancel"). Ils ressemblent superficiellement à des enregistrements normaux, donc filtrez-les dans index.py ou merge_insert échouera sur un job_id vide.
  6. Les salaires viennent en 2 périodes (yr et hr) mais le champ de schéma est le même. Sans normaliser en annuel (multiplier l’horaire par 2080), un filtre comme salary_min_annual >= 200000 manque silencieusement les contrats horaires bien rémunérés et inclut des rôles salariés au salaire invraisemblablement bas.
  7. Les chaînes d’aide argparse avec des % bruts se cassent sur Python 3.14. Écrire --where "salary > 200000 AND location LIKE '%SF%'" dans votre texte d’aide lève ValueError: badly formed help string car argparse essaie de le formater. Échappez comme %% ou reformulez l’exemple.
  8. Streamlit rend le texte entre les signes $ comme des mathématiques LaTeX. Un salaire comme $150k,$200k affiché avec st.markdown ou st.caption devient des mathématiques déformées. Échappez chaque $ dans vos chaînes d’affichage (le app.py du dépôt le fait avec un replace d’une ligne), ou les badges de salaire s’affichent comme du charabia.

Ce que vous pouvez construire ensuite

Le pattern, Bright Data ⟶ embeddings ⟶ base vectorielle ⟶ recherche hybride, se généralise à presque n’importe quel domaine :

Domaine Produit Bright Data Ce que vous interrogeriez
Accès web agentique The Web MCP (niveau gratuit actuellement 5 000 requêtes/mois) “donner à un agent IA des outils de recherche et de scraping en direct, puis ancrer ses réponses dans un cache soutenu par LanceDB de résultats passés”
Corpus de sites entiers API Crawl “indexer un site de documentation entier ou une base de connaissances pour la récupération hybride”
E-commerce API Web Scraper (produits Amazon) “chaussures de course confortables à moins de 100 $ avec 4+ étoiles”
Immobilier API Web Scraper (Zillow / Redfin) “maison familiale calme près de bonnes écoles, 3+ chambres”
Intelligence des actualités API SERP + Web Unlocker “articles sur la sécurité de l’IA de cette semaine, classés par pertinence par rapport à l’alignement”
Prospection commerciale Informations sur les entreprises LinkedIn “startups Série A dans la santé IA basées en Europe”
Restaurants Jeu de données Yelp “restaurant italien cosy avec terrasse”

Quelques extensions naturelles de ce projet exact :

  • Recherche multimodale. Passez à Cohere embed-v4.0 (nativement multimodal) et embedez les logos d’entreprises aux côtés des descriptions de postes.
  • Filtres extraits par LLM. Laissez l’utilisateur taper “emplois ML à distance payant 200k$+” et laissez un LLM extraire automatiquement remote=true, salary_min_annual >= 200000.
  • Recherches sauvegardées avec alertes email. Réexécutez une requête contre le scraping le plus récent et notifiez sur les nouvelles correspondances.
  • Correspondance de CV. Embedez un CV et recherchez des emplois par similarité avec le candidat. L’assistant IA de recherche d’emploi LinkedIn de Bright Data est un exemple plus complet.
  • Un Scraper auto-maintenu. Donnez à un agent l’accès au MCP de Bright Data et il peut inspecter la page, écrire le Scraper et tenter une correction quand la mise en page change, au lieu que vous patchiez scrape.py à la main. Le Scraper Studio de Bright Data emballe cela comme un produit géré, transformant une invite en langage naturel en un Scraper auto-réparant.

Prochaines étapes

La recherche par mots-clés a manqué les bons postes, et la recherche vectorielle les a trouvés même quand les titres ne correspondaient jamais à la requête. Dans l’évaluation, le vecteur a scoré 70% de precision@3 contre 43% pour les mots-clés, avec l’hybride n’apportant aucun gain à cette échelle.

Le projet complet sur GitHub comprend 9 petits fichiers. Pour l’utiliser sur vos propres données, exécutez d’abord python eval.py, car le meilleur mode dépend des données, pas de la complexité. Décidez ensuite d’une cadence d’actualisation, où merge_insert ne met à jour que ce qui a changé et versions.py snapshote chaque ingestion. Et avant que quoi que ce soit soit mis en production, planifiez une routine de rotation des clés, car les clés BD et Cohere vont dans .env.

Le même pattern fonctionne pour tout ce que Bright Data peut scraper, pas seulement les emplois. À partir de là, vous disposez d’un moteur de recherche sémantique réutilisable pour n’importe quel jeu de données que vous scrapez.

FAQ

Puis-je l’utiliser pour d’autres sites que LinkedIn ?

Oui. La Bibliothèque de Web Scrapers de Bright Data couvre des centaines de sites (Amazon, Zillow, Yelp, et plus), chacun avec son propre dataset_id. Remplacez le DATASET_ID dans scrape.py et le mapping to_row() dans index.py pour la nouvelle forme JSON. La logique de recherche et d’indexation est agnostique aux données et se transporte.

Ai-je besoin d’un compte Cohere payant pour cela ?

Non, une clé d’essai exécute toute la démo. L’endpoint Rerank d’essai de Cohere est actuellement limité à 10 appels/min, donc eval.py reçoit un 429 et recule automatiquement (~90s au lieu de ~15s). Le scraping, l’indexation et la recherche ad hoc restent bien en dessous des limites. Passez à la version supérieure seulement si vous itérez souvent sur l’évaluation.

Pourquoi LanceDB, et pas Pinecone, Weaviate ou pgvector ?

LanceDB est une bibliothèque embarquée sans serveur, sans base de données séparée et sans facture de service géré. Il supporte la recherche hybride et le reranking Cohere nativement, et chaque écriture est un snapshot de version. Pour un pipeline sur une seule machine sans ops, c’est la moins grande surcharge. Les autres sont capables mais ajoutent plus d’infrastructure.

À quelle fréquence devrais-je réexécuter le Scraper ?

Une fois par jour convient à un site d’offres d’emploi actif. Bright Data peut exécuter une collecte planifiée depuis le tableau de bord, et l’upsert merge_insert déduplique côté LanceDB, donc les réexécutions sont peu coûteuses. Les annonces de plus de ~30 jours sont généralement fermées, donc les anciens snapshots deviennent historiques, et versions.py les garde interrogeables.