pagepiper/app/services/colbert_index.py
pyr0ball 89a58ec9b0 feat: replace nomic-embed-text retriever with Agent-ModernColBERT
Bi-encoder embeddings collapse a whole query into one vector, losing
multi-part reasoning structure — queries like "the procedure for setting
an IP on an AVC-X" or "what is the action economy for a fighter casting
a spell while prone" lose nuance. Agent-ModernColBERT is a late-interaction
retriever: per-token embeddings, scored via MaxSim at query time, built
specifically for agentic/multi-hop queries.

Implements Option A from the issue (in-process, via `pylate`) rather than
Option B (managed cf-orch service) — cf-orch already has `agent-moderncolbert`
registered in model_registry.yaml with a `pagepiper/retrieve` assignment
in assignments.yaml pointing at it and referencing this issue directly,
someone had already pre-wired that side.

- app/services/colbert_index.py: new ColBERTIndex class, mirrors
  BM25Index's dirty-flag/rebuild-from-SQLite pattern exactly — no
  separate per-shelve indexing step needed, just mark_dirty() on the
  same callback that already marks BM25 dirty.
- app/services/retriever.py: hybrid_search's semantic half now merges
  BM25 with ColBERT MaxSim scores (min-max normalized per-batch, since
  MaxSim is unbounded unlike the old sqlite-vec L2-distance path) instead
  of Ollama-embed + sqlite-vec cosine. BM25 merge/rank/per-doc-cap/
  adjacent-chunk-window logic is unchanged.
- app/main.py / app/deps.py: per-user ColBERTIndex registry, same
  pattern as the existing per-user BM25Index registry.
- Existing BYOK tier gate preserved exactly (llm is None check) — this
  is a retrieval-technology swap, not a tier/licensing change. The
  ColBERT model runs locally via pylate with no Ollama dependency, but
  gating still follows product tiering.
- 12 new tests. pylate is intentionally NOT installed in the dev/test
  env — see the cf-sysadmin skill's "Known Gotchas" for why (installing
  it directly into the shared `cf` conda env broke several other
  services' torch/transformers pins on 2026-07-10). Tests inject fake
  pylate modules via sys.modules instead.

Known follow-up (not addressed here): shelve scripts still compute and
store Ollama embeddings into `page_vecs` at shelve time — that table is
no longer read by search/chat now that retrieval uses the ColBERT index.
Removing the now-redundant embedding step is separate cleanup.

Closes: #8
2026-07-10 19:02:12 -07:00

137 lines
4.9 KiB
Python

# app/services/colbert_index.py
"""
ColBERT late-interaction index (pagepiper#8).
Replaces nomic-embed-text bi-encoder + cosine similarity for the semantic
half of hybrid search with Agent-ModernColBERT (lightonai/Agent-ModernColBERT),
a late-interaction retriever that keeps per-token embeddings and scores via
MaxSim at query time — better suited to multi-part rulebook questions than a
single collapsed query vector.
BSL 1.1 — same BYOK gate as the rest of hybrid search (Retriever only reaches
this index when an LLM is configured). The model itself runs locally via
`pylate`, no Ollama call required — the gate matches product tiering, not a
technical dependency on Ollama.
Mirrors BM25Index's dirty-flag, rebuild-from-SQLite pattern: no separate
per-shelve indexing step is needed. `mark_dirty()` is called by the same
callback that already marks BM25 dirty on shelve completion; the next query
triggers a full rebuild from `page_chunks`.
"""
from __future__ import annotations
import logging
import os
import sqlite3
import threading
logger = logging.getLogger(__name__)
_DEFAULT_MODEL = "lightonai/Agent-ModernColBERT"
class ColBERTIndex:
def __init__(self, index_dir: str, model_name: str | None = None) -> None:
self._index_dir = index_dir
self._model_name = model_name or os.environ.get("PAGEPIPER_COLBERT_MODEL", _DEFAULT_MODEL)
self._model = None
self._index = None
self._chunks: dict[str, dict] = {}
self._dirty = True
self._lock = threading.Lock()
def mark_dirty(self) -> None:
"""Signal that the index needs rebuilding (call after any document is shelved)."""
self._dirty = True
def _get_model(self):
if self._model is None:
from pylate import models
logger.info("Loading ColBERT model %s", self._model_name)
self._model = models.ColBERT(model_name_or_path=self._model_name)
return self._model
def ensure_fresh(self, db_path: str) -> None:
"""Rebuild from SQLite if dirty."""
if not self._dirty:
return
with self._lock:
if not self._dirty:
return
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT id, doc_id, page_number, text FROM page_chunks ORDER BY doc_id, page_number"
).fetchall()
finally:
conn.close()
except sqlite3.Error as exc:
logger.error("ColBERT index rebuild failed: %s", exc)
return
self._chunks = {str(r["id"]): dict(r) for r in rows}
if not rows:
self._index = None
self._dirty = False
return
from pylate import indexes
model = self._get_model()
ids = [str(r["id"]) for r in rows]
texts = [r["text"] for r in rows]
embeddings = model.encode(texts, is_query=False, show_progress_bar=False)
os.makedirs(self._index_dir, exist_ok=True)
index = indexes.Voyager(
index_folder=self._index_dir, index_name="colbert", override=True
)
index.add_documents(documents_ids=ids, documents_embeddings=embeddings)
self._index = index
self._dirty = False
logger.info("ColBERT index rebuilt: %d chunks", len(rows))
def query(
self,
query_text: str,
top_k: int = 10,
doc_ids: list[str] | None = None,
) -> list[dict]:
"""Search the corpus. Returns results sorted by descending MaxSim score."""
if self._index is None:
return []
from pylate import retrieve
model = self._get_model()
query_embeddings = model.encode([query_text], is_query=True, show_progress_bar=False)
retriever = retrieve.ColBERT(index=self._index)
# Oversample when filtering to a doc subset — same pattern as the
# sqlite-vec path this replaces (see app/services/retriever.py).
k = top_k * 20 if doc_ids else top_k * 2
results = retriever.retrieve(queries_embeddings=query_embeddings, k=k)[0]
hits: list[dict] = []
for r in results:
chunk = self._chunks.get(str(r["id"]))
if not chunk:
continue
if doc_ids is not None and chunk["doc_id"] not in doc_ids:
continue
hits.append(
{
"chunk_id": chunk["id"],
"doc_id": chunk["doc_id"],
"page_number": chunk["page_number"],
"text": chunk["text"],
"score": r["score"],
}
)
if len(hits) >= top_k:
break
return hits