AI

Crie um Motor de Busca de Empregos Semântico com Bright Data, LanceDB e Cohere

Crie um motor de busca de empregos semântico. O Web Scraper da Bright Data retorna vagas estruturadas do LinkedIn; o Cohere fornece embeddings para correspondência baseada em significado.
31 min de leitura
Build a Semantic Job Search Engine with Bright Data, LanceDB, and Cohere

Os quadros de empregos pesquisam apenas por palavras exatas, então a vaga certa fica oculta quando sua formulação não corresponde ao anúncio. A busca semântica combina por significado. Construímos do início ao fim e medimos qual modo de busca vence, em vez de assumir que o mais complexo é o melhor.

TL;DR

Este guia cria um motor de busca de empregos semântico com 200 vagas reais do LinkedIn usando Bright Data (scraping), Cohere (embeddings + rerank) e LanceDB (armazenamento local de vetores).

  • A busca por palavras-chave combina palavras exatas. A busca vetorial combina significado. Uma consulta como “engenheiro que trabalha com LLMs” encontra uma vaga de “Desenvolvedor GenAI” que a busca por palavras-chave não encontra.
  • A API Web Scraper da Bright Data retorna vagas estruturadas do LinkedIn em JSON por $0,0015 por registro, sem parsing de HTML nem manutenção de Scraper.
  • O LanceDB roda localmente e combina busca vetorial com filtros SQL (salário, senioridade) em 1 consulta, além de busca em texto completo e reranking com Cohere.
  • Em 10 consultas de teste, a busca vetorial obteve 70% de precisão@3 contra 43% da busca por palavras-chave. Híbrido + rerank não acrescentou ganho mensurável nessa escala, então abaixo de ~10k linhas o vetor sozinho é um padrão razoável.
  • O projeto completo tem 9 arquivos pequenos, incluindo um harness de avaliação, e o código completo está no GitHub. A execução completa custa ~$0,34.

O problema com a busca por palavras-chave

A busca por palavras-chave em um quadro de empregos faz exatamente o que você pede. Ela retorna anúncios cujo título ou descrição contém os tokens literais da sua consulta. Pesquise por “engenheiro que trabalha com LLMs e engenharia de prompt” e você perderá vagas como “Desenvolvedor GenAI”, mesmo que sejam um ajuste perfeito. A busca lexical combina palavras exatas, não significado.

A busca vetorial combina por significado. Cada descrição de vaga é convertida em um embedding (um vetor de alta dimensão que captura seu conteúdo semântico), assim como sua consulta. Uma vaga cujo vetor está próximo ao da sua consulta é uma boa correspondência em significado, mesmo quando não compartilha nenhuma palavra em comum.

Transformar isso em um motor de busca funcional requer 3 componentes:

  1. Bright Data faz o scraping de 200 vagas reais do LinkedIn em JSON estruturado e limpo.
  2. Cohere transforma as descrições em embeddings e reordena os resultados finais.
  3. LanceDB armazena os embeddings localmente e serve consultas híbridas (vetor + texto completo) com filtros no estilo SQL.

A stack em resumo

O que cada camada faz e por que a usamos:

Camada Ferramenta Por que esta
Dados web Bright Data Web Scraper API O Scraper pré-construído do LinkedIn retorna JSON estruturado com salário, senioridade e localização, sem parsing de HTML nem manutenção de Scraper.
Embeddings Cohere embed-english-v3.0 Codificação assimétrica (tipos de entrada diferentes para documentos vs consultas). O Cohere também oferece embed-v4.0, que é multimodal. Usamos a v3 aqui pelo seu perfil de preço/latência apenas para inglês (planejamos re-embedar antes do fim de vida da v3).
Reranker Cohere rerank-v3.5 Fixamos a v3.5 pelo seu perfil de preço/latência. O Cohere também oferece rerank-v4.0 (-pro para qualidade, -fast para latência).
Armazenamento vetorial LanceDB Local, embarcado, sem servidores. Suporta busca híbrida (vetor + BM25) e pré-filtros SQL.
UI (opcional) Streamlit Interface web com código mínimo para um app de dados Python.

Esta stack roda a partir de um único venv Python no seu computador. Bright Data e Cohere são os únicos serviços gerenciados envolvidos.

Configuração

