Skip to content

Implementation Plan: Sherlock Universal RAG API

Spec: 013-sherlock-rag-api Date: 2026-03-03


Summary

013 extends Sherlock (012) into a Universal RAG engine by adding an OpenAI-compatible Files API, Vector Stores API, and Embeddings API on top of the existing chat surface. RAG is strictly additive: gated by SHERLOCK_RAG_ENABLED (default false), it initializes only in the reason/ultra-instinct profiles. All 012 routes and tests remain untouched when the flag is off.

The implementation lives entirely in services/reasoner/ (new rag/ package + 3 new routers). Persistence changes are additive SQL tables in services/persistence/. A new MinIO bucket (sherlock-files) is added to the reason profile compose.


Target Modules

ModuleLanguageChanges
services/reasoner/PythonNew rag/ package; 3 new routers; graph extension; config additions; RAGInfra in AppState
services/persistence/SQL4 new tables in sherlock schema (init script additive)
services/storage/ConfigNo change to service; sherlock-files bucket provisioned at startup by RAGInfra
services/reasoner/docker-compose.ymlYAMLAdd arc-storage to reason profile depends; add SHERLOCK_RAG_* env vars
services/reasoner/service.yamlYAMLBump to 0.3.0; add storage to depends_on
services/reasoner/pyproject.tomlTOMLAdd minio>=7.2, pypdf>=4.0, python-docx>=1.1; wire tiktoken
services/reasoner/DockerfileDockerAdd CPU-only torch constraint (CI build optimization)
.github/workflows/reasoner-images.ymlYAMLAdd pip cache to typecheck/test jobs

Technical Context

AspectValue
LanguagePython 3.13
FrameworksFastAPI, LangGraph, SQLAlchemy async, pgvector
StoragePostgreSQL 17 (pgvector) + MinIO (via minio SDK)
Testingpytest + httpx, pytest-asyncio, ≥ 75% coverage on rag/ + new routers
New Dependenciesminio>=7.2, pypdf>=4.0, python-docx>=1.1
Existing (already installed)sentence-transformers, tiktoken, pgvector, sqlalchemy, asyncpg
Lintruff check src/ && mypy src/ --strict (zero errors)

Architecture

Component Overview

Ingestion Flow

RAG Chat Flow

Hybrid Search SQL


Constitution Check

#PrincipleStatusEvidence
IZero-Dep CLIN/ANo CLI changes
IIPlatform-in-a-BoxPASSrag_enabled=false default — think profile byte-for-byte identical to 012; MinIO only in reason
IIIModular ServicesPASSAll changes inside services/reasoner/; MinIO referenced via existing arc-storage service
IVTwo-BrainPASSPython only — parsing, chunking, embedding, reranking are intelligence concerns
VPolyglot StandardsPASSFastAPI + Pydantic v2 + ruff + mypy strict + pytest fixtures; no AI attribution in commits
VILocal-FirstN/ANo CLI component
VIIObservabilityPASS6 new OTEL metrics (uploads.total, ingest.latency, retrieval.latency, search.latency, embed.latency, rag.errors); every route follows counter/timer/error pattern
VIIISecurityPASSMinIO creds via SecretStr in Settings; file content never logged or put in spans (SHERLOCK_CONTENT_TRACING=false default)
IXDeclarativeN/ANo CLI component
XStateful OpsN/ANo CLI component
XIResiliencePASSIngest failure → status=failed + error_message in DB, API unaffected; pgvector down → empty knowledge ctx, chat responds from conv memory only
XIIInteractiveN/ANo CLI component

Project Structure

