AI

Crea un motor de búsqueda de empleos semántico con Bright Data, LanceDB y Cohere

Crea un motor de búsqueda de empleos semántico. El Web Scraper de Bright Data devuelve empleos de LinkedIn estructurados; Cohere proporciona embeddings para coincidencias basadas en significado.
32 min de lectura
Build a Semantic Job Search Engine with Bright Data, LanceDB, and Cohere

Los portales de empleo solo buscan por palabras exactas, por lo que el puesto adecuado permanece oculto cuando tu redacción no coincide con la oferta. La búsqueda semántica encuentra coincidencias por significado. Lo construimos de principio a fin y medimos qué modo de búsqueda gana, en lugar de asumir que el más complejo lo hace.

TL;DR

Esta guía construye un motor de búsqueda de empleos semántico sobre 200 ofertas reales de LinkedIn usando Bright Data (scraping), Cohere (embeddings + reranking) y LanceDB (almacén de vectores local).

  • La búsqueda por palabras clave coincide con palabras exactas. La búsqueda vectorial coincide por significado. Una consulta como “ingeniero que trabaja con LLMs” encuentra un puesto de “Desarrollador GenAI” que la búsqueda por palabras clave no detecta.
  • La API Web Scraper de Bright Data devuelve empleos de LinkedIn estructurados en JSON a $0,0015 por registro, sin necesidad de parsear HTML ni mantener scrapers.
  • LanceDB se ejecuta localmente y combina búsqueda vectorial con filtros SQL (salario, seniority) en 1 consulta, además de búsqueda de texto completo y reranking con Cohere.
  • En 10 consultas de prueba, la búsqueda vectorial obtuvo un 70% de precisión@3 frente al 43% de la búsqueda por palabras clave. El híbrido + reranking no añadió mejora medible a esta escala, por lo que por debajo de ~10k filas, el vector por sí solo es un valor predeterminado razonable.
  • El proyecto completo consta de 9 archivos pequeños, incluido un arnés de evaluación, y el código completo está en GitHub. La ejecución completa cuesta ~$0,34.

El problema con la búsqueda por palabras clave

La búsqueda por palabras clave en un portal de empleo hace exactamente lo que pides. Devuelve ofertas cuyo título o descripción contiene los tokens literales de tu consulta. Si buscas “ingeniero que trabaja con LLMs e ingeniería de prompts”, te perderás puestos como “Desarrollador GenAI” aunque sean perfectos. La búsqueda léxica coincide con palabras exactas, no con significados.

La búsqueda vectorial coincide por significado. Cada descripción de empleo se convierte en un embedding (un vector de alta dimensión que captura su contenido semántico), al igual que tu consulta. Un empleo cuyo vector está cerca del de tu consulta es una buena coincidencia en significado, incluso cuando no comparte ninguna de las mismas palabras.

Convertir eso en un motor de búsqueda funcional requiere 3 componentes:

  1. Bright Data extrae 200 ofertas de empleo reales de LinkedIn en JSON estructurado y limpio.
  2. Cohere convierte las descripciones en embeddings y reordena los resultados finales.
  3. LanceDB almacena los embeddings localmente y sirve consultas híbridas (vector + texto completo) con filtros estilo SQL.

El stack de un vistazo

Lo que hace cada capa y por qué la usamos:

Capa Herramienta Por qué esta
Datos web Bright Data Web Scraper API El scraper de LinkedIn prediseñado devuelve JSON estructurado con salario, seniority y ubicación, sin parsear HTML ni mantener scrapers.
Embeddings Cohere embed-english-v3.0 Codificación asimétrica (diferentes tipos de entrada para documentos vs. consultas). Cohere también ofrece embed-v4.0, que es multimodal. Usamos v3 aquí por su perfil de precio/latencia solo en inglés (planeamos re-embeber antes del fin de vida de v3).
Reranker Cohere rerank-v3.5 Fijamos v3.5 por su perfil de precio/latencia. Cohere también ofrece rerank-v4.0 (-pro para calidad, -fast para latencia).
Almacén vectorial LanceDB Local, embebido, sin servidores. Admite búsqueda híbrida (vector + BM25) y prefiltros SQL.
UI (opcional) Streamlit Interfaz web con código mínimo para una aplicación de datos en Python.

Este stack se ejecuta desde un único entorno virtual de Python en tu laptop. Bright Data y Cohere son los únicos servicios gestionados involucrados.

