ブログを書きためていくと、だんだん「あれ、あの話どの記事で書いたっけ?」が増えてきます😅 キーワード検索だと「その単語を含む記事」しか出ませんが、本当に欲しいのは「意味が近い記事」。そこで今回は、自分のブログ全記事をローカルLLMに読ませて、意味で探せるAI検索を作りました。しかも新しいアプリではなく、以前作ったローカルAIエージェントへの機能追加として仕立てます。

🔎 この記事でわかること
RAG(検索拡張生成)の仕組み(図解つき)
・ブログ記事をチャンク分割 → ベクトル化 → ローカルDBに入れる手順
・質問に意味が近い記事を探して、記事タイトルとURL(出典)つきで答えさせる
再現できる実装コード(本文抽出/チャンク分割/ベクトル検索/エージェントの道具化)
・すべてローカル完結(記事データは外に出ない)・Mac/Windows共通

土台:p935 ローカルAIエージェント。実はRAGの部品(multilingual-e5の埋め込み+sqlite)を既に持っているので、それをブログ記事向けに使い回します。対象は現在公開中の全66記事。

作るもの:"あの記事どこ?"を出典つきで答えるAI検索

スポンサーリンク

完成形はこう動きます。エージェントに「オフラインで動くコーディングのAIについて書いた記事はどれ?」と聞くと、内部でブログ全記事から意味の近い記事を探し記事タイトルとURLを添えて答えてくれます。キーワードが一致していなくても、意味でたどり着けるのがポイントです。

エージェントがsearch_blogでブログを検索し、該当記事のタイトルとURLつきで答えた画面
質問すると内部で search_blog が動き、関連の高い記事を提示(上)。その上でAIが「記事タイトル+URL」つきで回答(下)。すべてローカルで、記事データは外に出ない

仕組み:RAGとは(図解)

この仕組みはRAG(Retrieval-Augmented Generation=検索拡張生成)と呼ばれます。ざっくり言うと「AIに答えさせる前に、関係する資料を“検索”して渡す」方法です。流れは大きく2段階。あらかじめ記事をベクトル(意味の座標)にして貯めておき(準備)、質問が来たら近いベクトルの記事を探してAIに渡す(検索)——という形です。

図解:RAGの流れ(準備=ベクトル化して貯める/検索=近い記事を探して答える) ① 準備(1回だけ) ブログ記事66本 チャンク分割数百字ずつ 埋め込みe5でベクトル化 ローカルDBsqlite ② 検索(質問のたび) 質問「あの記事どこ?」 埋め込み質問もベクトルに 近いベクトルを検索cosine類似度 AIが回答+出典URL ※「意味の近さ」で探すので、同じ単語が無くても関連記事にたどり着ける。すべてローカルで完結。
RAGの流れ。①記事をベクトル化してDBに貯める → ②質問もベクトルにして、近い記事を探し、AIが出典つきで答える

キモは「埋め込み(Embedding)」。文章を意味の近さが距離になるベクトルに変換します。今回は日本語対応の軽量モデルmultilingual-e5-smallを使います(エージェントが記憶用に既に持っているものを流用)。

記事を取り込む:抽出→チャンク→埋め込み

まず全記事をベクトル化して貯めます。記事HTMLから本文だけを抜き出し、長いので数百字ずつのチャンクに分割してから埋め込みます。長い文章をそのまま1本のベクトルにすると要点がぼやけるので、チャンクに分けるのがコツです。

import re

# 日本語向けに文(。!?)でまとめて ~420字ずつに分割(少しオーバーラップ)
def chunk_text(text, size=420, overlap=80):
    sents = re.split(r"(?<=[。!?])", text)     # 文単位に割る
    chunks, cur = [], ""
    for s in sents:
        if len(cur) + len(s) > size and cur:
            chunks.append(cur.strip())
            cur = cur[-overlap:] + s            # 直前の末尾を重ねて文脈を保つ
        else:
            cur += s
    if cur.strip():
        chunks.append(cur.strip())
    return chunks

各チャンクを埋め込んで、「記事ID・タイトル・URL・チャンク本文・ベクトル」をsqliteに保存します。e5系は文章に passage: / 質問に query: の接頭辞を付けると精度が上がります。

import numpy as np

for art in articles:                     # 66記事ぶん
    for ch in chunk_text(art["text"]):
        vec = embed(ch, "passage")        # e5でベクトル化(正規化済み)
        db.execute("INSERT INTO chunks(pid,title,url,chunk,vec) VALUES(?,?,?,?,?)",
                   (art["pid"], art["title"], art["url"], ch, vec.tobytes()))

今回の66記事は772チャンクになりました。取り込み(ベクトル化)は初回だけで、数十秒で終わります。

検索:質問に近い記事を出典つきで返す

質問が来たら、質問もベクトルにして、全チャンクとの近さ(cosine類似度)を測り、近い順に返します。ベクトルは正規化してあるので、内積を取るだけでcosine類似度になります。数百チャンク程度なら、総当たりで一瞬です。

def search(query, k=5):
    rows = db.execute("SELECT pid,title,url,chunk,vec FROM chunks").fetchall()
    qv  = embed(query, "query")                       # 質問をベクトル化
    mat = np.stack([np.frombuffer(r[4], np.float32) for r in rows])
    sims = mat @ qv                                   # 正規化済み=内積がcosine類似度
    # 記事(pid)ごとに最良スコアでまとめ、上位kを返す(タイトル・URL付き)
    best = {}
    for r, s in zip(rows, sims):
        if r[0] not in best or s > best[r[0]][0]:
            best[r[0]] = (float(s), r[1], r[2], r[3])
    return sorted(best.values(), key=lambda x: -x[0])[:k]

