AI

Bright Data、LanceDB、Cohereでセマンティック求人検索エンジンを構築する

セマンティック求人検索エンジンを構築しましょう。Bright DataのWeb Scraperが構造化されたLinkedIn求人を返し、Cohereが意味ベースのマッチングのための埋め込みを提供します。
10 分読
Build a Semantic Job Search Engine with Bright Data, LanceDB, and Cohere

求人ボードはキーワードの完全一致でしか検索できないため、表現が求人票と一致しない場合、適切なポジションが見つかりません。セマンティック検索は意味に基づいてマッチングします。エンドツーエンドで構築し、最も複雑なものが優れていると仮定するのではなく、どの検索モードが優れているかを実際に測定します。

TL;DR

このガイドでは、Bright Data(スクレイピング)、Cohere(埋め込み+リランク)、LanceDB(ローカルベクトルストア)を使用して、実際のLinkedIn求人200件を対象にセマンティック求人検索エンジンを構築します。

  • キーワード検索は完全一致で動作します。ベクトル検索は意味でマッチングします。「LLMに取り組むエンジニア」というクエリで、キーワード検索が見逃す「GenAI Developer」のポジションを見つけられます。
  • Bright DataのWeb Scraper APIは、構造化されたLinkedIn求人をJSONとして1件あたり$0.0015で返します。HTMLのパースやスクレイパーのメンテナンスは不要です。
  • LanceDBはローカルで動作し、ベクトル検索とSQLフィルター(給与、シニオリティ)を1クエリで組み合わせられます。全文検索とCohereリランクも利用可能です。
  • 10件のテストクエリで、ベクトル検索はprecision@3で70%を記録し、キーワードの43%を上回りました。ハイブリッド+リランクはこのスケールでは測定可能な改善をもたらさなかったため、約1万行以下ではベクトル単体が妥当なデフォルトです。
  • プロジェクト全体は9つの小さなファイルで構成され、評価ハーネスを含む完全なコードはGitHubで公開されています。全体の実行コストは約$0.34です。

キーワード検索の問題点

求人ボードのキーワード検索は、まさにその通りに動作します。クエリに含まれるリテラルトークンをタイトルや説明文に含む求人票を返します。「LLMとプロンプトエンジニアリングに取り組むエンジニア」と検索しても、完璧に適合していても「GenAI Developer」のような役職は見逃します。語彙的な検索は意味ではなく完全一致で動作するからです。

ベクトル検索は意味でマッチングします。各求人説明文は埋め込み(セマンティックコンテンツをキャプチャする高次元ベクトル)に変換され、クエリも同様に変換されます。クエリのベクトルに近いベクトルを持つ求人は、同じ単語を一つも共有していなくても意味的に良いマッチとなります。

これを機能する検索エンジンにするには3つのピースが必要です:

  1. Bright Dataが実際のLinkedIn求人200件をクリーンな構造化JSONとしてスクレイピングします。
  2. Cohereが説明文を埋め込みに変換し、最終結果をリランクします。
  3. LanceDBが埋め込みをローカルに保存し、SQLスタイルのフィルターを使ったハイブリッド(ベクトル+全文)クエリを提供します。

スタックの概要

各レイヤーの役割と採用理由:

レイヤー ツール 採用理由
Webデータ Bright Data Web Scraper API 構築済みのLinkedInスクレイパーが給与、シニオリティ、場所を含む構造化JSONを返します。HTMLのパースやスクレイパーのメンテナンスは不要です。
埋め込み Cohere embed-english-v3.0 非対称エンコーディング(ドキュメントとクエリで異なる入力タイプ)。Cohereはマルチモーダルなembed-v4.0も提供しています。ここでは英語専用の価格/レイテンシプロファイルのv3を使用します(v3のEOL前に再埋め込みを予定)。
リランカー Cohere rerank-v3.5 価格/レイテンシプロファイルのためv3.5を固定します。Cohereはrerank-v4.0も提供(品質重視の-pro、レイテンシ重視の-fast)。
ベクトルストア LanceDB ローカル組み込み型でサーバー不要。ハイブリッド(ベクトル+BM25)検索とSQL事前フィルターをサポート。
UI(オプション) Streamlit PythonデータアプリのためのミニマルコードWebUI。

このスタックはノートPC上の単一のPython venvから実行できます。管理サービスはBright DataとCohereのみです。

セットアップ

完全な実行可能プロジェクトはGitHubにあります。クローンして依存関係をインストールします(Python 3.10以上):

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

サンプルenvファイルをコピーして2つのAPIキーを追加します。Bright Dataトークンとdashboard.cohere.comのCohereキーです(トライアルキーでガイド全体を通して使用できます):

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

両方のキーを設定したら、python scrape.pyでデータを取得し、python index.pyでインデックスを構築します。

アーキテクチャ

システムは1つではなく2つのフローで構成されています。インジェストはインデックスを構築します(一度だけ、またはスケジュールに従って実行)。クエリは検索のたびに実行されます。両方ともCohereとLanceDBを使用しますが、異なる処理を行います。

Two-flow architecture diagram. INGEST (run once, or on a schedule): a "keyword" arrow enters Bright Data ("Discover jobs by keyword (async)"), which sends "jobs (JSON)" to Cohere ("embed (document)"), which sends "vectors" to LanceDB ("vector + FTS + scalar indexes, versioned"). QUERY (per search, default hybrid mode): a "query" arrow enters Cohere ("embed (query)"), which sends a "query vector" to LanceDB ("vector + FTS search + SQL prefilter"), which sends "candidates" to Cohere ("rerank"), which returns "ranked results". Cohere and LanceDB are tinted to show they are reused across both flows, and rerank runs in hybrid mode only.

2つのフローを並べて表示。インジェストはドキュメントを埋め込んで保存します。クエリは検索テキストを埋め込み、SQLプリフィルターを使ったベクトル+全文検索を実行し、その後リランクします。CohereとLanceDBは両方のフローに登場しますが、それぞれ異なる処理を行います。リランクはハイブリッドモードでのみ実行されます。

パイプラインを実行するスクリプトは3つです:scrape.pyindex.pysearch.py。さらに6つのヘルパー:lib.py(共有検索バックエンド)、compare.py(モード比較)、eval.py(precision@3)、stats.py(データセット概要)、versions.py(スナップショットブラウザ)、app.py(Streamlit UI)。

Bright DataでLinkedInをスクレイピング

LinkedInは求人データの主要なソースですが、信頼性の高いスクレイピングは困難です。レート制限、動的マークアップ、予告なく変更されるHTMLが課題です。Web Scraper APIは構築済みエンドポイントからクリーンな構造化JSONを返すため、パーサーのメンテナンスが不要です。

適切なエンドポイントの選択

Bright DataはいくつかのLinkedInスクレイパーを提供しています:

  • 人物プロフィール → 個人メンバーのプロフィール
  • 企業情報 → 企業ページ
  • 求人リスト → URLで収集 → すでに持っている特定の求人URL
  • 求人リスト → キーワードで検索 ← これを使用します
  • 求人リスト → URLで検索 → 検索結果URLからの求人
  • LinkedInの投稿人物検索 → その他のエンティティタイプ

検索クエリから大量の求人を発見したいため、キーワードで検索が適切です。1回のAPI呼び出しで、タイトル、企業、場所、シニオリティレベル、雇用形態、掲載されている場合の給与範囲、完全な求人説明を含む最大1,000件の構造化求人票をキーワードごとに返します。

各スクレイパーには固有のdataset_idがあります。見つけるには、Bright Dataのスクレイパーライブラリを開き、サイト(ここではlinkedin.com)を検索して開きます。求人リスト → キーワードで検索エンドポイントを選択すると、dataset_idgd_lpfll7v5hcqtkxl6l)と実行可能なリクエストがコード例パネルに表示されます。有効なトークンがあればscrape.pyから呼び出せます。

Bright Data dashboard showing the Web Scrapers Library menu, with the LinkedIn job listings → "Discover by keyword" endpoint selected in the left sidebar. The middle panel shows the Configuration tab with example inputs (paris/product manager, New York/python developer). The right panel shows the Code examples view with an authenticated curl request containing `dataset_id=gd_lpfll7v5hcqtkxl6l`.

