AI

Bright DataとChromaDBでローカルRAGパイプラインを構築する方法

Bright DataのWeb UnlockerとChromaDBを組み合わせたRAGパイプラインの構築方法を学び、ハルシネーションなしで最新のウェブデータにローカルでアクセスします。
5 分読
ChromaDB with Bright Data

この記事では、以下の内容を学びます:

  • Retrieval-Augmented Generation(RAG)とChromaDBとは何か、それぞれが提供するもの。
  • Bright DataのWeb Unlocker APIとChromaDBを組み合わせることが、言語モデルを新鮮なリアルワールドデータに基づかせる実践的な方法である理由。
  • ウェブコンテンツを収集し、ローカルで埋め込み、質問に回答するエンドツーエンドのパイプラインを、すべて自分のマシン上で構築する方法。

ツールとコードの説明に入る前に、概念を整理し、RAGワークフロー内でそれらがどのように組み合わさるかを確認しておきましょう。

Retrieval-Augmented Generation(RAG)とは?

大規模言語モデルは、学習したデータしか知りません。先週公開されたページ、社内wiki、ニッチな製品カタログについて質問すると、推測するか、わからないと答えるでしょう。Retrieval-Augmented Generationはそのギャップを埋めます。

アイデアはシンプルです。モデルの記憶に頼る代わりに、独自のドキュメントを検索可能なインデックスに保存します。質問が来たら、まずそれに最も関連するテキストのチャンクを検索し、それらをコンテキストとして貼り付けてプロンプトを補強し、最後にモデルがその素材に基づいた回答を生成します。モデルが文章を書くことには変わりありませんが、事実はあなたが管理するデータから来ます。

これが重要な理由は2つあります。インデックスに何を入れるか、いつ更新するかを自分で決めるため、回答は常に最新の状態を保てます。また、モデルが学習データの空白を埋めるのではなく、実際のソーステキストを推論するため、回答の精度も保たれます。ナレッジベース、サポートアシスタント、リサーチツール、独自情報や変化の速い情報を基盤とするものすべてにおいて、RAGはデフォルトのアプローチとなっています。

検索側を理解したところで、ドキュメントが実際にどこに保存されるかを見てみましょう。

ChromaDBとは?

ChromaDB(通常はChromaと呼ばれます)は、AIアプリケーション向けに構築されたオープンソースのベクトルデータベースです。通常のデータベースは正確な値を照合しますが、ベクトルデータベースは意味を照合します。これは埋め込みを保存することで実現されます。埋め込みとはテキストの数値表現であり、似たアイデアはベクトル空間で近くに配置されます。クエリを実行すると、Chromaは完全に同じ単語が含まれていなくても、質問に最も近い埋め込みを持つチャンクを返します。

Chromaが最初のRAG構築に適している理由は、要求が少ないことです。単一のpip installでインストールでき、別途サーバーを管理することなくPythonプロセス内に組み込んで実行でき、PersistentClientによってすべてをディスクに永続化するため、再起動後もインデックスが保持されます。OpenAIやGoogle APIからネットワークに接続しないローカルモデルまで、任意の埋め込みモデルを接続できます。ラップトップで実行してプライバシーを保つプロジェクトには、この組み合わせは最適です。

RAGとファインチューニング:違いは何か?

この分野に不慣れな人は、RAGとファインチューニングを競合する選択肢として比較しがちです。しかし、それぞれ異なる問題を解決します:

  • ファインチューニングはモデル自体を変更します。例を用いて再学習させることで、トーン、フォーマット、または動作を適応させます。モデルに異なる動作をさせたい場合に適したツールですが、時間とコストがかかり、組み込まれた知識はデータが変わった瞬間に古くなります。
  • RAGはモデルをそのままにして、質問時にモデルが見るものを変えます。リクエストごとに関連するコンテキストをプロンプトに注入します。モデルに特定の最新情報を知らせたい場合に適したツールであり、更新はインデックスにドキュメントを追加するだけで済みます。

多くのチームが落ち着く大まかなルール:新しい知識が必要な場合はRAGを選び、新しい動作が必要な場合はファインチューニングを検討する。本番環境では両方を使うシステムも多くあります。このチュートリアルでは完全にRAGに焦点を当てます。目標は、モデルを再学習させるには変化が速すぎるウェブコンテンツに関する質問に答えることだからです。

なぜBright DataをRAG + ChromaDBパイプラインに統合するのか?

