ブログを書きためていくと、だんだん「あれ、あの話どの記事で書いたっけ?」が増えてきます😅 キーワード検索だと「その単語を含む記事」しか出ませんが、本当に欲しいのは「意味が近い記事」。そこで今回は、自分のブログ全記事をローカルLLMに読ませて、意味で探せるAI検索を作りました。しかも新しいアプリではなく、以前作ったローカルAIエージェントへの機能追加として仕立てます。
・RAG(検索拡張生成)の仕組み(図解つき)
・ブログ記事をチャンク分割 → ベクトル化 → ローカルDBに入れる手順
・質問に意味が近い記事を探して、記事タイトルとURL(出典)つきで答えさせる
・再現できる実装コード(本文抽出/チャンク分割/ベクトル検索/エージェントの道具化)
・すべてローカル完結(記事データは外に出ない)・Mac/Windows共通
土台:p935 ローカルAIエージェント。実はRAGの部品(multilingual-e5の埋め込み+sqlite)を既に持っているので、それをブログ記事向けに使い回します。対象は現在公開中の全66記事。
作るもの:"あの記事どこ?"を出典つきで答えるAI検索
完成形はこう動きます。エージェントに「オフラインで動くコーディングのAIについて書いた記事はどれ?」と聞くと、内部でブログ全記事から意味の近い記事を探し、記事タイトルとURLを添えて答えてくれます。キーワードが一致していなくても、意味でたどり着けるのがポイントです。

search_blog が動き、関連の高い記事を提示(上)。その上でAIが「記事タイトル+URL」つきで回答(下)。すべてローカルで、記事データは外に出ない仕組み:RAGとは(図解)
この仕組みはRAG(Retrieval-Augmented Generation=検索拡張生成)と呼ばれます。ざっくり言うと「AIに答えさせる前に、関係する資料を“検索”して渡す」方法です。流れは大きく2段階。あらかじめ記事をベクトル(意味の座標)にして貯めておき(準備)、質問が来たら近いベクトルの記事を探して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です。

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

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チャンク取り込み済み」を認識→出典つきで正答しました。

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が出典つきで答えてくれる——ブログや資料がたまっている人ほど、この“意味で探せる検索”は効いてきます😊 部品は意外と少なく、今あるものを組み合わせるだけで作れるので、気になった方はぜひ。
それでは、今回はここまで。最後までありがとうございました😊