キーワードで検索スクレイパーページ。右側のコード例パネルでdataset_idを確認できます。

同期と非同期

Bright Dataは2つの配信モードを提供しています:

  • 同期POST /datasets/v3/scrape)はデータをインラインで返します。小さなバッチに最適です。
  • 非同期POST /datasets/v3/trigger)はスナップショットIDを返します。完了をポーリングして結果をダウンロードします。大きなバッチに最適です。

実行では、応答時間は入力ごとに平均約6秒でした。limit_per_input=100で2つのキーワード(合計200件)の場合、同期呼び出しはバッチ全体の接続を保持する必要があり、タイムアウトのリスクがあります。非同期が安全なデフォルトです。

入力ごとの制限でコストを管理

クエリパラメーターlimit_per_input=Nは各入力検索が返す結果数を制限します。予測可能なコスト管理のための正確なパラメーターです:

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

より大きな実行のために増やすことができます(キーワードあたり最大1,000件)。

コード

スクレイパーはスナップショットをトリガーし、準備が整うまでポーリングし、JSONをダウンロードします。コアは以下の通りです(本番バージョンではリトライ/バックオフとより豊富なエラーハンドリングを追加します):

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

実行結果:

$ 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

返されるデータ

JSONの各求人には25以上のフィールドがあります。重要なものを以下に示します:

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

構造化されたbase_salaryフィールドにより、次のステップで給与フィルタークエリが可能になります。

CohereとLanceDBでインデックスを構築

204件の生の求人レコードがあり、そのうち4件はロード時にフィルタリングするエラー行です。残りの200件をセマンティック検索可能にします。

Cohereを選ぶ理由

他の選択肢(OpenAIの埋め込みモデル、Voyage AI、ローカルのsentence-transformers)よりCohereを選んだ理由:

  1. 非対称エンコーディング。Cohereでは、インデックス時に入力をsearch_documentとして、検索時にsearch_queryとしてタグ付けできます。モデルは両側を異なる方法でエンコードするため、同じ扱いをするよりも良い結果が得られます。
  2. 宣言的な埋め込み。LanceDBのレジストリはCohereをネイティブにサポートしており(OpenAIやsentence-transformersと同様)、手動のembed()呼び出しなしに挿入とクエリで埋め込みが自動的に行われます。
  3. Rerank API。クエリと候補リストを受け取り、実際の関連性で候補を並べ替える別のモデルです。ハイブリッドパイプラインのランキングを精度向上させる第2段階であり、1つの.rerank()呼び出しで追加できます。

LanceDBの埋め込みレジストリ

LanceDBの埋め込みはその埋め込みレジストリを通じて機能します。スキーマを一度宣言するだけで、すべての挿入とクエリで適切な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()              # ← 埋め込む対象
    vector: Vector(cohere.ndims()) = cohere.VectorField()  # ← 保存される埋め込み
    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

vector以降はすべて、フィルタリングと表示に使用される通常の保存カラムです。

給与正規化のテクニック

ほとんどの求人は年収で給与が記載されていますが、一部は時給です。salary_min_annual >= 200000を一貫して機能させるために、インジェスト時に正規化します:

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

フィルター用の生の数値と、UI用の人間が読みやすい表示文字列の両方を保存します。

アップサートによる増分更新

index.pyの初回実行時にテーブルが作成されます。以降の実行はすべてjob_idをキーとしたアップサートです:

result = (
    table.merge_insert("job_id")
         .when_matched_update_all()       # 既存の求人票を更新
         .when_not_matched_insert_all()   # 新しく発見された求人を追加
         .execute(rows)
)
print(f"inserted={result.num_inserted_rows}, updated={result.num_updated_rows}")

新しいBright Dataスクレイプからの求人票は挿入され、再掲載された求人(同じjob_id)は給与、説明文、タイムスタンプが更新されます。古い求人票を完全に削除するには、.when_not_matched_by_source_delete()をチェーンします。

アップサート全体は単一のアトミックトランザクションです。Lanceはコピーオンライトで列指向にデータを保存するため、再インジェストはフルテーブルの再構築ではなく増分書き込みになります。

高速SQLフィルターのためのスカラーインデックス

search.py, where "salary_min_annual >= 200000"を実行すると、LanceDBはベクトルスキャンのにフィルターを適用します(prefilter=True)。200行では即座ですが、200,000行ではインデックスを指定しない限りカラム全体を走査します:

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種類のインデックスで必要なものをカバーします:

  • BTREEはソート可能な高カーディナリティカラムに使用します。salary_min_annualは範囲クエリ(>=BETWEEN)に対して有効です。
  • BITMAPは低カーディナリティの列挙型に使用します。seniorityは約6つの異なる値を持ち、employment_typeはほぼすべてFull-timeで、search_keywordは2つのスクレイプ入力のいずれかです。各異なる値には独自のビットマップがあり、=フィルターは単一のビット演算ANDになります。

両方ともreplace=Trueで実行されるため、index.pyを再実行するとべき等に再構築されます。呼び出し後、table.list_indices()は5つすべて(4つのスカラー+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']

インデックスされたデータの確認

python index.pyを実行した後、stats.pyスクリプトでデータベースの内容を要約できます:

$ 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

リランキングを使ったハイブリッド検索の実行

LanceDBは3つの検索モードをサポートしており、lib.pyは単一の関数でそれらすべてを公開しています:

# lib.py
from lancedb.rerankers import CohereReranker

reranker = CohereReranker(model_name="rerank-v3.5")  # 固定版; 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()

search()の3つの要素について説明します:

  • query_type="hybrid"はベクトル類似度と、インデックス時に構築した全文インデックス(LanceDBのネイティブFTS)のBM25スコアを組み合わせます。候補の和集合がリランクされます。
  • .rerank(reranker)は候補リストをCohereのRerank APIに送信し、その順序付けを返します。LanceDBのデフォルトは古いため、model_name="rerank-v3.5"を明示的に渡します。
  • prefilter=TrueはSQLのWHERE句をベクトルスキャンのに適用します(後ではなく)。これにより高速化(検索空間が小さくなる)と精度向上(切り捨てによる結果の損失がない)が実現します。

実際のクエリ

データセット内のどの求人タイトルとも多くのリテラルな単語を共有しないクエリの上位2件の結果を示します:

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

どちらの求人タイトルにも「GPUs」は含まれていませんが、両方の説明文は分散MLトレーニングに関するもので、クエリが求めているものです。純粋なキーワード検索では両方とも見逃す可能性が高いです。

各モードは異なる種類のスコアを返します。ベクトルモードはコサイン距離(低いほど近い)、ハイブリッド+リランクはCohereの関連性スコア(0から1、高いほど良い)、キーワードモードは生のBM25(上限なし、高いほどキーワードの重複が多い)を返します。これらの数値はモード間では比較できず、単一モード内でのみ比較可能です。

セマンティクスとハード制約の組み合わせ

LanceDBでは1つのクエリでセマンティック類似度とSQLフィルターを組み合わせられます:

$ 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

ベクトル部分は説明的な部分(「fintechのpythonでエクイティあり」)にマッチします。SQLフィルターは数値制約(>= $250k)を適用します。両方の結果が適切な給与帯のFintal Partnersのポジションです。

同じハイブリッド+フィルターのパターンがStreamlit UIでも動作します(ライブリストはCLI実行時と異なります):

Streamlit search interface with the query "fintech python role with equity" and the Min salary slider in the sidebar dragged to $250,000. The results header reads "3 results · mode: hybrid · filter: salary_min_annual >= 250000". The top result card shows Senior Software Engineer (Python) at Fintal Partners in New York, NY, with badges for Mid-Senior level, Full-time, and a green salary badge reading $300,000,$500,000/yr, relevance score 0.272, and a snippet about a quantitative trading firm. A second result, Data Scientist at OpenArt AI in San Francisco, score 0.165, begins below.

給与スライダーを設定してハイブリッド検索を実行するStreamlitアプリ(app.py)。各カードのスライダーがグリーンオンブラックのフィルターバナーに表示されるsalary_min_annual >= 250000プリフィルターを生成します。

キーワード、ベクトル、ハイブリッドの違い

compare.pyは同じクエリを3つのモードすべてで実行し、並べて比較したレポートを表示します:

$ 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

オーバーラップ行では、キーワードとベクトルはトップ3で同じ求人を0件しか見つけませんでした。それぞれ異なる概念空間を検索しています。

  • キーワード(BM25)はリテラルトークン「LLMs」と「prompt」が最も頻繁に出現する求人票を見つけます。汎用的なAI/MLタイトルを返します。
  • ベクトル(Cohere)はSenior Software Engineer (Prompt Engineer Python/GenAI)の求人を1位に見つけます。ユーザークエリは「prompt engineering」(動名詞)と言っているのに対して、タイトルは「Prompt Engineer」(名詞)と表現していても関係ありません。また、クエリからは語彙的に遠いがセマンティック的には強いマッチとなるJobs via DiceのLLMに焦点を当てたリストも返します。
  • ハイブリッド+リランクは和集合を取り、重複を排除し、Cohere Rerankを実行します。ServalのApplied AI Engineerポジション($200k〜$325k)が1位に上がります。その説明文はプロンプトエンジニアリングとLLMエージェントの仕事で密度が高いですが、タイトルもBM25の上位重み付き用語も、このポジションをこれほど高くランク付けしていませんでした。

この特定のクエリでは、ベクトルとハイブリッドの両方がキーワードよりも優れていました。生のトークンオーバーラップは、セマンティック関連性が示すよりもGenpactとServalの結果を低くランク付けしました。しかし、1つのクエリは逸話であり、証拠ではありません。そのパターンが一般的に成立するかどうかは、実際の評価のみが答えられる質問です。

precision@3で品質を測定

適切に測定するために、eval.py手書きの10クエリを3つのモードすべてに対してスコアリングし、precision@3(上位3件の結果のうち、透明なグラウンドトゥルース述語に一致する割合)を計算します。

各クエリのグラウンドトゥルースは魔法の数字ではなくPython述語であるため、読者は結果を同様に評価するかどうかを判断できます。

「OpenAIの機械学習エンジニア」の場合、companyフィールドに「OpenAI」が含まれている場合のみ関連とみなします。「取引会社の量的開発者」の場合、ルールはより広くなります。タイトルに「Quant」または「Trading」が含まれているか、会社が既知の取引会社(Fintal Partners、DRW、Hudson River Trading、Tower Research、Mondrian Alpha)の場合に関連とみなします。これらの述語はサンプルデータセットに合わせて調整されているため、新しい求人ではスコアが変わります。独自のデータに合わせて再調整してください。正確なパーセンテージが変わっても、モード間のギャップは維持されます。

実行結果:

$ 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

チャートとして同じ数値:

Bar chart of precision@3 over 10 test queries: keyword 43%, vector 70%, hybrid + rerank 67%.

10件の評価クエリで平均したprecision@3。ベクトルはキーワードを大幅に上回り、ハイブリッドはベクトルから数ポイント以内に収まっています。

数値が示すもの

テーブルから:

  • ベクトル検索はキーワード検索を大幅に上回りました平均precision@3で70%対43%)。キーワードが0点だった3つのクエリ(「プロンプトエンジニア」、「fintechのpython開発者」、「エクイティ付き高給MLロール」)はすべて、ベクトルで少なくとも1件の関連ヒットがありました。
  • ハイブリッド+リランクはこのスケールではベクトルを上回りませんでした。67%対70%のギャップはノイズの範囲内です。リランカーはクエリごとにCohere呼び出しを追加し、FTS部分は後でフィルタリングが必要な語彙的な近似ミスを供給します。
  • どのモードも完全に劣っているわけではありません。「コンピュータビジョンとロボティクス」は、関連する企業の説明文にリテラルなロボティクス用語が含まれているため、キーワード(1.00)がベクトル(0.67)を上回る唯一のクエリです。

ハイブリッド+リランクを有効にするタイミング

いくつかの要因によります:

  • 候補プールのサイズ。数百行ではベクトル単体で通常十分です。ハイブリッドの2段階検索はリランクステップのコストに見合うために10k以上の大きなプールが必要です。
  • クエリタイプ。セマンティックな意図と特徴的なキーワード(ブランド名、特定の技術)の両方を含むクエリはハイブリッドから恩恵を受けます。純粋なセマンティッククエリは通常そうではありません。
  • リランカーの品質。Cohereのrerank-v3.5は評価で良好なパフォーマンスを発揮しました。別のリランカーに交換する場合は、弱いリランカーが小さな候補プールで良いベクトル結果を下位に並べ替える可能性があるため、信頼する前にeval.pyを再実行してください。

独自のデータでeval.pyを実行して判断してください。クエリの追加は文字列とグラウンドトゥルース述語だけです。

注意:ハイブリッド評価は無料のCohereキーで問題なく実行できます。トライアルのレート制限により、約15秒ではなく約90秒でバックオフして完了します。

StreamlitでWebUIを追加

Streamlitは同じ検索バックエンドをクリッカブルなWebアプリに変換します。検索とレンダリングのコアは以下の通りです:

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

実行:

streamlit run app.py

localhost:8501で完全な検索ページが表示されます。検索ボックス、モード切り替え、シニオリティ・ソースキーワード・給与のサイドバーフィルター、バッジ・スコア・スニペットプレビュー付きの結果カードが含まれます。