RAGシステムの品質は、投入するドキュメントの品質に依存します。粗悪なデータを入力すれば、自信ありげな粗悪な出力が返ってきます。そのため、ほとんどのパイプラインにおける真のボトルネックは、ベクトル検索やモデルではなく、最初から清潔で信頼性が高く最新のソース素材を入手することです。

データがPDFのフォルダにすでに存在する場合は簡単です。しかし、知識をオープンウェブから取得する必要がある場合は困難になります。公開ページはボット検出の背後に隠れ、JavaScriptでコンテンツをレンダリングし、地域によって異なる結果を返し、自動化されたアクセスにはCAPTCHAを表示します。これらすべてに対して独自のスクレイパーを維持することは、それ自体が一つの仕事であり、ターゲットサイトがマークアップを変更するたびに壊れる仕事です。

そこでBright DataのWeb Unlocker APIが役立ちます。ターゲットURLを送信すると、アンチボット対策、プロキシローテーション、JavaScriptレンダリング、ジオターゲティングを裏側で処理してページコンテンツを返します。ページをクリーンなMarkdownとして返すよう指定することもでき、ナビゲーションや定型文を取り除いてチャンク化・埋め込みに適したテキストを残してくれるため、RAGにほぼ理想的です。ブラウザ自動化もプロキシプールの管理も不要です。

Chromaのローカルベクトルストアとローカルで実行されるモデルと組み合わせることで、必要な部分では最新の状態を保ち、それ以外ではプライベートなパイプラインが実現します。データ収集ステップだけがウェブにアクセスし、埋め込み、ストレージ、検索、生成はすべてマシン上で完結します。

このパターンは特に以下の用途に役立ちます:

  • モデルの学習スナップショットではなく、最新の記事、論文、ドキュメントに基づいて質問に答えるリサーチアシスタント。
  • 競合サイトの製品、価格、機能ページを追跡する競合情報ツール。
  • ライブドキュメントに基づく社内サポートボット。数ヶ月前のバージョンではなく、現在のドキュメントを反映した回答を提供。
  • 最新のリスティングやニュースを取得し、アナリストが平易な言語でクエリできる市場監視システム。

Bright Dataのウェブデータインフラに収集を任せ、Chromaに検索を任せることで、スクレイピングコードを一行も書かずに本番環境向けのRAGエンジンが手に入ります。

Bright DataとChromaDBでローカルRAGパイプラインを構築する方法

このガイド付きセクションでは、3つのステージからなるパイプラインを構築します:

  1. ウェブコンテンツの収集:スクリプトがBright DataのWeb Unlocker APIを呼び出して一連のページを取得し、それぞれをMarkdownとして返します。
  2. 埋め込みと保存:2番目のスクリプトがコンテンツをチャンク化し、sentence-transformerモデルでローカルに埋め込みを生成し、ベクトルをChromaDBに書き込みます。
  3. 検索と生成:クエリスクリプトが質問を埋め込み、Chromaから最も関連性の高いチャンクを取得し、ローカルモデルに渡してソース付きの根拠のある回答を生成します。

注:これは多くの可能な設計の一つです。ローカルモデルをAPI呼び出しに置き換えてより高品質な回答を得たり、検索と生成の間に再ランキングステップを追加したり、収集ステージを3つではなく数百のURLに向けたりすることもできます。構造は同じです。

以下の手順に従って、Bright DataのWeb Unlocker APIとChromaDBを使った完全なローカルRAGパイプラインを構築してください。

前提条件

この手順を進めるには、以下が必要です:

  • 有効なWeb Unlockerゾーンを持つBright Dataアカウント。ダッシュボードにログインし、アカウント設定に移動してAPIトークン(UUID形式)をコピーしてください。ゾーン名もメモしておいてください。両方が必要です。
  • ローカルにインストールされたPython 3.10以上
  • 生成ステップ用のモデルをプルしたOllama(このチュートリアルではllama3.1を使用しますが、任意のチャットモデルが動作します)。ホスト型モデルを使用したい場合は、ステップ5でAPI呼び出しへの切り替え箇所を示します。
Bright Data Web UnlockerゾーンとAPIトークン

ステップ1:プロジェクトのセットアップ

作業ディレクトリを作成し、仮想環境をセットアップして、依存関係をインストールします:

mkdir local-rag-pipeline && cd local-rag-pipeline
python -m venv venv
source venv/bin/activate        # Windowsの場合: venv\Scripts\activate
pip install requests chromadb sentence-transformers

