Multimodal PDF Ingestion¶
Status: Phase 1+2 — iteration 3.1 landed (2026-05-08). All four
hosted readers (Mistral OCR / Anthropic / Gemini / LlamaParse)
plus three self-hosted deployments (Marker / Docling / MinerU)
selectable via the top-level knowledge: YAML section.
GROBID demoted to a metadata-only sibling reader. Cost/quality
tradeoff is first-class — every backend exposes a tier knob via
its reader constructor, forwarded verbatim through the options
block. Knowledge-stack knobs (PDF extractor, image dir, Qdrant
URL, GROBID URL) live in the typed KnowledgeConfig and flow to
every Ray worker via POLYMATHERA_CONFIG; env-var passthrough is
limited to bootstrap and secrets.
Owner: knowledge layer
Related: knowledge-capabilities.md, literature-context-page-source.md
This document is the working design for replacing GROBID as the content extractor in the knowledge layer, lifting figures and tables into the data model as first-class entities, and growing toward a full multimodal pipeline (figure captioning, multimodal retrieval, agent vision system).
It is also the progress tracker — each phase has a checklist; update it as work lands.
1. Problem statement¶
The knowledge layer currently routes every PDF through
GrobidPdfReader
when default_registry_with_grobid is the active reader registry
(see readers/__init__.py:49-77).
GROBID was designed for bibliographic extraction: header,
authors, affiliations, references. Its full-text segmentation
identifies figures, tables, and formulas mostly so it can route
surrounding prose correctly — not so it can faithfully reconstruct
them. The maintainer has publicly acknowledged that formula, figure,
and table extraction "is really bad" and that has not changed
materially.
Concretely, in the operator's KB tab today: - Equations come through as broken plain text ("∇ × E = -∂B/∂t" lost to "∇× E= − ∂B∂t" or worse) - Tables collapse to whitespace-shuffled prose - Figure captions and bodies are torn apart - No image bytes are extracted at all
CPS designs (the colony's primary use case) are inherently multimodal: control diagrams, schematics, datasheets, simulation plots, mechanical CAD views, oscilloscope traces. A text-only extractor cannot cover this corpus.
2. Goals / non-goals¶
Goals¶
- Replace GROBID as the content extractor with a layout-aware reader that produces clean Markdown (LaTeX equations preserved, GFM tables, figure references) — selectable between Marker, Docling, and MinerU per cluster config so the operator can evaluate them on real corpora.
- Lift figures into the data model as first-class
FigureRef(image_uri, bbox, page, caption_hint)so chunkers, retrievers, and agents can address them by ID. - Provide a third-party-API path for CPU-only environments (Anthropic native PDF, Mistral OCR) so local testing isn't gated on a 20s-per-page CPU run of Marker/Docling/MinerU.
- Preserve GROBID for what it's actually good at — header/author/
affiliation/bibliography metadata — by routing those into
ParsedSection.extrarather than the chunk text. - Add a
VisionDeploymentthat wrapsAnthropicLLMDeploymentfor per-figure captioning at ingest time. Captions are stored as siblingChunks withdata_type="figure_caption"so plain Qdrant text search finds them. - Wire image bytes into a content-addressed
ImageStoreunder/mnt/shared/(no S3 dep, fits the existingcolony-sharedvolume).
Non-goals (this design doc)¶
- ColPali / page-image multi-vector retrieval (Phase 5, deferred — expensive, only worthwhile after caption-first is measured).
- Per-document-type pipelines for datasheets / patents / standards (Phase 4, deferred — needs throughput data first).
- A self-hosted Qwen2-VL captioner (deferred — local-CPU testing uses Anthropic; Phase 3 ships with hosted only).
- Replacing the agent's planner with a multimodal LLM as policy itself (Phase 6, distinct workstream).
3. Operator decisions (locked)¶
These are settled — they shape the architecture below.
| # | Decision | Owner | Date |
|---|---|---|---|
| 1 | Three self-hosted extractors as separate serving.deployments: Marker, Docling, MinerU. The cluster config selects which to deploy. |
operator | 2026-05-08 |
| 2 | Captioner v1 = AnthropicVisionDeployment (wraps the existing AnthropicLLMDeployment). No GPUs in local testing. |
operator | 2026-05-08 |
| 3 | Image store backend = local filesystem under /mnt/shared/colony-images/<sha256[:2]>/<sha256>.{ext}. Reuses the colony-shared Docker volume. |
operator | 2026-05-08 |
| 4 | Phase 1 scope = reader swap + figures-in-data-model (Phase 1+2 ship together). | operator | 2026-05-08 |
| 5 | Plus: a hosted-API extractor path so CPU testing doesn't hinge on Marker/Docling/MinerU latency. See §5 for the survey and §6 for the recommended primary. | operator | 2026-05-08 |
| 6 | Marker is GPL-3.0 — keep it behind a build flag. It is registered as the marker backend in :func:default_registry_with_pdf_extractor, but is not installed by the default colony:local image. Operators opt in by adding marker to the knowledge_marker poetry extra and rebuilding. Docling (MIT) and MinerU (AGPL-3.0) ship by default. |
operator | 2026-05-08 |
| 7 | Markdown chunker question (open #2 in §11): subclass ProseChunker to treat fenced code blocks, GFM tables, and $...$ math as atomic units. Defer the implementation until row 1.14 hits a real corpus where blocks split across chunks; ProseChunker today already preserves paragraph boundaries which is sufficient for Mistral OCR's per-page emission. Locked direction; follow-up implementation tracked in iteration 2. |
operator | 2026-05-08 |
4. Architecture overview¶
┌──────────────────────────────────────────────┐
│ Ingestor (knowledge.ingestion) │
└─────┬──────────────────────────────────┬─────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌─────────────────────────────┐
│ FormatReader (PDF) │ │ ImageStore (NEW) │
│ one of: │ │ • InMemoryImageStore │
│ • MarkerPdfReader │◄──►│ • LocalFsImageStore │
│ • DoclingPdfReader │ │ /mnt/shared/colony- │
│ • MinerUPdfReader │ │ images/<sha[:2]>/<sha> │
│ • AnthropicPdfReader │ └─────────────────────────────┘
│ • MistralOcrPdfReader │
│ (selectable) │
└──────────────┬───────────┘
│
ParsedSection(text=md, figures=(FigureRef, ...))
│
▼
┌──────────────────────────┐
│ Chunker │ produces Chunk + propagates
└──────────────┬───────────┘ figure refs into chunk.extra
│
┌────────────┴────────────┐
▼ ▼
┌───────────────┐ ┌─────────────────────┐
│ Embedder │ │ Captioner (NEW) │
│ → vector │ │ AnthropicVision │
└───────┬───────┘ │ → caption text │
│ └────────┬────────────┘
▼ ▼
┌──────────────────────────────────────────────────┐
│ Vector store — text chunks + figure_caption │
│ chunks (separate data_type, linked by figure_id)│
└──────────────────────────────────────────────────┘
Three new components, three modified ones:
New:
- ImageStore ABC + InMemoryImageStore, LocalFsImageStore
- FigureRef model
- AnthropicVisionDeployment (or extend AnthropicLLMDeployment)
- MarkerPdfReader, DoclingPdfReader, MinerUPdfReader plus
AnthropicPdfReader, MistralOcrPdfReader
- MarkerExtractorDeployment, DoclingExtractorDeployment,
MinerUExtractorDeployment (Ray serving deployments)
- Per-cluster pdf_extractor config field
Modified:
- ParsedSection gains figures: tuple[FigureRef, ...] and format ("text" vs "markdown")
- Chunk.extra gains documented keys figure_ids: list[str],
metadata_origin: "grobid" | "marker" | ...
- RetrievalHit gains optional figures: tuple[FigureRef, ...] so
the agent's planner can fetch image URIs alongside text
5. Library / API survey¶
Self-hosted (Phase 1 deployments — picked by operator decision #1)¶
| Tool | Strength | Output | CPU latency (rough, A4 page) | License |
|---|---|---|---|---|
| Marker | General-purpose; equations via Texify; broad format coverage (PDF/PPTX/DOCX/XLSX/EPUB) | Markdown / JSON / HTML | 5–15 s/page CPU, sub-1 s GPU | GPL-3.0 (commercial license available) |
| Docling | IBM Research; strong table model (TableFormer); LangChain/LlamaIndex integrations | DoclingDocument → Markdown |
10–30 s/page CPU | MIT |
| MinerU | OpenDataLab; excellent on heavy/complex layouts and CJK | Markdown / JSON | 8–20 s/page CPU | AGPL-3.0 |
CPU latency is the operator's pain point — a 50-page paper on a 3-worker dev cluster is ≥5 minutes. None of these scale linearly with cores; they are GPU-shaped tools.
Hosted APIs — cost / quality tradeoff matrix¶
The operator picks a row, not a vendor. The framework supports seven hosted backends across four cost / quality tiers; pricing is per-page in USD, all batch / cache discounts available where the vendor offers them.
| Tier | Backend (pdf_extractor.backend=…) |
Tier knob (options=…) |
Per-page cost | Latency | Image bytes? | Notes |
|---|---|---|---|---|---|---|
| Cheap-OCR | mistral_ocr |
(none — single model) | $0.001 batch / $0.002 std | <1 s | ✅ | Pure OCR; cheapest by 10× (Mistral OCR 3). |
| Cheap-OCR | llamaparse |
tier=fast |
$0.00125 | 5-15 s | ✅ (presigned) | No-AI text extract; competitive with Mistral on simple PDFs (LlamaParse pricing). |
| Balanced multimodal | gemini |
model=gemini-2.5-flash (default) |
~$0.003 | 2-5 s | ❌ | Native PDF, fast multimodal reasoning; the recommended balanced default (Gemini docs, pricing). |
| Balanced multimodal | llamaparse |
tier=cost_effective (LlamaParse default) |
$0.00375 | 5-15 s | ✅ (presigned) | LLM-assisted layout reasoning, RAG-tuned markdown. |
| Premium multimodal | anthropic |
model=claude-sonnet-4-5 (default) |
~$0.009 | 3-8 s | ❌ | Best Anthropic visual reasoning; prompt caching halves cost on re-runs (Anthropic docs). |
| Premium multimodal | gemini |
model=gemini-2.5-pro |
~$0.010 | 5-15 s | ❌ | Premium multimodal quality, native PDF. |
| Highest-fidelity | llamaparse |
tier=agentic |
$0.0125 | 15-60 s | ✅ (presigned) | Sonnet-class agent walks the document; best semantic structure preservation. |
| Highest-fidelity | llamaparse |
tier=agentic_plus |
$0.056 | 30-120 s | ✅ (presigned) | Highest-fidelity tier; reserve for visually-dense docs the agentic tier missed. |
Other surveyed vendors that didn't make the lineup:
- Reducto — ~$0.015/page entry. Best on RD-TableBench (90.2%) but enterprise-leaning (SOC 2, on-prem); overkill for our v1. Easy to add as another backend later if a corpus warrants it.
Self-hosted deployments — tier 5 ("highest fidelity, no vendor cost")¶
| Tool | Strength | Output | CPU latency (A4 page) | License |
|---|---|---|---|---|
| Marker | General-purpose; equations via Texify; broad format coverage | Markdown / JSON / HTML | 5-15 s CPU, sub-1 s GPU | GPL-3.0 (behind knowledge_marker extra) |
| Docling | IBM Research; strong table model (TableFormer) | DoclingDocument → Markdown |
10-30 s CPU | MIT |
| MinerU | OpenDataLab; excellent on visually-dense / CJK | Markdown / JSON | 8-20 s CPU | AGPL-3.0 |
GPU-shaped tools — none scale linearly with cores. Operators run them in production where they have the GPU budget; CPU is for correctness validation only.
Picking a row¶
- Just bulk-ingest a known-clean corpus:
mistral_ocr(default). - Mixed corpus where you want LLM-assisted layout reasoning:
geminiwithmodel=gemini-2.5-flash— the cost / quality sweet spot. - Visually-dense documents the cheap tiers miss:
geminiwithmodel=gemini-2.5-pro, oranthropic. - Citation-graph + RAG-tuned markdown for academic literature:
llamaparsewithtier=cost_effectiveoragentic. - Self-hosted, no vendor cost:
docling(default GPU tool),mineru(heaviest layouts),marker(broadest format support, GPL).
Per-document-type routing (Phase 4) eventually picks the right
tier per doc class — datasheets through mistral_ocr, complex
patents through llamaparse:agentic, etc. Until then, operators
pin one backend at deploy time and override per-call via the KB
tab's extractor_override.
For both, the operator only pays per-page when they choose to use
the hosted path. The default cluster config keeps the self-hosted
extractor selected; the hosted readers are explicit
opt-ins via the pdf_extractor config field.
6. Data model changes¶
6.1 New: FigureRef¶
Lives in knowledge/models.py:
class FigureRef(BaseModel):
"""One figure / image / table-as-image extracted from a document.
The reader emits these alongside the section text. The chunker
forwards the IDs into ``Chunk.extra["figure_ids"]`` so retrievers
and the agent's planner can fetch image bytes by URI.
"""
model_config = ConfigDict(frozen=True)
figure_id: str = Field(default_factory=lambda: f"fig_{uuid.uuid4().hex[:16]}")
image_uri: str
"""Content-addressed URI returned by ``ImageStore.put`` —
typically ``colony-image://<sha256>``. Resolves via the active
``ImageStore`` instance bound to ``RetrievalDeps``."""
page: int | None = None
bbox: tuple[float, float, float, float] | None = None
"""``(x0, y0, x1, y1)`` in PDF user-space units, when the reader
can supply them."""
caption_hint: str = ""
"""Caption text the reader extracted from below the figure (or
similar). Used as a hint for the captioner; not authoritative."""
kind: Literal["figure", "table", "diagram", "equation", "other"] = "figure"
extra: dict[str, Any] = Field(default_factory=dict)
6.2 Modified: ParsedSection¶
class ParsedSection(BaseModel):
section_path: str = ""
heading: str = ""
text: str
citation: CitationSpan
figures: tuple[FigureRef, ...] = Field(default_factory=tuple) # NEW
format: Literal["text", "markdown"] = "text" # NEW
extra: dict[str, Any] = Field(default_factory=dict)
format="markdown" flags sections whose text carries Markdown the
chunker should preserve through chunking (don't split mid-table,
don't break equations across chunks). Existing readers default to
"text"; the new readers emit "markdown".
6.3 Modified: Chunk.extra documented keys¶
| Key | Type | Set by | Meaning |
|---|---|---|---|
figure_ids |
list[str] |
chunker | Figures referenced by this chunk's text. |
metadata_origin |
str |
reader | Which reader produced the section ("grobid", "marker", "mistral_ocr", …). |
bibliographic |
dict |
metadata reader (GROBID) | Authors, affiliations, citations parsed from the doc header. |
6.4 New: ImageStore¶
Lives in knowledge/stores/image.py. Same shape as VectorStore —
ABC + two impls.
class ImageStore(ABC):
@abstractmethod
async def put(self, payload: bytes, *, mime: str = "image/png") -> str:
"""Persist ``payload`` and return a content-addressed URI."""
@abstractmethod
async def get(self, uri: str) -> bytes | None:
"""Resolve a URI returned by ``put``."""
@abstractmethod
async def has(self, uri: str) -> bool: ...
@abstractmethod
async def delete(self, uri: str) -> bool: ...
@abstractmethod
async def stat(self, uri: str) -> dict[str, Any] | None:
"""Return ``{size, mime, created_at}`` for the image, or
``None`` if absent."""
LocalFsImageStore writes to /mnt/shared/colony-images/<sha[:2]>/<sha>.{ext},
indexes mime + size in a sidecar JSON. Sharded by sha-prefix so a
single directory never grows past a few hundred entries.
InMemoryImageStore keeps everything in a dict[str, bytes] for
tests.
RetrievalDeps and Ingestor both gain an image_store field
(blueprint chain extended, same pattern as the vector store).
6.5 Modified: RetrievalHit¶
class RetrievalHit(BaseModel):
chunk: Chunk
score: float = 0.0
rank: int = 0
explanation: str = ""
figures: tuple[FigureRef, ...] = Field(default_factory=tuple) # NEW
The figures are denormalised onto the hit so the agent can pull
images without a second round-trip. KnowledgeRetrievalCapability
populates them by joining chunk.extra["figure_ids"] against a
small per-source figure index (built at ingest time).
7. Reader interface¶
The existing FormatReader ABC already has the right shape — it
returns Sequence[ParsedSection]. Image-emitting readers stash
bytes via the ImageStore and reference them in FigureRefs
inside the section. No new abstract method is needed — readers
already have access to RawDocument.metadata and can pull the
ImageStore from a constructor injection.
class MarkerPdfReader(FormatReader):
handles = (KnowledgeFormat.PDF,)
def __init__(self, *, deployment_handle, image_store):
self._handle = deployment_handle # Ray serving handle
self._image_store = image_store
async def read_async(self, document: RawDocument) -> Sequence[ParsedSection]:
# 1. ship the bytes to MarkerExtractorDeployment.extract(...)
result = await self._handle.extract(pdf_bytes=document.bytes_)
# 2. for each figure in result.images, store via image_store.put(...)
# 3. rewrite markdown image refs to colony-image:// URIs
# 4. emit ParsedSection(text=md, figures=tuple(refs), format="markdown")
...
The read() synchronous path raises NotImplementedError for
deployment-backed readers; the registry already prefers
read_async() when available.
8. Serving deployments¶
Per operator decision #1, three sibling deployments under
cluster/extractors/:
cluster/extractors/
__init__.py
base.py # PdfExtractorDeployment ABC
marker_deployment.py
docling_deployment.py
mineru_deployment.py
Each exposes:
@serving.endpoint
async def extract(
self, *, pdf_bytes: bytes, options: ExtractOptions | None = None,
) -> ExtractResult:
"""Returns ExtractResult(markdown, figures=[FigureBlob, ...])."""
FigureBlob = (image_bytes, mime, page, bbox, caption_hint, kind).
Reader code stores them via ImageStore.put(...) and converts to
FigureRef.
The hosted-API readers (AnthropicPdfReader,
MistralOcrPdfReader) do not need their own deployment — they
hit external endpoints directly through the existing
AnthropicLLMDeployment / a new lightweight MistralOcrClient.
Cluster config selector¶
# cluster_config.yaml
knowledge:
pdf_extractor:
backend: marker # marker | docling | mineru | anthropic | mistral_ocr
# Self-hosted backends:
marker:
replicas: 1
gpus_per_replica: 0 # 1 in production
docling: { replicas: 1 }
mineru: { replicas: 1 }
# Hosted-API backends:
anthropic:
model: claude-sonnet-4-6
use_files_api: true # avoid re-uploading repeated PDFs
prompt_cache: true
mistral_ocr:
api_base: https://api.mistral.ai/v1
batch: false # set true for $0.001/page corpus runs
PolymatheraClusterConfig.add_deployments_to_app reads knowledge.pdf_extractor.backend and only instantiates the matching deployment class. The other two binaries don't get pulled into the image (Dockerfile keeps them behind extras).
9. Captioner deployment (Phase 3)¶
AnthropicVisionDeployment extends AnthropicLLMDeployment. New
endpoint:
@serving.endpoint
async def caption_figure(
self, *, image_uri: str, hint: str = "", style: CaptionStyle = "cps",
) -> str:
"""Return a structured caption for the figure at ``image_uri``.
``style="cps"`` uses a CPS-tuned prompt:
describe the figure type, list any labeled components,
identify any equations or units, transcribe any axis labels
and legends, identify the apparent purpose.
"""
The ingestor calls caption_figure per extracted FigureRef, writes the caption back as a sibling chunk with data_type="figure_caption" and chunk.extra["figure_id"]. PlainQdrant text search then surfaces figures by description. No second collection.
10. Phase plan¶
Phase 1+2 — Reader swap + figures in the data model (in progress)¶
Goal: a clean PDF → markdown-with-figure-refs pipeline behind a config switch, with images persisted to disk and round-tripped to the chat UI's KB tab.
| # | Task | Files | Owner | Status |
|---|---|---|---|---|
| 1.1 | Add FigureRef model + figures + format fields on ParsedSection |
knowledge/models.py |
claude | ✅ |
| 1.2 | Add ImageStore ABC + InMemoryImageStore + LocalFsImageStore (sharded layout, sidecar meta, atomic writes, @blueprint) |
knowledge/stores/image.py (new) |
claude | ✅ |
| 1.3 | Wire image_store into RetrievalDeps + Ingestor (blueprint chain); env-driven KB_IMAGE_DIR selector in set_knowledge_deps + default_*_blueprint factories |
knowledge/retrieval/base.py, knowledge/ingestion.py, knowledge/deps.py |
claude | ✅ |
| 1.4 | PdfExtractorDeployment ABC + ExtractResult / FigureBlob / ExtractOptions types (no Ray-serve deps in the ABC — concrete deployments apply @serving.deployment themselves) |
cluster/extractors/base.py (new), cluster/extractors/__init__.py |
claude | ✅ |
| 1.5 | MarkerExtractorDeployment (behind the knowledge_marker poetry extra per decision #6; lazy ML-model load on first extract; PIL → PNG re-encode for figures; CPU + GPU autoscaling profiles; @serving.deployment on the class; @serving.endpoint on extract) |
cluster/extractors/marker_deployment.py (new) |
claude | ✅ |
| 1.6 | DoclingExtractorDeployment (default knowledge extra; lazy DocumentConverter build with table structure + image generation enabled; DocumentStream upload path so we never write the PDF to disk; figures re-encoded as PNG) |
cluster/extractors/docling_deployment.py (new) |
claude | ✅ |
| 1.7 | MinerUExtractorDeployment (default knowledge extra; lazy magic_pdf import; tempdir-per-call so concurrent extracts don't collide; parse_method toggle for OCR / TXT / auto) |
cluster/extractors/mineru_deployment.py (new) |
claude | ✅ |
| 1.8 | One generic RemotePdfExtractorReader(backend=…, image_store=…) instead of three near-identical reader classes — the deployment is the per-backend specialisation, the reader is backend-agnostic glue. Resolves the deployment handle lazily on first call (so the reader is picklable across the Ray boundary); ExtractResult dict-shape coercion via model_validate for cloudpickle round-trips; <!-- page: N --> markers split sections; figure refs propagate per-page |
knowledge/readers/remote_pdf.py (new) |
claude | ✅ |
| 1.9 | AnthropicPdfReader (hosted, uses the anthropic SDK directly via messages.create with a base64 document content block; <!-- page: N --> page markers in the prompt → one ParsedSection per marker; figures=() since Anthropic returns no image bytes; prompt_cache toggle; picklable api-key resolution) |
knowledge/readers/anthropic_pdf.py (new) |
claude | ✅ |
| 1.9b | GeminiPdfReader (hosted, google-genai SDK; model is the cost/quality tier knob — gemini-2.5-flash ≈ $0.003/page balanced default, gemini-2.5-pro ≈ $0.010/page premium; same page-marker prompt as Anthropic; cached_content_name for the 90% repeat-input discount; api-key resolves from GOOGLE_API_KEY; picklable) |
knowledge/readers/gemini_pdf.py (new) |
claude | ✅ |
| 1.9c | LlamaParsePdfReader (hosted, full LlamaParse v2 REST: upload → poll → result with expand=markdown,images_content_metadata,metadata → presigned-URL image fetch; tier knob (fast / cost_effective / agentic / agentic_plus); per-call timeout + poll-interval bounds; image-fetch failures degrade to skipped figures rather than failed document; picklable) |
knowledge/readers/llamaparse_pdf.py (new) |
claude | ✅ |
| 1.10 | MistralOcrPdfReader (hosted; full HTTP impl: Files API → signed URL → /v1/ocr; base64 image decode; markdown image-ref rewrite to colony-image://; picklable; no instance-level httpx.AsyncClient) |
knowledge/readers/mistral_ocr_pdf.py (new) |
claude | ✅ |
| 1.11 | KnowledgeConfig + PdfExtractorConfig Pydantic models (@register_polymathera_config(path="knowledge")); top-level knowledge.pdf_extractor YAML section with backend, free-form options dict (forwarded as backend kwargs), replicas, num_gpus; add_deployments_to_app sets KB_PDF_EXTRACTOR env var on every worker AND brings up the matching *ExtractorDeployment for self-hosted backends via the lazy registry; missing-library import errors logged + skipped (env var still applied); hosted backends are no-ops at deploy time (vendor-direct calls) |
knowledge/cluster_config.py (new), system.py |
claude | ✅ |
| 1.12 | default_registry_with_pdf_extractor(backend=...) factory in readers; env-driven KB_PDF_EXTRACTOR selector in set_knowledge_deps (auto-fallback when the selected backend is reserved-but-unimplemented) |
knowledge/readers/__init__.py, knowledge/deps.py |
claude | ✅ |
| 1.13 | GROBID demote: GrobidPdfReader.mode="metadata_only" skips body extraction and emits ONLY title + abstract sections with a structured extra["bibliographic"] payload (authors with affiliations, parsed reference list); GrobidMetadataReader is the operator-facing alias with header / citation consolidation enabled by default. Multi-reader pass (running GROBID-metadata alongside the body extractor) deferred to its own iteration — needs a clean Ingestor refactor |
knowledge/readers/grobid_pdf.py |
claude | ✅ |
| 1.14 | Chunker propagates figure_ids through chunk.extra (matches colony-image:// URIs against section.figures); also forwards metadata_origin provenance; both ProseChunker + CodeChunker; empty-extra preserved for plain-text sections |
knowledge/chunking.py |
claude | ✅ |
| 1.15 | KB tab renders multimodal chunks: full markdown via react-markdown + remark-gfm, inline figure previews via colony-image://<sha> → /api/v1/kb/images/<sha> rewrite, expandable <pre> blocks for long fenced code, "📷 N" badge per chunk for figure-id count, metadata_origin badge for extractor provenance; backend exposes GET /kb/images/{sha} with Cache-Control: immutable (content-addressed, never changes), 400 on non-hex sha, 404 on missing |
web_ui/frontend/src/components/kb/ChunkMarkdown.tsx (new), KnowledgeBaseTab.tsx, web_ui/backend/routers/kb.py |
claude | ✅ |
| 1.16 | KB ingest endpoint accepts extractor_override so operator can A/B from the dashboard |
web_ui/backend/routers/kb.py |
claude | ✅ |
| 1.17 | Tests (76 new across iterations 1+2, all passing): ImageStore contract + sharded layout + idempotency + sidecar fallback + blueprint pickle, MistralOcrPdfReader end-to-end with mocked httpx.MockTransport + auth-header assertions + 3-call ordering + malformed-image tolerance + error paths, AnthropicPdfReader with monkeypatched anthropic.AsyncAnthropic + page-marker round-trip + cache-control toggle + custom-prompt + empty-response handling, chunker figure_ids propagation per-chunk + metadata_origin forwarding + plain-text empty-extra invariant, MarkdownChunker keeps fenced code / GFM tables / $$...$$ / \\[...\\] atomic + oversized-block preserved + ingestor wiring, deps.py env-driven KB_PDF_EXTRACTOR selection + fallback for unknown / unimplemented backends, /kb/images/{sha} round-trip + 400 hex-only validation + 404 on miss + Cache-Control: immutable |
knowledge/tests/test_image_store.py, test_mistral_ocr_pdf.py, test_anthropic_pdf.py, test_markdown_chunker.py, test_chunker_figures.py, test_deps_pdf_extractor.py, web_ui/backend/routers/tests/test_kb.py |
claude | ✅ |
| 1.18 | Re-ingest the existing literature corpus with KB_PDF_EXTRACTOR=mistral_ocr (cheapest fast path) and confirm KB tab shows multi-line markdown with linked figures |
manual / dashboard | operator | ⬜ |
| 1.19 | Iteration 3.1 — knowledge-stack knobs absorbed into typed KnowledgeConfig. New QdrantConfig, GrobidConfig, and image_dir field; knowledge/deps.py, web_ui/backend/routers/kb.py, design_monorepo/capabilities.py read via get_component_or_default("knowledge", KnowledgeConfig) instead of os.environ. RUNTIME_ENV_PREFIXES pruned to bootstrap (POLYMATH, RAY_, REDIS_) + LLM-provider secrets; KB_*, QDRANT_*, GROBID_* removed from the proxy allowlist and from docker-compose.yml. add_deployments_to_app no longer writes to os.environ. Supersedes the env-var paths in rows 1.3, 1.11, 1.12 — every Ray worker re-reads the YAML via POLYMATHERA_CONFIG and resolves the typed config in its own process |
knowledge/cluster_config.py, knowledge/deps.py, web_ui/backend/routers/kb.py, design_monorepo/capabilities.py, distributed/ray_utils/serving/proxy.py, cli/deploy/docker/docker-compose.yml, cli/deploy/.env.template, configs/example.yaml |
claude | ✅ |
Iteration 1 wrap-up (2026-05-08)¶
What landed in this iteration: rows 1.1, 1.2, 1.3, 1.4, 1.10, 1.12, 1.14, 1.16, 1.17. Net new SLOC ≈ 1,500 (production) + 700 (tests). 47 new tests, 353 in the relevant suites green, no regressions.
What an operator can now do without writing code:
# Iteration 1 shipped an env-var-driven path on every container that
# runs ingestion (ray-head, ray-workers, dashboard). Iteration 3.1
# replaced this with the typed KnowledgeConfig (see the iteration 3.1
# wrap-up below); the snippet is preserved verbatim as historical
# context.
export KB_PDF_EXTRACTOR=mistral_ocr
export MISTRAL_API_KEY=<key>
export KB_IMAGE_DIR=/mnt/shared/colony-images # optional; falls back to in-mem
After redeploy, set_knowledge_deps() resolves a registry whose
PDF reader is MistralOcrPdfReader wired to a LocalFsImageStore
under KB_IMAGE_DIR. Every PDF ingest goes through Mistral OCR;
figures land in /mnt/shared/colony-images/<sha[:2]>/<sha>.{ext};
Chunk.extra["figure_ids"] lists the figures each chunk
references. The KB tab renders chunk text correctly (existing
markdown renderer); inline figure previews wait for row 1.15.
What is intentionally NOT in this iteration:
- Self-hosted Marker / Docling / MinerU deployments (rows 1.5-1.8) — each needs its own Docker variant + library.
- AnthropicPdfReader (row 1.9) — Mistral covers the hosted path; Anthropic adds a second hosted backend in iteration 2.
- KB tab figure rendering (row 1.15) — frontend work; chunks already carry figure_ids so the renderer pickup is mechanical.
- cluster_config.yaml integration (row 1.11) — env var path is sufficient for now; YAML wiring lands with the self-hosted deployments.
- GROBID metadata-only mode (row 1.13) — GROBID still runs as today; the new readers shadow it via the registry's last-write-wins rule.
Iteration 2 wrap-up (2026-05-08)¶
What landed: rows 1.9, 1.15 plus operator decision #7's MarkdownChunker implementation. Net new SLOC ≈ 1,300 (production: AnthropicPdfReader, MarkdownChunker, ChunkMarkdown.tsx, /kb/images endpoint) + 950 (tests). 29 new tests this iteration, 76 cumulative across 1+2; 382 passing in the relevant suites with zero regressions. Frontend type-clean.
What an operator can now do without writing code:
# Two hosted backends are live; pick one with KB_PDF_EXTRACTOR.
# Both rely on the same KB_IMAGE_DIR for figure persistence (Mistral
# emits image bytes; Anthropic doesn't, but the env var is harmless
# either way).
export KB_PDF_EXTRACTOR=anthropic # or mistral_ocr
export ANTHROPIC_API_KEY=<key> # or MISTRAL_API_KEY=<key>
export KB_IMAGE_DIR=/mnt/shared/colony-images
After redeploy, the KB tab renders each chunk as full markdown with:
- LaTeX math ($inline$ / $$display$$) shown verbatim — picked up by any downstream KaTeX integration; today renders as code-styled text.
- GFM tables rendered natively.
- Fenced code blocks shown in a max-height collapsible <pre> with an inline expand toggle.
- Figure references resolved through /api/v1/kb/images/{sha} so images appear inline (Mistral OCR path); the Anthropic path describes figures in prose since the API returns no image bytes.
- A "📷 N" badge per chunk when figure_ids is non-empty.
- A metadata_origin badge naming the extractor that produced the chunk (mistral_ocr / anthropic / …).
MarkdownChunker keeps fenced code, GFM tables, and display math ($$...$$, \\[...\\]) atomic — a 200-line function emerges as one (oversized) chunk rather than being torn apart. The Ingestor picks the chunker per section: format="markdown" → MarkdownChunker; format="text" → ProseChunker (existing behaviour preserved).
Acceptance: opening any chunk in the KB tab for a paper shows properly-formatted markdown (LaTeX equations as $...$, tables as GFM tables, figures as inline previews resolved through /api/v1/kb/images/<sha>).
What is intentionally NOT in this iteration (deferred to iteration 3):
- Self-hosted Marker / Docling / MinerU deployments (rows 1.5-1.8) — each needs its own Docker variant + library; not blocking on a CPU dev box where they would be too slow to use anyway.
- cluster_config.yaml integration (row 1.11) — env vars cover the live path; YAML wiring lands with the self-hosted deployments.
- GROBID demote (row 1.13) — GROBID still runs as today; the new readers already shadow it via the registry's last-write-wins rule, so behaviour is correct. The structural rewrite into a sibling metadata-only reader needs a multi-reader Ingestor pass and is a clean slice of work on its own.
Iteration 3 wrap-up (2026-05-08)¶
What landed: rows 1.5, 1.6, 1.7, 1.8, 1.9b (NEW), 1.9c (NEW), 1.11, 1.13, plus the knowledge_marker poetry extra and the cost/quality matrix rewrite of §5. Net new SLOC ≈ 2,800 (production: 4 readers + 3 deployments + cluster config + GROBID demote) + 1,500 (tests). 47 new tests this iteration, 123 cumulative across 1+2+3; 428 passing in the relevant suites with zero regressions. Frontend type-clean.
The two bonus rows (1.9b / 1.9c) were added in response to the operator's "we need cost/quality tradeoffs, not just cheapest" point — Gemini covers the balanced-multimodal price/quality sweet spot at $0.003/page, LlamaParse covers the highest-fidelity end at $0.05/page agentic-plus. The operator now has seven hosted backend × tier combinations plus three self-hosted deployments to A/B against one another via the pdf_extractor.backend YAML field and /kb/ingest's extractor_override.
Note (superseded by iteration 3.1): iteration 3 originally shipped a dual env-var + YAML path with
KB_PDF_EXTRACTORwrites on the driver and a wide proxy allowlist forwarding the knob to workers. Iteration 3.1 deletes that workaround — knowledge-stack knobs are typed fields onKnowledgeConfigand reach workers viaPOLYMATHERA_CONFIG. The runbook below reflects iteration 3.1.
Iteration 3.1 wrap-up (2026-05-08)¶
What landed: knowledge-stack knobs absorbed into typed
ConfigComponents. New QdrantConfig, GrobidConfig, and
image_dir field on KnowledgeConfig. knowledge/deps.py,
web_ui/backend/routers/kb.py, design_monorepo/capabilities.py
all read via get_component_or_default("knowledge",
KnowledgeConfig) instead of os.environ.get. Proxy
RUNTIME_ENV_PREFIXES pruned to bootstrap (POLYMATH, RAY_,
REDIS_) plus secrets (LLM provider key prefixes); KB_,
QDRANT_, GROBID_ removed. Compose env block stripped of the
config-knob passthroughs; .env.template carries only secrets.
example.yaml carries the docker-stack URLs as canonical YAML
defaults. Regression tests rewritten to lock the new contract.
320 passing (knowledge + serving + cluster + dashboard
routers); zero regressions outside live-Redis-only tests.
What an operator can now do without writing code:
# operator.yaml
knowledge:
pdf_extractor:
backend: gemini # cost/quality tier 2
options:
model: gemini-2.5-flash # ~$0.003/page balanced default
image_dir: /mnt/shared/colony-images
qdrant:
url: http://qdrant:6333
collection: colony_knowledge
grobid:
url: http://grobid:8070
# Or, for the highest-fidelity tier:
# pdf_extractor:
# backend: llamaparse
# options:
# tier: agentic_plus
# Or, for self-hosted:
# pdf_extractor:
# backend: docling
# replicas: 2
# num_gpus: 1
add_deployments_to_app brings up the matching *ExtractorDeployment
for self-hosted backends; hosted backends are no-ops at deploy
time. Every Ray worker re-reads the same YAML via its own
ConfigurationManager (loaded from POLYMATHERA_CONFIG), so
driver + actors agree on the configured backends without env-var
passthrough.
extractor_override on /kb/ingest continues to accept the same
backend names — operators A/B between Mistral / Gemini / Anthropic
/ LlamaParse on a single doc from the dashboard without
redeploying.
What is NOT in this iteration: - Multi-reader Ingestor pass (running GROBID metadata-only ALONGSIDE the layout-aware body extractor). Needs a small Ingestor refactor and an explicit cross-reader merge contract; deferred until we have a corpus where citation-graph + layout-aware-body together is genuinely worth it. - Phase 3 (figure captioning), Phase 4 (per-document-type routing), Phase 5 (ColPali), Phase 6 (multimodal at synthesis). Each is its own design iteration tracked separately in §10.
Operator runbook (the actual sequence to test iteration 3.1):
-
Regenerate
poetry.lock— already done in iteration 3;pyproject.tomlgaineddocling,magic-pdf,google-genaiin theknowledgeextra and the lock was refreshed. If you pull a future commit that touches deps, runpoetry lock --no-updatebefore rebuilding. -
Set API keys for the backend(s) you want to use. API keys are secrets, so they live in
.env(compose forwards them as env vars) or your host shell, not in YAML. Pick the rows you need:
# In .env (recommended) or exported in your host shell.
ANTHROPIC_API_KEY=...
OPENAI_API_KEY=...
GOOGLE_API_KEY=...
MISTRAL_API_KEY=...
LLAMA_CLOUD_API_KEY=...
- Pick a backend in YAML. Edit your operator config (or a copy) under the top-level
knowledge.pdf_extractorkey:
The default configs/example.yaml already has docker-stack URLs for qdrant / grobid / image_dir. Override anything else you need.
-
colony-env down && colony-env up --workers 3 --config operator.yaml— rebuilds the image (poetry installs the new deps from the refreshed lock), brings up the stack, mounts the YAML at/mnt/shared/config.yaml. Every container'sPOLYMATHERA_CONFIGpoints at it, so driver + workers + dashboard all resolve the sameKnowledgeConfig. -
Re-ingest the literature corpus from the chat UI. The chunks land in Qdrant via the chosen reader; the KB tab renders multi-line markdown with inline figure previews (Mistral / LlamaParse) or prose figure descriptions (Anthropic / Gemini).
-
Operator A/B from the dashboard.
/kb/ingestacceptsextractor_overrideto swap backends per call without redeploying — flip Mistral → Gemini → LlamaParse on the same PDF and compare.
Special cases:
- Marker (GPL-3.0) is intentionally NOT poetry-managed because its
openai <2.0pin conflicts with the colony'sopenai ^2.21.0. Operators who specifically want Marker rundocker exec colony-ray-head pip install --no-deps marker-pdf(and the same on workers) inside the live containers, then setpdf_extractor.backend: markerin YAML. The deployment lazy-importsmarker, so its absence is a clean error rather than a startup crash. - Self-hosted backends on CPU are slow (5-30 s/page). The ML libraries (
docling,magic-pdf) load on first call and download model weights — first ingest of a doc takes minutes. Subsequent ingests are normal-speed. Use the hosted backends for fast iteration; reserve self-hosted for correctness validation against your specific corpus. - YAML didn't load? If a worker resolves the default
KnowledgeConfig(in-memory vector store,mistral_ocrbackend) when you expected your YAML overrides, check thatPOLYMATHERA_CONFIGpoints at the file inside the container and thatcolony-env up --configwas passed.
Phase 3 — Caption-first multimodality¶
| # | Task | Files | Owner | Status |
|---|---|---|---|---|
| 3.1 | AnthropicVisionDeployment adds caption_figure(image_uri, hint, style) |
cluster/anthropic_deployment.py (extend) |
claude | ⬜ |
| 3.2 | Ingestor optionally captions every FigureRef after image_store.put |
knowledge/ingestion.py |
claude | ⬜ |
| 3.3 | Caption stored as Chunk(data_type="figure_caption", source=<doc>, extra={figure_id}) |
knowledge/ingestion.py |
claude | ⬜ |
| 3.4 | RetrievalHit.figures populated by joining chunk.extra["figure_ids"] against the figure index |
knowledge/retrieval/*.py |
claude | ⬜ |
| 3.5 | Caption prompt template + style registry (cps, paper, datasheet, patent) |
cluster/anthropic_deployment.py |
claude | ⬜ |
| 3.6 | Dashboard shows caption next to figure preview in KB tab | web_ui/frontend/.../KnowledgeBaseTab.tsx |
claude | ⬜ |
| 3.7 | Tests: caption round-trip with mocked deployment, figure-search recall on a 5-doc fixture | knowledge/tests/test_captioner.py |
claude | ⬜ |
Phase 4 — Per-document-type routing (deferred)¶
| # | Task | Status |
|---|---|---|
| 4.1 | IngestionRouter chooses an extractor per data_type (papers→Marker, datasheets→Marker+Camelot, patents→Marker+xref-preserver, standards→Marker+clause splitter). |
⬜ |
| 4.2 | data_type detection heuristic at ingest time (path-based + reader-emitted hints). |
⬜ |
| 4.3 | Datasheet table specialist: pull tables into a structured store keyed by datasheet section. | ⬜ |
| 4.4 | Patent xref preserver: keep Fig. 3 / element 42 semantics intact through chunking. |
⬜ |
Defer until Phase 1+2 ship and we have throughput data.
Phase 5 — ColPali / page-image multi-vector retrieval (deferred, optional)¶
| # | Task | Status |
|---|---|---|
| 5.1 | Decision: only proceed if caption-first measurably underperforms on visually-dense subcorpora (define a small eval set). | ⬜ |
| 5.2 | Second VectorStore collection in Qdrant multi-vector mode. |
⬜ |
| 5.3 | ColPaliEmbedder wraps a hosted endpoint (or vLLM-served ColQwen2.5 in production). |
⬜ |
| 5.4 | RetrievalCapability fuses caption-first text scores with page-level ColPali scores via reciprocal rank fusion. |
⬜ |
Phase 6 — Multimodal at synthesis (separate workstream)¶
| # | Task | Status |
|---|---|---|
| 6.1 | Agent's planner can pass RetrievalHit.figures image URIs to a vision-capable model in its loop. |
⬜ |
| 6.2 | VisionDeployment reused as the agent's vision system (different prompt template, same backend). |
⬜ |
| 6.3 | Sim/visualization-result understanding: agent runs visualization code → captures plot bytes → captions via VisionDeployment → reasons over caption + numeric metrics. |
⬜ |
11. Open questions¶
- Marker license. Resolved 2026-05-08 (decision #6). Marker stays behind a
knowledge_markerpoetry extra; not in the defaultcolony:localimage. Docling (MIT) and MinerU (AGPL-3.0) ship by default. - Chunker boundary preservation for markdown. Direction locked 2026-05-08 (decision #7). Subclass
ProseChunkerinto aMarkdownChunkerthat treats fenced code, GFM tables, and$...$math as atomic units. Implementation deferred to iteration 2 — Mistral OCR emits one section per page so markdown blocks stay within section boundaries today, and the paragraph-aware ProseChunker is sufficient for the iteration-1 smoke test (verified bytest_chunker_figures.py::test_prose_chunker_propagates_figure_ids_per_chunk). - Mistral OCR rate limits. The cookbook claims ~2,000 pages/min but the public tier has unspecified rate caps. Phase 1 stays sequential per-doc and parallel across docs; revisit if throughput hits a ceiling.
- Image store eviction policy. None for v1 — local FS, operator manages. Phase 4 may want size-bounded LRU.
- Figure cross-references. Marker / Mistral emit markdown like
. The reader needs to map "Fig. 3" back to thefigure_idso chunks that mention "Fig. 3" can populatefigure_ids. Plan: maintain a (label →figure_id) table per document during the section walk.
12. Tracking discipline¶
- Update the checklists in §10 as work lands. A row is ⬜ → 🟨 (PR open) → ✅ (merged + on a deployed cluster).
- Each PR references this doc in the commit body so the chain is navigable.
- Acceptance criteria are concrete — Phase 1+2 is done when the operator can re-ingest the literature corpus and the KB tab shows formatted markdown with linked figure previews. Anything short of that is not "done."