Streamlit search interface for the query "founding ML engineer at AI startup with computer vision" showing a hybrid result list. The sidebar contains filters for search mode, seniority, source-search keyword, minimum salary, and number of results. The top result card is "Founding ML Engineer | Frontier Medical AI | $150k,$200k | SF" from CoffeeSpace in San Francisco Bay Area, with Mid-Senior level + Full-time badges, a Cohere relevance score of 0.720, and a description snippet. A second result, "AI/ML Engineer - AI Design Software Leader" with score 0.711, begins below.

ハイブリッド検索を実行するStreamlitアプリ。各カードのスコアバッジはCohereの関連性スコアで、バッジ下のスニペットは各結果がトップ3に入った理由を示しています。

LanceDBで無料のタイムトラベル

これで検索とUIをカバーしました。LanceDBにはもう一つ紹介する価値のある機能があります。LanceDBへのすべての書き込みは追加コストやインフラなしに自動的に新しいバージョンを作成します。これは基盤となるLance列指向フォーマットの仕組みです。バージョンを後で簡単に見つけられるように、index.pyは各インジェスト後にタグを付けます:

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

versions.pyスクリプトを使用して履歴スナップショットを閲覧・開くことができます。python index.pyを一度実行すると1つのタグが表示されます。2回目のインジェスト(例えば1週間後に再スクレイプ)後は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

タイムトラベルは1回のtable.checkout(tag_or_version)呼び出しです。求人検索プロダクトでは「先四半期に掲載されたポジションは?」「給与分布は時間とともに変化しているか?」といった質問に、別の時系列データベースなしで答えられます。それがここでLanceDBを選んだ理由の一つです。

コストとスケール

デモ(200件の求人、約5件のサンプルクエリ)の場合:

項目 コスト
Bright Dataスクレイプ(204件 @ $0.0015/件) $0.31
Cohere埋め込み(合計約228kトークン @ $0.10/1M) ~$0.02
Cohereリランク(約$0.002/クエリ、Rerank v3.5で$2/1k検索) 5クエリで~$0.01
LanceDB 無料

エンドツーエンドのデモコストは合計約$0.34です。これらは2026年の実行時の価格であるため、各プロバイダーの現在のレートを確認してください。

スケールアップ

ローカルデモは200件の求人を処理します。本番スケールのデータセットへの道にはいくつかのレバーがあります:

  • より多くの求人。LIMIT_PER_INPUTを変更(キーワードあたり最大1,000件)するか、より多くのキーワード検索を追加します。10,000件の求人にはBright Dataクレジットで約$15かかります。
  • より多くのキーワード/場所。scrape.pySEARCHESリストにエントリを追加します。
  • スケジュール更新。構築したmerge_insertアップサートにより、パイプラインを再実行すると変更されたものが更新されます。Bright Dataはダッシュボードからスケジュールされたコレクションと配信をサポートしています。アップサートと組み合わせると自己更新データセットができます。
  • ベクトルインデックス。約1万行を超えたら、table.create_index(vector_column_name="vector")でブルートフォース検索をHNSWまたはIVF_PQインデックスに切り替えます。デフォルトではCPUで構築されます。GPUビルドには、PyTorch>2.0でaccelerator="cuda"(またはApple Siliconでは"mps")を渡します。自動GPUインデックスは現在LanceDB Enterpriseの機能です。
  • 本番ベクトルストア。LanceDB OSSは単一ノードで数百万ベクトルまでスケールします。数億ベクトルまたはテラバイト規模のデータを超えると、LanceDB CloudとEnterpriseが分散インデックスとクエリ実行を追加します(ドキュメントは約100〜500億行/約10〜30TBを対象としています)。