パイプラインを初めて実行する際、sentence-transformersが埋め込みモデル(数百メガバイト)をダウンロードします。その後はキャッシュから読み込まれ、オフラインで動作します。

まだ行っていない場合は、生成ステップ用のモデルをプルしてください:

ollama pull llama3.1

ステップ2:プロジェクト構造の作成

パイプラインが書き込むフォルダを作成します:

mkdir -p data/raw data/chroma

プロジェクト構造は次のようになります:

local-rag-pipeline/
├── data/
│   ├── raw/            # ウェブから収集した生のMarkdown
│   └── chroma/         # 永続化されたChromaDBインデックス
├── collect.py          # ステージ1: Bright Data経由でページを取得
├── ingest.py           # ステージ2: チャンク化、埋め込み、保存
└── rag.py              # ステージ3: 検索と生成

ステップ3:Bright Dataでウェブデータを収集する

collect.pyファイルを作成します:

import json
import requests
from pathlib import Path

API_KEY = "your-brightdata-api-token-here"
ZONE = "web_unlocker1"
BASE_URL = "https://api.brightdata.com/request"
RAW_DATA_PATH = "data/raw/pages.json"

HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}

TARGETS = [
    "https://en.wikipedia.org/wiki/Retrieval-augmented_generation",
    "https://en.wikipedia.org/wiki/Vector_database",
    "https://en.wikipedia.org/wiki/Large_language_model",
]


def fetch_pages():
    results = []
    for url in TARGETS:
        print(f"Fetching: {url}")
        response = requests.post(
            BASE_URL,
            headers=HEADERS,
            json={
                "zone": ZONE,
                "url": url,
                "format": "raw",
                "data_format": "markdown",
            },
            timeout=60,
        )
        response.raise_for_status()
        results.append({"url": url, "content": response.text})
        print(f"  -> {len(response.text)} chars")

    Path(RAW_DATA_PATH).parent.mkdir(parents=True, exist_ok=True)
    with open(RAW_DATA_PATH, "w") as f:
        json.dump(results, f, indent=2)

    print(f"Saved {len(results)} pages to {RAW_DATA_PATH}")


if __name__ == "__main__":
    fetch_pages()

your-brightdata-api-token-hereを実際のAPIトークンに置き換え、ZONEをWeb Unlockerゾーン名に合わせて更新してください。

各部分の役割:

  • API_KEYZONE:Bright Dataの認証情報。APIトークンはアカウント設定からのUUID形式のトークンで、ゾーンのパスワードではありません。
  • TARGETS:取り込むページ。ここにある3つのWikipedia記事は質問するのに適したコーパスを提供します。独自のURLに置き換えてください。ニュースサイト、製品ページ、通常のリクエストをブロックするJavaScript多用のアプリが、APIを通じてクリーンに返ってくるため、まさにWeb Unlockerが力を発揮する部分です。
  • fetch_pages:各URLをループしてWeb UnlockerエンドポイントにPOSTリクエストを送ります。data_format: "markdown"オプションはBright Dataに生のHTMLではなく読みやすいMarkdownを返すよう指示し、パースのステップを省きます。結果は次のステージが読み込む単一のJSONファイルに書き込まれます。

注意:サイトがBright Dataのイミディエイトアクセスモードで制限されている場合、一部のページはbad_endpointメッセージで返ってくることがあります。これは想定された動作です。Bright Dataはサイレントに失敗するのではなく、レスポンスにエラーを表示します。制限されたターゲットへのフルアクセスが必要な場合は、アカウントマネージャーにお問い合わせください。

collect.py実行後のターミナル出力

ステップ4:コンテンツをChromaDBに埋め込む

ingest.pyファイルを作成します:

import json
import chromadb
from chromadb.utils import embedding_functions

RAW_DATA_PATH = "data/raw/pages.json"
CHROMA_PATH = "data/chroma"
COLLECTION_NAME = "web_knowledge"


def chunk_text(text, size=800, overlap=100):
    words = text.split()
    chunks, i = [], 0
    while i < len(words):
        chunks.append(" ".join(words[i:i + size]))
        i += size - overlap
    return chunks