これで「意味が近い記事」がタイトル・URLつきで手に入ります。あとはこれをAIに渡して、言葉で答えてもらうだけです。

エージェントの道具にする

最後に、この検索をエージェントの“道具”として登録します。エージェントは「ブログ内を探す質問」だと判断したら、自分で search_blog を呼び、結果をもとに出典(タイトル+URL)つきで答えます。p935の道具の仕組みにそのまま乗せるだけです。

# 道具の定義(エージェントに「こういう道具があるよ」と教える)
_f("search_blog", "自分のブログ全記事から関連記事を出典つきで探す(ローカルRAG)",
   {"query": {"type": "string"}}, ["query"])

# 呼ばれたときの処理:検索して、タイトル+URL+抜粋を返す
if name == "search_blog":
    hits = blog_index.search(embed, args["query"], k=5)
    return "\n".join(f"{i}. {h['title']}\n   {h['url']}\n   …{h['snippet'][:120]}…"
                     for i, h in enumerate(hits, 1))

システムプロンプトに「ブログ内を探す質問は search_blog を使い、答えに記事タイトルとURLを必ず添える」と一文足しておくと、あとはエージェントが自分で使ってくれます。

使ってみる

まずは「ブログ記事を取り込む」ボタンで全記事をベクトル化(初回だけ)。「66記事・772チャンク取り込み済み」と出れば準備OKです。

ブログ全記事の取り込みが完了し、66記事・772チャンクと表示された画面
「📚 ブログ記事を取り込む」で全66記事をベクトル化(数十秒)。下部に「🔎 ブログ検索: 66記事・772チャンク取り込み済み」

あとはチャットで聞くだけ。たとえば「Unrealをスマホ向けに軽量化した記事はどこ?」と聞くと、キーワードが完全一致していなくても、ちゃんと該当記事をURLつきで見つけてくれます。

Unrealのスマホ軽量化について質問し、該当記事p939をURLつきで回答した画面
「Unrealをスマホ向けに軽量化した記事は?」→ search_blog が該当記事を見つけ、タイトルとURL(/p939)つきで回答。出典があるので確認もすぐ

つまずき・気づき

  • 新旧テンプレで本文の場所が違う:古い記事と新しい記事でHTMLの作りが微妙に違い、最初は27記事しか取れませんでした。本文の“終わりの目印”を複数用意(タグ・関連記事・フッター等の最初に現れた所で切る)にしたら、全66記事を取り込めました。
  • チャンク分割は“文単位+少し重ねる”:ぶつ切りにすると文の途中で切れて意味が壊れます。。!?で区切って数百字にまとめ、境目を少しオーバーラップさせると精度が上がりました。
  • 埋め込みモデルの流用:新しく用意せず、エージェントが記憶用に持っているmultilingual-e5-smallをそのまま使えました(日本語も十分な精度)。
  • 記事を書き足したら再インデックス:新記事を公開したら「取り込む」を押し直すだけ。66記事・772チャンクなら数十秒です。

Mac/Windows 共通で動く

このエージェントは前回前々回と同じくMac/Windows同一コード。今回のブログ検索も、埋め込み(e5)+sqlite+cosineという標準的な部品だけなので、両OSでそのまま動くはず——ということで、Windows機(RTX 3060)でも確かめました。

やったのはMacで作ったインデックス(blog_index.db、3MB)を丸ごとコピーして渡すだけ。記事データはMac側にあるので、重い取り込みはMacで済ませ、Windowsは検索するだけ、という分担です。結果、コードを1行も変えずに起動→「66記事・772チャンク取り込み済み」を認識→出典つきで正答しました。

Windows機で同じコードを動かし、ブログ検索が記事タイトルとURLつきで回答した画面
Windows(RTX 3060)でも同じ質問に search_blog が反応し、/p941 をタイトル+URLつきで回答。下部の「🔎 ブログ検索: 66記事・772チャンク取り込み済み」はMacから運んだDBを読んだもの

気持ちよかったのはインデックスの可搬性です。ベクトルはfloat32の生バイト列としてsqliteのBLOBに入れているだけ(tobytes()frombuffer())なので、DBファイルをコピーすればそのまま別マシンで検索できる。バイトオーダーの違いで壊れる、といったこともありませんでした。取り込みは重い処理(66記事で数十秒)なので、1回作って配る運用ができるのは実用上けっこう効きます👍

Windows固有のつまずきは今回はゼロp941のCUDAまわり、p942のPillow依存でつまずいた分の下地がそのまま効いた形です。検索そのもの(772チャンクの総当たり計算)は行列積1発でミリ秒。待ち時間はもっぱらLLMが文章を書く側でした。

まとめ

自分のブログ全記事を、ローカルLLMで意味検索できるAI検索(RAG)を作れました。ポイントを整理します。

  • 独立アプリを作らず既存エージェントに機能追加(e5埋め込み+sqliteを流用)
  • 記事を抽出 → チャンク分割 → ベクトル化 → ローカルDB(準備は初回だけ)
  • 質問もベクトルにして近い記事を検索し、タイトル+URLの出典つきで回答
  • キーワード一致に頼らず「意味」でたどり着ける。すべてローカル完結・記事は外に出ない

「あの話どこで書いたっけ?」を、手元のAIが出典つきで答えてくれる——ブログや資料がたまっている人ほど、この“意味で探せる検索”は効いてきます😊 部品は意外と少なく、今あるものを組み合わせるだけで作れるので、気になった方はぜひ。

それでは、今回はここまで。最後までありがとうございました😊