Projects

AI Engineering / MLOps / GenAI

LLMOps, agentic systems, and production GenAI infrastructure — drawn from my current work leading AI R&D.

Case Study

Event-Driven Document ETL Pipeline for RAG

Turns unstructured and semi-structured documents into a retrieval-ready knowledge base — a scale-to-zero worker that replaced a fragile three-Lambda chain.

Trigger
SQS message from any authorized producer
Compute
ECS/Fargate — 0-5 tasks, scales to zero
Stages
Parse → Process → Extract → Index
Sinks
Hybrid search + SQL store + job tracker
Architecture diagram: SQS-triggered ECS/Fargate worker running Parse, Process, Extract, and Index stages, writing to a hybrid search index, relational store, and job-tracking table
High-level architecture — ingestion, pipeline stages, and storage sinks.
Implementation details
  • Format-aware parsing (PDF/DOCX/PPTX/CSV/XLSX/JSON) with structure-preserving, section-aware chunking
  • Per-document LLM entity extraction — 50-100x cheaper than per-chunk, and clinical/technical documents still fit in one call
  • Hybrid retrieval: dense embeddings + sparse neural vectors + BM25, with a structural metadata prefix and IDF-weighted tags for ranking quality
  • Native tabular data (CSV/XLSX) routed to a relational store for SQL-queryable access with row-level RBAC; PDF-extracted tables stay in the search index
  • Content-hash idempotency, dead-letter handling, and per-item error isolation so one bad chunk never blocks a whole document
Case Study

Row-Level RBAC for Agentic Data Access via MCP

An LLM agent can call tools across a search index, a relational store, and external APIs. The access model asks two separate questions: which tools should this user even see, and — independently — which rows or documents can any given tool call actually return.

Layer 1 · Tool gating
Which tools exist for this user

Resolved once, upstream, from the user's role: someone without a given grant never sees the related tool offered at all — not a rejected call, an absent option. Decided before any request reaches a data source.

Layer 2 · Row / document gating
Which rows a tool call can return

Independent of layer 1: every query to a data source carries the caller's access tags and filters by them at query time — so a tool that's available still can't surface a record outside the caller's grant.

Source
How access is enforced
Row-level
Search index
Every query carries the caller's access tags and applies them as a filter, so gating never affects relevance ranking — a restricted document simply isn't in the candidate set, not down-ranked
Enforced
Relational store
Before a generated query runs, the tables it touches are checked against an allow-list resolved from the caller's tags; anything outside that list — or any query that can't be confidently parsed — is refused rather than partially executed
Enforced
External APIs
These sources have no stored corpus to filter row-by-row — access control is entirely layer 1: if a user's role doesn't include the source, the tool for it is never offered
Tool-gated only

Enforcement depth is matched to what each source actually needs — not every source gets the same treatment, and the table says so honestly.

Design notes
  • The two layers are owned by different services on purpose: the service that authenticates users resolves roles into access tags and a tool list; the service that talks to data sources trusts those tags and never re-derives a role itself — a clean split between who someone is and what a query is allowed to return
  • Access tags are free-text strings, not a fixed enum — adding a new access tier is a new tag value, not a schema or code change to the filtering logic itself
  • Visibility is additive: a record is visible if the caller holds any one of its tags, and nothing defaults to visible unless it's explicitly untagged or tagged public
  • The trust boundary is written down, not assumed: the filtering layer documents that it relies on the upstream layer having set tags correctly — an honest architecture names where a guarantee actually lives instead of implying it's stronger than it is
Practice

One Toolchain, Local and CI Identical

Across the backend and frontend services I own, "works on my machine" and "works in CI" are the same command, run the same way, on the same pinned tool versions — not two workflows that happen to usually agree.

Lint Type check Unit tests Integration (real DB) UI tests (sharded)
Implementation notes
  • Fail-fast ordering: lint, format, and type-check run first and gate everything else, so CI rejects trivially-broken code in seconds, not minutes
  • Integration tests hit a real containerized database, never mocks, for anything touching persistence — the extra runtime buys a suite that can't lie about a schema mismatch
  • UI tests run browser-driven and sharded across CI runners, so end-to-end coverage doesn't become the bottleneck as the suite grows
  • Secret scanning runs as a pre-commit gate, not a CI afterthought — leaked credentials get caught before they ever reach a remote branch
  • Toolchain pinning: a version manager and task runner (e.g. mise) pins exact language/runtime and package-manager versions in a lockfile, and every workflow — setup, lint, test tiers, migrations, build — is defined as a task, so a developer and CI run the identical command. A declarative task file (e.g. Taskfile) plays the same role on projects that predate or don't need that tool — different runner, same principle
More coming soon

This section will grow to cover tool-calling agents, multi-agent orchestration, and the broader MLOps infrastructure that keeps GenAI systems reliable in production.