def main():
    with open(RAW_DATA_PATH) as f:
        pages = json.load(f)

    client = chromadb.PersistentClient(path=CHROMA_PATH)
    embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
        model_name="all-MiniLM-L6-v2"
    )
    collection = client.get_or_create_collection(
        name=COLLECTION_NAME,
        embedding_function=embed_fn,
    )

    documents, metadatas, ids = [], [], []
    for page in pages:
        for idx, chunk in enumerate(chunk_text(page["content"])):
            documents.append(chunk)
            metadatas.append({"source": page["url"], "chunk": idx})
            ids.append(f"{page['url']}#{idx}")

    collection.upsert(documents=documents, metadatas=metadatas, ids=ids)

    print(f"Indexed {len(documents)} chunks from {len(pages)} pages")
    print(f"Collection now holds {collection.count()} chunks")


if __name__ == "__main__":
    main()

このステージは生のテキストを検索可能なものに変換する重要な作業を行います:

  • chunk_text:各ページをおよそ800単語の重複するウィンドウに分割します。チャンク化が重要なのは、ページ全体ではなく焦点を絞ったパッセージを検索したいためであり、100単語の重複により境界をまたぐ文が途中で切れるのを防ぎます。
  • SentenceTransformerEmbeddingFunction:ローカルで動作する小型で高速な埋め込みモデルall-MiniLM-L6-v2を読み込みます。Chromaはドキュメントの追加やクエリの際に自動的に呼び出すため、ベクトルを手動で扱う必要はありません。
  • get_or_create_collection:ディスク上の永続コレクションを開き、初回実行時に作成し、それ以降は再利用します。
  • collection.upsert:チャンク、メタデータ、安定したIDをChromaに書き込みます。addの代わりにupsertを使うことで、新鮮なコンテンツを収集した後にスクリプトを再実行しても重複IDエラーが発生しません。

ステップ5:検索と生成のステップを構築する

rag.pyファイルを作成します:

import sys
import requests
import chromadb
from chromadb.utils import embedding_functions

CHROMA_PATH = "data/chroma"
COLLECTION_NAME = "web_knowledge"
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL = "llama3.1"

PROMPT_TEMPLATE = """You are a research assistant. Answer the question using only the context below.
If the context does not contain the answer, say you don't have enough information.

Context:
{context}

Question: {question}

Answer:"""


def retrieve(question, n_results=4):
    client = chromadb.PersistentClient(path=CHROMA_PATH)
    embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
        model_name="all-MiniLM-L6-v2"
    )
    collection = client.get_collection(
        name=COLLECTION_NAME, embedding_function=embed_fn
    )
    results = collection.query(query_texts=[question], n_results=n_results)
    chunks = results["documents"][0]
    sources = [m["source"] for m in results["metadatas"][0]]
    return chunks, sources


def generate(question, chunks):
    prompt = PROMPT_TEMPLATE.format(context="\n\n".join(chunks), question=question)
    response = requests.post(
        OLLAMA_URL,
        json={"model": MODEL, "prompt": prompt, "stream": False},
        timeout=120,
    )
    response.raise_for_status()
    return response.json()["response"]


def main(question):
    chunks, sources = retrieve(question)
    answer = generate(question, chunks)

    print("\n=== Answer ===")
    print(answer.strip())
    print("\n=== Sources ===")
    for src in dict.fromkeys(sources):   # 重複排除、順序を保持
        print(f"  - {src}")


if __name__ == "__main__":
    main(" ".join(sys.argv[1:]))

フローは以下の通りです:

  • retrieve:取り込み時に使用したのと同じモデルで質問を埋め込み(この一貫性が類似検索を機能させます)、Chromaに最も近い4つのチャンクを要求します。テキストと各チャンクのソースURLの両方を返します。
  • generate:モデルを検索されたコンテキストに固定し、答えがない場合は認めるよう指示するプロンプトを構築します。これがハルシネーションに対する最も効果的な防御策です。そしてOllamaのREST APIを通じてローカルモデルを呼び出します。
  • main:2つをつなぎ合わせ、回答と重複排除されたソースリストを出力します。これにより、すべての回答が収集したページまで追跡可能になります。

ホスト型モデルを使用したい場合は、この関数だけを変更します。Ollamaへのrequests.post呼び出しをAnthropicまたはOpenAI APIへの呼び出しに置き換え、同じプロンプトを渡してください。検索の部分はそのままです。

ステップ6:パイプラインを実行する

3つのステージを順番に実行します。まず、ページを収集します:

python collect.py

次に、チャンク化してChromaに埋め込みます:

python ingest.py
RAG取り込みの実行

質問してみましょう:

python rag.py "What problem does retrieval-augmented generation solve?"

ステップ7:結果を確認する

クエリは根拠のある回答と、それが依拠したソースを返します:

=== Answer ===
Retrieval-augmented generation addresses the fact that a language model only
knows what it was trained on. By retrieving relevant passages from an external
index at query time and adding them to the prompt, the model can answer using
current, specific information it was never trained on, which also reduces
hallucination because the response is anchored to real source text.

=== Sources ===
  - https://en.wikipedia.org/wiki/Retrieval-augmented_generation
  - https://en.wikipedia.org/wiki/Large_language_model
ターミナルにソース引用付きのRAG回答が表示されている

スクリーンショットの提案:rag.pyクエリのターミナル出力。生成された回答とその下のソースリストが表示されています。

検索品質を確認するためにいくつか追加の質問を試してみましょう:

python rag.py "How does a vector database differ from a relational database?"
python rag.py "What are common limitations of large language models?"

回答が薄いと感じた場合、まず試すべき2つの調整があります。rag.pyn_resultsを増やすと、モデルにより多くのコンテキストを与えられ、広範な質問に役立ちますがプロンプトが長くなります。ingest.pysizeoverlapを調整するとテキストの分割方法が変わります。小さなチャンクは精密な検索を鋭くし、大きなチャンクはより多くの周辺コンテキストを保持します。チャンク化を変更した後はingest.pyを再実行してインデックスに反映させてください。

このループ全体が、スクレイピングやプロキシのコードを一行も書かずに実行されました。Bright DataがウェブからクリーンなMarkdownを提供し、ChromaがローカルでEmbeddingと類似検索を処理し、ローカルモデルが回答を生成しました。すべてが選択して収集したページに基づいています。

さらに発展させる

このパイプラインは動作する基盤であり、いくつかの方向に発展させることができます:

  • より強力な推論や長いコンテキストが必要な場合は、ローカルモデルをAnthropicやOpenAIなどのホスト型APIに置き換え、検索の部分はそのまま維持します。
  • Bright DataのSERP APIで発見ステップを追加し、固定URLリストではなく検索クエリから取り込む関連ページをパイプラインが見つけられるようにします。
  • 120以上のドメインをカバーするBright DataのWeb Scraper APIを使って自由テキストの代わりに構造化されたレコードを取得します。エージェント型RAGのウォークスルーでこのパターンをエンドツーエンドで示しています。
  • Chromaのメタデータフィルタリング(querywhere引数)を使って、特定のソース、日付、またはカテゴリに検索を絞り込みます。
  • ドキュメントに純粋なセマンティック検索が苦手とする固有名詞やコードが多い場合は、再ランキングやハイブリッド検索レイヤーを追加します。
  • 定期的なリフレッシュをスケジュールしたり、MCPサーバーを通じてアシスタントに公開してClaude Desktopなどのツールがオンデマンドで収集・クエリできるようにします。
  • コレクションが単一マシンの容量を超えたら、ローカルChromaからPineconeやpgvectorなどのマネージドベクトルストアに移行します。取り込みと検索のロジックは最小限の変更で移植できます。

可能性は事実上無限です。

まとめ

この記事では、Bright DataのWeb Unlocker APIとChromaDBを組み合わせて、動作するローカルRAGパイプラインを構築しました。

ChromaはローカルでEmbedding、ストレージ、類似検索を処理するため、インデックスとクエリがマシンの外に出ることはありません。ローカルモデルが検索されたコンテキストをソース引用付きの根拠のある回答に変換します。そしてBright Dataがプロセス全体で最も困難な部分を取り除きます。プロキシを管理したり、スクレイパーを書いたり、アンチボットシステムと戦うことなく、オープンウェブから新鮮でクリーンなページコンテンツを収集することです。

ノーコードアシスタントとは異なり、このスタックはすべての層を完全に制御できます。収集するページ、チャンク化と埋め込みの方法、検索する数、そして回答を書くモデル。より大きなデータやAIプラットフォームに自然に組み込まれ、ニーズに合わせてスケールします。

より豊かなパイプラインを構築するには、Bright Dataのウェブデータツールの全スイートを探索してください。ボット保護されたページ向けのWeb Unlocker、検索データ向けのSERP API、一般的なユースケース向けの既製データセットが含まれています。

今すぐBright Dataの無料アカウントに登録して、実際のウェブデータにモデルを基づかせましょう。