ただし、これらのスケーリング手順の前に、デモ自体にはいくつかの鋭いエッジがあります。

遭遇した8つのバグと落とし穴

私たちが費やした時間を節約できるかもしれないので:

  1. list_tables()はリストを返しません。LanceDB 0.30ではListTablesResponseオブジェクトを返し、REPLでは反復可能に見えますが、if TABLE in db.list_tables()は黙って失敗します。代わりにtry: db.open_table(TABLE)を使って例外をキャッチするか、レスポンスの.tablesを使用してください。
  2. table.checkout(tag)Noneを返し、テーブルハンドルをその場で変更します。バグのように見えますが、そうではありません。t = db.open_table(...); t.checkout(tag); use(t)のように使用し、t = db.open_table(...).checkout(tag)ではありません。
  3. デフォルトのCohereReranker()は古いモデルを使用します(テストしたバージョンではrerank-english-v3.0)。モデルを明示的に渡してください。ここでピン留めしているrerank-v3.5か、より高品質なrerank-v4.0-proのいずれかです。デフォルトは警告を表示しません。
  4. 実際のバッチには/scrapeではなく/trigger+ポーリングを使用してください。同期(/scrape)は小さなプル用に設計されています。limit_per_input=100×2キーワード(約200件)の接続を保持するとタイムアウトする可能性があるため、50件を超えるものには/trigger+ポーリングを使用してください。
  5. スクレイプされたレコードの一部はエラー行です。204件の求人のうち4件にはjob_titleの代わりにerrorフィールドが設定されていました(例:"Crawl aborted on job cancel")。表面上は通常のレコードのように見えるため、index.pyでフィルタリングしてください。そうしないと空のjob_idmerge_insertが失敗します。
  6. 給与は2つの期間(yrhr)で提供されますが、スキーマフィールドは同じです。年収に正規化(時給×2080)しないと、salary_min_annual >= 200000のようなフィルターが高給の時給契約を黙って見逃し、不自然に低い年収のポジションを含めます。
  7. 生の%を含むargparseヘルプ文字列はPython 3.14でエラーになります。ヘルプテキストに--where "salary > 200000 AND location LIKE '%SF%'"と書くと、argparseがフォーマットしようとしてValueError: badly formed help stringが発生します。%%でエスケープするか、例を言い換えてください。
  8. Streamlitは$記号の間のテキストをLaTeX数式としてレンダリングします。st.markdownst.caption$150k,$200kのような給与を表示すると、数式として文字化けします。表示文字列のすべての$をエスケープしてください(リポジトリのapp.pyは1行のreplaceで対応しています)。そうしないと給与バッジが文字化けして表示されます。

次に構築できるもの

このパターン(Bright Data ⟶ 埋め込み ⟶ ベクトルDB ⟶ ハイブリッド検索)はほぼあらゆるドメインに一般化できます:

ドメイン Bright Dataプロダクト クエリ対象
エージェントWebアクセス The Web MCP(現在無料ティアは月5,000リクエスト) 「AIエージェントにライブ検索+スクレイプツールを与え、過去の結果のLanceDBキャッシュに基づいて回答を根拠づける」
サイト全体のコーパス Crawl API 「ドキュメントサイトまたはナレッジベース全体をハイブリッド検索のためにインデックス化する」
Eコマース Web Scraper API(Amazon商品) 「4つ星以上の$100以下の快適なランニングシューズ」
不動産 Web Scraper API(Zillow/Redfin) 「良い学校の近くの静かな家族向け住宅、3ベッドルーム以上」
ニュースインテリジェンス SERP APIWeb Unlocker 「今週のAI安全記事、アライメントへの関連性でランク付け」
営業見込み客発掘 LinkedIn企業情報 「ヨーロッパを拠点とするヘルスケアAIのシリーズAスタートアップ」
レストラン Yelpデータセット 「アウトドア席のある居心地の良いイタリアンレストラン」