services/reasoner/
├── src/sherlock/
│   ├── config.py                     # + rag_enabled, minio_*, hybrid_alpha, max_file_bytes, sync_timeout_s
│   ├── main.py                       # + RAGInfra in AppState; lifespan init; router mounts
│   ├── openai_router.py              # + file_search tool detection → vector_store_ids passthrough
│   ├── graph.py                      # + rag_retrieve node (no-op when vector_store_ids absent)
│   ├── files_router.py               # NEW — POST/GET/DELETE /v1/files + /content
│   ├── vector_stores_router.py       # NEW — CRUD + /files sub-resource + /search
│   ├── embeddings_router.py          # NEW — POST /v1/embeddings
│   └── rag/
│       ├── __init__.py
│       ├── store.py                  # RAGInfra factory + dataclass
│       ├── chunker.py                # Fixed-size chunker (512 tokens, 50 overlap)
│       ├── nats_handler.py           # sherlock.v1.rag.{ingest,chat,search,embed}.*
│       ├── domain/
│       │   ├── models.py             # VectorStore, KnowledgeFile, VectorStoreFile, KnowledgeChunk, SearchResult, IngestJob
│       │   └── ports.py              # FileStorePort, VectorStorePort, EmbedderPort, RerankerPort (Protocols)
│       ├── adapters/
│       │   ├── pgvector.py           # VectorStorePort impl — init tables, upsert_chunks, search_hybrid, delete_vs
│       │   ├── minio.py              # FileStorePort impl — upload, download, delete
│       │   ├── embedder.py           # EmbedderPort impl — wraps existing SentenceTransformer
│       │   └── reranker.py           # RerankerPort impl — lazy CrossEncoder(ms-marco-MiniLM-L-6-v2)
│       ├── parsers/
│       │   ├── __init__.py           # dispatch_parser(filename, bytes) → ParsedDocument
│       │   ├── base.py               # ParsedDocument dataclass + ParserPort Protocol
│       │   ├── text_parser.py        # .txt, .md, .rst, .py, .go, .ts, .js, .tsx, .jsx (UTF-8 passthrough)
│       │   ├── pdf_parser.py         # .pdf → pypdf
│       │   ├── docx_parser.py        # .docx → python-docx
│       │   ├── json_parser.py        # .json → pretty-print string
│       │   └── csv_parser.py         # .csv → row-per-chunk
│       └── application/
│           ├── ingest.py             # IngestPipeline.ingest(file_id, vs_id) → chunk_count
│           └── retrieve.py           # HybridRetriever.search(query, vs_ids, alpha) → list[SearchResult]

services/persistence/
└── initdb/
    └── 004_sherlock_rag_schema.sql   # NEW — 4 tables: vector_stores, knowledge_files, vector_store_files, knowledge_chunks

services/reasoner/
├── pyproject.toml                    # + minio>=7.2, pypdf>=4.0, python-docx>=1.1
├── service.yaml                      # version: 0.3.0; depends_on += storage
├── docker-compose.yml                # + SHERLOCK_RAG_ENABLED, MINIO_* env vars
└── Dockerfile                        # CPU-only torch constraint (CI build fix)

specs/013-sherlock-rag-api/
└── contracts/
    ├── openapi.yaml                  # + 13 new paths + schemas
    └── asyncapi.yaml                 # + 4 sherlock.v1.rag.* channel pairs

.github/workflows/
└── reasoner-images.yml               # + pip cache on typecheck/test jobs

Key Technical Decisions

1. RAGInfra dataclass in AppState (not a service singleton)

RAGInfra holds file_store, vector_store, embedder, reranker, ingest_pipeline, retriever. Injected into AppState.rag: RAGInfra | None. Routers check state.rag and return 503 immediately if None — zero impact on existing code paths.

2. Shared SentenceTransformer instance

SherlockMemory already holds a SentenceTransformer for conversation embeddings. EmbedderAdapter accepts an encoder instance — main.py passes memory._encoder when constructing RAGInfra. No second model load.

3. fts_vector as GENERATED ALWAYS column

sql
fts_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED

Eliminates application-side tsvector construction. pgvector adapter only inserts content + embedding.

4. Hybrid search as a single SQL query

sql
SELECT id, content, file_id, chunk_index,
    ($1::vector <=> embedding) * (-0.7) + ts_rank(fts_vector, query) * 0.3 AS score
FROM sherlock.knowledge_chunks
WHERE vector_store_id = ANY($2)
  AND ($3::tsquery IS NULL OR fts_vector @@ $3::tsquery)
ORDER BY score DESC
LIMIT 50

hybrid_alpha=0.0 → coalesce dense weight to 0; hybrid_alpha=1.0 → coalesce FTS weight to 0.

5. ?sync=true with configurable timeout

IngestPipeline.ingest() is awaited directly (no BackgroundTask). A asyncio.wait_for(..., timeout=settings.sync_timeout_s) wraps it. On TimeoutError → falls back to creating a BackgroundTask and returns 202.

6. DB tables created by pgvector.py adapter init_schema()

Called inside RAGInfra init (after memory.init()). Mirrors the existing SherlockMemory.init() pattern: CREATE TABLE IF NOT EXISTS, best-effort, logs warning on failure.

7. Tiktoken wiring (FR-14 — fixes 012 gap)

openai_router.py already imports tiktoken but returns (0, 0, 0). Add _count_tokens(model, messages) → int helper using tiktoken.encoding_for_model with fallback to cl100k_base. Wire into both /v1/chat/completions and /v1/embeddings response objects.


Parallel Execution Strategy

Parallelizable agent groups:

  • Group A (Phase 2): minio.py + pgvector.py + embedder.py + reranker.py + parsers + chunker.py
  • Group B (Phase 4): files_router.py + vector_stores_router.py + embeddings_router.py
  • Group C (Phase 5): CI Dockerfile changes + CI workflow changes (independent of Python code)

New Settings Fields (config.py additions)

python
# RAG feature gate
rag_enabled: bool = Field(False, alias="SHERLOCK_RAG_ENABLED")

# MinIO (Tardis)
minio_endpoint: str = Field("arc-storage:9000", alias="SHERLOCK_MINIO_ENDPOINT")
minio_access_key: SecretStr = Field("minioadmin", alias="SHERLOCK_MINIO_ACCESS_KEY")
minio_secret_key: SecretStr = Field("minioadmin", alias="SHERLOCK_MINIO_SECRET_KEY")
minio_bucket: str = Field("sherlock-files", alias="SHERLOCK_MINIO_BUCKET")
minio_secure: bool = Field(False, alias="SHERLOCK_MINIO_SECURE")

# RAG tunables
max_file_bytes: int = Field(50 * 1024 * 1024, alias="SHERLOCK_MAX_FILE_BYTES")  # 50 MB
hybrid_alpha: float = Field(0.7, alias="SHERLOCK_HYBRID_ALPHA")  # 0=FTS, 1=dense
chunk_size_tokens: int = Field(512, alias="SHERLOCK_CHUNK_SIZE_TOKENS")
chunk_overlap_tokens: int = Field(50, alias="SHERLOCK_CHUNK_OVERLAP_TOKENS")
retrieval_candidate_k: int = Field(50, alias="SHERLOCK_RETRIEVAL_CANDIDATE_K")
retrieval_top_k: int = Field(5, alias="SHERLOCK_RETRIEVAL_TOP_K")
reranker_model: str = Field(
    "cross-encoder/ms-marco-MiniLM-L-6-v2", alias="SHERLOCK_RERANKER_MODEL"
)
sync_timeout_s: float = Field(30.0, alias="SHERLOCK_SYNC_TIMEOUT_S")

DB Schema (004_sherlock_rag_schema.sql)

sql
-- Requires: 003_enable_pgvector.sql (CREATE EXTENSION vector already applied)

