← 参照コンテンツに戻る MOKUMOKU GUIDE

RRF 融合検索の3本柱(Reciprocal Rank Fusion)

実践技術教科書 · ハイブリッド検索 / RAG基盤

ハイブリッド知識発見 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 技術解説


目次

  1. 第Ⅰ部 RRF融合検索の設計思想
  2. 第Ⅱ部 主要用語と概念
  3. 第Ⅲ部 実装ハンドブック

第Ⅰ部 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)

なぜこれで解決するのか:

  1. 弱点の相互補完: FTSが見落とした書籍がVectorやGraphで高順位なら、スコアが加算される
  2. スケール平準化: 3つのエンジンが異なるスケール(距離・グラフ距離・ベクトル値)を持っていても、RRFは「順位」で統一
  3. 合意度: 複数エンジンで上位なら最終スコアが高くなる。合意度が検索信頼度の証になる
  4. 多様性: 単一ソースの偏り(ノイズ・偶発的なマッチ)を減らす

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

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 を有効化。

前提条件

実装ステップ

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件

実装チェックリスト

実装を進める際の確認項目:


Dead Ends と次ステップ

既知の制限

  1. nlm query インターフェース不整合 (未解決, 次セッション) - distill スクリプトが v0.5.15 API 仕様変更に未対応 - exit=3: invalid argument

  2. ChromaDB メタデータフィルタの性能 - 大規模スキャン(100K+)で遅延あり - offset ベース pagination の改善検討中

  3. Vector embedding の言語特性 - 日本語での質問とブリティッシュ英語表記の本の関連度は中程度 - キューレーション(hot/warm/cold)で補完

次ステップ(ロードマップ)

  1. Phase 9(2026-W15): NLM distill パイプラインの API 更新
  2. Phase 10(2026-W16): Artifact lane の ベクトル化(書籍 + PDF + コード)
  3. 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

← 参照コンテンツ一覧に戻る