Skip to content

feat(core): SPI split — RetrievalBackend / IndexBackend (Step 1.1b)#45

Merged
officialCodeWork merged 1 commit into
mainfrom
build/phase-1/step-1.1b-spi-split-retrieval-index
May 24, 2026
Merged

feat(core): SPI split — RetrievalBackend / IndexBackend (Step 1.1b)#45
officialCodeWork merged 1 commit into
mainfrom
build/phase-1/step-1.1b-spi-split-retrieval-index

Conversation

@officialCodeWork

Copy link
Copy Markdown
Owner

Summary

Step 1.1b of the Phase 1 architecture-refactor window. Splits the storage-side
SPIs into narrow read / write roles, formalises ID-only retrieval, and
introduces the IndexHint parameter that ADR-0009 calls for.

  • SPI split: VectorStore / KeywordStore / GraphStore become thin
    composite ABCs over *RetrievalBackend (read) and *IndexBackend (write).
    Consumers can depend on the narrower role; backends serving both sides
    keep one class to implement.
  • ID-only retrieval: retrieve_ids(ctx, …) → list[ChunkRef] replaces the
    old query / search methods that returned (ChunkId, score) tuples.
    hydrate(ctx, chunk_refs) loads full Chunk payloads from keyword stores;
    vector stores pass-through (content lives elsewhere — Storage SPI / keyword
    index).
  • Bulk + streaming writes: bulk_index / bulk_delete are the canonical
    write methods. stream_index accepts an AsyncIterator and batches into
    bulk_index calls (default impl; backends with native streaming can
    override). Graph adds bulk_upsert_nodes, bulk_upsert_edges,
    bulk_delete_nodes, stream_upsert_nodes.
  • IndexHint + WriteVolume added to rag_core.types (ADR-0009);
    passed to all bulk_index / stream_index calls and reserved for
    index-implementation selection at initialize() time.
  • Embedder split: canonical bulk_embed(ctx, texts, chunk_ids); thin
    embed(ctx, text, chunk_id) default wraps bulk_embed for single-item
    callers (per performance.md p99 budgets).
  • Signature linter (tests/contract/spi_signature.py) extended to
    enforce ctx-first on the six new role ABCs.
  • Backends migrated: NoopVectorStore, NoopKeywordStore, NoopEmbedder,
    PgVectorStore, QdrantVectorStore (NoopGraphStore inherits the new
    bulk defaults unchanged).

Embedding still lacks a typed corpus_id field (ADR-0004 gap); pgvector
and qdrant continue to default to "" for now — addressed in a later step.

Documentation

  • docs/reference/rag-core.md — adds "Read/write
    SPI split (Step 1.1b)" and "Choosing an IndexHint" sections; updates the
    type table with IndexHint / WriteVolume.
  • docs/architecture/storage-backends.md
    — points the "add a new VectorStore backend" checklist at the new method
    names.
  • ADR-0009 (already merged in the prior planning PR) defines the IndexHint
    shape and per-size index choice; this PR is the SPI implementation it
    describes.

Test plan

  • uv run ruff check . + uv run ruff format --check . — clean
  • uv run mypy packages/ apps/gateway/ — 76 source files, no issues
  • uv run python scripts/check_logging.py — RAG001 clean
  • uv run pytest tests/ packages/ --ignore=tests/integration -x -q — 507 passed, 1 skipped
  • uv run python -m rag_core.gen_schemas dist/schemasIndexHint.json generated
  • Integration tests (tests/integration/test_pgvector.py, test_qdrant.py)
    updated for the new method names — run with task dev services up
    before merge.

🤖 Generated with Claude Code

Split VectorStore / KeywordStore / GraphStore into narrow read+write roles
(*RetrievalBackend / *IndexBackend) so consumers depend on the role they
actually use; legacy composite ABCs preserved for backends serving both
sides. Adds bulk + streaming + ID-only retrieval to the SPI surface,
introduces IndexHint + WriteVolume for scale-tier writes (ADR-0009), and
splits Embedder into single embed + canonical bulk_embed.

- Retrieval side: retrieve_ids → list[ChunkRef]; hydrate on keyword stores
  loads full Chunks (vector stores pass-through, content lives elsewhere).
- Index side: bulk_index / bulk_delete; stream_index default batches an
  AsyncIterator into bulk_index calls. Graph adds bulk_upsert_nodes/edges
  and bulk_delete_nodes + stream_upsert_nodes.
- Backends migrated: NoopVectorStore / NoopKeywordStore / NoopGraphStore /
  NoopEmbedder + PgVectorStore + QdrantVectorStore.
- tests/contract/spi_signature.py extended to enforce ctx-first on all
  new role ABCs.
- Schemas regenerated (IndexHint.json now produced by gen_schemas).
- Docs: rag-core.md adds "Read/write SPI split" + IndexHint section;
  storage-backends.md points to the new method names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@officialCodeWork
officialCodeWork merged commit b98cb5a into main May 24, 2026
10 checks passed
officialCodeWork pushed a commit that referenced this pull request May 24, 2026
… (Step 1.1c)

New workspace package packages/policy/ (import root rag_policy v0.1.0).
Establishes the single Policy Decision Point ADR-0005 calls for: every
retrieval / ingest / egress code path consults PolicyEngine, and a CI
linter fails when a governance-relevant SPI call lands without an
adjacent consultation.

