RRF 融合検索の3本柱(Reciprocal Rank Fusion)
ハイブリッド知識発見 FTS5 + Concept Graph + ChromaDB Vector
作成日: 2026-04-11
テーマ: Session RRF 3本柱完成(1,170冊book embedding backfill + discover vector lane + launchd自動化)
対象読者: Python中級者、ChromaDB/SQLite 経験あり
公開予定: note.com 技術解説
目次
第Ⅰ部 RRF融合検索の設計思想
1. なぜ3本柱か:単一検索の限界
SAME(Session Artifact Memory Engine)の知識発見は、従来の「単一検索エンジン」では補えない3つの課題を解決する必要があった。
課題1: FTS5だけでは意味の繋がりを見落とす
Full-Text Search(FTS5)の強みと限界: - 強み: 正確な語彙マッチ、高速 - 限界: 同義語・関連概念・間接的な関係を完全には捕捉できない
例: 「ミリ波通信」を検索しても「mmWave」「60GHz帯」「ビームフォーミング」といった関連書籍が漏れる可能性がある。
課題2: グラフだけでは意味の深さを失う
Concept Graph(知識概念ネットワーク)の強みと限界: - 強み: 概念間の関係性、「どの本がこのトピックの中心か」を把握 - 限界: テキスト全体の意味(セマンティクス)を計算していない
例: グラフに登録されていない新しい視点の本が、実は最適な回答である場合を逃す。
課題3: ベクトル検索だけでは正確性が曖昧
Vector Similarity(cosine distance)の強みと限界: - 強み: セマンティック類似度、言語非依存 - 限界: ノイズ・冗長性に弱い、ベクトル化品質に依存
例: 「破壊的イノベーション」で検索すると、ベクトル距離が近いが関係ない書籍が混入する可能性。
3本柱の合成戦略:RRF Fusion
Reciprocal Rank Fusion(RRF) は、複数の検索結果をスコアスケール上で融合する手法。
RRF Score = Σ 1 / (k + rank_i)
where:
k = constant (60を採用)
rank_i = 各検索エンジン i での順位(0スタート)
各エンジンの結果を以下のように加算する: - FTS5がrank=3なら、スコア += 1/(60+3) - Graphがrank=5なら、スコア += 1/(60+5) - Vectorがrank=2なら、スコア += 1/(60+2)
なぜこれで解決するのか:
- 弱点の相互補完: FTSが見落とした書籍がVectorやGraphで高順位なら、スコアが加算される
- スケール平準化: 3つのエンジンが異なるスケール(距離・グラフ距離・ベクトル値)を持っていても、RRFは「順位」で統一
- 合意度: 複数エンジンで上位なら最終スコアが高くなる。合意度が検索信頼度の証になる
- 多様性: 単一ソースの偏り(ノイズ・偶発的なマッチ)を減らす
2. SAME知識体系における3本柱の役割
FTS5 Lane(全文検索)
何をするか: SQLiteの artifacts_fts で、タイトル・要約・キーワード・タグの全文マッチ
結果: (score, book_id, relevance_flag) の配列
実装: _discover_fts_seed()関数で seed_book_ids を取得
def _discover_fts_seed(con: sqlite3.Connection, query: str, limit: int) -> list:
"""
FTS5で初期種を取得。
- SELECT a.*, rank AS fts_score FROM artifacts_fts
- WHERE artifacts_fts MATCH ?
- ORDER BY rank LIMIT limit*2
"""
rows = con.execute(f"""
SELECT a.*, rank AS fts_score
FROM artifacts_fts
JOIN artifacts a ON artifacts_fts.rowid = a.artifact_id
WHERE artifacts_fts MATCH ? AND {where_clause}
ORDER BY rank
LIMIT ?
""", (query, limit)).fetchall()
return [(row['artifact_id'], row['book_id'], row['fts_score']) for row in rows]
用途: - 正確な語彙マッチが必須な場面 - 「特定の技術用語で本を探す」 - 他2つのエンジンの種子として機能
Concept Graph Lane(知識構造)
何をするか: knowledge_concepts テーブルから、seed_book_ids の関連書籍をグラフ走査
結果: (book_id, edge_weight) の配列
実装: _discover_graph_expand() 関数で隣接ノードを展開
def _discover_graph_expand(con: sqlite3.Connection, seed_ids: list, limit: int) -> list:
"""
seed_ids から +1 hop の関連書籍を取得。
- seed_ids のそれぞれが connected_concepts テーブルで
どの book_id と繋がっているかを走査
- edge_weight を累積スコアとして使用
"""
# SQLに相当する処理
# SELECT DISTINCT kc.target_book_id,
# SUM(kc.edge_weight) as agg_weight
# FROM knowledge_concepts kc
# WHERE kc.source_book_id IN (seed_ids)
# ORDER BY agg_weight DESC
# LIMIT limit * 3
用途: - 「このトピックの関連書籍全体を見たい」 - 知識の「周辺領域」を探索 - 構造化された概念ネットワークに頼る場面
Vector Similarity Lane(セマンティクス)
何をするか: ChromaDB に投入された 1,170冊の nomic-embed-text (768dim) embedding から cosine距離で最近傍を取得
結果: (book_id, distance) の配列
実装: _discover_vector() 関数で ChromaDB クエリ実行
def _discover_vector(query: str, limit: int) -> list:
"""
query をOllama nomic-embed-text で埋め込み、
ChromaDB same_artifacts コレクションから
source_type='book' の最近傍を取得
"""
embedding = ollama_embed(query)
results = collection.query(
query_embeddings=[embedding],
where={"source_type": "book"},
n_results=limit,
include=["metadatas", "distances"]
)
return [(meta.get("book_id"), dist) for meta, dist in zip(...)]
用途: - 「このテーマの本を見たい(正確な言葉でなく概念で)」 - 多言語対応(日本語・英語が混在した質問) - 新しい視点からの発見
3. 3本柱を統合する RRF スコア計算
融合アルゴリズムの流れ
Phase 1: FTS seed を取得
seed_book_ids = [bid1, bid2, bid3, ...] (top limit*2)
fts_rank = {bid1: 0, bid2: 1, bid3: 2, ...}
Phase 2: Graph 拡張
graph_results = _discover_graph_expand(seed_ids)
graph_rank = {bid_a: 0, bid_b: 1, ...}
Phase 2.5: Vector 検索(新規Phase)
vec_results = _discover_vector(query)
vec_rank = {bid_x: 0, bid_y: 1, ...}
Phase 3: RRF 融合
candidates = union(fts_rank, graph_rank, vec_rank)
for each bid in candidates:
score = 0
if bid in fts_rank: score += 1/(60 + fts_rank[bid])
if bid in graph_rank: score += 1/(60 + graph_rank[bid])
if bid in vec_rank: score += 1/(60 + vec_rank[bid])
Phase 4: Temperature boost
score += TEMP_BOOST.get(temperature[bid], 0)
(hot=+1.0, warm=+0.5, cold=0)
Result: Rank by descending score, return top limit
RRF_K = 60 の意味
RRF_K = 60 という定数は「各エンジンの順位差の影響度」を制御する。
K が小さい(K=10):
1位と2位の差:1/(10+0) - 1/(10+1) = 0.1 - 0.091 = 0.009(大きい)
→ 順位差に敏感
K が大きい(K=100):
1位と2位の差:1/(100+0) - 1/(100+1) = 0.01 - 0.0099 = 0.0001(小さい)
→ 順位差に鈍感、複数エンジンでの「登場」重視
K=60 を採用した根拠:
- limit(取得件数)が 20〜50 程度のとき、
rank 0〜20 の差が適度に反映される
- 1位と60位の差: 1/60 - 1/120 = 0.0167 - 0.0083 = 0.0084
- 複数エンジンで上位(各rank<20)なら スコア >= 0.05
- 単一エンジンだけで高い場合は スコア <= 0.02
→ 「合意度」が明確に区別される
第Ⅱ部 主要用語と概念
1. Reciprocal Rank Fusion (RRF)
定義: 複数の検索結果リストを、順位を基準に融合するアルゴリズム
発案: 1995年 Cormack et al.(メタサーチエンジン研究)
SAME での採用理由: - 実装が簡潔(足し算だけで OK) - 各エンジンが正規化不要(順位という共通スケール) - 外れ値(ノイズ)に強い - 複数エンジンの「合意度」が直感的
計算式の詳細解釈:
score(doc) = Σ_{i=1}^{n} 1 / (k + r_i(doc))
i = n個のエンジン
r_i(doc) = エンジン i でのドキュメントの順位(0 from top)
k = 調整定数
例:
doc="ビームフォーミング本"
FTSで rank=5, Graph で rank=2, Vector で rank=8
score = 1/(60+5) + 1/(60+2) + 1/(60+8)
= 1/65 + 1/62 + 1/68
= 0.0154 + 0.0161 + 0.0147
= 0.0462
2. FTS5(Full-Text Search)
SQLiteの組み込み全文検索エクステンション
SAMEでの用途: artifacts_fts virtual table として実装
索引対象フィールド: title, summary, keywords, tags
マッチング方式:
-- MATCH演算子で複数条件可能
WHERE artifacts_fts MATCH '"ミリ波" AND (通信 OR 給電)'
実装上の勘所: - 日本語は形態素解析してトークン化(外部ライブラリ不要で動作) - FTS5_TOKENIZE でシンプルなカナ・英数対応 - rank関数で BM25相当のスコア計算
制限: セマンティック類似度は計算しない(語彙マッチのみ)
3. ChromaDB Vector Collection
定義: ベクトルデータベース(ナレッジ埋め込み保管所)
SAME での構成:
Collection Name: "same_artifacts"
Embedding Model: nomic-embed-text (768 dims, 384M)
Distance Metric: cosine
Partition Field: source_type
- "book" (1,170件) ← 20260411 時点
- "artifact"(PDFなど)も将来対応予定
メタデータ構造(各ベクトル):
{
"source_type": "book",
"book_id": "inbox_abc123",
"title": "ビームフォーミング入門",
"author": "John Doe"
}
ChromaDB Query インターフェース:
results = collection.query(
query_embeddings=[embedding], # shape: (1, 768)
where={"source_type": "book"}, # メタデータフィルタ
n_results=40, # 取得件数
include=["metadatas", "distances"]
)
# results["ids"] = [id1, id2, ...]
# results["distances"] = [[d1, d2, ...]] # cosine distance
# results["metadatas"] = [[m1, m2, ...]]
距離の解釈:
cosine distance = 1 - cosine_similarity
範囲: 0 ~ 2
distance=0: 完全一致
distance=1: 直交(無関係)
distance=2: 反対方向
4. Nomic Embed Text Model
モデル: nomic-embed-text-1.5.5 (GGUF)
仕様: - 埋め込み次元: 768 - トークン長: 8192(長いテキスト対応) - 学習データ: 235M テキストペア - ライセンス: CC-BY-NC-4.0(商用利用要問い合わせ)
SAMEでの実行環境: Ollama(ローカルLLM)
ollama pull nomic-embed-text
ollama run nomic-embed-text "query text"
埋め込み品質の特性: - 日本語対応: ○ (Multilingual) - 長文対応: ○ (8K tokens) - セマンティック品質: ★★★★☆(Claude/GPT比較で高品質) - 速度: ~100 tokens/sec(GPU), ~10 tokens/sec(CPU)
5. knowledge_temperature テーブル
定義: 各知識アーティファクト(主に書籍)の「温度」を管理
スキーマ:
CREATE TABLE knowledge_temperature (
entity_type TEXT,
entity_id TEXT,
access_count INTEGER DEFAULT 0,
last_accessed DATETIME,
enrichment_level INTEGER DEFAULT 0,
temperature TEXT DEFAULT 'cold',
updated_at DATETIME,
PRIMARY KEY (entity_type, entity_id)
);
温度カテゴリ(状態):
hot : enrichment_level >= 2 AND access_count >= 5 AND last_accessed >= now-7d
→ 積極的に参照されている「ホットな」知識
→ RRF スコア +1.0 boost
warm : enrichment_level >= 1 AND access_count >= 1 AND last_accessed >= now-30d
→ 一定のエンゲージメントがある
→ RRF スコア +0.5 boost
cold : 上記以外
→ アクセスが少ない、エンリッチメント未実施
→ RRF スコア +0.0(boost なし)
enrichment_level の定義:
3 : >= 2 knowledge_insights 行が参照している
2 : ちょうど 1 knowledge_insights 行がある
1 : nlm_enriched_at IS NOT NULL(でも structured insight なし)
0 : メタデータのみ
6. launchd EnvironmentVariables.PATH
背景: macOS のサービス自動化機構 launchd は、ログインシェルの PATH 設定を引き継がない
問題事例:
# ~/.zshrc で
export PATH=$PATH:~/.local/bin
# しかし launchd plist では見えない
# → nlm CLI コマンドが見つからない(exit=3)
解決策:
<!-- com.same.temperature-curator.plist -->
<plist version="1.0">
<dict>
...
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
</dict>
...
</dict>
</plist>
勘所: - PATH を完全に明示する(append ではなく) - ~/.local/bin を明記(Python pip install --user の実行ファイルはここに入る)
第Ⅲ部 実装ハンドブック
1. Book Embedding Backfill(same_embed_books.py)
目的
1,170冊の knowledge_books テーブルをすべて ChromaDB に埋め込み、discover vector lane を有効化。
前提条件
- ChromaDB 初期化済み:
~/same/data/chromadb/ - Ollama サーバ起動:
http://100.108.154.115:11434で nomic-embed-text が動作 - SQLite DB:
~/same/data/same.dbに knowledge_books テーブル存在
実装ステップ
Step 1: 書籍テキスト生成
def build_book_text(row: sqlite3.Row) -> str:
"""
knowledge_books の複数フィールドから埋め込み用テキスト構築
フィールド:
title, author, one_line_summary, tags, keywords, key_thesis
出力形式:
"{title} | author: {author} | {summary} | tag1 tag2 ... | kw1 kw2 ... | {thesis}"
"""
parts = [row["title"]]
if row["author"]:
parts.append(f"author: {row['author']}")
if row["one_line_summary"]:
parts.append(row["one_line_summary"])
# tags: JSON array string → リスト化して結合
if row["tags"]:
try:
tag_list = json.loads(row["tags"])
if tag_list:
parts.append(" ".join(str(t) for t in tag_list[:10]))
except (json.JSONDecodeError, TypeError):
pass
# keywords も同様
if row["keywords"]:
try:
kw_list = json.loads(row["keywords"])
if kw_list:
parts.append(" ".join(str(k) for k in kw_list[:10]))
except (json.JSONDecodeError, TypeError):
pass
if row["key_thesis"]:
parts.append(row["key_thesis"])
# 長さ制限(Ollama 8K token 以下を想定)
return " | ".join(parts)[:2000]
注意点: - JSON フィールドの例外処理(tag が NULL や malformed の場合) - 長さ制限(embedding model の token 上限対策)
Step 2: 既存埋め込み状態確認
def get_existing_book_ids(collection) -> set[str]:
"""
ChromaDB から既に埋め込み済みの book-* ID を取得
→ upsert で重複投入を避けるため
"""
existing = set()
offset = 0
chunk = 5000
# ChromaDB は limit/offset をサポート(ただし大規模スキャンは遅い)
while True:
batch = collection.get(
where={"source_type": "book"},
limit=chunk,
offset=offset,
)
if not batch["ids"]:
break
for cid in batch["ids"]:
if cid.startswith("book-"):
existing.add(cid)
offset += chunk
if len(batch["ids"]) < chunk:
break
return existing
冪等性: INSERT OR IGNORE + WHERE NOT EXISTS でスキップ
Step 3: バッファードバッチ埋め込み
# 1冊ずつ Ollama に投げるのは遅い(ネットワークオーバーヘッド)
# → バッチバッファで 50冊単位にまとめて ChromaDB upsert
buf_ids: list[str] = []
buf_docs: list[str] = []
buf_metas: list[dict] = []
buf_embeddings: list[list[float]] = []
for row in cursor.fetchmany(batch_size):
chroma_id = f"book-{row['book_id']}"
if chroma_id in existing_ids:
skipped += 1
continue
text = build_book_text(row)
if not text.strip():
skipped += 1
continue
# Ollama で埋め込み(3回 retry)
emb = ollama_embed(text, retries=3)
if emb is None:
failed += 1
continue
buf_ids.append(chroma_id)
buf_docs.append(text)
buf_metas.append({
"source_type": "book",
"book_id": row["book_id"],
"title": (row["title"] or "")[:200],
"author": (row["author"] or "")[:100],
})
buf_embeddings.append(emb)
# バッファ満杯 or 最終フラッシュ時に upsert
if len(buf_ids) >= batch_size:
collection.upsert(
ids=buf_ids,
documents=buf_docs,
metadatas=buf_metas,
embeddings=buf_embeddings,
)
embedded_count += len(buf_ids)
# バッファをリセット
パフォーマンス実績: - 1,170冊 × 52秒 = 22.6冊/秒 - Batch size 50 でメモリ効率と速度のバランス取得
Step 4: 実行とモニタリング
# Dry-run(書込なし)で確認
python3 ~/same/scripts/same_embed_books.py --dry-run
# 全量 backfill(既存スキップ)
python3 ~/same/scripts/same_embed_books.py
# 出力例:
# 既存 book embeddings: 0件をスキップ
# knowledge_books: 1170冊
# Ollama: http://100.108.154.115:11434 / Model: nomic-embed-text
# Collection: same_artifacts / ChromaDB: ~/same/data/chromadb
# バッチサイズ: 50
# Ollama OK (dim=768)
# [HH:MM:SS] 100 embedded, 5 skipped, 0 failed | 22.6/s
# ...
# 完了: 2026-04-11 14:23:45
# Embedded: 1170 / Skipped: 0 / Failed: 0
# 所要時間: 52.1s
# ChromaDB total (all): 1170
幂等性と再実行
# 1回目: 1170冊埋め込み
python3 same_embed_books.py
# → 出力: Embedded: 1170
# 2回目: 既存をスキップ(upsert で重複させない)
python3 same_embed_books.py
# → 出力: Embedded: 0 Skipped: 1170
2. Vector Lane を discover に統合(same_cli.py)
実装内容
Phase 2.5: _discover_vector() 関数追加
def _discover_vector(query: str, limit: int) -> list[tuple[str, float]]:
"""
query をベクトル化 → ChromaDB で最近傍検索
Args:
query: 検索文字列
limit: 取得件数
Returns:
[(book_id, cosine_distance), ...] のリスト
"""
try:
import chromadb
client = chromadb.PersistentClient(path=CHROMADB_PATH)
collection = client.get_collection(name="same_artifacts")
except Exception as e:
# ChromaDB 未初期化なら []を返す
return []
# query をベクトル化
embedding = ollama_embed(query)
if not embedding:
return []
try:
results = collection.query(
query_embeddings=[embedding],
where={"source_type": "book"}, # book only
n_results=limit,
include=["metadatas", "distances"],
)
except Exception as e:
return []
if not results["ids"] or not results["ids"][0]:
return []
# メタデータから book_id を抽出
out = []
for meta, dist in zip(results["metadatas"][0], results["distances"][0]):
bid = meta.get("book_id")
if bid:
out.append((bid, dist))
return out
cmd_discover の RRF 融合を3本柱対応
def cmd_discover(args):
"""Hybrid book discovery: FTS5 + graph + vector + temperature boost (RRF fused)."""
con = get_db()
con.row_factory = sqlite3.Row
query = args.query
limit = args.limit
# Phase 1: FTS seed を取得
seeds = _discover_fts_seed(con, query, limit=limit * 2)
fts_rank: dict[str, int] = {bid: i for i, (_, bid, _) in enumerate(seeds)}
seed_book_ids = [bid for _, bid, _ in seeds]
# Phase 2: Graph 拡張
graph_results: list[tuple[str, float]] = []
if not args.no_graph and seed_book_ids:
graph_results = _discover_graph_expand(con, seed_book_ids, limit=limit * 3)
graph_rank: dict[str, int] = {bid: i for i, (bid, _) in enumerate(graph_results)}
# Phase 2.5: Vector similarity(新規)
vec_results: list[tuple[str, float]] = []
if not args.no_vector: # --no-vector フラグで無効化可能
vec_results = _discover_vector(query, limit=limit * 2)
vec_rank: dict[str, int] = {bid: i for i, (bid, _) in enumerate(vec_results)}
# Phase 3: RRF 融合
candidates = set(fts_rank) | set(graph_rank) | set(vec_rank)
scores: dict[str, float] = {}
for bid in candidates:
s = 0.0
if bid in fts_rank:
s += 1.0 / (RRF_K + fts_rank[bid])
if bid in graph_rank:
s += 1.0 / (RRF_K + graph_rank[bid])
if bid in vec_rank:
s += 1.0 / (RRF_K + vec_rank[bid])
scores[bid] = s
# Phase 4: Temperature boost(既存)
temp_map = _discover_temperature(con, list(candidates))
for bid in candidates:
t = temp_map.get(bid, "cold")
scores[bid] += TEMPERATURE_BOOST.get(t, 0.0)
# Rank + fetch metadata
ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)[:limit]
top_ids = [bid for bid, _ in ranked]
meta = _discover_book_meta(con, top_ids)
# Output:source ラベル付き
print(f"[discover] query={query!r}")
vec_label = f"{len(vec_results)}" if vec_results else "N/A (books not embedded)"
print(f" FTS seeds: {len(seeds)} | Graph neighbors: {len(graph_results)} | Vector: {vec_label}")
print(f" Temperature buckets: " + ", ".join(
f"{t}={sum(1 for v in temp_map.values() if v == t)}"
for t in ("hot", "warm", "cold")
))
print()
if not ranked:
print(" (no results)")
con.close()
return
for rank, (bid, score) in enumerate(ranked, 1):
m = meta.get(bid, {})
t = temp_map.get(bid, "cold")
# Source ラベル:どのエンジンで見つかったか
srcs = []
if bid in fts_rank:
srcs.append(f"fts#{fts_rank[bid] + 1}")
if bid in graph_rank:
srcs.append(f"graph#{graph_rank[bid] + 1}")
if bid in vec_rank:
srcs.append(f"vec#{vec_rank[bid] + 1}")
title = m.get("title") or bid
author = m.get("author") or "—"
domain = m.get("domain") or "—"
print(f" {rank:>2}. {title}")
print(f" author={author} domain={domain} score={score:.4f}")
print(f" [{', '.join(srcs)}]")
con.close()
出力例:
[discover] query='ミリ波通信'
FTS seeds: 8 | Graph neighbors: 24 | Vector: 15
Temperature buckets: hot=3 warm=7 cold=17
1. ビームフォーミング入門
author=John Doe domain=wireless score=0.1847
[fts#1, graph#2, vec#3]
2. 60GHz帯通信実装ガイド
author=Jane Smith domain=wireless score=0.1562
[graph#1, vec#2]
3. ミリ波レーダー応用例集
author=IEEE domain=sensing score=0.0956
[fts#3, vec#8]
フラグ追加:
# Vector lane を無効化
python3 same_cli.py discover "ミリ波" --no-vector
# すべての lane の有効/無効切り替え可能
parser.add_argument("--no-vector", action="store_true", help="Disable vector similarity lane")
3. Temperature Backfill(same_temperature_curator.py)
実装内容
knowledge_books の全冊を knowledge_temperature に登録(初期温度 = cold)
BACKFILL_INSERT = """
INSERT OR IGNORE INTO knowledge_temperature
(entity_type, entity_id, access_count, last_accessed,
enrichment_level, temperature, updated_at)
SELECT 'book', kb.book_id, 0, NULL, 0, 'cold', datetime('now','localtime')
FROM knowledge_books kb
WHERE NOT EXISTS (
SELECT 1 FROM knowledge_temperature kt
WHERE kt.entity_type = 'book' AND kt.entity_id = kb.book_id
)
"""
def backfill(conn: sqlite3.Connection, dry_run: bool) -> int:
"""
既存の temperature 行をスキップ(cold は保持)
→ INSERT OR IGNORE で冪等性確保
"""
before = conn.execute(
"SELECT COUNT(*) FROM knowledge_temperature WHERE entity_type='book'"
).fetchone()[0]
if dry_run:
missing = conn.execute(
"""SELECT COUNT(*) FROM knowledge_books kb
WHERE NOT EXISTS (SELECT 1 FROM knowledge_temperature kt
WHERE kt.entity_type='book' AND kt.entity_id=kb.book_id)"""
).fetchone()[0]
print(f"[dry-run] {missing}行が追加される予定")
return missing
conn.execute(BACKFILL_INSERT)
conn.commit()
after = conn.execute(
"SELECT COUNT(*) FROM knowledge_temperature WHERE entity_type='book'"
).fetchone()[0]
print(f"Temperature backfill: {before} → {after} (+{after-before}行)")
return after - before
実行:
# Dry-run
python3 ~/same/scripts/same_temperature_curator.py --backfill --dry-run
# [dry-run] 1166行が追加される予定
# 本実行
python3 ~/same/scripts/same_temperature_curator.py --backfill
# Temperature backfill: 4 → 1170 (+1166行)
4. launchd 登録と PATH 修正
com.same.temperature-curator.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.same.temperature-curator</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>~/same/scripts/same_temperature_curator.py</string>
</array>
<!-- 毎日 04:00 JST に実行 -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>4</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<!-- 標準出力をログファイルに -->
<key>StandardOutPath</key>
<string>~/same/logs/temperature-curator.log</string>
<key>StandardErrorPath</key>
<string>~/same/logs/temperature-curator-err.log</string>
<!-- 終了コード 0 の場合は再実行しない -->
<key>KeepAlive</key>
<false/>
<!-- 環境変数を明示的に指定 -->
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
</dict>
</dict>
</plist>
登録と確認:
# plist ファイルを LaunchAgents にコピー
cp com.same.temperature-curator.plist \
~/Library/LaunchAgents/
# ロード(即座に登録、スケジュール開始)
launchctl load ~/Library/LaunchAgents/com.same.temperature-curator.plist
# 状態確認
launchctl list | grep temperature-curator
# - 0 com.same.temperature-curator
# (最初の0は exit code = 正常)
# 手動実行(即座にテスト)
launchctl start com.same.temperature-curator
# ログ確認
tail -f ~/same/logs/temperature-curator.log
PATH 問題の診断と解決
症状: nlm コマンドが見つからない
2026-04-11 03:00:00 ERROR: nlm command not found
exit status: 127
原因: launchd は shell の PATH を継承しない
診断スクリプト:
# plist 内に PATH 確認用スクリプト
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>echo $PATH >> /tmp/launchd_path.txt && python3 /same/scripts/...</string>
</array>
解決策: plist の EnvironmentVariables.PATH に完全パスを明示
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
<key>HOME</key>
<string>~</string>
</dict>
</dict>
5. NLM Notebook 統合(nlm_catalog_add_notebook.py)
概要
新しい NotebookLM Notebook を登録し、その中の PDF sources を nlm_source_artifacts に記録
実装
def add_notebook_to_catalog(notebook_id: str, nlm_cli_path: str = None):
"""
NotebookLM の notebook_id を登録し、sources を同期
Args:
notebook_id: NLM Notebook の UUID
nlm_cli_path: nlm CLI へのパス(デフォルト ~/.local/bin/nlm)
"""
# 1. notebook info 取得(NLM CLI から)
result = subprocess.run(
["nlm", "notebook", "get", notebook_id],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"ERROR: Notebook {notebook_id} not found")
return False
nb_info = json.loads(result.stdout)
# 2. nlm_notebooks テーブルに upsert
conn.execute("""
INSERT OR REPLACE INTO nlm_notebooks
(notebook_id, title, source_count, updated_at)
VALUES (?, ?, ?, datetime('now'))
""", (notebook_id, nb_info["title"], nb_info["source_count"]))
# 3. sources を同期
sources = nb_info.get("sources", [])
for src in sources:
# PDF ファイルなら book_id と関連付け
if src["type"] == "pdf":
book_id = extract_book_id_from_pdf(src["file_path"])
conn.execute("""
INSERT OR REPLACE INTO nlm_source_artifacts
(notebook_id, source_id, book_id, match_method, confidence)
VALUES (?, ?, ?, 'pdf_filename', 0.8)
""", (notebook_id, src["id"], book_id))
conn.commit()
print(f"Notebook {notebook_id} added with {len(sources)} sources")
return True
実行例
# SONY_HONDA notebook を登録
python3 ~/same/scripts/nlm_catalog_add_notebook.py \
--notebook-id 5791a7e0-a04d-4f2e-b8c5-8ff7d1234567
# 結果:
# Notebook 5791a7e0... added with 110 sources
# nlm_notebooks: 500 → 501件
実装チェックリスト
実装を進める際の確認項目:
- [ ] ChromaDB
same_artifactsコレクション初期化済み - [ ] Ollama nomic-embed-text モデル pull 済み(768dim)
- [ ] SQLite
~/same/data/same.db存在 - [ ] knowledge_books テーブル: 1,170冊以上
- [ ]
same_embed_books.py実行: 1,170冊 embedded - [ ]
same_cli.py discoverで vector lane 動作確認 - [ ] knowledge_temperature テーブル backfill: 全冊登録
- [ ] launchd plist 登録:
launchctl load - [ ] PATH 명시화:
~/.local/bin포함 - [ ] 마스터 테스트:
launchctl start com.same.temperature-curatorで exit=0
Dead Ends と次ステップ
既知の制限
-
nlm query インターフェース不整合 (未解決, 次セッション) - distill スクリプトが v0.5.15 API 仕様変更に未対応 - exit=3: invalid argument
-
ChromaDB メタデータフィルタの性能 - 大規模スキャン(100K+)で遅延あり - offset ベース pagination の改善検討中
-
Vector embedding の言語特性 - 日本語での質問とブリティッシュ英語表記の本の関連度は中程度 - キューレーション(hot/warm/cold)で補完
次ステップ(ロードマップ)
- Phase 9(2026-W15): NLM distill パイプラインの API 更新
- Phase 10(2026-W16): Artifact lane の ベクトル化(書籍 + PDF + コード)
- Phase 11(2026-W17): RRF K パラメータの自動チューニング
参考資料: - [Cormack et al. 1995] Reciprocal Rank Fusion - Nomic Embed – Text Embeddings - ChromaDB Docs – Vector DB - SQLite FTS5 – Full-text search
← 参照コンテンツ一覧に戻る