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
168 lines
5.7 KiB
Python
168 lines
5.7 KiB
Python
# tests/test_colbert_index.py
|
|
"""Tests for app.services.colbert_index.
|
|
|
|
pylate is NOT installed in the dev/test env by design (see cf-sysadmin skill's
|
|
"Known Gotchas" — installing it directly into the shared `cf` conda env broke
|
|
several other services' pinned torch/transformers versions on 2026-07-10).
|
|
These tests inject fake `pylate`/`pylate.models`/`pylate.indexes`/`pylate.retrieve`
|
|
modules via sys.modules so ColBERTIndex's lazy imports resolve to mocks without
|
|
pylate ever needing to be installed here.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from app.services.colbert_index import ColBERTIndex
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_pylate(monkeypatch):
|
|
fake_models_mod = types.ModuleType("pylate.models")
|
|
fake_indexes_mod = types.ModuleType("pylate.indexes")
|
|
fake_retrieve_mod = types.ModuleType("pylate.retrieve")
|
|
fake_pylate_mod = types.ModuleType("pylate")
|
|
fake_pylate_mod.models = fake_models_mod
|
|
fake_pylate_mod.indexes = fake_indexes_mod
|
|
fake_pylate_mod.retrieve = fake_retrieve_mod
|
|
|
|
mock_model = MagicMock()
|
|
mock_model.encode.return_value = [[0.1, 0.2], [0.3, 0.4]]
|
|
fake_models_mod.ColBERT = MagicMock(return_value=mock_model)
|
|
|
|
mock_index = MagicMock()
|
|
fake_indexes_mod.Voyager = MagicMock(return_value=mock_index)
|
|
|
|
mock_retriever = MagicMock()
|
|
fake_retrieve_mod.ColBERT = MagicMock(return_value=mock_retriever)
|
|
|
|
monkeypatch.setitem(sys.modules, "pylate", fake_pylate_mod)
|
|
monkeypatch.setitem(sys.modules, "pylate.models", fake_models_mod)
|
|
monkeypatch.setitem(sys.modules, "pylate.indexes", fake_indexes_mod)
|
|
monkeypatch.setitem(sys.modules, "pylate.retrieve", fake_retrieve_mod)
|
|
|
|
return types.SimpleNamespace(
|
|
model_cls=fake_models_mod.ColBERT,
|
|
model=mock_model,
|
|
index_cls=fake_indexes_mod.Voyager,
|
|
index=mock_index,
|
|
retriever_cls=fake_retrieve_mod.ColBERT,
|
|
retriever=mock_retriever,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_db(tmp_path) -> str:
|
|
db_path = str(tmp_path / "test.db")
|
|
schema = Path("migrations/001_initial_schema.sql").read_text()
|
|
conn = sqlite3.connect(db_path)
|
|
conn.executescript(schema)
|
|
conn.execute(
|
|
"INSERT INTO documents(id, title, file_path, status) VALUES ('d1','Test','test.pdf','ready')"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO page_chunks(id, doc_id, page_number, text, source, word_count) "
|
|
"VALUES ('c1','d1',1,'Setting the IP on the AVC-X','text',6)"
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO page_chunks(id, doc_id, page_number, text, source, word_count) "
|
|
"VALUES ('c2','d1',2,'Filter cartridge replacement steps','text',5)"
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return db_path
|
|
|
|
|
|
def test_ensure_fresh_builds_index_from_sqlite(fake_pylate, seeded_db, tmp_path):
|
|
idx = ColBERTIndex(index_dir=str(tmp_path / "colbert_index"))
|
|
idx.ensure_fresh(seeded_db)
|
|
|
|
fake_pylate.model.encode.assert_called_once()
|
|
call_args = fake_pylate.model.encode.call_args
|
|
assert set(call_args[0][0]) == {"Setting the IP on the AVC-X", "Filter cartridge replacement steps"}
|
|
assert call_args[1]["is_query"] is False
|
|
|
|
fake_pylate.index.add_documents.assert_called_once()
|
|
add_kwargs = fake_pylate.index.add_documents.call_args[1]
|
|
assert set(add_kwargs["documents_ids"]) == {"c1", "c2"}
|
|
|
|
|
|
def test_ensure_fresh_skips_rebuild_when_not_dirty(fake_pylate, seeded_db, tmp_path):
|
|
idx = ColBERTIndex(index_dir=str(tmp_path / "colbert_index"))
|
|
idx.ensure_fresh(seeded_db)
|
|
idx.ensure_fresh(seeded_db)
|
|
|
|
fake_pylate.model.encode.assert_called_once()
|
|
|
|
|
|
def test_mark_dirty_forces_rebuild(fake_pylate, seeded_db, tmp_path):
|
|
idx = ColBERTIndex(index_dir=str(tmp_path / "colbert_index"))
|
|
idx.ensure_fresh(seeded_db)
|
|
idx.mark_dirty()
|
|
idx.ensure_fresh(seeded_db)
|
|
|
|
assert fake_pylate.model.encode.call_count == 2
|
|
|
|
|
|
def test_ensure_fresh_with_empty_corpus_leaves_index_none(fake_pylate, tmp_path):
|
|
db_path = str(tmp_path / "empty.db")
|
|
schema = Path("migrations/001_initial_schema.sql").read_text()
|
|
conn = sqlite3.connect(db_path)
|
|
conn.executescript(schema)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
idx = ColBERTIndex(index_dir=str(tmp_path / "colbert_index"))
|
|
idx.ensure_fresh(db_path)
|
|
|
|
fake_pylate.index_cls.assert_not_called()
|
|
assert idx.query("anything") == []
|
|
|
|
|
|
def test_query_maps_results_back_to_chunks(fake_pylate, seeded_db, tmp_path):
|
|
idx = ColBERTIndex(index_dir=str(tmp_path / "colbert_index"))
|
|
idx.ensure_fresh(seeded_db)
|
|
|
|
fake_pylate.retriever.retrieve.return_value = [
|
|
[{"id": "c1", "score": 13.5}, {"id": "c2", "score": 9.2}]
|
|
]
|
|
|
|
results = idx.query("how do I set the IP on the AVC-X", top_k=10)
|
|
|
|
assert len(results) == 2
|
|
assert results[0]["chunk_id"] == "c1"
|
|
assert results[0]["doc_id"] == "d1"
|
|
assert results[0]["score"] == 13.5
|
|
assert results[1]["chunk_id"] == "c2"
|
|
|
|
|
|
def test_query_filters_by_doc_ids(fake_pylate, seeded_db, tmp_path):
|
|
idx = ColBERTIndex(index_dir=str(tmp_path / "colbert_index"))
|
|
idx.ensure_fresh(seeded_db)
|
|
|
|
fake_pylate.retriever.retrieve.return_value = [
|
|
[{"id": "c1", "score": 13.5}, {"id": "c2", "score": 9.2}]
|
|
]
|
|
|
|
results = idx.query("query", top_k=10, doc_ids=["other-doc"])
|
|
|
|
assert results == []
|
|
|
|
|
|
def test_query_respects_top_k(fake_pylate, seeded_db, tmp_path):
|
|
idx = ColBERTIndex(index_dir=str(tmp_path / "colbert_index"))
|
|
idx.ensure_fresh(seeded_db)
|
|
|
|
fake_pylate.retriever.retrieve.return_value = [
|
|
[{"id": "c1", "score": 13.5}, {"id": "c2", "score": 9.2}]
|
|
]
|
|
|
|
results = idx.query("query", top_k=1)
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["chunk_id"] == "c1"
|