Surface
- PolicyEngine ABC: async evaluate(ctx, decision, subject) -> PolicyResult
  and async filter_pushdown(ctx, decision) -> FilterExpr.
- NoopPolicyEngine: always-ALLOW; filter_pushdown still emits a
  tenant-scoped And(Eq("tenant_id", ...)) so backends never cross-tenant
  leak even with the noop loaded.
- PolicyWriter facade (mirrors AuditWriter): delegates to engine, emits
  policy.decision structured log via rag-observability.
- PolicyDecision enum: read_chunk / ingest_doc / egress_text /
  quota_check / rate_limit / execute_plan.
- PolicyResult: frozen union (allow / deny(reason) / transform(subject))
  with is_allow / is_deny / is_transform / transformed_or helpers.
- QuotaSubject / RateLimitSubject for the non-Chunk decision subjects.
- FilterExpr mini-language (Eq, AnyIn, And, Or, Not, TrueExpr):
  discriminated-union of frozen Pydantic models; backends translate to
  native filter languages.

Coverage linter
- tests/policy/coverage.py greps for retrieve_ids / hydrate / bulk_index
  / stream_index / bulk_embed / .complete call sites without an adjacent
  PolicyEngine / PolicyWriter marker. File-allowlist at top; failures
  block CI. Consumers (gateway, ingest) shrink the allowlist as they
  wire the PDP in. Future Step 1.1f tightens to call-pattern matching.
- Collected via pytest python_files extended to include coverage.py.

Wiring
- packages/policy added to [tool.uv.workspace].members and to pytest
  pythonpath in root pyproject.toml.
- Dependencies: rag-core + rag-observability only (matches backends
  precedent; CLAUDE.md graph: policy -> core).

Tests + gates
- 20 conformance tests under tests/contract/test_policy_engine.py.
- 1 coverage-linter run under tests/policy/coverage.py.
- ruff + mypy --strict (83 source files, +7 from 1.1b) + RAG001 logging
  check all green; full non-integration suite 528 tests pass.

Docs
- New docs/reference/rag-policy.md (Overview / Usage / Internals /
  Extension points), docs/README.md index updated. ADR-0005 and
  docs/architecture/policy-engine.md already match the shipped shape.

TRACKER housekeeping
- Step 1.1c flipped to done; Next action set to 1.1d (Pipeline +
  Batcher primitives).
- PR history table: added rows for #44 (1.1a) and #45 (1.1b) which
  were missed when those steps merged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
officialCodeWork pushed a commit that referenced this pull request May 24, 2026
… (Step 1.1c)

New workspace package packages/policy/ (import root rag_policy v0.1.0).
Establishes the single Policy Decision Point ADR-0005 calls for: every
retrieval / ingest / egress code path consults PolicyEngine, and a CI
linter fails when a governance-relevant SPI call lands without an
adjacent consultation.

Surface
- PolicyEngine ABC: async evaluate(ctx, decision, subject) -> PolicyResult
  and async filter_pushdown(ctx, decision) -> FilterExpr.
- NoopPolicyEngine: always-ALLOW; filter_pushdown still emits a
  tenant-scoped And(Eq("tenant_id", ...)) so backends never cross-tenant
  leak even with the noop loaded.
- PolicyWriter facade (mirrors AuditWriter): delegates to engine, emits
  policy.decision structured log via rag-observability.
- PolicyDecision enum: read_chunk / ingest_doc / egress_text /
  quota_check / rate_limit / execute_plan.
- PolicyResult: frozen union (allow / deny(reason) / transform(subject))
  with is_allow / is_deny / is_transform / transformed_or helpers.
- QuotaSubject / RateLimitSubject for the non-Chunk decision subjects.
- FilterExpr mini-language (Eq, AnyIn, And, Or, Not, TrueExpr):
  discriminated-union of frozen Pydantic models; backends translate to
  native filter languages.

Coverage linter
- tests/policy/coverage.py greps for retrieve_ids / hydrate / bulk_index
  / stream_index / bulk_embed / .complete call sites without an adjacent
  PolicyEngine / PolicyWriter marker. File-allowlist at top; failures
  block CI. Consumers (gateway, ingest) shrink the allowlist as they
  wire the PDP in. Future Step 1.1f tightens to call-pattern matching.
- Collected via pytest python_files extended to include coverage.py.

Wiring
- packages/policy added to [tool.uv.workspace].members and to pytest
  pythonpath in root pyproject.toml.
- Dependencies: rag-core + rag-observability only (matches backends
  precedent; CLAUDE.md graph: policy -> core).

Tests + gates
- 20 conformance tests under tests/contract/test_policy_engine.py.
- 1 coverage-linter run under tests/policy/coverage.py.
- ruff + mypy --strict (83 source files, +7 from 1.1b) + RAG001 logging
  check all green; full non-integration suite 528 tests pass.

Docs
- New docs/reference/rag-policy.md (Overview / Usage / Internals /
  Extension points), docs/README.md index updated. ADR-0005 and
  docs/architecture/policy-engine.md already match the shipped shape.

TRACKER housekeeping
- Step 1.1c flipped to done; Next action set to 1.1d (Pipeline +
  Batcher primitives).
- PR history table: added rows for #44 (1.1a) and #45 (1.1b) which
  were missed when those steps merged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant