最小構成で動かす — 150 行で作る検索可能なノート基盤
まずは動く骨格を作ります。ここでは Python で、ノート文書を埋め込みに変換して sqlite-vec に保存し、ベクトル類似度で検索して返すまでの最小コードを示します。Gemini の埋め込みモデルは text-embedding-001 を使います(本番向けの選択については後述します)。
# app/indexer.py
# 役割: ノート文書を Gemini で埋め込み、sqlite-vec に保存する。
# 依存: pip install google-genai sqlite-vec numpy python-dotenv
import os
import sqlite3
import struct
from pathlib import Path
import sqlite_vec
from google import genai
from dotenv import load_dotenv
load_dotenv()
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
DB_PATH = "notes.db"
EMBED_MODEL = "text-embedding-001"
EMBED_DIM = 768 # text-embedding-001 の次元
def serialize (vec: list[ float ]) -> bytes :
"""sqlite-vec 用のバイナリ形式にシリアライズする。"""
return struct.pack( f " { len (vec) } f" , * vec)
def open_db (path: str = DB_PATH ) -> sqlite3.Connection:
"""sqlite-vec 拡張をロードして接続を返す。"""
con = sqlite3.connect(path)
con.enable_load_extension( True )
sqlite_vec.load(con)
con.enable_load_extension( False )
con.execute( "PRAGMA journal_mode=WAL" ) # 読み書き並行性を確保
return con
def init_schema (con: sqlite3.Connection) -> None :
"""ノート本体と埋め込みテーブルを作成する。"""
con.executescript( f """
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE VIRTUAL TABLE IF NOT EXISTS note_vec USING vec0(
id INTEGER PRIMARY KEY,
embedding float[ { EMBED_DIM } ]
);
""" )
def embed (text: str ) -> list[ float ]:
"""Gemini の埋め込み API を呼び出す。空文字はゼロベクトルで回避。"""
if not text.strip():
return [ 0.0 ] * EMBED_DIM
resp = client.models.embed_content( model = EMBED_MODEL , contents = text)
return resp.embeddings[ 0 ].values
def upsert_note (con: sqlite3.Connection, title: str , body: str ) -> int :
"""ノートと埋め込みを1トランザクションで書き込む。"""
vec = embed( f " { title }\n{ body } " )
with con:
cur = con.execute(
"INSERT INTO notes(title, body) VALUES (?, ?)" , (title, body)
)
note_id = cur.lastrowid
con.execute(
"INSERT INTO note_vec(id, embedding) VALUES (?, ?)" ,
(note_id, serialize(vec)),
)
return note_id
if __name__ == "__main__" :
con = open_db()
init_schema(con)
# サンプル投入
upsert_note(con, "朝のランニング" , "5km を 28 分。呼吸が落ち着いてきた。" )
upsert_note(con, "Gemini API メモ" , "embed_content の次元は 768。正規化は不要。" )
print ( "OK" )
次に検索側です。
# app/search.py
# 役割: 問い合わせ文を Gemini で埋め込み、類似するノートを返す。
import sqlite3
from typing import Any
from app.indexer import open_db, embed, serialize
def search (query: str , limit: int = 5 ) -> list[dict[ str , Any]]:
q_vec = embed(query)
con = open_db()
# vec0 テーブルに対する MATCH クエリ。distance は L2 距離。
rows = con.execute(
"""
SELECT n.id, n.title, n.body, v.distance
FROM note_vec v
JOIN notes n ON n.id = v.id
WHERE v.embedding MATCH ? AND k = ?
ORDER BY v.distance
""" ,
(serialize(q_vec), limit),
).fetchall()
return [
{ "id" : r[ 0 ], "title" : r[ 1 ], "body" : r[ 2 ], "distance" : r[ 3 ]} for r in rows
]
if __name__ == "__main__" :
for hit in search( "朝のトレーニング記録" ):
print ( f " { hit[ 'distance' ] :.3f } { hit[ 'title' ] } " )
この最小コードを保存して python -m app.indexer && python -m app.search を実行すると、距離つきの類似ノート候補が返ってきます。ここまでは本当に地味ですが、この時点で「個人のノートを意味検索する」という要件は満たせています。以降のセクションで、この骨格を本番投入可能な水準まで引き上げていきます。
チャンク分割の設計 — Gemini の埋め込みを活かす粒度
最小コードでは「タイトル + 本文」をそのまま埋め込みに渡していましたが、本番ではこの粒度が勝敗を分けます。ノート1件が1000 文字を超えるようになると、1本の埋め込みベクトルに情報が圧縮されすぎて、部分的な話題に対する検索精度が目に見えて落ちます。
私が個人ノートで試した範囲では、次のようなチャンク方針が落ち着きました。
日本語のメモ・議事録では 400 〜 600 文字の固定長チャンク + 100 文字オーバーラップ
ブログ記事や技術ドキュメントでは H2 見出し単位で区切り、最大 1500 文字でフォールバック分割
コードを含む場合は、コード部分と説明部分を別チャンクに分ける
この方針をスキーマに反映すると、ノート本体と「チャンク」を別テーブルに持つ構造になります。
# app/chunking.py
from dataclasses import dataclass
@dataclass
class Chunk :
text: str
start: int
end: int
def chunk_by_length (text: str , size: int = 500 , overlap: int = 100 ) -> list[Chunk]:
if not text:
return []
step = max ( 1 , size - overlap)
out: list[Chunk] = []
i = 0
while i < len (text):
j = min ( len (text), i + size)
out.append(Chunk( text = text[i:j], start = i, end = j))
if j == len (text):
break
i += step
return out
スキーマは次のように拡張します。
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY ,
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE ,
ord INTEGER NOT NULL , -- ノート内の順序
text TEXT NOT NULL ,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS chunks_note_id ON chunks(note_id);
CREATE VIRTUAL TABLE IF NOT EXISTS chunk_vec USING vec0(
id INTEGER PRIMARY KEY ,
embedding float [768]
);
チャンク単位で埋め込みを作り、検索時はヒットしたチャンクから親ノートを引き直します。この分離を入れておくと、後で埋め込みモデルを入れ替えたくなったときに、ノート本体のテーブルを触らずに chunk_vec を作り直すだけで済むという副次効果もあります。
チャンク数が増えてくると、埋め込み API の呼び出し回数も素直に増えます。Gemini の埋め込みはバッチ送信に対応しているので、client.models.embed_content(contents=[chunk1, chunk2, ...]) のようにリスト渡しで叩くようにしてください。これだけでレイテンシと実効コストが一桁変わります。
ハイブリッド検索 — FTS5 とベクトルを組み合わせる
純粋なベクトル検索には固有の弱点があります。固有名詞・日付・ID のような「意味ではなく一致で引っかかってほしいキーワード」に対しては、埋め込み類似度だと思ったように上位に来ないことが頻繁に起きます。SQLite にはこれを補完する FTS5 が標準で載っているので、同じデータベース内に全文検索インデックスも併設しておくと、検索品質が一段上がります。
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
text ,
content = 'chunks' ,
content_rowid = 'id' ,
tokenize = 'trigram'
);
-- 日本語は tokenize='trigram' が実用上は最も安定します。
-- FTS5 と実テーブルを同期させるトリガーも合わせて仕掛けておきます。
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
INSERT INTO chunks_fts(rowid, text ) VALUES ( new . id , new . text );
END ;
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
DELETE FROM chunks_fts WHERE rowid = old . id ;
END ;
CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
DELETE FROM chunks_fts WHERE rowid = old . id ;
INSERT INTO chunks_fts(rowid, text ) VALUES ( new . id , new . text );
END ;
ハイブリッド検索のスコアリングにはいくつか流派がありますが、個人開発の規模では「相対順位による単純加重」が最も扱いやすいです。以下のコードは、ベクトル検索と FTS5 の上位 50 件をそれぞれ取得し、両方のランクを合算して最終順位を決めるパターンです。
# app/hybrid_search.py
from collections import defaultdict
from app.indexer import open_db, embed, serialize
def hybrid_search (query: str , k: int = 10 , pool: int = 50 ) -> list[ dict ]:
con = open_db()
# 1) ベクトル側: 距離の昇順で上位 pool 件
vec_rows = con.execute(
"""
SELECT c.id, c.note_id, c.text, v.distance
FROM chunk_vec v
JOIN chunks c ON c.id = v.id
WHERE v.embedding MATCH ? AND k = ?
ORDER BY v.distance
""" ,
(serialize(embed(query)), pool),
).fetchall()
# 2) FTS 側: BM25 スコアの降順で上位 pool 件
fts_rows = con.execute(
"""
SELECT c.id, c.note_id, c.text, bm25(chunks_fts) AS score
FROM chunks_fts
JOIN chunks c ON c.id = chunks_fts.rowid
WHERE chunks_fts MATCH ?
ORDER BY score
LIMIT ?
""" ,
(query, pool),
).fetchall()
# 3) 逆順位スコアで合算 (RRF, Reciprocal Rank Fusion)
rr: dict[ int , float ] = defaultdict( float )
meta: dict[ int , dict ] = {}
for rank, row in enumerate (vec_rows):
cid = row[ 0 ]
rr[cid] += 1 / ( 60 + rank)
meta[cid] = { "id" : cid, "note_id" : row[ 1 ], "text" : row[ 2 ]}
for rank, row in enumerate (fts_rows):
cid = row[ 0 ]
rr[cid] += 1 / ( 60 + rank)
meta.setdefault(cid, { "id" : cid, "note_id" : row[ 1 ], "text" : row[ 2 ]})
ranked = sorted (rr.items(), key =lambda x: x[ 1 ], reverse = True )[:k]
return [meta[c] | { "score" : s} for c, s in ranked]
この RRF 方式は調整パラメータが少なく、タスクに合わせた微調整が不要なのが利点です。ベクトル側だけに依存していたときに漏れていた「型番や固有名詞を含む問い合わせ」が、目に見えて拾えるようになります。
本番運用で効いてくる SQLite の設定
sqlite-vec を本番で扱うときに必ず触るべき設定がいくつかあります。手順が正しくても、デフォルト設定のまま動かすとパフォーマンスが頭打ちになったり、障害時にデータが壊れるリスクが残ったりします。
WAL モードを強く推奨
先ほどのコードでも PRAGMA journal_mode=WAL を入れていますが、WAL(Write-Ahead Logging)モードは読み出しと書き込みの並行性を大幅に改善します。RAG のワークロードは「書き込みは散発的、読み出しは常時」なので、WAL の恩恵がもっとも出やすいパターンです。
synchronous は NORMAL が実用的
PRAGMA synchronous=NORMAL にすると書き込みスループットが数倍になります。厳密な ACID を捨てているように聞こえますが、WAL モードとの組み合わせでは「クラッシュ時の耐性」は十分に残ります。個人開発の用途では問題になりません。
busy_timeout を必ず設定する
並行アクセスが増えると「database is locked」で書き込みが弾かれるのが SQLite あるあるです。PRAGMA busy_timeout=5000(5 秒)を設定しておくと、短い競合は自動的に待機してリトライしてくれるので、アプリ側のエラーハンドリングがぐっと楽になります。
mmap_size で読み出しを高速化
PRAGMA mmap_size=268435456(256 MB)のように OS のメモリマップを有効にすると、読み出し系のクエリがキャッシュに乗りやすくなります。ベクトル検索は読み出しが主役なので、チャンク数が増えてきたら真っ先に効く設定です。
設定をまとめると、アプリ起動直後に以下を流しておけば十分です。
def tune (con: sqlite3.Connection) -> None :
con.executescript( """
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA busy_timeout=5000;
PRAGMA mmap_size=268435456;
PRAGMA temp_store=MEMORY;
""" )
よくある間違いと落とし穴
sqlite-vec の運用で私が実際に踏んだ落とし穴を、再現性の高い順に並べます。
1) vec0 テーブルの次元と埋め込みモデルの次元が合わない
text-embedding-001 は 768 次元、gemini-embedding-001(上位モデル)は 3072 次元です。途中でモデルを切り替えたのに vec0 の宣言を更新し忘れると、INSERT 時に黙ってエラーになったり、検索結果が空になったりします。モデルを切り替えるときは、必ず新しい仮想テーブルを作り直し、インデックス全体を入れ直してください。
2) 埋め込みの正規化をやってしまう
Gemini の埋め込みは L2 距離を使う前提なら正規化は不要です。それに気づかず vec / norm を挟むと、sqlite-vec 側で MATCH したときの距離が本来の値からずれて、ヒットのランキングが不自然になります。他のベクトル DB からの移植コードをそのまま流用すると起きがちなので注意してください。
3) MATCH 句を WHERE 節に複数置く
WHERE v.embedding MATCH ? AND note_id = ? のように、vec0 の仮想テーブルに通常の等値条件を重ねると、フィルタ後の件数が k を下回る挙動になります。この場合はいったんベクトル検索で広めに取ってから、アプリ側でフィルタするのが定石です。ベクトル検索と通常テーブルの結合は JOIN で後段に置くのが素直です。
4) ファイルロックの競合を放置する
先述の busy_timeout を入れずに並行書き込みを走らせると、高確率で「database is locked」が起きます。とくに Web サーバーを複数ワーカーで動かしている場合、起動時に必ず busy_timeout を設定する仕組みにしてください。
5) ベクトルだけを信じてチューニングする
RAG の品質評価で、ベクトル検索の類似度だけを指標に使うと、固有名詞や記号に弱いことに気づきにくくなります。評価データセットには「意味検索が得意なケース」と「キーワード一致が必要なケース」を意図的に混ぜてください。ハイブリッド検索を導入する判断が早くなります。
バックアップとマイグレーションの戦略
sqlite-vec の運用が楽なのは、データベース全体が単一ファイルで完結している点にあります。ただし、WAL モードで運用していると、notes.db 本体に加えて notes.db-wal と notes.db-shm が生成されるため、単純な cp ではバックアップとしては不十分になります。SQLite 公式が推奨する VACUUM INTO を使うと、スナップショットとしての整合を取ったうえで別ファイルに書き出せます。
def backup (src_path: str = "notes.db" , dst_path: str = "backup.db" ) -> None :
con = open_db(src_path)
con.execute( f "VACUUM INTO ?" , (dst_path,))
con.close()
これを日次 cron で実行し、生成されたファイルをそのままオブジェクトストレージ(Cloudflare R2 や S3)にアップロードしておけば、個人開発としては十分な冗長構成になります。復旧は「ダウンロードして notes.db にリネームする」だけで完了します。
マイグレーションの観点では、埋め込みモデルを入れ替えるケースに備えて「再インデックス用の CLI」を最初から用意しておくことをおすすめします。具体的には、chunks テーブルは不変と考え、chunk_vec を別テーブル名で作って再構築してからテーブルを差し替える、という運用にしておくと安全です。
def reindex (new_dim: int , new_table: str = "chunk_vec_v2" ) -> None :
con = open_db()
con.execute( f "CREATE VIRTUAL TABLE IF NOT EXISTS { new_table } USING vec0(id INTEGER PRIMARY KEY, embedding float[ { new_dim } ])" )
rows = con.execute( "SELECT id, text FROM chunks" ).fetchall()
for cid, text in rows:
vec = embed(text) # 新しいモデルで再計算
con.execute( f "INSERT INTO { new_table } (id, embedding) VALUES (?, ?)" , (cid, serialize(vec)))
con.commit()
本番切替時は chunk_vec を chunk_vec_old にリネームし、新インデックスを chunk_vec に昇格させて、問題なければ古いテーブルを落とす、という流れが事故を最小化できます。
どこから Pinecone や Qdrant に切り替えるべきか
sqlite-vec は万能ではありません。私の肌感では、次の3つのシグナルのうち2つ以上が揃い始めたら、マネージドのベクトル DB への移行を具体的に検討する段階だと判断しています。
ベクトル数が 300 万件を超え、検索レイテンシが 200 ms を常態的に上回る
1つのアプリから読みに行くインスタンスが3つ以上に増える(= レプリケーションが必要になる)
書き込みが秒間 10 件以上のオーダーで定常的に走る
このシグナルが出るまでは、sqlite-vec で十分に引っ張れるというのが経験からの結論です。逆に、最初から「将来 Pinecone に行くことを前提にする」なら、インタフェースを VectorStore のような抽象クラスで分離しておけば、実装差し替えで済みます。Gemini の埋め込みで作る意味検索の本番設計 の設計とほぼ同じ抽象化が使えます。
Gemini とベクトル検索の組み合わせに特化した国内書籍はまだ少ないのですが、上記の書籍は設計論として参考になります。
埋め込みモデルをどう選ぶか
システム全体の輪郭を決めるのは、結局のところ埋め込みモデルの選択です。Gemini には現時点で主流の選択肢が2つあります。
古参の text-embedding-001 は 768 次元のベクトルを返します。安く、速く、無料枠も広めです。日本語の一般的な文書に対する検索品質は、私がこれまで投入した用途では十分でした。平均 200 トークン前後の文書を 10 万件インデックスしても、バッチ投入であれば数分で終わり、API 料金も珈琲1杯分に届きません。
新しい gemini-embedding-001 は標準で 3072 次元です。コード・学術文書・英語以外の言語など、ドメイン特化の領域では品質が一段上がります。ただし、ディスク容量が4倍、近傍検索の計算量も増えます。Matryoshka 方式で先頭 1024 次元や 1536 次元に切り詰める機能もあり、品質と容量のバランスを取るレバーとして使えます。
私の経験則は、どのプロジェクトも最初は text-embedding-001 で始め、自作の評価セットで recall を測り、下位モデルが明確に足を引っ張っていると判明したときだけ上位モデルに上げる、というものです。個人開発で起きる RAG の不満は、埋め込みモデルの品質よりもチャンク戦略やクエリの書き方に根があるケースが多く、モデル差し替えだけでは解決しないことがしばしばです。
もう1点、しばしば見落とされる小技があります。文書を埋め込むときは task_type="retrieval_document"、クエリを埋め込むときは task_type="retrieval_query" を指定してください。Gemini の埋め込みは文書とクエリで非対称に最適化されており、同じタスクタイプで揃えてしまうと top-5 の精度が目に見えて落ちます。1行の設定で数ポイント稼げるので、入れない理由はありません。
評価セットを用意する — 30〜60 ケースで十分
「動いている気がする」と「動いていると断言できる」の差は、評価セットの有無です。個人開発の用途では学術ベンチマークほどの規模は不要で、現実的な質問と「正解として返ってほしい note_id」を対にしたリストがあれば足ります。
# eval/dataset.py
EVAL_CASES = [
{ "query" : "3月にキャッシュの方針をどう決めたか" , "expected_ids" : [ 104 ]},
{ "query" : "Stripe Webhook の署名エラー対処" , "expected_ids" : [ 218 , 221 ]},
{ "query" : "iPhone 壁紙アプリの売上急増" , "expected_ids" : [ 33 ]},
# ... 合計 30〜60 ケース
]
def evaluate (cases, k = 5 ):
hits = 0
rr = 0.0
for case in cases:
results = hybrid_search(case[ "query" ], k = k)
rank = None
for i, r in enumerate (results, 1 ):
if r[ "note_id" ] in case[ "expected_ids" ]:
rank = i; break
if rank:
hits += 1
rr += 1 / rank
n = len (cases)
return { "recall@k" : hits / n, "mrr" : rr / n}
この評価セットをリポジトリに入れておき、チャンク戦略を変えた・モデルを差し替えた・RRF の係数を触った、のような変更の前後で recall@5 と MRR を取ります。数字が動けば改善、動かなければ「効果がないことが分かった」という収穫になります。感覚だけで判断していたチューニングが、ぐっと地に足のついた作業に変わります。
実運用での容量・レイテンシ・コスト感
実数で見ると判断が速くなるので、私が運用してきた範囲のおおよその数値を並べておきます。
text-embedding-001 で 20,000 チャンクを投入すると、データベースファイルは 90 MB 前後に落ち着きます。1 vCPU / 1 GB RAM の VPS で、ベクトル検索のレイテンシは Gemini の埋め込み呼び出しを含めて 20 〜 40 ms。大半はネットワークに消えており、SQL 自体は数 ms です。ハイブリッド検索で RRF を掛けても +15 〜 25 ms 程度です。
20 万チャンクまで伸ばすと、ファイルは 900 MB 前後。2 vCPU / 4 GB RAM の VPS なら中央値で 60 〜 90 ms に収まり、ユーザー向け機能として使うにはまだ十分に快適です。インデックス構築のスループットは、バッチを適正サイズに保てば毎秒 200 チャンク程度です。
100 万チャンクに近づくと、小型 VPS では明らかに苦しくなってきます。中央値が 150 ms を超え、p95 は 400 ms を超えることも増えてきます。この帯域に入ったら、サーバーを増強するか、Pinecone や Qdrant のような専用サービスに移すかを「実測値を元に」判断してください。
月額コスト感は、20 万チャンクで日次 1000 クエリ規模の運用を想定すると、およそ以下の通りです。
VPS: Hetzner または DigitalOcean で 6 〜 12 ドル
ストレージ: VPS に含まれる
Gemini 埋め込み(クエリ分のみ): text-embedding-001 で月 2 ドル未満
R2 や S3 へのバックアップ: 月 1 ドル未満
マネージド型ベクトル DB と同等容量で比較すると、おおむね 10 分の 1 程度に収まります。
デプロイパターン — サーバー1台で回す前提で
私は sqlite-vec を使うときに、2つの構成のどちらかに落とし込むことが多いです。
1つめは「アプリとファイルを同居させる」構成です。アプリのバイナリの横に notes.db を置き、アプリプロセスから直接開きます。日次の VACUUM INTO バックアップは cron のサイドカーに任せます。ありきたりですが、ストレージに求めるのは「退屈さ」そのものなので、これで十分です。
2つめは「Litestream によるリードレプリカ」構成です。Litestream は SQLite の書き込みをオブジェクトストレージへ継続的にストリーミングしてくれます。これを使うと、スタンバイ機が起動時にストレージから直近のスナップショットを復元してから立ち上がるので、読み取りが主体の RAG ワークロードにとっては事実上のアクティブ・パッシブ冗長構成が組めます。真のマルチライターにはなりませんが、個人開発のフェイルオーバーとしては十分です。
逆に避けたいのは、リクエストのたびに接続を作り直すサーバレス構成です。SQLite はプロセスが長生きして、ページキャッシュが温まっている状態で本領を発揮します。Cloudflare Workers や Vercel 中心のアプリでは、検索部分だけ Fly.io の小さいマシン(月 5 ドル前後)に置くのが扱いやすいです。
埋め込みを差分更新する
ドキュメントが更新されたときに、毎回全体を埋め込み直すのは避けたいところです。素直なやり方は「チャンクのハッシュを保存しておき、同じ内容なら既存の埋め込みを再利用する」パターンです。
import hashlib
def chunk_hash (text: str ) -> str :
return hashlib.sha256(text.encode( "utf-8" )).hexdigest()
def upsert_chunk (con, note_id: int , ord: int , text: str ) -> int :
exists = con.execute(
"SELECT id FROM chunks WHERE note_id=? AND ord=? AND text=?" ,
(note_id, ord , text),
).fetchone()
if exists:
return exists[ 0 ]
vec = embed(text)
cur = con.execute(
"INSERT INTO chunks(note_id, ord, text) VALUES (?, ?, ?)" ,
(note_id, ord , text),
)
cid = cur.lastrowid
con.execute( "INSERT INTO chunk_vec(id, embedding) VALUES (?, ?)" , (cid, serialize(vec)))
return cid
ノートが編集されたら、新しい本文を再チャンクして (note_id, ord) で既存と差分を取り、テキストが変わったチャンクだけを再埋め込みします。ブログのように数段落だけ修正されるケースでは、追加の API 呼び出しは限定的に済み、インクリメンタルな更新が無理なく回ります。
まずは手元の1つのアプリに、今週のうちに
ここまでの材料を全部組み合わせても、Python で 500 行ほどに収まります。外部インフラを足さず、notes.db という 1 ファイルを丁寧に育てていく運用は、個人開発の精神衛生にやさしい選択です。
次のステップとして、手元で最も相性の良さそうなアプリを1つ選んでみてください。自分の Obsidian ノート、過去のブログ記事、社内 FAQ — どれでも構いません。今日の午後に時間を割いて、最小コードを動かしてみてください。検索結果が返ってきた瞬間から、ベクトル DB に対する心理的なハードルはぐっと下がります。
sqlite-vec のような軽量実装からマネージド型までを俯瞰できるので、「今の自分はどこにいるのか」を把握するのに向いています。
そこから先は、チャンク戦略を磨き、ハイブリッド検索を足し、評価セットを育て、バックアップを回していくだけで、気づけば本番運用に耐える RAG 基盤が手元に残っています。Pinecone に移行するかどうかの判断は、その基盤の使い心地を知ってから決めても十分間に合います。