Back to work
Resume Let's talk
Case study · Personal project

PulseSearch — real-time
CDC search + RAG

An AI-native search system over a live data firehose: change data capture streams every write from MySQL into Elasticsearch, served as hybrid keyword + vector search and a grounded RAG assistant via Groq (free tier). The full pipeline runs on docker compose up; the app tier also ships as Kubernetes manifests with probes and Ingress, and GitHub Actions lint, test, scan, and publish images to GHCR — embeddings stay local, no paid cloud services.

Role Solo — design & build
Type Streaming search + RAG platform
Stack FastAPI · Kafka · Elasticsearch · K8s · GHA

The problem

Most "AI search" demos are thin wrappers on a static index. Real products aren't.

In production, the data never stops moving and the index has to keep up: writes land in a database of record, and search has to reflect them within seconds — not overnight — while staying consistent under retries, restarts and out-of-order delivery. On top of that, users increasingly expect semantic relevance and a question-answering assistant that is grounded in the freshest data, not hallucinated.

I wanted to build the whole spine of that system end to end — streaming ingestion, CDC, relevance engineering, real-time fan-out and retrieval-augmented generation — as one coherent, observable pipeline, and prove it with metrics rather than adjectives.

The approach

PulseSearch treats MySQL as the system of record and streams every row change into Elasticsearch, then serves both hybrid search and grounded RAG over that live index. It ingests a real firehose — Wikipedia's live edit stream — so the pipeline is genuinely continuous rather than a seeded demo.

Architecture

Firehose Wikimedia SSE Ingest producer MySQL record of truth Debezium binlog CDC Kafka Redpanda Sync worker embed + upsert Elasticsearch BM25 + kNN WS hub fan-out Dashboard Next.js live feed FastAPI hybrid search + RAG Groq free-tier LLM search generate query

One spine from firehose to searchable; Kafka also drives the live dashboard, and FastAPI serves hybrid search + grounded RAG.

In action

Screenshots from a live local run — the product UI recruiters can relate to, plus the Grafana panels that make the pipeline's SLOs and failure modes observable rather than asserted.

PulseSearch UI: hybrid RRF search for Science with BM25 and kNN ranks per hit, plus a live Wikimedia change stream and 18,627 indexed documents
Product UI — Hybrid (RRF) search returns hits with per-result BM25 and kNN rank provenance (here: 10 hits in ~264 ms). The right rail is the live CDC-backed change stream; Elasticsearch, the live stream, and the RAG LLM all show healthy.
Grafana PulseSearch Pipeline dashboard showing end-to-end sync latency p95, docs indexed per second, active WebSocket clients, sync latency percentiles, and ingest versus index throughput
Pipeline SLOs — End-to-end commit→searchable latency (p50 / p95 / p99), ingest vs index throughput, and active WebSocket clients. Spikes during backfill or burst traffic are visible instead of hidden behind averages.
Grafana panels for CDC events by operation type, search latency p95 by BM25 hybrid and vector modes, dead-letter queue rate at zero, and embedding latency p95
Ops detail — CDC volume broken out by create / read / update; search p95 by mode (BM25 vs vector vs hybrid); embedding p95; and a flat DLQ rate — poison messages stay isolated without wedging the consumer.

The hard parts

Effectively-once indexing on at-least-once delivery

Kafka offsets are committed only after a batch is durably written to Elasticsearch. Upserts are version-guarded using Debezium's ts_ms as an external monotonic version (version_type=external_gte), so replays and out-of-order delivery can never regress a document to an older state.

Replayable backfill & poison-message isolation

Because the change history lives in Kafka, rebuilding the index is a replay from the log, not a database-crushing reindex. Unparseable or repeatedly failing messages are routed to a dead-letter topic instead of wedging the whole stream.

Ranking that's free and explicit

Native RRF retrievers require a paid Elastic tier, so I fuse BM25 and kNN result lists in code (k = 60). Every hit exposes its BM25 and kNN ranks, making relevance transparent and tunable rather than a black box.

Keeping the CDC path fast

Wikipedia page summaries are useful for search and RAG, but fetching them on the critical path inflated sync latency. Enrichment now runs after the document is searchable, so commit→index stays lean while summaries still land asynchronously.

Grounding the assistant

The RAG endpoint answers only from retrieved context, cites sources with timestamps, reports the freshest source, and returns grounded: false when retrieval is empty. Generation uses Groq (llama-3.1-8b-instant, free tier) behind an LLMClient protocol — with an extractive fallback if the API is unavailable, so the endpoint stays demonstrable.

Engineering

The codebase is deliberately layered and dependency-inverted so every concern is swappable and testable: a shared kernel for config / models / embeddings / the ES repository / metrics; Strategy interfaces behind Protocols for firehose sources, embeddings and the LLM client; a Repository layer that keeps SQL and query DSL out of business logic; and an anti-corruption layer that translates Debezium envelopes into clean domain events. Unit suites cover the shared kernel, the Debezium adapter and the RRF fusion logic with no external services required; CI also runs a live Elasticsearch smoke (upsert → BM25 find) plus Ruff and Bandit on every PR.

CI/CD & Kubernetes

Recruiters ask for delivery, not only demos. PulseSearch wires that in:

Outcome

Measured on a local Docker/Colima laptop demo (~38k Wikipedia change docs) — not cloud prod. BM25-only search is much cheaper (p50 ~27 ms); hybrid cost is mostly query embedding.

1.1s / 2.0s CDC → Elasticsearch p50 / p95
310 ms hybrid search p50 (warm); p95 1.6 s
~5/s index throughput (peak ~6/s); RAG ~0.8–1.0 s; DLQ ~0

Stack

FastAPIDebeziumKafka / RedpandaMySQLElasticsearchsentence-transformersGroqNext.jsPrometheusGrafanaDocker ComposeKubernetesGitHub ActionsGHCRRuffBandit

Want the deep dive?

Read the architecture wiki Back to all work