CREATE TABLE IF NOT EXISTS sherlock.vector_stores (
    id          text PRIMARY KEY DEFAULT 'vs-' || gen_random_uuid()::text,
    name        text NOT NULL,
    file_count  integer NOT NULL DEFAULT 0,
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS sherlock.knowledge_files (
    id          text PRIMARY KEY DEFAULT 'file-' || gen_random_uuid()::text,
    filename    text NOT NULL,
    purpose     text NOT NULL DEFAULT 'assistants',
    bytes       bigint NOT NULL DEFAULT 0,
    minio_key   text NOT NULL,
    status      text NOT NULL DEFAULT 'uploaded',
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS sherlock.vector_store_files (
    vector_store_id text NOT NULL REFERENCES sherlock.vector_stores(id) ON DELETE CASCADE,
    file_id         text NOT NULL REFERENCES sherlock.knowledge_files(id) ON DELETE CASCADE,
    status          text NOT NULL DEFAULT 'queued',
    chunk_count     integer,
    error_message   text,
    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (vector_store_id, file_id)
);

CREATE TABLE IF NOT EXISTS sherlock.knowledge_chunks (
    id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    vector_store_id text NOT NULL REFERENCES sherlock.vector_stores(id) ON DELETE CASCADE,
    file_id         text NOT NULL REFERENCES sherlock.knowledge_files(id) ON DELETE CASCADE,
    chunk_index     integer NOT NULL,
    content         text NOT NULL,
    embedding       vector(384),
    fts_vector      tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
    created_at      timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_vs_id
    ON sherlock.knowledge_chunks (vector_store_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_embedding
    ON sherlock.knowledge_chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_fts
    ON sherlock.knowledge_chunks USING gin (fts_vector);

Cross-Module Dependencies

DependencyDirectionNotes
services/reasonerservices/persistenceStartup: DDL via pgvector.py init_schema()Tables created idempotently; safe on 012 cold start if rag_enabled=false (skipped)
services/reasonerservices/storageRuntime: MinIO S3 API :9000Only when rag_enabled=true; reason profile includes arc-storage
services/persistence/initdb/004_...sqlinitdb (first Oracle boot only)Additive — does not alter existing tables

Risks & Mitigations

RiskImpactMitigation
CrossEncoder lazy download (~200 MB) blocks first requestMLazy init on first rerank() call with cached instance; document in README
MinIO unreachable at startupHminio.py upload() raises StorageUnavailableError → router returns 503; startup continues
fts_vector GENERATED column requires PG 12+LOracle uses PG 17 — no risk
hybrid_alpha edge cases (0.0, 1.0)MSQL handles via CASE WHEN alpha = 0.0 THEN 0 ELSE ... weight; tested in unit tests
mypy strict on minio SDK (minimal type stubs)L[[tool.mypy.overrides]] module = ["sherlock.rag.adapters.minio"] ignore_missing_imports = true
Large DOCX/PDF parse time blocks HTTP requestMIngest runs in BackgroundTask by default (async, non-blocking)
CI build >20 min (H-1/H-4)MDockerfile CPU-torch + uv (M-1 + M-3) — implement before merge

Reviewer Checklist

  • [ ] SHERLOCK_RAG_ENABLED=false (default): think profile starts, all 012 tests pass unchanged
  • [ ] SHERLOCK_RAG_ENABLED=true: all 6 success criteria (SC-1 through SC-6) pass
  • [ ] ruff check src/ && mypy src/ --strict — zero errors
  • [ ] pytest tests/ --cov=sherlock — ≥ 75% on rag/, files_router.py, vector_stores_router.py, embeddings_router.py
  • [ ] All 012 tests pass: pytest tests/ -k "not rag"
  • [ ] usage.prompt_tokens + completion_tokens non-zero in all chat responses (SC-8)
  • [ ] NATS RAG subjects wired and tested (SC-9)
  • [ ] service.yaml bumped to 0.3.0, storage in depends_on
  • [ ] openapi.yaml and asyncapi.yaml updated with new paths/channels
  • [ ] File content never appears in logs or OTEL spans
  • [ ] SecretStr used for all MinIO credentials
  • [ ] CI: torch.version.cuda returns None in rebuilt image (BC-4)
  • [ ] No AI attribution in any commit messages

Contributors

Changelog