O projeto completo e executável está no GitHub. Clone-o e instale as dependências (Python 3.10 ou mais recente):

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

Copie o arquivo env de exemplo e adicione suas duas chaves de API, um token da Bright Data e uma chave do Cohere em dashboard.cohere.com (uma chave de teste funciona para todo o guia):

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

Com ambas as chaves configuradas, execute python scrape.py para obter os dados e python index.py para construir o índice.

Arquitetura

O sistema tem dois fluxos, não um. Ingestão constrói o índice (executado uma vez ou em agendamento). Consulta é executada em cada busca. Ambos usam Cohere e LanceDB, mas para trabalhos diferentes.

Diagrama de arquitetura de dois fluxos. INGESTÃO (executar uma vez ou em agendamento): uma seta 'keyword' entra na Bright Data ('Descobrir vagas por palavra-chave (async)'), que envia 'vagas (JSON)' ao Cohere ('embed (document)'), que envia 'vetores' ao LanceDB ('índices vetorial + FTS + escalar, versionados'). CONSULTA (por busca, modo híbrido padrão): uma seta 'query' entra no Cohere ('embed (query)'), que envia um 'vetor de consulta' ao LanceDB ('busca vetorial + FTS + pré-filtro SQL'), que envia 'candidatos' ao Cohere ('rerank'), que retorna 'resultados classificados'. Cohere e LanceDB são destacados por aparecerem em ambos os fluxos, e o rerank só executa no modo híbrido.

Os dois fluxos lado a lado. A ingestão embeda documentos e os armazena. A consulta embeda o texto de busca, executa busca vetorial + texto completo com pré-filtro SQL e depois reordena. Cohere e LanceDB aparecem em ambos os fluxos, mas fazem trabalhos diferentes em cada um, por isso o rerank nunca toca no caminho de ingestão.

3 scripts executam o pipeline: scrape.py, index.py, search.py. Mais 6 auxiliares: lib.py (backend de busca compartilhado), compare.py (comparação de modos), eval.py (precisão@3), stats.py (resumo do conjunto de dados), versions.py (navegador de snapshots) e app.py (UI Streamlit).

Faça Scraping do LinkedIn com a Bright Data

O LinkedIn é uma fonte importante de dados de vagas, mas é difícil de fazer scraping de forma confiável: limites de taxa, marcação dinâmica e HTML que muda sem aviso. A Web Scraper API retorna JSON estruturado e limpo a partir de endpoints pré-construídos, para que você não precise manter parsers.

Escolha o endpoint correto

A Bright Data expõe vários Scrapers do LinkedIn:

  • Perfis de pessoas → perfis individuais de membros
  • Informações de empresas → páginas de empresas
  • Vagas → Coletar por URL → URLs específicas de vagas que você já possui
  • Vagas → Descobrir por palavra-chave ← queremos este
  • Vagas → Descobrir por URL → vagas a partir de uma URL de resultados de busca
  • Posts do LinkedIn e Busca de pessoas → outros tipos de entidade

Descobrir por palavra-chave é a opção certa porque queremos descoberta em massa de vagas a partir de uma consulta de busca. Uma única chamada de API retorna até 1.000 vagas estruturadas por palavra-chave, incluindo título, empresa, localização, nível de senioridade, tipo de emprego, faixa salarial quando listada e a descrição completa da vaga.

Cada Scraper tem seu próprio dataset_id. Para encontrar um, abra a Biblioteca de Scrapers da Bright Data, pesquise pelo site (aqui, linkedin.com) e abra-o. Escolha o endpoint Vagas → Descobrir por palavra-chave, e seu dataset_id (gd_lpfll7v5hcqtkxl6l) e uma solicitação pronta para uso aparecem no painel de exemplos de código. Um token válido é tudo que o scrape.py precisa para chamá-lo.

Dashboard da Bright Data mostrando o menu da Biblioteca de Web Scrapers, com o endpoint de vagas do LinkedIn → 'Descobrir por palavra-chave' selecionado na barra lateral esquerda. O painel central mostra a aba de Configuração com entradas de exemplo (paris/gerente de produto, Nova York/desenvolvedor python). O painel direito mostra a visualização de exemplos de código com uma solicitação curl autenticada contendo `dataset_id=gd_lpfll7v5hcqtkxl6l`.