Configuración

El proyecto completo y ejecutable está en GitHub. Clónalo e instala las dependencias (Python 3.10 o más reciente):

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

Copia el archivo de entorno de ejemplo y añade tus dos claves API, un token de Bright Data y una clave de Cohere desde dashboard.cohere.com (una clave de prueba funciona para toda la guía):

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

Con ambas claves configuradas, ejecuta python scrape.py para obtener los datos y python index.py para construir el índice.

Arquitectura

El sistema tiene dos flujos, no uno. Ingesta construye el índice (se ejecuta una vez o de forma programada). Consulta se ejecuta en cada búsqueda. Ambos usan Cohere y LanceDB, pero para tareas diferentes.

Diagrama de arquitectura de dos flujos. INGESTA (ejecutar una vez o de forma programada): una flecha "keyword" entra en Bright Data ("Descubrir empleos por keyword (async)"), que envía "jobs (JSON)" a Cohere ("embed (document)"), que envía "vectors" a LanceDB ("índices vector + FTS + escalares, versionados"). CONSULTA (por búsqueda, modo híbrido predeterminado): una flecha "query" entra en Cohere ("embed (query)"), que envía un "query vector" a LanceDB ("búsqueda vector + FTS + prefiltro SQL"), que envía "candidates" a Cohere ("rerank"), que devuelve "ranked results". Cohere y LanceDB están sombreados para mostrar que se reutilizan en ambos flujos, y el reranking solo se ejecuta en modo híbrido.

Los dos flujos uno al lado del otro. La ingesta embebe documentos y los almacena. La consulta embebe el texto de búsqueda, ejecuta búsqueda vectorial + texto completo con prefiltro SQL y luego reordena. Cohere y LanceDB aparecen en ambos flujos pero realizan trabajo diferente en cada uno, por eso el reranking nunca toca la ruta de ingesta.

3 scripts ejecutan el pipeline: scrape.py, index.py, search.py. 6 helpers más: lib.py (backend de búsqueda compartido), compare.py (comparación de modos), eval.py (precisión@3), stats.py (resumen del dataset), versions.py (navegador de snapshots) y app.py (UI de Streamlit).

Scraping de LinkedIn con Bright Data

LinkedIn es una fuente principal de datos de empleo, pero es difícil de hacer scraping de forma fiable: límites de tasa, marcado dinámico y HTML que cambia sin previo aviso. La Web Scraper API devuelve JSON estructurado y limpio desde endpoints prediseñados, por lo que no necesitas mantener parsers.

Elige el endpoint correcto

Bright Data expone varios scrapers de LinkedIn:

  • Perfiles de personas → perfiles individuales de miembros
  • Información de empresas → páginas de empresas
  • Ofertas de empleo → Recopilar por URL → URLs de empleo específicas que ya tienes
  • Ofertas de empleo → Descubrir por keyword ← esto es lo que queremos
  • Ofertas de empleo → Descubrir por URL → empleos desde una URL de resultados de búsqueda
  • Publicaciones de LinkedIn y Búsqueda de personas → otros tipos de entidades

Descubrir por keyword es la opción adecuada porque queremos descubrimiento masivo de empleos a partir de una consulta de búsqueda. Una sola llamada a la API devuelve hasta 1.000 ofertas de empleo estructuradas por keyword, incluyendo título, empresa, ubicación, nivel de seniority, tipo de empleo, rango salarial donde figure y la descripción completa del puesto.

Cada scraper tiene su propio dataset_id. Para encontrar uno, abre la Biblioteca de Scrapers de Bright Data, busca el sitio (aquí, linkedin.com) y ábrelo. Selecciona el endpoint Ofertas de empleo → Descubrir por keyword, y su dataset_id (gd_lpfll7v5hcqtkxl6l) junto con una solicitud lista para ejecutar aparecen en el panel de ejemplos de código. Un token válido es todo lo que scrape.py necesita para llamarlo.

Panel de Bright Data mostrando el menú de la Biblioteca de Web Scrapers, con el endpoint de ofertas de empleo de LinkedIn → "Descubrir por keyword" seleccionado en la barra lateral izquierda. El panel central muestra la pestaña de Configuración con entradas de ejemplo (paris/product manager, New York/python developer). El panel derecho muestra la vista de ejemplos de código con una solicitud curl autenticada que contiene `dataset_id=gd_lpfll7v5hcqtkxl6l`.

