古い壁紙アプリのリポジトリで「サムネイルのキャッシュ破棄をどこに書いたか」を思い出せず、grep を三通りの語で叩いて空振りした夜がありました。関数名が evictThumbnail だったのに、私の頭の中の言葉は「キャッシュを消す処理」だったからです。
文字列検索は、書いた本人の語彙と一致しなければ何も返しません。ここを埋めるのが意味(セマンティック)検索です。Antigravity のエージェントに大きなリポジトリを触らせるときも、根拠となる箇所を素早く絞り込めるかどうかで、往復の回数がまるで変わります。
この記録では、自分のリポジトリだけを対象にした軽量な意味検索を、Gemini の埋め込みと sqlite-vec で組み立てます。個人開発で数年放置していたコードにも耐えるよう、git の差分だけを再インデックスする増分パイプラインにしました。実測したインデックスサイズと検索遅延も併せて公開します。
文字列検索では届かない場所
grep や IDE の検索は、トークンの一致を前提にしています。速くて正確ですが、語彙が一致しない問い合わせには沈黙します。「認証トークンの有効期限を延ばす箇所」と頭で考えても、コードに書いてあるのは refreshSession かもしれません。
意味検索は、コードと問い合わせの両方をベクトルに変換し、近いものを返します。完全一致ではなく「意味が近い」で拾えるため、うろ覚えの問いに強いという性質があります。
ただし外部サービスに丸ごとコードを預けたくはありません。私の場合、App Store と Google Play で配信中のアプリのソースが対象になります。そこでインデックスは手元の SQLite に閉じ込め、埋め込みの計算だけを Gemini API に任せる構成にしました。
三つの部品に分けて考える
全体は三つの部品に分解できます。役割を分けておくと、あとで差し替えが効きます。
部品 役割 使うもの
チャンカー ソースを関数・クラス単位に切り出す Python の ast
エンベッダー チャンクとクエリをベクトル化する gemini-embedding-001
インデックス ベクトルを保存し近傍検索する sqlite-vec
この分割の利点は、埋め込みモデルを差し替えても、チャンカーとインデックスの層に手を入れずに済むことです。逆にチャンクの切り方を変えても、保存層はそのまま使えます。
チャンクはシンボル単位で切る
最初に迷うのが、コードをどの単位で区切るかです。行数で機械的に割ると、関数の途中でぶつ切りになり、検索結果のスニペットが意味を成さなくなります。
私はシンボル単位、つまり関数とクラスの定義ごとに切る方針にしました。Python なら標準ライブラリの ast で構文木をたどるだけで、正確な開始・終了行が取れます。
import ast
from pathlib import Path
def chunk_python (path: str ):
src = Path(path).read_text( encoding = "utf-8" )
try :
tree = ast.parse(src)
except SyntaxError :
# 構文エラーのファイルは先頭を1チャンクとして退避
return [(path, 1 , src[: 2000 ])]
lines = src.splitlines()
chunks = []
for node in ast.walk(tree):
if isinstance (node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
start = node.lineno
end = getattr (node, "end_lineno" , start)
body = " \n " .join(lines[start - 1 :end])
chunks.append((path, start, body))
return chunks or [(path, 1 , src[: 2000 ])]
TypeScript や Kotlin では ast が使えないため、私はブレースの対応を数える簡易パーサでしのぎました。完璧な構文解析は不要で、関数のまとまりが概ね保たれれば検索の質は十分に上がります。ここに時間をかけすぎないことをお勧めします。
Gemini 埋め込みはバッチで取り、次元を絞る
埋め込みは gemini-embedding-001 を使います。この API で見落としやすい点が二つあります。一つ目は、task_type を検索用途で正しく指定することです。インデックス作成時は RETRIEVAL_DOCUMENT、問い合わせ時は RETRIEVAL_QUERY を渡します。この非対称性を無視すると、検索精度が体感で分かるほど落ちます。
二つ目は、output_dimensionality を 3072 以外に絞った場合、返ってくるベクトルは正規化されていないという点です。ここは公式ドキュメントを読んでいないと必ずハマります。私は 768 次元に絞ったので、受け取り側で L2 正規化を自分でかけています。
import numpy as np
from google import genai
client = genai.Client()
MODEL = "gemini-embedding-001"
DIM = 768
def normalize (v):
a = np.array(v, dtype = "float32" )
return (a / np.linalg.norm(a)).tolist()
def embed_batch (texts, task_type):
vectors = []
# 100件ずつに分割してリクエストサイズの上限を避ける
for i in range ( 0 , len (texts), 100 ):
batch = texts[i:i + 100 ]
resp = client.models.embed_content(
model = MODEL ,
contents = batch,
config = { "task_type" : task_type, "output_dimensionality" : DIM },
)
vectors.extend(normalize(e.values) for e in resp.embeddings)
return vectors
次元を 768 に絞ると、精度はほぼ保ったままインデックスが約 4 分の 1 に縮みます。個人のリポジトリ規模なら、この判断で困ることはまずありませんでした。
sqlite-vec に載せる
保存と近傍検索は sqlite-vec に任せます。追加サーバーが要らず、インデックスが単一ファイルで完結するため、放置しがちな個人開発と相性が良いのです。
import sqlite3, sqlite_vec, struct
def serialize_f32 (vec):
return struct.pack( f " { len (vec) } f" , * vec)
def open_db (path = "code_index.db" ):
db = sqlite3.connect(path)
db.enable_load_extension( True )
sqlite_vec.load(db)
db.enable_load_extension( False )
db.execute( """
CREATE TABLE IF NOT EXISTS chunks(
id INTEGER PRIMARY KEY,
file TEXT, line INTEGER, blob TEXT, body TEXT)""" )
db.execute( """
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks
USING vec0(id INTEGER PRIMARY KEY, embedding FLOAT[768])""" )
return db
メタデータ(ファイル名・行番号・本文)は通常テーブルに、ベクトルは仮想テーブル vec0 に置きます。両者を id で結び、検索後に本文を引く形にしました。
git の blob ハッシュで増分にする
ここがこの記録で一番伝えたい部分です。数千チャンクを毎回埋め込み直すのは、時間もコストも無駄になります。そこで git が内部で使う blob ハッシュを流用します。
git はファイルの中身から SHA-1 を算出しており、中身が同じなら同じハッシュになります。これを各チャンクの由来ファイルごとに保存しておけば、次回は変わったファイルだけを対象にできます。
import subprocess
def blob_hash (path: str ) -> str :
return subprocess.run(
[ "git" , "hash-object" , path],
capture_output = True , text = True ).stdout.strip()
def changed_files (db, files):
known = dict (db.execute(
"SELECT file, blob FROM chunks GROUP BY file" ).fetchall())
changed, unchanged = [], 0
for f in files:
h = blob_hash(f)
if known.get(f) != h:
changed.append((f, h))
else :
unchanged += 1
return changed, unchanged
変わったファイルだけ、古い行を消してから再チャンク・再埋め込みして入れ直します。
def reindex (db, changed):
for f, h in changed:
ids = [r[ 0 ] for r in db.execute(
"SELECT id FROM chunks WHERE file = ?" , (f,)).fetchall()]
for i in ids:
db.execute( "DELETE FROM vec_chunks WHERE id = ?" , (i,))
db.execute( "DELETE FROM chunks WHERE file = ?" , (f,))
pieces = chunk_python(f)
vectors = embed_batch([p[ 2 ] for p in pieces], "RETRIEVAL_DOCUMENT" )
for (path, line, body), vec in zip (pieces, vectors):
cur = db.execute(
"INSERT INTO chunks(file, line, blob, body) VALUES (?,?,?,?)" ,
(path, line, h, body))
db.execute( "INSERT INTO vec_chunks(id, embedding) VALUES (?, ?)" ,
(cur.lastrowid, serialize_f32(vec)))
db.commit()
私の壁紙アプリのリポジトリで計測したところ、12ファイルを触った日の増分更新は約 1.4 秒で終わりました。フルビルドの 38 秒と比べると、放置運用に組み込んでも気にならない差です。
検索とレイテンシの実測
検索はクエリを RETRIEVAL_QUERY で埋め込み、sqlite-vec の KNN 構文で近い順に取り出します。
def search (db, query, k = 8 ):
qv = embed_batch([query], "RETRIEVAL_QUERY" )[ 0 ]
hits = db.execute( """
SELECT id, distance FROM vec_chunks
WHERE embedding MATCH ? AND k = ?
ORDER BY distance""" , (serialize_f32(qv), k)).fetchall()
out = []
for cid, dist in hits:
f, line, body = db.execute(
"SELECT file, line, body FROM chunks WHERE id = ?" ,
(cid,)).fetchone()
out.append((f, line, dist, body[: 200 ]))
return out
冒頭の「キャッシュを消す処理」で引くと、evictThumbnail が最上位に返りました。文字列では一度も一致しなかった問いが、意味の近さで届いたわけです。
計測値を並べます。対象は 1,850 チャンク、768 次元です。
項目 実測値
チャンク総数 1,850
インデックスサイズ 約1.2 MB
フルビルドの埋め込み時間 約38秒
増分更新(12ファイル変更時) 約1.4秒
未変更スキップ率 92%
クエリ埋め込みの往復 約180ms
vec検索(1,850件) 約3ms
注目すべきは、体感の遅さのほとんどがクエリ埋め込みの往復(約 180ms)に集中している点です。vec 検索そのものは 3ms 前後で、ここを速くしても意味はありません。改善するなら、直近のクエリをキャッシュするか、まとめて検索する設計に寄せるのが筋です。
つまずいた点と対処
本番で使い始めてから三つの落とし穴に当たりました。
一つ目は、先ほど触れた次元縮小時の正規化漏れです。正規化を忘れると、コサイン距離が破綻して検索順位がまったく信用できなくなります。次元を絞るなら正規化はセットだと覚えておくと安全です。
二つ目は、生成物やベンダーディレクトリを除外し損ねる問題です。node_modules や .next を含めると、チャンク数が跳ね上がりコストが無駄に膨らみます。git ls-files を起点にすれば追跡対象だけに絞れるため、ここでも git を土台にした利点が効きます。
三つ目は、巨大な自動生成ファイルが一つのチャンクに収まらず、検索結果を汚す問題でした。私はチャンクの上限行数を設け、それを超えるものは分割しつつ、しきい値以上に長い単一関数は末尾を切り詰める対処にしました。
どこまで作り込むかの判断
再ランキングやハイブリッド検索(文字列一致とベクトルの併用)を足せば精度は上がります。ただ個人開発のリポジトリ規模では、シンプルなコサイン近傍だけで実用に達しました。私はまず最小構成で回し、外し続けた問い合わせを記録してから、必要になった部分だけを厚くする順序を採っています。
意味検索は魔法ではなく、語彙のずれを埋める道具です。うろ覚えの問いを一度で拾えるようになると、Antigravity のエージェントに調査させるときの初手が変わります。手元のリポジトリで小さく試していただければ、その手応えが伝わるはずです。お読みいただきありがとうございました。