A página do Scraper ‘Descobrir por palavra-chave’. O painel de exemplos de código à direita é onde você encontrará o dataset_id.

Síncrono vs assíncrono

A Bright Data oferece 2 modos de entrega:

  • Síncrono (POST /datasets/v3/scrape) retorna os dados inline, ideal para lotes pequenos.
  • Assíncrono (POST /datasets/v3/trigger) retorna um ID de snapshot. Você verifica o status até a conclusão e baixa o resultado, ideal para volumes maiores.

Em nossas execuções, o tempo de resposta foi em média ~6 segundos por entrada. Para 2 palavras-chave com limit_per_input=100 (200 vagas no total), uma chamada síncrona precisa manter a conexão aberta durante todo o lote, o que pode causar timeout. O assíncrono é o padrão seguro.

Controle o custo com limites por entrada

O parâmetro de consulta limit_per_input=N limita quantos resultados cada busca de entrada retorna, que é exatamente o controle que você precisa para gastos previsíveis:

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

Aumente para execuções maiores, até 1.000 vagas por palavra-chave.

O código

O Scraper aciona um snapshot, verifica o status até estar pronto e baixa o JSON. O núcleo está abaixo (uma versão de produção adicionaria retry/backoff e tratamento de erros mais 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()

Executando:

$ 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

O que você recebe de volta

Cada vaga no JSON tem mais de 25 campos. Aqui estão os que importam:

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

O campo estruturado base_salary é o que torna possíveis as consultas com filtro de salário na próxima etapa.

Indexe com Cohere e LanceDB

Temos 204 registros brutos de vagas, sendo 4 linhas de erro que filtramos no carregamento. Agora tornamos os 200 restantes pesquisáveis semanticamente.

Por que Cohere

Escolhemos o Cohere em vez das alternativas (modelos de embedding da OpenAI, Voyage AI ou sentence-transformers locais):

  1. Codificação assimétrica. O Cohere permite marcar a entrada como search_document ao indexar ou search_query ao pesquisar. O modelo codifica cada lado de forma diferente, o que funciona melhor do que tratar ambos da mesma forma.
  2. Embedding declarativo. O registro do LanceDB suporta Cohere nativamente (assim como OpenAI e sentence-transformers), então o embedding acontece na inserção e na consulta sem chamadas manuais de embed().
  3. A API Rerank. É um modelo separado que recebe uma consulta mais uma lista de candidatos e reordena os candidatos por relevância real. É o segundo estágio que pode aprimorar o ranking de um pipeline híbrido, e adicionamos esse estágio com uma chamada .rerank().

O registro de embeddings do LanceDB

Os embeddings no LanceDB passam pelo seu registro de embeddings. Você declara seu schema uma vez e os embeddings acontecem automaticamente em cada inserção e consulta, cada um com o input_type correto.

# 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()              # ← o que embedar
    vector: Vector(cohere.ndims()) = cohere.VectorField()  # ← embedding armazenado
    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

Tudo após vector é uma coluna armazenada simples, usada para filtragem e exibição.

O truque da normalização de salário

A maioria das vagas tem salários cotados por ano, mas alguns são por hora. Para que salary_min_annual >= 200000 funcione de forma consistente, normalizamos na ingestão:

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

Armazenamos tanto os valores numéricos brutos (para filtros) quanto uma string de exibição legível por humanos (para a UI).

Atualizações incrementais com upserts

Na primeira execução do index.py, ele cria a tabela. Cada execução subsequente é um upsert com chave em job_id:

result = (
    table.merge_insert("job_id")
         .when_matched_update_all()       # atualiza vagas existentes
         .when_not_matched_insert_all()   # adiciona novas descobertas
         .execute(rows)
)
print(f"inserted={result.num_inserted_rows}, updated={result.num_updated_rows}")

Novas vagas de um novo scraping da Bright Data são inseridas, e vagas repostadas (mesmo job_id) têm seus salários, descrições e timestamps atualizados. Para remover completamente anúncios obsoletos, encadeie .when_not_matched_by_source_delete().

Todo o upsert é uma única transação atômica. Como o Lance armazena dados de forma colunar com copy-on-write, a reingestão é uma escrita incremental em vez de uma reconstrução completa da tabela.

Índices escalares para filtros SQL rápidos

Quando search.py, where "salary_min_annual >= 200000" é executado, o LanceDB aplica o filtro antes da varredura vetorial (prefilter=True). Com 200 linhas, isso é instantâneo de qualquer forma. Com 200.000 linhas, o filtro percorreria a coluna inteira a menos que digamos ao LanceDB como indexá-la:

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 cobrem o que precisamos:

  • BTREE para colunas ordenáveis de maior cardinalidade. salary_min_annual se beneficia porque queremos consultas de intervalo (>=, BETWEEN).
  • BITMAP para enums de baixa cardinalidade. seniority tem ~6 valores distintos, employment_type é quase todo Full-time, e search_keyword é uma das 2 entradas de scraping. Cada valor distinto recebe seu próprio bitmap. Um filtro = se torna um único AND bit a bit.

Ambos executam com replace=True, então reexecutar o index.py os reconstrói de forma idempotente. Após a chamada, table.list_indices() reporta todos os 5 (os 4 escalares + o í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']

Inspecione os dados indexados

Após executar o python index.py, nosso script auxiliar stats.py resume o que está no banco de dados:

$ 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

Execute busca híbrida com reranking

O LanceDB suporta 3 modos de busca, e nosso lib.py expõe todos os 3 por trás de uma única função:

# lib.py
from lancedb.rerankers import CohereReranker

reranker = CohereReranker(model_name="rerank-v3.5")  # fixado; o modelo mais recente do Cohere é 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()

Três partes do search() merecem explicação:

  • query_type="hybrid" combina similaridade vetorial e pontuações BM25 do índice de texto completo que construímos no momento da indexação (FTS nativo do LanceDB). A união de candidatos é então reordenada.
  • .rerank(reranker) envia a lista de candidatos para a API Rerank do Cohere e retorna sua ordenação. Passamos model_name="rerank-v3.5" explicitamente porque o padrão do LanceDB é mais antigo.
  • prefilter=True aplica a cláusula SQL WHERE antes da varredura vetorial, não depois. Isso é mais rápido (espaço de busca menor) e mais preciso (você não perde resultados por truncamento).

Uma consulta real

Aqui estão os 2 principais resultados para uma consulta que não compartilha muitas palavras literais com nenhum título de vaga no conjunto de dados:

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

Nenhum título de vaga contém “GPUs”, mas ambas as descrições são sobre treinamento distribuído de ML, que é exatamente o que a consulta pergunta. A busca pura por palavras-chave provavelmente perderia ambas.

Cada modo retorna um tipo diferente de pontuação. O modo vetorial retorna distância cosseno (menor = mais próximo), híbrido+rerank retorna a pontuação de relevância do Cohere (0 a 1, maior = melhor), e o modo por palavras-chave retorna BM25 bruto (ilimitado, maior = mais sobreposição de palavras). Os números não são comparáveis entre modos, apenas dentro de um único modo.

Combine semântica com restrições rígidas

Similaridade semântica e filtros SQL se combinam em uma única consulta no 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

A parte vetorial combina o componente descritivo (“fintech python com equity”). O filtro SQL aplica a restrição numérica (>= $250k). Ambos os resultados são vagas da Fintal Partners na faixa salarial correta.

O mesmo padrão híbrido + filtro é executado na UI Streamlit, em um scraping posterior (os anúncios ao vivo diferem da execução CLI acima):

Interface de busca Streamlit com a consulta 'fintech python role with equity' e o slider de salário mínimo na barra lateral arrastado para $250.000. O cabeçalho de resultados mostra '3 resultados · modo: híbrido · filtro: salary_min_annual >= 250000′. O card do resultado principal mostra Senior Software Engineer (Python) na Fintal Partners em Nova York, NY, com badges de nível Sênior/Pleno, tempo integral e um badge verde de salário mostrando $300.000,$500.000/ano, pontuação de relevância 0,272 e um trecho sobre uma empresa de trading quantitativo. Um segundo resultado, Data Scientist na OpenArt AI em São Francisco, pontuação 0,165, começa abaixo.”/></figure>
<p class=O app Streamlit executando uma busca híbrida. O badge de pontuação em cada card é a pontuação de relevância do Cohere, e o trecho abaixo dos badges mostra por que cada resultado entrou no top 3.

Onde palavras-chave, vetor e híbrido discordam

compare.py executa a mesma consulta nos 3 modos e imprime um relatório lado a lado:

$ 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

Na linha de sobreposição, palavras-chave e vetor encontraram 0 das mesmas vagas no top 3. Eles pesquisam em espaços conceituais diferentes.

  • Palavras-chave (BM25) encontra anúncios onde os tokens literais “LLMs” e “prompt” aparecem com mais frequência. Retorna títulos genéricos de IA/ML.
  • Vetor (Cohere) encontra o anúncio Senior Software Engineer (Prompt Engineer Python/GenAI) em #1, mesmo que a consulta do usuário diga “prompt engineering” (gerúndio) e o título diga “Prompt Engineer” (substantivo). Também retorna um anúncio focado em LLM do Jobs via Dice que é uma forte correspondência semântica, mas lexicalmente distante da consulta.
  • Híbrido + rerank pega a união, remove duplicatas e executa pelo Cohere Rerank. A vaga Applied AI Engineer da Serval ($200k a $325k) sobe para #1. Sua descrição é densa em trabalho de engenharia de prompt e agentes LLM, mas nem seu título nem seus termos BM25 mais pesados teriam classificado a vaga tão alto.

Para esta consulta específica, vetor e híbrido foram melhores do que palavras-chave. A sobreposição bruta de tokens classificou os resultados da Genpact e Serval abaixo de onde sua relevância semântica os colocou. Mas uma única consulta é uma anedota, não evidência. Se esse padrão se mantém em geral é uma questão que só uma avaliação real pode responder.

Meça qualidade com precisão@3

Para medir isso adequadamente, eval.py pontua 10 consultas escritas manualmente contra os 3 modos e calcula a precisão@3, a fração dos 3 principais resultados que correspondem a um predicado de verdade fundamental transparente.

A verdade fundamental para cada consulta é um predicado Python, não um número mágico, então o leitor pode decidir se avaliaria os resultados da mesma forma.

Para “engenheiro de machine learning na OpenAI”, um resultado conta como relevante apenas se o campo company contiver “OpenAI”. Para “desenvolvedor quantitativo em empresa de trading”, a regra é mais ampla. Um resultado conta se o título contiver “Quant” ou “Trading”, ou se a empresa for uma empresa de trading conhecida (Fintal Partners, DRW, Hudson River Trading, Tower Research, Mondrian Alpha). Esses predicados são ajustados para o conjunto de dados de amostra, então suas pontuações mudarão com novas vagas. Ajuste-os para seus próprios dados. A diferença entre os modos se mantém mesmo quando as porcentagens exatas não se mantêm.

Executando:

$ 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

Os mesmos números em um gráfico:

Gráfico de barras de precisão@3 em 10 consultas de teste: palavras-chave 43%, vetor 70%, híbrido + rerank 67%.

Precisão@3 calculada como média das 10 consultas de avaliação. O vetor pontua bem acima das palavras-chave, e o híbrido fica a alguns pontos do vetor.

O que os números dizem

Da tabela:

  • A busca vetorial pontuou bem acima da busca por palavras-chave com 70% vs 43% de precisão@3 média. Todas as 3 consultas onde palavras-chave pontuou 0 (“engenheiro de prompt”, “desenvolvedor python em fintech”, “ML bem remunerado com equity”) tiveram pelo menos 1 resultado relevante com vetor.
  • Híbrido + rerank não superou o vetor nessa escala. A diferença de 67% vs 70% está dentro do ruído: o reranker adiciona uma chamada ao Cohere por consulta, e a parte FTS alimenta candidatos lexicalmente próximos que ele então precisa filtrar de volta.
  • Nenhum modo é estritamente dominado. “Visão computacional e robótica” é a única consulta onde palavras-chave (1,00) pontua acima do vetor (0,67), porque as empresas relevantes contêm termos literais de robótica em suas descrições.

Quando habilitar híbrido + rerank

Depende de alguns fatores:

  • Tamanho do pool de candidatos. Com algumas centenas de linhas, o vetor sozinho geralmente é suficiente. A recuperação em 2 estágios do híbrido precisa de um pool maior (10k+) antes que a etapa de rerank valha seu custo.
  • Tipo de consulta. Consultas com intenção semântica e palavras-chave distintivas (um nome de marca, uma tecnologia específica) se beneficiam do híbrido. Consultas puramente semânticas geralmente não.
  • Qualidade do reranker. O rerank-v3.5 do Cohere teve bom desempenho em nossa avaliação. Se você trocar por um reranker diferente, reexecute o eval.py antes de confiar nele, pois um reranker mais fraco pode reordenar bons resultados vetoriais para baixo em um pool pequeno de candidatos.

Execute o eval.py em seus próprios dados para decidir. Adicionar uma consulta é uma string mais um predicado de verdade fundamental.

Nota: a avaliação híbrida roda bem em uma chave gratuita do Cohere. O limite de taxa do plano de teste faz com que ele recue e termine em ~90s em vez de ~15.

Adicione uma UI web com Streamlit

O Streamlit transforma o mesmo backend de busca em um app web clicável. O núcleo de busca e renderização está abaixo:

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

Execute:

streamlit run app.py

Você obtém uma página de busca completa em localhost:8501 com uma caixa de busca, alternador de modo, filtros na barra lateral para senioridade, palavra-chave de origem e salário, além de cards de resultados com badges, pontuações e pré-visualizações de trechos.

Interface de busca Streamlit para a consulta 'founding ML engineer at AI startup with computer vision' mostrando uma lista de resultados híbridos. A barra lateral contém filtros para modo de busca, senioridade, palavra-chave de busca de origem, salário mínimo e número de resultados. O card do resultado principal é 'Founding ML Engineer | Frontier Medical AI | $150k,$200k | SF' da CoffeeSpace na Área da Baía de São Francisco, com badges de nível Sênior/Pleno + tempo integral, pontuação de relevância Cohere de 0,720 e um trecho de descrição. Um segundo resultado, 'AI/ML Engineer - AI Design Software Leader' com pontuação 0,711, começa abaixo.

O app Streamlit executando uma busca híbrida. O badge de pontuação em cada card é a pontuação de relevância do Cohere, e o trecho abaixo dos badges mostra por que cada resultado entrou no top 3.

Viagem no tempo gratuita com LanceDB

Isso cobre a busca e a UI. O LanceDB tem mais um recurso que vale mostrar. Cada escrita no LanceDB cria uma nova versão automaticamente, sem custo ou infraestrutura extra. É assim que o formato colunar Lance subjacente funciona. Para facilitar a localização de uma versão mais tarde, o index.py a marca após cada ingestão:

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

Nosso script auxiliar versions.py permite que você navegue e abra snapshots históricos. Após executar o python index.py uma vez, você verá 1 tag. Após uma segunda ingestão (por exemplo, fazendo scraping novamente uma semana depois) você verá 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

A viagem no tempo é uma chamada table.checkout(tag_or_version). Para um produto de busca de empregos, ele responde perguntas como “quais vagas foram publicadas no último trimestre?” ou “a distribuição salarial está mudando ao longo do tempo?” sem um banco de dados de séries temporais separado. Esse é um dos motivos pelos quais escolhemos o LanceDB aqui.

Custo e escala

Para a demonstração (200 vagas, ~5 consultas de exemplo):

Item Custo
Scraping da Bright Data (204 registros @ $0,0015/registro) $0,31
Embeddings Cohere (~228k tokens no total @ $0,10/1M) ~$0,02
Rerank Cohere (~$0,002/consulta, Rerank v3.5 a $2 / 1k buscas) ~$0,01 para 5 consultas
LanceDB gratuito

A demonstração completa custa ~$0,34 no total. Esses preços são de uma execução de 2026, então verifique as taxas atuais dos provedores.

Escale para mais

A demonstração local lida com 200 vagas. Algumas alavancas cobrem o caminho daqui até um conjunto de dados em escala de produção:

  • Mais vagas. Altere o LIMIT_PER_INPUT (máximo de 1.000 por palavra-chave) ou adicione mais buscas por palavras-chave. 10.000 vagas custam ~$15 em créditos da Bright Data.
  • Mais palavras-chave / localizações. Adicione entradas à lista SEARCHES em scrape.py.
  • Atualização agendada. O upsert merge_insert que construímos significa que reexecutar o pipeline atualiza o que mudou. A Bright Data suporta coleta e entrega agendadas pelo dashboard. Combine isso com o upsert e você terá um conjunto de dados que se atualiza automaticamente.
  • Índice vetorial. Acima de ~10k linhas, substitua a busca por força bruta por um índice HNSW ou IVF_PQ via table.create_index(vector_column_name="vector"). Ele é construído em CPU por padrão. Para uma construção em GPU, passe accelerator="cuda" (ou "mps" no Apple Silicon) com PyTorch>2.0. A indexação automática em GPU é atualmente um recurso do LanceDB Enterprise.
  • Armazenamento vetorial para produção. O LanceDB OSS escala para milhões de vetores em um único nó. Acima de centenas de milhões de vetores ou terabytes de dados, o LanceDB Cloud e Enterprise adicionam indexação distribuída e execução de consultas (sua documentação visa ~10 a 50B linhas / ~10 a 30 TB).

Antes de qualquer uma dessas medidas de escalonamento, porém, a própria demonstração tem arestas.

8 bugs e armadilhas que encontramos

Caso economize as horas que eles nos custaram:

  1. list_tables() não retorna uma lista. No LanceDB 0.30 ele retorna um objeto ListTablesResponse que parece iterável no REPL, mas if TABLE in db.list_tables() falha silenciosamente. Use try: db.open_table(TABLE) e capture a exceção, ou use .tables na resposta.
  2. table.checkout(tag) retorna None e muta o handle da tabela no lugar. Parece um bug, mas não é. Faça t = db.open_table(...); t.checkout(tag); use(t), não t = db.open_table(...).checkout(tag).
  3. O CohereReranker() padrão usa um modelo antigo (rerank-english-v3.0 nas versões que testamos). Passe um modelo explicitamente, seja rerank-v3.5 (o que fixamos aqui) ou rerank-v4.0-pro para maior qualidade. O padrão não avisa você.
  4. Use /trigger + polling, não /scrape, para lotes reais. O síncrono (/scrape) é feito para pulls pequenos. Manter a conexão aberta para limit_per_input=100 × 2 palavras-chave (~200 vagas) pode atingir um timeout, então use /trigger + polling para qualquer coisa acima de ~50 registros.
  5. Alguns registros obtidos pelo scraping são linhas de erro. De 204 vagas, 4 tinham um campo error definido em vez de um job_title (por exemplo, "Crawl aborted on job cancel"). Elas parecem registros normais superficialmente, então filtre-as em index.py ou o merge_insert falhará em um job_id vazio.
  6. Salários vêm em 2 períodos (yr e hr), mas o campo do schema é o mesmo. Sem normalizar para anual (multiplicar por hora por 2.080), um filtro como salary_min_annual >= 200000 silenciosamente perde contratos por hora bem pagos e inclui funções com salário implausivamente baixo.
  7. Strings de ajuda do argparse com % bruto quebram no Python 3.14. Escrever --where "salary > 200000 AND location LIKE '%SF%'" no seu texto de ajuda gera ValueError: badly formed help string porque o argparse tenta formatá-lo. Escape como %% ou reformule o exemplo.
  8. O Streamlit renderiza texto entre sinais $ como matemática LaTeX. Um salário como $150k,$200k mostrado com st.markdown ou st.caption fica distorcido como matemática. Escape cada $ em suas strings de exibição (o app.py do repositório faz isso com um replace de uma linha), ou os badges de salário renderizam como caracteres sem sentido.

O que você pode construir a seguir

O padrão, Bright Data ⟶ embeddings ⟶ banco de dados vetorial ⟶ busca híbrida, generaliza para quase qualquer domínio:

Domínio Produto Bright Data O que você consultaria
Acesso web agêntico The Web MCP (nível gratuito atualmente 5.000 solicitações/mês) “dê a um agente de IA ferramentas de busca + scraping ao vivo, depois fundamente suas respostas em um cache de resultados anteriores com suporte do LanceDB”
Corpora de sites completos Crawl API “indexe um site de documentação inteiro ou base de conhecimento para recuperação híbrida”
E-commerce Web Scraper API (produtos da Amazon) “tênis de corrida confortáveis abaixo de $100 com 4+ estrelas”
Imóveis Web Scraper API (Zillow / Redfin) “casa familiar tranquila perto de boas escolas, 3+ quartos”
Inteligência de notícias SERP API + Web Unlocker “artigos sobre segurança de IA desta semana, classificados por relevância para alinhamento”
Prospecção de vendas Informações de empresas do LinkedIn “startups Série A em IA para saúde baseadas na Europa”
Restaurantes Conjunto de dados do Yelp “lugar italiano aconchegante com área ao ar livre”

Algumas extensões naturais deste projeto exato:

  • Busca multimodal. Mude para o Cohere embed-v4.0 (nativamente multimodal) e embeda logotipos de empresas junto com descrições de vagas.
  • Filtros extraídos por LLM. Deixe o usuário digitar “vagas remotas de ML pagando $200k+” e tenha um LLM extraindo remote=true, salary_min_annual >= 200000 automaticamente.
  • Buscas salvas com alertas por e-mail. Reexecute uma consulta contra o scraping mais recente e notifique sobre novas correspondências.
  • Correspondência de currículo. Embeda um currículo e busque vagas por similaridade com o candidato. O assistente de IA para busca de empregos no LinkedIn da Bright Data é um exemplo mais completo.
  • Um Scraper que se mantém. Dê a um agente acesso ao MCP da Bright Data e ele pode inspecionar a página, escrever o Scraper e tentar uma correção quando o layout mudar, em vez de você corrigir o scrape.py manualmente. O Scraper Studio da Bright Data empacota isso como um produto gerenciado, transformando um prompt em linguagem simples em um Scraper que se autocorrige.

Próximos passos

A busca por palavras-chave perdeu as vagas certas, e a busca vetorial as encontrou mesmo quando os títulos nunca corresponderam à consulta. Na avaliação, o vetor pontuou 70% de precisão@3 contra 43% das palavras-chave, com o híbrido não adicionando ganho nessa escala.

O projeto completo no GitHub tem 9 arquivos pequenos. Para usá-lo em seus próprios dados, execute primeiro o python eval.py, porque o melhor modo depende dos dados, não de qual é mais complexo. Em seguida, decida uma cadência de atualização, onde o upsert merge_insert atualiza apenas o que mudou e o versions.py faz snapshot de cada ingestão. E antes de qualquer coisa ir para produção, planeje uma rotina de rotação de chaves, pois as chaves de BD e Cohere ficam no .env.

O mesmo padrão funciona para qualquer coisa que a Bright Data possa fazer scraping, não apenas vagas. A partir daí, você tem um motor de busca semântico que pode reutilizar para qualquer conjunto de dados que você scrape.

FAQ

Posso usar isso para sites além do LinkedIn?

Sim. A Biblioteca de Web Scrapers da Bright Data cobre centenas de sites (Amazon, Zillow, Yelp e mais), cada um com seu próprio dataset_id. Troque o DATASET_ID em scrape.py e o mapeamento to_row() em index.py pelo novo formato JSON. A lógica de busca e indexação é agnóstica em relação aos dados e se mantém.

Preciso de uma conta paga do Cohere para isso?

Não, uma chave de teste executa toda a demonstração. O endpoint Rerank de teste do Cohere está atualmente limitado a 10 chamadas/min, então o eval.py recebe um 429 e recua automaticamente (~90s em vez de ~15s). Scraping, indexação e busca ad-hoc ficam bem dentro dos limites. Faça upgrade apenas se você iterar na avaliação com frequência.

Por que LanceDB e não Pinecone, Weaviate ou pgvector?

O LanceDB é uma biblioteca embarcada sem servidor, sem banco de dados separado e sem cobrança de serviço gerenciado. Ele suporta busca híbrida e reranking do Cohere nativamente, e cada escrita é um snapshot de versão. Para um pipeline de máquina única sem operações, é a menor sobrecarga. Os outros são capazes, mas adicionam mais infraestrutura.

Com que frequência devo reexecutar o scraper?

Uma vez por dia é adequado para um quadro de empregos ativo. A Bright Data pode executar coleta agendada pelo dashboard, e o upsert merge_insert desduplicata no lado do LanceDB, então as reexecuções são baratas. Anúncios com mais de ~30 dias geralmente estão fechados, então snapshots antigos se tornam históricos, e o versions.py os mantém consultáveis.