La página del scraper Descubrir por keyword. El panel de ejemplos de código a la derecha es donde encontrarás el dataset_id.

Síncrono vs asíncrono

Bright Data ofrece 2 modos de entrega:

  • Síncrono (POST /datasets/v3/scrape) devuelve los datos en línea, ideal para lotes pequeños.
  • Asíncrono (POST /datasets/v3/trigger) devuelve un ID de snapshot. Sondeas hasta que esté listo y descargas el resultado, ideal para cualquier cosa más grande.

En nuestras ejecuciones, el tiempo de respuesta promedió ~6 segundos por entrada. Para 2 keywords con limit_per_input=100 (200 empleos en total), una llamada síncrona debe mantener la conexión abierta durante todo el lote, lo que arriesga un timeout. El modo asíncrono es el valor predeterminado seguro.

Controla el costo con límites por entrada

El parámetro de consulta limit_per_input=N limita cuántos resultados devuelve cada búsqueda de entrada, que es exactamente el control que necesitas para un gasto predecible:

2 keywords × 100 jobs × $0.0015 = $0.30 por ejecución

Auméntalo para ejecuciones más grandes, hasta 1.000 empleos por keyword.

El código

El scraper activa un snapshot, sondea hasta que esté listo y descarga el JSON. El núcleo está a continuación (una versión de producción añadiría reintentos/backoff y manejo de errores más robusto):

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

Ejecutándolo:

$ 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

Lo que obtienes de vuelta

Cada empleo en el JSON tiene más de 25 campos. Aquí están los que importan:

{
  "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>"
}

El campo estructurado base_salary es lo que hace posibles las consultas con filtro de salario en el siguiente paso.

Indexar con Cohere y LanceDB

Tenemos 204 registros de empleos sin procesar, 4 de ellos son filas de error que filtramos al cargar. Ahora hacemos que los 200 restantes sean semánticamente buscables.

Por qué Cohere

Elegimos Cohere sobre las alternativas (modelos de embedding de OpenAI, Voyage AI o sentence-transformers locales):

  1. Codificación asimétrica. Cohere te permite etiquetar la entrada como search_document al indexar o search_query al buscar. El modelo codifica cada lado de forma diferente, lo que funciona mejor que tratar ambos igual.
  2. Embedding declarativo. El registro de LanceDB admite Cohere de forma nativa (al igual que OpenAI y sentence-transformers), por lo que el embedding ocurre al insertar y al consultar sin llamadas manuales a embed().
  3. La API Rerank. Es un modelo separado que toma una consulta más una lista de candidatos y reordena los candidatos por relevancia real. Es la segunda etapa que puede mejorar la clasificación de un pipeline híbrido, y añadimos esa etapa con una sola llamada a .rerank().

El registro de embeddings de LanceDB

Los embeddings en LanceDB pasan por su registro de embeddings. Declaras tu esquema una vez y los embeddings ocurren automáticamente en cada inserción y cada consulta, cada uno con el input_type correcto.

# 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()              # ← qué embeber
    vector: Vector(cohere.ndims()) = cohere.VectorField()  # ← embedding almacenado
    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

Todo después de vector es una columna almacenada simple, utilizada para filtrado y visualización.

El truco de normalización de salarios

La mayoría de los empleos tienen salarios cotizados por año, pero algunos son por hora. Para que salary_min_annual >= 200000 funcione de forma consistente, normalizamos durante la ingesta:

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

Almacenamos tanto los valores numéricos sin procesar (para filtros) como una cadena de visualización legible por humanos (para la UI).

Actualizaciones incrementales con upserts

La primera vez que se ejecuta index.py crea la tabla. Cada ejecución posterior es un upsert con clave en job_id:

result = (
    table.merge_insert("job_id")
         .when_matched_update_all()       # actualizar ofertas de empleo existentes
         .when_not_matched_insert_all()   # añadir las recién descubiertas
         .execute(rows)
)
print(f"inserted={result.num_inserted_rows}, updated={result.num_updated_rows}")

Las nuevas ofertas de empleo de un nuevo scrape de Bright Data se insertan, y los empleos re-publicados (mismo job_id) tienen sus salarios, descripciones y marcas de tiempo actualizados. Para eliminar completamente las publicaciones obsoletas, encadena .when_not_matched_by_source_delete().

