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
Extends the shelve pipeline to cover spreadsheets, closing the Excel gap
called out in the PR's original "known gaps" list — Windchill/DocPortal
corpora commonly include parts lists and spec sheets as spreadsheets, not
just prose documents.
- scripts/shelve_xlsx.py — openpyxl, chunked by sheet with row-window
splitting for large sheets (header row repeated in every window so
each chunk stays self-describing for retrieval).
- scripts/shelve_ods.py — same chunking strategy via odfpy (already a
dependency from ODT support), OpenDocumentSpreadsheet's Table/TableRow/
TableCell.
- scripts/shelve_numbers.py — converts via headless LibreOffice to XLSX
and delegates to shelve_xlsx, mirroring shelve_pages.py's pattern for
.pages. Adds libreoffice-calc to the Docker image alongside the
existing libreoffice-writer.
- Upload button text changed from an ever-growing format list to
"Upload Document or Spreadsheet" — the Supported Formats table in
README/docs is now the source of truth for the full list.
- 13 new tests (XLSX, ODS, Numbers); full suite (85 tests) passing.
Manually verified via Playwright against an isolated test instance:
XLSX and ODS both upload, shelve to "ready", and store correctly
row-serialized, header-repeated chunks (confirmed via sample-chunks).
BM25 search against a 2-chunk toy corpus returned no hits for terms
split 1-vs-1 across the two chunks — traced to Okapi BM25's IDF formula
giving an exact 0 for terms in exactly half a tiny corpus
(log((N-n+0.5)/(n+0.5)) = log(1.0) = 0, filtered by `score <= 0`), not a
defect in the new shelvers. The earlier DOCX/ODT/PDF Playwright pass
(5 chunks total) diluted this enough to return real results.
Extends Pagepiper's document shelving pipeline (renamed from "ingest" —
see below) to cover the formats most likely to appear in a real-world
engineering document corpus, prompted by scoping a STERIS licensing pitch
that needs DOCX/ODT coverage.
- Rename the ingest pipeline to "shelve" throughout (scripts/, app/api,
tests, docs, frontend). "Glean" (Turnstone's term) was considered and
rejected — that's a harvest metaphor for log/knowledge extraction,
not a fit for documents entering a library. Documented as a general
CF naming principle in the org-level CLAUDE.md.
- Wire DOCX into the upload/scan UI, README, and docs — the extraction
logic (heading-based chunking, table serialization) already existed
but wasn't exposed to users or covered by tests.
- Add ODT support via odfpy, mirroring DOCX's chunking strategy.
- Add Apple Pages support via headless LibreOffice conversion to ODT.
No maintained Python library parses the IWA format directly; libreoffice
bundles libetonyek, the only real open-source Pages parser. Adds
libreoffice-writer to the Docker image (~300-400MB) for this.
- 24 new/updated tests across shelve_docx, shelve_odt, and shelve_pages;
full suite (72 tests) passing.
Known gaps not addressed here: no Windchill/DocPortal connector exists
yet (metadata-only PowerShell recon only), Excel/.xlsx is unsupported,
and circuitforge_core.tasks.dispatch_task does not currently exist in
circuitforge-core — cf-orch dispatch is dead code, always falling
through to local BackgroundTasks. See
circuitforge-plans/pagepiper/superpowers/plans/2026-07-10-steris-licensing-pitch.md
for the full writeup.
Adds pyproject.toml, environment.yml, Dockerfile, docker/web (Vue+nginx),
compose.yml, compose.override.yml.example, manage.sh, .env.example,
.gitignore, and config stubs for the pagepiper self-hosted PDF library tool.
Port 8521. No secrets committed.