Adds technical design docs for the foundation of the doc-centric ingest pivot: - 202 — Document lifecycle state machine - 203 — Per (document, store) ingestion state - 204 — Auto-detect Stale state via chunk content hash - 205 — Audit trail for chunk edits (chunks → first-class entity) - 206 — Migration of existing documents to the new model Status: Accepted on all five. Each doc spells out the domain entities, persistence schema, services orchestration, API contract, alternatives considered, risks, and testing strategy. ADR placeholders called out where load-bearing decisions warrant a follow-up document. Refs #202 #203 #204 #205 #206
329 lines
15 KiB
Markdown
329 lines
15 KiB
Markdown
# Design: Per (document, store) ingestion state
|
|
|
|
- **Issue:** #203
|
|
- **Title on issue:** [FEATURE] Track ingestion state per (document, store) pair
|
|
- **Author:** Pier-Jean Malandrino
|
|
- **Date:** 2026-04-29
|
|
- **Status:** Accepted
|
|
- **Target milestone:** 0.6.0 — Doc-centric ingest
|
|
- **Impacted layers:** backend: domain · persistence · services · api · infra · frontend: features/ingestion · features/document · shared
|
|
- **Audit dimensions likely touched:** Hexagonal Architecture · DDD · Decoupling · Tests · Documentation
|
|
- **ADR spawned?:** ADR-NNN — "Multiple vector stores as first-class entities"
|
|
|
|
---
|
|
|
|
## 1. Problem
|
|
|
|
In the current architecture, "the vector store" is a single OpenSearch index hard-coded to `docling-studio-chunks`. Documents either are or are not indexed in that one place. The new doc-centric model assumes documents can live in **multiple stores** simultaneously — `rh-corpus-v3`, `legal-v1`, a snapshot for a customer A/B — and each `(doc, store)` pair has its own state.
|
|
|
|
This is also where the killer flow lives: a customer reports bad answers from a specific store; the eng opens the doc, sees that it is `Ingested` in `rh-corpus-v3` and `Stale` in `rh-corpus-v2`, fixes the chunks, and re-ingests **the right one**. Without a per-pair state, that flow is impossible.
|
|
|
|
## 2. Goals
|
|
|
|
- [ ] Introduce a `Store` entity in the domain (id, name, kind, config, embedder).
|
|
- [ ] Introduce a `DocumentStoreLink` entity tying a document to a store with its own state and metadata.
|
|
- [ ] States on the link: `Ingested`, `Stale`, `Failed`. (No `Uploaded`/`Parsed`/`Chunked` — those are doc-level only.)
|
|
- [ ] The `Document.lifecycle_state` (#202) aggregates the per-store states by a documented rule.
|
|
- [ ] API exposes the per-store list on the document DTO.
|
|
- [ ] One default `Store` row is seeded on first migration so existing single-index deployments keep working.
|
|
|
|
## 3. Non-goals
|
|
|
|
- Auto-stale detection logic — that is **#204**; this issue ships the schema slot for the content hash but not the detection.
|
|
- Multi-tenant isolation between stores — the existing tenant model (if any) is not changed; stores are global today.
|
|
- A UI to create/manage stores — that is a follow-up. For 0.6.0 we ship the data model and the seed; a single store is enough for the killer flow on a single tenant.
|
|
- Cross-doc bulk re-ingest — that is **#213**; this issue exposes the per-link state needed by it.
|
|
|
|
## 4. Context & constraints
|
|
|
|
### Existing code surface
|
|
|
|
- `document-parser/domain/ports.py` — `VectorStore` protocol (line 124).
|
|
- `document-parser/infra/opensearch_store.py` — `OpenSearchStore` adapter.
|
|
- `document-parser/services/ingestion_service.py` — `IngestionService.ingest()` and `ensure_index()`.
|
|
- `document-parser/persistence/database.py` — schema + migrations.
|
|
- `document-parser/api/ingestion.py` — endpoints.
|
|
- `frontend/src/features/ingestion/` — Pinia store + API client.
|
|
|
|
### Hexagonal Architecture constraints (backend)
|
|
|
|
- `Store` and `DocumentStoreLink` are domain entities (`domain/models.py`). Pure data + invariants. No ORM concern.
|
|
- `StoreRepository` and `DocumentStoreLinkRepository` are ports (`domain/ports.py`).
|
|
- aiosqlite adapters live in `persistence/store_repo.py` and `persistence/document_store_link_repo.py`.
|
|
- The existing `VectorStore` port stays — it represents the **technology** (OpenSearch). The new `Store` entity represents the **logical store** (a named, configurable target). One `VectorStore` adapter can serve many `Store` entities by namespacing the index name.
|
|
|
|
### Deployment modes
|
|
|
|
- Single `OpenSearchStore` adapter handles all stores via per-store `index_name = f"docling-studio-{store.slug}"`. Existing default index gets migrated to `docling-studio-default` (with a redirect alias to keep backwards-compatible reads).
|
|
- HF Space deployment ships with the same one-store seed.
|
|
|
|
### Hard constraints
|
|
|
|
- Existing OpenSearch index must remain readable during migration; reads against the legacy name are aliased.
|
|
- API is additive: existing `/api/ingestion/{analysis_id}` keeps working with an implicit default-store target until the UI explicitly picks one (#222).
|
|
- Tests must not require a real OpenSearch instance for the link layer — repo tests stub the adapter.
|
|
|
|
## 5. Proposed design
|
|
|
|
### 5.1 Domain
|
|
|
|
`document-parser/domain/value_objects.py`:
|
|
|
|
```python
|
|
class StoreKind(StrEnum):
|
|
OPENSEARCH = "opensearch"
|
|
# future: PINECONE, QDRANT, …
|
|
|
|
class DocumentStoreLinkState(StrEnum):
|
|
INGESTED = "Ingested"
|
|
STALE = "Stale"
|
|
FAILED = "Failed"
|
|
```
|
|
|
|
`document-parser/domain/models.py`:
|
|
|
|
```python
|
|
@dataclass
|
|
class Store:
|
|
id: str
|
|
name: str # "rh-corpus-v3"
|
|
slug: str # "rh-corpus-v3" (URL-safe; usually = name)
|
|
kind: StoreKind
|
|
embedder: str # e.g. "bge-m3" — record the embedder used for this store
|
|
config: dict # adapter-specific config (index_name override, etc.)
|
|
created_at: datetime
|
|
is_default: bool
|
|
|
|
@dataclass
|
|
class DocumentStoreLink:
|
|
id: str
|
|
document_id: str
|
|
store_id: str
|
|
state: DocumentStoreLinkState
|
|
chunkset_hash: str | None # set by #204 on push
|
|
last_push_at: datetime | None
|
|
last_run_id: str | None # FK to a future runs table; nullable in 0.6.0
|
|
error_message: str | None # populated on FAILED state
|
|
|
|
def mark_ingested(self, *, hash_: str, at: datetime, run_id: str | None) -> None: ...
|
|
def mark_stale(self, *, at: datetime) -> None: ...
|
|
def mark_failed(self, *, at: datetime, error: str) -> None: ...
|
|
```
|
|
|
|
Aggregation rule for `Document.lifecycle_state` (defined as a pure function in `domain/lifecycle_aggregation.py`):
|
|
|
|
```
|
|
if any link state == FAILED → Document is FAILED
|
|
elif any link state == STALE → Document is STALE
|
|
elif any link state == INGESTED → Document is INGESTED
|
|
elif chunks present (state Chunked) → Document keeps its current state
|
|
else → keep current state
|
|
```
|
|
|
|
The aggregation runs as a side effect of any link write. The doc's own state column (#202) is the materialized result; it is not recomputed at read time.
|
|
|
|
### 5.2 Persistence
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS stores (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
kind TEXT NOT NULL,
|
|
embedder TEXT NOT NULL,
|
|
config TEXT NOT NULL DEFAULT '{}', -- JSON
|
|
is_default INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS document_store_links (
|
|
id TEXT PRIMARY KEY,
|
|
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
store_id TEXT NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
|
|
state TEXT NOT NULL,
|
|
chunkset_hash TEXT,
|
|
last_push_at TEXT,
|
|
last_run_id TEXT,
|
|
error_message TEXT,
|
|
UNIQUE (document_id, store_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_dsl_doc ON document_store_links(document_id);
|
|
CREATE INDEX IF NOT EXISTS idx_dsl_store ON document_store_links(store_id);
|
|
CREATE INDEX IF NOT EXISTS idx_dsl_state ON document_store_links(state);
|
|
```
|
|
|
|
Seed migration inserts one row in `stores`:
|
|
|
|
```sql
|
|
INSERT OR IGNORE INTO stores (id, name, slug, kind, embedder, is_default, created_at)
|
|
VALUES ('default', 'default', 'default', 'opensearch',
|
|
'<env: DEFAULT_EMBEDDER, fallback bge-m3>',
|
|
1, datetime('now'));
|
|
```
|
|
|
|
The OpenSearch index name for `slug=default` is `docling-studio-default`. To keep existing data readable, an alias migration in `OpenSearchStore.ensure_index()` adds `docling-studio-chunks` as a read-only alias to `docling-studio-default` if the legacy index exists with rows. Operators with non-trivial data trigger a one-shot reindex via #206.
|
|
|
|
### 5.3 Infra adapters
|
|
|
|
`OpenSearchStore` is parameterised by store slug:
|
|
|
|
```python
|
|
class OpenSearchStore(VectorStore):
|
|
def __init__(self, client, index_prefix: str = "docling-studio") -> None: ...
|
|
def _index_for(self, store_slug: str) -> str:
|
|
return f"{self.index_prefix}-{store_slug}"
|
|
```
|
|
|
|
Calls become `index_chunks(store_slug, doc_id, chunks, embeddings)` etc. The protocol in `domain/ports.py` is updated to take `store_slug` as an explicit argument.
|
|
|
|
### 5.4 Services
|
|
|
|
New: `StoreService` (`document-parser/services/store_service.py`) — list, get, create (admin only — locked behind a future flag; for 0.6.0 only seeded rows exist).
|
|
|
|
`IngestionService.ingest(analysis_id, store_slug = "default")`:
|
|
1. Read chunks from `analysis_jobs.chunks_json`.
|
|
2. Compute embeddings.
|
|
3. Call adapter `index_chunks(store_slug, ...)`.
|
|
4. Upsert link via `DocumentStoreLinkRepository.upsert(doc_id, store_id, state=Ingested, chunkset_hash=..., at=now)`.
|
|
5. Recompute document aggregate state via the rule in §5.1.
|
|
|
|
If any step fails: link is marked `Failed` with the error; doc state aggregates to `Failed`.
|
|
|
|
### 5.5 API
|
|
|
|
`schemas.py`:
|
|
|
|
```python
|
|
class StoreLinkResponse(BaseModel):
|
|
storeId: str
|
|
storeName: str
|
|
state: str # DocumentStoreLinkState value
|
|
chunksetHash: str | None
|
|
lastPushAt: datetime | None
|
|
lastRunId: str | None
|
|
|
|
class DocumentResponse(BaseModel):
|
|
... # from #202
|
|
stores: list[StoreLinkResponse] = Field(default_factory=list)
|
|
```
|
|
|
|
`stores` is a read-side aggregate computed by `DocumentService.find_by_id()` joining the link table.
|
|
|
|
New endpoint `GET /api/stores` returns the list of stores (id, name, slug, embedder). Used by #222's target picker.
|
|
|
|
### 5.6 Frontend — feature module
|
|
|
|
- `frontend/src/features/ingestion/` — `Ingestion.run()` accepts an optional `storeSlug`; defaults to "default" if not supplied.
|
|
- New `frontend/src/features/stores/` — Pinia store, API client, types.
|
|
- `frontend/src/features/document/api.ts` — `Document` type gains `stores: StoreLink[]`.
|
|
|
|
### 5.7 Cross-cutting
|
|
|
|
- Feature flag: none.
|
|
- i18n: `stores.state.<state>` keys in `shared/i18n.ts`.
|
|
- New env var documented in `.env.example`: `DEFAULT_EMBEDDER` (existing implicitly; now formalised in the seed).
|
|
|
|
## 6. Alternatives considered
|
|
|
|
### Alternative A — One row per (doc, store) embedded in the analysis job
|
|
|
|
- **Summary:** Add a JSON column `stores_json` on `analysis_jobs`.
|
|
- **Why not:** Conflates an *analysis attempt* with a *long-lived ingestion link*. Re-analysis would erase per-store state. The relational shape is necessary for filters, indexes, and idempotent updates.
|
|
|
|
### Alternative B — One adapter per store (parallel `VectorStore` instances)
|
|
|
|
- **Summary:** Hold a registry of `VectorStore` adapters keyed by store id.
|
|
- **Why not:** Duplicates connection pools, breaks singleton pattern in FastAPI's dependency wiring, and forces N OpenSearch clients for what is logically one client serving N indexes. Parameterising the existing single adapter is cheaper.
|
|
|
|
## 7. API & data contract
|
|
|
|
### Endpoints
|
|
|
|
| Method | Path | Request | Response | Breaking? |
|
|
|--------|------|---------|----------|-----------|
|
|
| GET | `/api/documents/{id}` | — | now includes `stores: StoreLinkResponse[]` | No (additive) |
|
|
| GET | `/api/stores` | — | `StoreResponse[]` | No (new) |
|
|
| POST | `/api/ingestion/{analysis_id}` | `{ "storeSlug": "default" }` (optional) | unchanged | No (additive) |
|
|
|
|
### Persistence schema
|
|
|
|
See §5.2.
|
|
|
|
### Env vars / config
|
|
|
|
| Name | Default | Allowed | Notes |
|
|
|------|---------|---------|-------|
|
|
| `DEFAULT_EMBEDDER` | `bge-m3` | any registered embedder slug | recorded on the seeded `default` store |
|
|
| `DOCLING_STUDIO_INDEX_PREFIX` | `docling-studio` | URL-safe slug | adapter-level prefix for OpenSearch indexes |
|
|
|
|
### Breaking changes
|
|
|
|
Additive.
|
|
|
|
## 8. Risks & mitigations
|
|
|
|
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|
|
|------|-----------------|------------|--------|---------------|------------------------|
|
|
| Existing OpenSearch data invisible after rename | Decoupling | Medium | High | Smoke test on staging | Read alias `docling-studio-chunks → docling-studio-default` set in `ensure_index()`; explicit reindex documented in runbook |
|
|
| Multiple stores with conflicting embedders mixed at search time | Security/Performance | Low | Medium | Mismatched dim error from OpenSearch | Embedder is recorded per store; queries route by store; cross-store search is out of scope for 0.6.0 |
|
|
| Link table grows large over time on prod corpus | Performance | Medium | Medium | Slow `/api/documents/{id}` join | Index on `document_id` and `store_id`; pagination on the doc list (#211) |
|
|
| Aggregation rule drifts from per-store reality on partial writes | DDD | Medium | High | Tests fail | Aggregation runs in the same transaction as the link write |
|
|
|
|
## 9. Testing strategy
|
|
|
|
### Backend — pytest
|
|
|
|
- **Unit (domain):** `test_store_link_transitions.py`, `test_lifecycle_aggregation.py` (table-driven over per-store state combinations).
|
|
- **Persistence:** `test_store_repo.py`, `test_document_store_link_repo.py` (round-trip; UNIQUE constraint enforced; cascade delete works).
|
|
- **Services / integration:** `test_ingestion_service_with_store.py` (default store path), `test_ingestion_service_two_stores.py` (push to two stores → two links).
|
|
- **Architecture:** import-boundary test ensures `domain/` does not import `infra/opensearch_store.py`.
|
|
|
|
### Frontend — Vitest
|
|
|
|
- `features/stores/api.test.ts` — list endpoint round-trip.
|
|
- `features/document/store.test.ts` — `Document.stores` populated from API.
|
|
|
|
### E2E — Karate UI
|
|
|
|
Out of scope here. Lands with #211 / #218.
|
|
|
|
### Manual QA
|
|
|
|
1. Boot. `GET /api/stores` returns the seeded `default`.
|
|
2. Upload a doc, run ingestion → `GET /api/documents/{id}` returns `stores[0].state == "Ingested"`.
|
|
3. Inspect SQLite: `document_store_links` has the row.
|
|
|
|
## 10. Rollout & observability
|
|
|
|
### Release branch
|
|
|
|
`release/0.6.0`.
|
|
|
|
### Feature flag
|
|
|
|
None for the data; #222 will gate the multi-store UI.
|
|
|
|
### Observability
|
|
|
|
- Log `store_link_changed` with `doc_id`, `store_id`, `from`, `to`, `at`.
|
|
- Counter (Prometheus, future): `ingestion_links_total{state}`. Optional in 0.6.0.
|
|
|
|
### Rollback plan
|
|
|
|
The migration is additive. To roll back the code: revert; the unused tables stay empty for new docs but contain rows for already-pushed docs — harmless (no read path uses them after revert). For full cleanup, a follow-up migration drops the two tables.
|
|
|
|
## 11. Open questions
|
|
|
|
- Should `Store.config` be typed in the domain (via a discriminated union on `kind`)? **Decision for 0.6.0:** keep as opaque `dict`; introduce a typed wrapper when we add a second `kind`.
|
|
- Cross-store search (single query → many stores) — **explicitly punted** to a later release.
|
|
|
|
## 12. References
|
|
|
|
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/203
|
|
- **Related issues:** #202 (lifecycle), #204 (hash), #205 (audit), #206 (migration), #211 (library), #213 (bulk push), #222 (push UI), #223 (diff-aware ingest)
|
|
- **ADRs:** ADR — "Multiple vector stores as first-class entities" (to be drafted alongside the implementation PR)
|
|
- **Project docs:**
|
|
- Architecture: `docs/architecture.md`
|
|
- Coding standards: `docs/architecture/coding-standards.md`
|
|
- ADR guide: `docs/architecture/adr-guide.md`
|