この正確なプロジェクトの自然な拡張:

  • マルチモーダル検索。Cohere embed-v4.0(ネイティブマルチモーダル)に切り替えて、求人説明と並んで企業ロゴを埋め込みます。
  • LLM抽出フィルター。ユーザーが「リモートMLジョブ$200k以上」と入力し、LLMが自動的にremote=true, salary_min_annual >= 200000を抽出します。
  • メールアラート付き保存検索。最新のスクレイプに対してクエリを再実行し、新しいマッチを通知します。
  • 履歴書マッチング。履歴書を埋め込み、候補者との類似度で求人を検索します。Bright DataのLinkedInジョブハンティングAIアシスタントはより完全な例です。
  • 自己メンテナンス型スクレイパー。エージェントにBright DataのMCPへのアクセスを与えると、ページを検査してスクレイパーを書き、レイアウトが変わったときに修正を試みることができます。手動でscrape.pyをパッチする代わりに。Bright Dataのスクレイパースタジオはこれを管理プロダクトとしてパッケージ化し、平易な英語のプロンプトを自己修復スクレイパーに変換します。

次のステップ

キーワード検索は適切なポジションを見逃しましたが、ベクトル検索はタイトルがクエリに一致しない場合でもそれらを見つけました。評価では、ベクトルはprecision@3でキーワードの43%に対して70%を記録し、ハイブリッドはこのスケールでは改善をもたらしませんでした。

GitHubの完全なプロジェクトは9つの小さなファイルです。独自のデータで使用するには、まずpython eval.pyを実行してください。最適なモードはデータに依存し、複雑さではないからです。次に更新頻度を決定します。merge_insertアップサートは変更されたものだけを更新し、versions.pyは各インジェストをスナップショットします。そして本番前に、BDとCohereの両方のキーが.envに入るため、キーローテーションルーティンを計画してください。

同じパターンは求人だけでなく、Bright Dataがスクレイピングできるものすべてに機能します。そこから、スクレイピングしたあらゆるデータセットに再利用できるセマンティック検索エンジンができあがります。

FAQ

LinkedIn以外のサイトでも使えますか?

はい。Bright DataのWebスクレイパーライブラリは数百のサイト(Amazon、Zillow、Yelpなど)をカバーし、それぞれに固有のdataset_idがあります。scrape.pyDATASET_IDindex.pyto_row()マッピングを新しいJSONの形状に合わせて変更するだけです。検索とインデックスのロジックはデータに依存せず、そのまま使えます。

有料のCohereアカウントが必要ですか?

いいえ、トライアルキーでデモ全体を実行できます。CohereのトライアルRerankエンドポイントは現在10コール/分に制限されているため、eval.pyは429エラーが発生して自動的にバックオフします(約15秒ではなく約90秒)。スクレイピング、インデックス、アドホック検索は制限をはるかに下回ります。評価を頻繁に繰り返す場合のみアップグレードしてください。

Pinecone、Weaviate、pgvectorではなくLanceDBを選ぶ理由は?

LanceDBはサーバーなし、別のデータベースなし、マネージドサービス料金なしの組み込みライブラリです。ハイブリッド検索とCohereリランキングをネイティブにサポートし、すべての書き込みはバージョンスナップショットです。運用なしの単一マシンパイプラインでは、最小限のオーバーヘッドです。他のものも有能ですが、より多くのインフラを追加します。

スクレイパーはどのくらいの頻度で再実行すべきですか?

アクティブな求人ボードには1日1回が適しています。Bright Dataはダッシュボードからスケジュールされたコレクションを実行でき、merge_insertアップサートはLanceDB側で重複排除するため、再実行は安価です。約30日以上経過した求人は通常クローズされているため、古いスナップショットは履歴となり、versions.pyでクエリ可能な状態を維持します。