Todo el upsert es una única transacción atómica. Debido a que Lance almacena datos en columnas con copia en escritura, la re-ingesta es una escritura incremental en lugar de una reconstrucción completa de la tabla.

Índices escalares para filtros SQL rápidos

Cuando search.py, where "salary_min_annual >= 200000" se ejecuta, LanceDB aplica el filtro antes del escaneo vectorial (prefilter=True). Con 200 filas es instantáneo de cualquier manera. Con 200.000 filas el filtro recorrería toda la columna a menos que le digamos a LanceDB cómo indexarla:

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 tipos de índice cubren lo que necesitamos:

  • BTREE para columnas ordenables de mayor cardinalidad. salary_min_annual se beneficia porque queremos consultas de rango (>=, BETWEEN).
  • BITMAP para enumeraciones de baja cardinalidad. seniority tiene ~6 valores distintos, employment_type es casi todo Full-time, y search_keyword es una de nuestras 2 entradas de scraping. Cada valor distinto obtiene su propio bitmap. Un filtro = se convierte en un solo AND bit a bit.

Ambos se ejecutan con replace=True, por lo que volver a ejecutar index.py los reconstruye de forma idempotente. Después de la llamada, table.list_indices() reporta los 5 (los 4 escalares + el índice 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']

Inspecciona los datos indexados

Después de ejecutar python index.py, nuestro script complementario stats.py resume lo que hay en la base de datos:

$ 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

Ejecutar búsqueda híbrida con reranking

LanceDB admite 3 modos de búsqueda, y nuestro lib.py expone los 3 detrás de una única función:

# lib.py
from lancedb.rerankers import CohereReranker

reranker = CohereReranker(model_name="rerank-v3.5")  # fijado; el modelo más nuevo de Cohere es 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()

Tres aspectos de search() merecen explicación:

  • query_type="hybrid" combina similitud vectorial y puntuaciones BM25 del índice de texto completo que construimos en el momento de indexación (FTS nativo de LanceDB). La unión de candidatos se reordena a continuación.
  • .rerank(reranker) envía la lista de candidatos a la API Rerank de Cohere y devuelve su ordenación. Pasamos model_name="rerank-v3.5" explícitamente porque el valor predeterminado de LanceDB es más antiguo.
  • prefilter=True aplica la cláusula SQL WHERE antes del escaneo vectorial, no después. Esto es más rápido (espacio de búsqueda más pequeño) y más preciso (no pierdes resultados por truncamiento).

Una consulta real

Aquí están los 2 mejores resultados para una consulta que no comparte muchas palabras literales con ningún título de empleo en el dataset:

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

Ningún título de empleo contiene “GPUs”, pero ambas descripciones tratan sobre entrenamiento distribuido de ML, que es lo que pregunta la consulta. La búsqueda por palabras clave pura probablemente se perdería ambos.

Cada modo devuelve un tipo diferente de puntuación. El modo vectorial devuelve distancia coseno (menor = más cercano), híbrido + reranking devuelve la puntuación de relevancia de Cohere (0 a 1, mayor = mejor), y el modo por palabras clave devuelve BM25 sin procesar (sin límite, mayor = más superposición de palabras clave). Los números no son comparables entre modos, solo dentro de un mismo modo.

Combina semántica con restricciones estrictas

La similitud semántica y los filtros SQL se combinan en una sola consulta en 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 parte vectorial coincide con la parte descriptiva (“fintech python con equity”). El filtro SQL aplica la restricción numérica (>= $250k). Ambos resultados son puestos de Fintal Partners en la banda salarial correcta.

El mismo patrón híbrido + filtro se ejecuta en la UI de Streamlit, en un scrape posterior (las ofertas en vivo difieren de la ejecución de CLI anterior):

Interfaz de búsqueda de Streamlit con la consulta "fintech python role with equity" y el control deslizante de salario mínimo en la barra lateral arrastrado a $250,000. El encabezado de resultados dice "3 results · mode: hybrid · filter: salary_min_annual >= 250000". La tarjeta del primer resultado muestra Senior Software Engineer (Python) en Fintal Partners en New York, NY, con insignias de Mid-Senior level, Full-time, y una insignia de salario verde que dice $300,000,$500,000/yr, puntuación de relevancia 0.272, y un fragmento sobre una firma de trading cuantitativo. Un segundo resultado, Data Scientist en OpenArt AI en San Francisco, puntuación 0.165, comienza debajo.

La aplicación Streamlit ejecutando una búsqueda híbrida. La insignia de puntuación en cada tarjeta es la puntuación de relevancia de Cohere, y el fragmento debajo de las insignias muestra por qué cada resultado llegó al top 3.

Dónde discrepan la búsqueda por palabras clave, vectorial e híbrida

compare.py ejecuta la misma consulta a través de los 3 modos e imprime un informe comparativo:

$ 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

En la fila de superposición, las palabras clave y el vector encontraron 0 de los mismos empleos en el top 3. Están buscando en espacios conceptuales diferentes.

  • Palabras clave (BM25) encuentra publicaciones donde los tokens literales “LLMs” y “prompt” aparecen con mayor frecuencia. Devuelve títulos genéricos de IA/ML.
  • Vector (Cohere) encuentra la publicación Senior Software Engineer (Prompt Engineer Python/GenAI) en el #1, aunque la consulta del usuario decía “prompt engineering” (gerundio) y el título dice “Prompt Engineer” (sustantivo). También devuelve un listado centrado en LLM de Jobs via Dice que es una fuerte coincidencia semántica pero léxicamente distante de la consulta.
  • Híbrido + reranking toma la unión, deduplica y la ejecuta a través de Cohere Rerank. El puesto Applied AI Engineer de Serval ($200k a $325k) sube al #1. Su descripción está repleta de trabajo de ingeniería de prompts y agentes LLM, pero ni su título ni sus términos mejor ponderados por BM25 habrían clasificado el puesto tan alto.

Para esta consulta específica, tanto el vector como el híbrido funcionaron mejor que las palabras clave. La superposición de tokens sin procesar clasificó los resultados de Genpact y Serval por debajo de donde los situó su relevancia semántica. Pero una sola consulta es una anécdota, no evidencia. Si ese patrón se mantiene en general es una pregunta que solo puede responder una evaluación real.

Medir la calidad con precisión@3

Para medir esto correctamente, eval.py puntúa 10 consultas escritas a mano contra los 3 modos y calcula la precisión@3, la fracción de los 3 mejores resultados que coinciden con un predicado de verdad fundamental transparente.

La verdad fundamental para cada consulta es un predicado de Python, no un número mágico, para que el lector pueda decidir si calificaría los resultados de la misma manera.

Para “machine learning engineer en OpenAI”, un resultado cuenta como relevante solo si su campo company contiene “OpenAI”. Para “quantitative developer en una firma de trading”, la regla es más amplia. Un resultado cuenta si el título contiene “Quant” o “Trading”, o la empresa es una firma de trading conocida (Fintal Partners, DRW, Hudson River Trading, Tower Research, Mondrian Alpha). Estos predicados están ajustados al dataset de muestra, por lo que tus puntuaciones cambiarán con empleos nuevos. Ajústalos a tus propios datos. La brecha entre los modos se mantiene incluso cuando los porcentajes exactos no lo hacen.

Ejecutándolo:

$ 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

Los mismos números en un gráfico:

Gráfico de barras de precisión@3 sobre 10 consultas de prueba: palabras clave 43%, vector 70%, híbrido + reranking 67%.

Precisión@3 promediada sobre las 10 consultas de evaluación. El vector puntúa muy por encima de las palabras clave, y el híbrido está a pocos puntos del vector.

Lo que dicen los números

De la tabla:

  • La búsqueda vectorial puntuó muy por encima de la búsqueda por palabras clave con 70% vs 43% de precisión@3 promedio. Las 3 consultas donde las palabras clave puntuaron 0 (“prompt engineer”, “python developer en fintech”, “ML de alto pago con equity”) tuvieron al menos 1 resultado relevante con vector.
  • El híbrido + reranking no superó al vector a esta escala. La brecha del 67% vs 70% está dentro del ruido: el reranker añade una llamada a Cohere por consulta, y la parte FTS le alimenta casi-coincidencias léxicas que luego tiene que filtrar.
  • Ningún modo está estrictamente dominado. “Visión por computadora y robótica” es la única consulta donde las palabras clave (1.00) superan al vector (0.67), porque las empresas relevantes contienen términos literales de robótica en sus descripciones.

Cuándo habilitar híbrido + reranking

Depende de varios factores:

  • Tamaño del grupo de candidatos. Con unos pocos cientos de filas, el vector solo suele ser suficiente. La recuperación en 2 etapas de híbrido necesita un grupo más grande (10k+) antes de que el paso de reranking valga su costo.
  • Tipo de consulta. Las consultas con intención semántica y palabras clave distintivas (un nombre de marca, una tecnología específica) se benefician del híbrido. Las consultas puramente semánticas generalmente no.
  • Calidad del reranker. El rerank-v3.5 de Cohere funcionó bien en nuestra evaluación. Si cambias a un reranker diferente, vuelve a ejecutar eval.py antes de confiar en él, ya que un reranker más débil puede reordenar buenos resultados vectoriales hacia abajo en un grupo de candidatos pequeño.

Ejecuta eval.py en tus propios datos para decidir. Añadir una consulta es una cadena más un predicado de verdad fundamental.

Nota: la evaluación híbrida se ejecuta bien con una clave gratuita de Cohere. El límite de tasa de prueba hace que retroceda y termine en ~90s en lugar de ~15.

Añadir una UI web con Streamlit

Streamlit convierte el mismo backend de búsqueda en una aplicación web interactiva. El núcleo de búsqueda y renderizado está a continuación:

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

Ejecutarlo:

streamlit run app.py

Obtienes una página de búsqueda completa en localhost:8501 con un cuadro de búsqueda, selector de modo, filtros de barra lateral para seniority, keyword de origen y salario, además de tarjetas de resultados con insignias, puntuaciones y vistas previas de fragmentos.

Interfaz de búsqueda de Streamlit para la consulta "founding ML engineer at AI startup with computer vision" mostrando una lista de resultados híbridos. La barra lateral contiene filtros para modo de búsqueda, seniority, keyword de búsqueda de origen, salario mínimo y número de resultados. La tarjeta del primer resultado es "Founding ML Engineer | Frontier Medical AI | $150k,$200k | SF" de CoffeeSpace en San Francisco Bay Area, con insignias de Mid-Senior level + Full-time, una puntuación de relevancia de Cohere de 0.720, y un fragmento de descripción. Un segundo resultado, "AI/ML Engineer - AI Design Software Leader" con puntuación 0.711, comienza debajo.

La aplicación Streamlit ejecutando una búsqueda híbrida. La insignia de puntuación en cada tarjeta es la puntuación de relevancia de Cohere, y el fragmento debajo de las insignias muestra por qué cada resultado llegó al top 3.

Viaje en el tiempo gratuito con LanceDB

Eso cubre la búsqueda y la UI. LanceDB tiene una característica más que vale la pena mostrar. Cada escritura en LanceDB crea una nueva versión automáticamente, sin costo adicional ni infraestructura. Así es como funciona el formato columnar Lance subyacente. Para facilitar encontrar una versión más tarde, index.py la etiqueta después de cada ingesta:

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

Nuestro script complementario versions.py te permite navegar y abrir snapshots históricos. Después de ejecutar python index.py una vez verás 1 etiqueta. Después de una segunda ingesta (digamos, volviendo a hacer scraping una semana después) verás 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

El viaje en el tiempo es una sola llamada a table.checkout(tag_or_version). Para un producto de búsqueda de empleo responde preguntas como “¿qué puestos se publicaron el trimestre pasado?” o “¿está cambiando la distribución salarial con el tiempo?” sin una base de datos de series temporales separada. Esa es una razón por la que elegimos LanceDB aquí.

Costo y escala

Para la demo (200 empleos, ~5 consultas de ejemplo):

Elemento Costo
Scrape de Bright Data (204 registros @ $0,0015/registro) $0,31
Embeddings de Cohere (~228k tokens en total @ $0,10/1M) ~$0,02
Reranking de Cohere (~$0,002/consulta, Rerank v3.5 a $2/1k búsquedas) ~$0,01 por 5 consultas
LanceDB gratuito

La demo de principio a fin cuesta ~$0,34 en total. Estos precios son de una ejecución de 2026, así que verifica las tarifas actuales de los proveedores.

Escalar

La demo local maneja 200 empleos. Algunos controles cubren el camino desde aquí hasta un dataset a escala de producción:

  • Más empleos. Cambia LIMIT_PER_INPUT (máximo 1.000 por keyword) o añade más búsquedas por keyword. 10.000 empleos cuesta ~$15 en créditos de Bright Data.
  • Más keywords / ubicaciones. Añade entradas a la lista SEARCHES en scrape.py.
  • Actualización programada. El upsert merge_insert que construimos significa que volver a ejecutar el pipeline actualiza lo que ha cambiado. Bright Data admite colección y entrega programada desde el dashboard. Combina eso con el upsert y tendrás un dataset que se actualiza solo.
  • Índice vectorial. Pasadas ~10k filas, cambia la búsqueda por fuerza bruta a un índice HNSW o IVF_PQ mediante table.create_index(vector_column_name="vector"). Se construye en CPU de forma predeterminada. Para una construcción en GPU, pasa accelerator="cuda" (o "mps" en Apple Silicon) con PyTorch>2.0. La indexación automática en GPU es actualmente una característica de LanceDB Enterprise.
  • Almacén vectorial de producción. LanceDB OSS escala a millones de vectores en un solo nodo. Pasados cientos de millones de vectores o terabytes de datos, LanceDB Cloud y Enterprise añaden indexación distribuida y ejecución de consultas (su documentación apunta a ~10 a 50B filas / ~10 a 30 TB).

Antes de cualquiera de esos movimientos de escala, sin embargo, la demo tiene aristas afiladas.

8 errores y problemas que encontramos

Por si te ahorra las horas que nos costaron a nosotros:

  1. list_tables() no devuelve una lista. En LanceDB 0.30 devuelve un objeto ListTablesResponse que parece iterable en el REPL pero if TABLE in db.list_tables() falla silenciosamente. Usa try: db.open_table(TABLE) y captura la excepción en su lugar, o .tables en la respuesta.
  2. table.checkout(tag) devuelve None y muta el manejador de tabla en su lugar. Parece un error, pero no lo es. Haz t = db.open_table(...); t.checkout(tag); use(t), no t = db.open_table(...).checkout(tag).
  3. El CohereReranker() predeterminado usa un modelo antiguo (rerank-english-v3.0 en las versiones que probamos). Pasa un modelo explícitamente, ya sea rerank-v3.5 (lo que fijamos aquí) o rerank-v4.0-pro para mayor calidad. El predeterminado no te avisa.
  4. Usa /trigger + sondeo, no /scrape, para lotes reales. El modo síncrono (/scrape) está diseñado para extracciones pequeñas. Mantener la conexión abierta para limit_per_input=100 × 2 keywords (~200 empleos) puede agotar el tiempo, así que usa /trigger + sondeo para más de ~50 registros.
  5. Algunos registros extraídos son filas de error. De 204 empleos, 4 tenían un campo error en lugar de job_title (por ejemplo, "Crawl aborted on job cancel"). Parecen registros normales superficialmente, así que fíltralos en index.py o merge_insert fallará con un job_id vacío.
  6. Los salarios vienen en 2 períodos (yr y hr) pero el campo del esquema es el mismo. Sin normalizar a anual (multiplicar por hora por 2080), un filtro como salary_min_annual >= 200000 silenciosamente pierde contratos por hora bien pagados e incluye puestos asalariados implausiblemente bajos.
  7. Las cadenas de ayuda de argparse con % sin procesar fallan en Python 3.14. Escribir --where "salary > 200000 AND location LIKE '%SF%'" en tu texto de ayuda genera ValueError: badly formed help string porque argparse intenta formatearlo. Escapa como %% o reformula el ejemplo.
  8. Streamlit renderiza el texto entre signos $ como matemática LaTeX. Un salario como $150k,$200k mostrado con st.markdown o st.caption se vuelve matemática ilegible. Escapa cada $ en tus cadenas de visualización (el app.py del repositorio lo hace con un replace de una línea), o las insignias de salario se renderizan como caracteres sin sentido.

Qué puedes construir a continuación

El patrón, Bright Data ⟶ embeddings ⟶ base de datos vectorial ⟶ búsqueda híbrida, se generaliza a casi cualquier dominio:

Dominio Producto de Bright Data Lo que consultarías
Acceso web agéntico The Web MCP (nivel gratuito actualmente 5.000 solicitudes/mes) “dar a un agente de IA herramientas de búsqueda y scraping en vivo, luego fundamentar sus respuestas en una caché respaldada por LanceDB de resultados anteriores”
Corpus de sitios completos Crawl API “indexar un sitio de documentación completo o base de conocimientos para recuperación híbrida”
E-commerce Web Scraper API (productos de Amazon) “zapatillas para correr cómodas por menos de $100 con 4+ estrellas”
Bienes raíces Web Scraper API (Zillow / Redfin) “casa familiar tranquila cerca de buenas escuelas, 3+ habitaciones”
Inteligencia de noticias API SERP + Web Unlocker “artículos de seguridad de IA de esta semana, clasificados por relevancia para la alineación”
Prospección de ventas Información de empresas de LinkedIn “startups Serie A en IA de salud basadas en Europa”
Restaurantes Dataset de Yelp “lugar italiano acogedor con terraza exterior”

Algunas extensiones naturales de este proyecto exacto:

  • Búsqueda multimodal. Cambia a Cohere embed-v4.0 (nativamente multimodal) e incrusta logotipos de empresas junto con descripciones de empleos.
  • Filtros extraídos por LLM. Deja que el usuario escriba “empleos de ML remotos pagando $200k+” y que un LLM extraiga remote=true, salary_min_annual >= 200000 automáticamente.
  • Búsquedas guardadas con alertas por correo. Vuelve a ejecutar una consulta contra el scrape más reciente y notifica sobre nuevas coincidencias.
  • Coincidencia de currículum. Incrusta un currículum y busca empleos por similitud con el candidato. El asistente de IA para búsqueda de empleo en LinkedIn de Bright Data es un ejemplo más completo.
  • Un scraper de mantenimiento automático. Da a un agente acceso al MCP de Bright Data y podrá inspeccionar la página, escribir el scraper e intentar una corrección cuando cambie el diseño, en lugar de que tú parchees scrape.py manualmente. El Scraper Studio de Bright Data empaqueta esto como un producto gestionado, convirtiendo un prompt en lenguaje sencillo en un scraper auto-reparable.

Próximos pasos

La búsqueda por palabras clave se perdió los puestos correctos, y la búsqueda vectorial los encontró incluso cuando los títulos nunca coincidieron con la consulta. En la evaluación, el vector obtuvo un 70% de precisión@3 frente al 43% de las palabras clave, con el híbrido sin añadir mejora a esta escala.

El proyecto completo en GitHub tiene 9 archivos pequeños. Para usarlo con tus propios datos, ejecuta primero python eval.py, porque el mejor modo depende de los datos, no de cuál es más complejo. Luego decide una cadencia de actualización, donde los upserts de merge_insert solo actualizan lo que cambió y versions.py hace un snapshot de cada ingesta. Y antes de que cualquier cosa se publique, planifica una rutina de rotación de claves, porque las claves de BD y Cohere van en .env.

El mismo patrón funciona para cualquier cosa que Bright Data pueda hacer scraping, no solo empleos. A partir de ahí, tienes un motor de búsqueda semántico que puedes reutilizar para cualquier dataset que extraigas.

FAQ

¿Puedo usar esto para sitios distintos de LinkedIn?

Sí. La Biblioteca de Web Scrapers de Bright Data cubre cientos de sitios (Amazon, Zillow, Yelp y más), cada uno con su propio dataset_id. Cambia el DATASET_ID en scrape.py y el mapeo to_row() en index.py para la nueva forma JSON. La lógica de búsqueda e indexación es agnóstica a los datos y se mantiene.

¿Necesito una cuenta de pago de Cohere para esto?

No, una clave de prueba ejecuta toda la demo. El endpoint Rerank de prueba de Cohere está actualmente limitado a 10 llamadas/min, por lo que eval.py recibe un 429 y retrocede automáticamente (~90s en lugar de ~15s). El scraping, la indexación y la búsqueda ad-hoc se mantienen bien dentro de los límites. Actualiza solo si iteras en la evaluación con frecuencia.

¿Por qué LanceDB y no Pinecone, Weaviate o pgvector?

LanceDB es una biblioteca embebida sin servidor, sin base de datos separada y sin factura de servicio gestionado. Admite búsqueda híbrida y reranking de Cohere de forma nativa, y cada escritura es un snapshot de versión. Para un pipeline de máquina única sin operaciones, eso es la menor sobrecarga. Los otros son capaces pero añaden más infraestructura.

¿Con qué frecuencia debo volver a ejecutar el scraper?

Una vez al día es adecuado para un portal de empleo activo. Bright Data puede ejecutar colección programada desde el dashboard, y el upsert merge_insert deduplica en el lado de LanceDB, por lo que las re-ejecuciones son económicas. Las publicaciones de más de ~30 días generalmente están cerradas, por lo que los snapshots antiguos se vuelven históricos, y versions.py los mantiene consultables.