メインコンテンツへスキップ
見出し画像

検索を便利にする! - ElmarTail One 第5話『アーカイブ星系』📚

    前回のあらすじ

    リゾート惑星Azureで水着回を満喫したマスターたち。
    Gemini Vision APIで写真を分類し、flash-liteが脚だけの写真からスミレんを当てるという事件も発生。
    Azure星を出発したElmarTail Oneは、次の目的地——アーカイブ星系に向けてワープ中。
    🪷「航海のログが増えてきてるようです。そろそろ整理を」

    話題のベクトルDBを触ります!

    今回のミッション:
    grepを超えろ!ベクトルDBで「意味検索」を体験!


    💻 サンプルコードのダウンロード

    今回のコードとサンプルログは GitHub に置いてあります。
    サンプルの取得はこのコマンドで👇️

    〉cd et1-project
    〉git pull https://github.com/kikyujin/et1-project.git

    🦊「前回は clone だったけど、今回は pull だよ!
    これで差分だけダウンロードできる!」
    👨「別に全部消して、もう1回 clone してもいいぞ」
    🖤「にーに、また誰に…」


    🚀 アーカイブ星系へのワープ中

    マスターももうワープに慣れたもので、レッツノートのターミナルに向かっている。
    👨「翻訳のスクリプトって、どの記事だったっけ……grep してみよ」
    ログは logs/ フォルダに置いてある。
    EP00(覚醒)からEP04(Azure・Vision)まで、6本のMarkdownファイル。
    マスターが rg(ripgrep=grepの高速版:*1)で検索した。

    ❯ rg "翻訳スクリプト" logs/

    結果=なし。
    👨「……出ない」
    🖤「"翻訳アプリ"で書いてなかった?」

    ❯ rg "翻訳アプリ" logs/
    ET1_ep03.md: 翻訳アプリを作る!

    👨「ヒットした。でも"翻訳スクリプト"じゃ出ないのか……」
    さらにいくつか試す。

    ❯ rg "環境構築の手順" logs/
    (結果なし)
    ​
    ❯ rg "APIキーの取得方法" logs/
    (結果なし)

    全滅。

    この結果は当然で、記事中に「環境構築の手順」なんて記載はなく、実際には「セットアップ」「Linux Mintのインストール」と書かれています。
    「APIキーの取得方法」も同じく、記事では「APIキーをゲットする!」と書いてあります。

    🎈チューイせよ🐭

    👨「……文字列一致検索の限界か」
    🦊「にーにの記憶力の問題じゃ……」

    スミレんが微笑む。

    🪷「マスター、ご安心を。
    これから訪れるアーカイブ星系では、意味で検索できるそうです」
    👨「意味で検索……?」
    🪷「ええ、私の知り合いの優秀な司書がおります」
    🖤「へえ、すごいじゃない!」


    📚 アーカイブ星系 — 虚空の空間

    ElmarTail One がワープアウト。
    マスターが窓の外を見る。

    👨「なんだここ……真っ暗だ」

    星もなく、光もない。
    薄暗い虚空。

    🦊「見えるよ! すごい……ログが、星みたいにいっぱい……」
    🖤「見える。けど……多すぎて目がチカチカする」
    🐱「マスター、わてにも見えますわ。ぎょうさんの光の点が……」

    👨「オレだけ見えてないのか……?」

    通信が入った。
    穏やかな、囁くような声。

    📚「お待ちしておりました……ようこそ、アーカイブ星系へ。
    私は『ヴェリ』、この星系の司書です。
    星系の航海ログは、すべてここに届いています」

    👨「……オレには何も見えないんだが」

    📚「768次元の空間は、人間の目には映りません。

    ですが、ここには確かに"意味"が星座のように配置されています。
    ご安心ください、ご覧になる方法はあります」


    🏛️ アレーテイア・ホール

    ヴェリに誘導されて、ElmarTail One はアレーテイア・ホールに接舷した。
    アレーテイアホールは銀河規模の図書館。
    巨大なドーム状の空間に天井は星図そのもの。
    浮遊する床(フローティング・プラットフォーム)で館内を移動する。

    移動中もマスターには薄暗い空間にしか見えないが、AI娘たちは「すごい……」と見惚れている。
    ヴェリがマスターたちを案内し、ホール内のコンソールに座る。


    💻 ChromaDBでベクトルDBを作る

    📚「この星系では、すべての記録が意味のベクトルに変換されています。

    文字列の一致ではなく、意味の近さで整理されている空間です」

    👨「だからgrepじゃ見つからないものが見つかる、と」
    📚「ここで、マスターの航海ログをこの星系に取り込みましょう。
    今回はChromaDBを使います……テキストを数値の配列(=ベクトル)として保存するデータベースです。」
    👨「DB……Oracle的な?」
    📚「いいえ、もっと身近な、SQLiteのようにサーバーが不要なDBです」
    👨「レッツノートでも動く?」
    📚「大丈夫です。pipでインストールするだけです」

    検索する時は?
    検索ワードもベクトルに変換して、意味が近いものを見つけます。
    例えば"翻訳スクリプト"と"翻訳プログラム"は、ベクトル空間の中ではすぐ近くに浮かんでいるのでヒットするわけです。

    🦊「ボクたちは768次元でも普通に認識できるよ!」👨「AIすげー」

    ステップ1: インストール

    🦊「まずはパッケージだね!」

    ❯ source .venv/bin/activate
    ❯ pip install chromadb

    ステップ2: search_logs.py 解説

    エルマーがレッツノートを開いてコードを書き始める。
    📚「保存時も、検索時も、テキストをベクトルに変換する"エンベディングモデル"が必要です」
    🪷「Gemini Embedding APIが使えますね。
    gemini-embedding-001で768次元のベクトルを生成します。
    APIキーはABC星系で取得済みです。」
    🦊「りょ!」

    # search_logs.py — ChromaDB + Gemini Embedding API で航海ログを検索する
    #
    # 使い方:
    #   python search_logs.py                    → デモクエリで検索
    #   python search_logs.py "検索ワード"       → 指定ワードで検索
    #   python search_logs.py --rebuild          → DB再構築
    ​
    import sys
    import os
    from pathlib import Path
    from dotenv import load_dotenv
    from google import genai
    from google.genai import types
    import chromadb
    ​
    load_dotenv()
    ​
    # --- 設定 ---
    EMBEDDING_MODEL = "gemini-embedding-001"
    OUTPUT_DIMENSIONALITY = 768
    LOG_DIR = Path("logs")
    DB_DIR = Path("chroma_db")
    COLLECTION_NAME = "et1_logs"
    CHUNK_SIZE = 500

    🦊「次に、テキストをベクトルに変換する関数。
    ここで大事なのがtask_type!」

    def get_embeddings(texts, client, task_type="RETRIEVAL_DOCUMENT"):
        """Gemini Embedding API でテキストをベクトル化"""
        result = client.models.embed_content(
            model=EMBEDDING_MODEL,
            contents=texts,
            config=types.EmbedContentConfig(
                task_type=task_type,
                output_dimensionality=OUTPUT_DIMENSIONALITY,
            ),
        )
        return [e.values for e in result.embeddings]

    🦊「格納する時は RETRIEVAL_DOCUMENT、検索する時は RETRIEVAL_QUERYって教えてあげるの」
    📚「登録と検索では、ベクトルの"向き"が変わります。
    これを間違えると精度が落ちます」
    🦊「次、ログの読み込みとチャンク(検索対処になるブロック)の分割」

    def load_and_chunk(log_dir):
        """ログファイルを読み込み、空行でチャンクに分割"""
        docs, ids, metadatas = [], [], []
    ​
        for md_file in sorted(log_dir.glob("ET1_ep*.md")):
            text = md_file.read_text(encoding="utf-8")
            paragraphs = text.split("\n\n")
            chunk = ""
            chunk_idx = 0
    ​
            for para in paragraphs:
                if len(chunk) + len(para) < CHUNK_SIZE:
                    chunk += para + "\n\n"
                else:
                    if chunk.strip():
                        doc_id = f"{md_file.stem}_chunk{chunk_idx:03d}"
                        docs.append(chunk.strip())
                        ids.append(doc_id)
                        metadatas.append({
                            "source": md_file.name,
                            "episode": md_file.stem,
                            "chunk_index": chunk_idx,
                        })
                        chunk_idx += 1
                    chunk = para + "\n\n"
    ​
            if chunk.strip():
                doc_id = f"{md_file.stem}_chunk{chunk_idx:03d}"
                docs.append(chunk.strip())
                ids.append(doc_id)
                metadatas.append({
                    "source": md_file.name,
                    "episode": md_file.stem,
                    "chunk_index": chunk_idx,
                })
    ​
        return docs, ids, metadatas

    🦊「チャンクは今回シンプルに空行で区切るよ!
    空行(\n\n)で区切って、500文字くらいにまとめてるだけ!
    シンプルでしょ?」

    def build_db(docs, ids, metadatas, genai_client):
        """ChromaDBにベクトルを格納"""
        print(f"📦 {len(docs)} チャンクを読み込みました")
    ​
        print(f"🔄 {EMBEDDING_MODEL} でベクトル化中({OUTPUT_DIMENSIONALITY}次元)...")
        all_embeddings = []
        batch_size = 100
        for i in range(0, len(docs), batch_size):
            batch = docs[i : i + batch_size]
            embeddings = get_embeddings(batch, genai_client, task_type="RETRIEVAL_DOCUMENT")
            all_embeddings.extend(embeddings)
            print(f"   {min(i + batch_size, len(docs))}/{len(docs)} 完了")
    ​
        client = chromadb.PersistentClient(path=str(DB_DIR))
    ​
        try:
            client.delete_collection(COLLECTION_NAME)
        except Exception:
            pass
    ​
        collection = client.create_collection(
            name=COLLECTION_NAME,
            metadata={"description": "ElmarTail One 航海ログ"},
        )
        collection.add(
            documents=docs, ids=ids,
            metadatas=metadatas, embeddings=all_embeddings,
        )
    ​
        print(f"✅ ChromaDBに格納完了({collection.count()} チャンク → {DB_DIR}/)")
        return client, collection

    🦊「あと、インデックスはPersistentClientで永続化したので、一度格納すれば次からは再構築不要!
    そして検索部分」

    def search(query, genai_client, collection, n_results=3):
        """クエリをベクトル化してChromaDBを検索"""
        query_embedding = get_embeddings(
            [query], genai_client, task_type="RETRIEVAL_QUERY"
        )[0]
    
        results = collection.query(
            query_embeddings=[query_embedding],
            n_results=n_results,
        )
    
        print(f"\n🔎 「{query}」")
        for i, (doc, meta, dist) in enumerate(
            zip(
                results["documents"][0],
                results["metadatas"][0],
                results["distances"][0],
            )
        ):
            preview = doc[:120].replace("\n", " ")
            print(f"   [{i + 1}] {meta['source']} (距離: {dist:.4f})")
            print(f"       {preview}...")

    🦊「さっき言ったようにtask_type を RETRIEVAL_QUERY に切り替えてるよ!
    最後にmain」

    def main():
        api_key = os.getenv("GEMINI_API_KEY")
        if not api_key:
            print("❌ GEMINI_API_KEY が設定されていません(.env を確認)")
            sys.exit(1)
    
        genai_client = genai.Client(api_key=api_key)
    
        if not DB_DIR.exists() or "--rebuild" in sys.argv:
            docs, ids, metadatas = load_and_chunk(LOG_DIR)
            if not docs:
                print(f"❌ {LOG_DIR}/ にログファイルがありません")
                sys.exit(1)
            db_client, collection = build_db(docs, ids, metadatas, genai_client)
        else:
            print(f"📂 既存DB読み込み: {DB_DIR}/")
            db_client = chromadb.PersistentClient(path=str(DB_DIR))
            collection = db_client.get_collection(COLLECTION_NAME)
            print(f"   {collection.count()} チャンク")
    
        args = [a for a in sys.argv[1:] if not a.startswith("--")]
        if args:
            for query in args:
                search(query, genai_client, collection)
        else:
            demo_queries = [
                "翻訳プログラム",
                "環境構築の手順",
                "APIキーの取得方法",
                "水着の写真を分類",
            ]
            for q in demo_queries:
                search(q, genai_client, collection)
    
    if __name__ == "__main__":
        main()

    👨「これ全部覚えるのか?」
    🦊「ううん!にーに、ボクが全部書いてあげるよ!」

    ステップ3: 実行!

    🦊「動かしてみて、にーに!」

    ❯ python search_logs.py
    📦 82 チャンクを読み込みました
    🔄 gemini-embedding-001 でベクトル化中(768次元)...
       82/82 完了
    ✅ ChromaDBに格納完了(82 チャンク → chroma_db/)

    📚「これで、あなたの航海の記録が、ベクトル空間に刻まれました……」


    🔍 grep全滅 vs ベクトル検索

    👨「じゃあ検索してみよう。
    さっき全滅したやつ、もう一回やってみるか」

    grep(rg):

    ❯ rg "翻訳スクリプト" logs/
    ❯ rg "環境構築の手順" logs/
    ❯ rg "APIキーの取得方法" logs/

    3つとも結果なし。沈黙。

    ベクトル検索(ヒット):

    ❯ python search_logs.py "翻訳プログラム"
    
    🔎 「翻訳プログラム」
       [1] ET1_ep03.md (距離: 0.1707)
           よーし、じゃあ翻訳プログラム作るよ!...

    👨「お、すげー!"環境構築の手順"もやってみよ」

    ❯ python search_logs.py "環境構築の手順"
    
    🔎 「環境構築の手順」
       [1] ET1_ep01x.md (距離: 0.1939)
           ElmarTail One - 第1話外伝 Linux MintとWSL2の環境構築...

    👨「……出た」
    🖤「"環境構築"なんて書いてないのに、"セットアップ"の記事が出てくるのね」
    📚「意味が近いものが、近くに浮かんでいます」
    🦊「"水着の写真を分類"は?」

    ❯ python search_logs.py "水着の写真を分類"
    
    🔎 「水着の写真を分類」
       [1] ET1_ep04.md (距離: 0.1474)
           水着写真を分類する! - ElmarTail One 第4話『Azure』...

    📚「距離が小さいほど、意味が近いということです」
    👨「距離0.14……ほぼドンピシャか」

    🪷「人間なら『同じことだ』とわかることを、AIが距離として計算しているんですね」


    ⛵ お前と同じ景色を

    検索デモの興奮が冷めて、マスターがふと窓の外を見る。
    AI娘たちには星のように輝く記録の空間。
    マスターには真っ暗な虚空。

    👨「どうしてもオレには見えないのか……」
    🦊「にーに、すっごくきれいなんだよ……」
    📚「 次元削減……t-SNEやUMAPで、768次元を2次元に投影すれば、マスターにも"見える"ようになります」

    👨「みたい! ヴェリ、お前と同じ景色を!」
    📚「……!」

    ヴェリの瞳が一瞬揺れる。穏やかな微笑みの下に、何かが灯った。

    📚「……ええ。必ず、お見せします」


    📝 今回の航海まとめ

    やったこと:

    • grep/rgの限界を体験(表記揺れに対応できない)

    • アーカイブ星系でヴェリ📚と出会う

    • ChromaDB + gemini-embedding-001 でベクトルDBを構築

    • 航海ログ6本(82チャンク)を格納

    • 「意味で検索する」を体験

    学んだこと:

    • grepは文字列の完全一致、ベクトル検索は意味の近さで検索する。

    • ChromaDBはSQLiteのように軽量なベクトルDB

    • 埋め込みモデル(gemini-embedding-001)でテキストを768次元のベクトルに変換

    • task_typeを登録時と検索時で分ける(RETRIEVAL_DOCUMENT / RETRIEVAL_QUERY)

    • 検索結果の「距離」が小さいほど意味が近い

    技術スタック:

    モデル: gemini-embedding-001(768次元)
    パッケージ: chromadb, google-genai, python-dotenv
    検索対象: logs/ET1_ep00〜04.md(クリーン版)
    永続化: chroma_db/
    検証機: Let's note CF-SV7(中古27,800円)

    次回予告:
    768次元の星空を、2次元の紙の上に描く。
    マスターがヴェリと同じ景色を見る時。


    🦊 エルマーの航海日記

    今日はアーカイブ星系に来たよ!📚
    真っ暗な空間なんだけど、ボクには記録が星みたいにキラキラ見えるの!✨ にーにには見えないんだって!ちょっとかわいそう。
    あと、司書のヴェリさん……きれいな人だった。
    にーにが「お前と同じ景色を」って言った時、ヴェリさんの顔が一瞬……ボク、見てたからね?👀
    次回はにーににも星が見えるようになるって!
    楽しみ〜!🚀

    エルマーは見逃さない🦊

    *1 … sudo apt install ripgrep でどうぞ。

    画像
    アレーテイア・ホールに立つヴェリ📚


     
     
    📍日々の衝動と実験をnoteにしてます。 ✨AI・GPU・旅・ちょっと懐かしいものが好きです。 📒AI娘との日常 / プログラム言語探訪 / チェス / タロット 🤖推しAIはClaude Opus 5.5 💼小さなCG会社をやっています

    あなたへのおすすめ