The Batch Loader Fallacy in Financial RAG
LangChain’s document loader ecosystem is fundamentally batch-oriented. PyPDFLoader, CSVLoader, and UnstructuredLoader assume documents arrive as complete, static files. This assumption collapses in financial services where data arrives as high-velocity streams: Bloomberg terminal feeds, SEC EDGAR filings, broker-dealer trade confirmations, and real-time market microstructure data. In these environments, waiting for a "complete document" means missing the alpha window entirely. A 10-K filing is a discrete artifact; a stream of earnings revisions, insider transactions, and options flow is not. You need loaders that treat streaming data as first-class documents with temporal semantics, partial state management, and backpressure-aware ingestion—all integrated into a stateful multi-agent RAG system. This article demonstrates building custom streaming document loaders for a real-time institutional trading desk, integrated into a LangGraph multi-agent architecture with persistent memory and state.
Real-Time Use Case: Institutional Event-Driven Trading Desk
The Scenario
A quantitative hedge fund’s event-driven desk trades around corporate events. Analysts must synthesize signals from four concurrent high-velocity streams:
SEC EDGAR Stream: 8-K filings, Form 4 insider transactions (arrives within seconds of filing)
Earnings Revision Stream: Broker estimates updated in real-time via Refinitiv API
Options Flow Stream: Unusual block trades detected by proprietary scanner
News Wire Stream: PR Newswire/Business Wire filtered for covered universe
An analyst asks: "What's driving the sudden SPX put skew in NVDA? Cross-reference with any recent insider activity and estimate revisions."
The system must ingest, index, and reason over data arriving at 50-200 events/second while maintaining conversation state across multiple analyst queries.
Why Standard Loaders Fail
| Requirement | Batch Loader Limitation | Streaming Loader Solution |
|---|---|---|
| Sub-second latency | Wait for file completion | Process events as they arrive |
| Partial document assembly | All-or-nothing loading | Incremental chunking with sequence tracking |
| Temporal ordering | File modification time only | Event-level timestamps with causal ordering |
| Backpressure handling | OOM on burst traffic | Bounded buffers with adaptive throttling |
| Deduplication across streams | Post-hoc dedup | Content-hash dedup at ingestion |
| State continuity | Stateless reload | Cursor-based resumption after restart |
| Schema evolution | Fixed schema per loader | Adaptive parsing with version detection |
Architecture: Streaming Loaders Inside LangGraph

Implementation
Step 1: Define Streaming-Aware State
Standard RAG state assumes static documents. Streaming RAG requires cursors, sequence numbers, and buffer health metrics.
from typing import Annotated, List, Dict, Any, Optional
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from datetime import datetime
import operator
class StreamCursor(TypedDict):
"""Resumable position marker for each data stream."""
stream_id: str
last_sequence: int
last_timestamp: datetime
checkpoint_hash: str # For integrity verification
class IngestedEvent(TypedDict):
"""Normalized event envelope across all streams."""
event_id: str
stream_id: str
sequence: int
timestamp: datetime
ticker: str
event_type: str # "8k_filing", "estimate_revision", "options_block", "news_wire"
content: str
metadata: Dict[str, Any]
content_hash: str
class TradingDeskState(TypedDict):
messages: Annotated[list, add_messages]
# Resumable cursors for each stream
stream_cursors: Dict[str, StreamCursor]
# Recently ingested events (bounded window)
ingested_events: Annotated[List[IngestedEvent], operator.add]
# Buffer health metrics for backpressure
buffer_health: Dict[str, Dict[str, Any]]
# Cross-stream signal cache (avoids re-computation)
signal_cache: Dict[str, Any]
# Current analyst query
current_query: str
# Generated trade thesis
trade_thesis: Optional[str]
# Audit trail
audit_trail: Annotated[List[Dict], operator.add]Step 2: Base Streaming Loader Infrastructure
All custom loaders inherit from this base class that handles the hard parts: async iteration, backpressure, deduplication, and cursor management.
import asyncio
import hashlib
from abc import ABC, abstractmethod
from collections import deque
from typing import AsyncIterator, Set, Callable, Optional
from langchain_core.documents import Document
class AsyncEventBuffer:
"""
Backpressure-aware bounded buffer for streaming events.
Prevents OOM during burst traffic while maintaining ordering.
"""
def __init__(self, max_size: int = 10000, flush_threshold: float = 0.8):
self._buffer: deque = deque(maxlen=max_size)
self._max_size = max_size
self._flush_threshold = flush_threshold
self._lock = asyncio.Lock()
self._seen_hashes: Set[str] = set()
self._dropped_count = 0
@property
def utilization(self) -> float:
return len(self._buffer) / self._max_size
@property
def should_flush(self) -> bool:
return self.utilization >= self._flush_threshold
async def put(self, event: IngestedEvent) -> bool:
"""Add event with dedup. Returns False if dropped due to backpressure."""
async with self._lock:
# Content-hash deduplication across streams
if event["content_hash"] in self._seen_hashes:
return True # Duplicate, silently skip
if len(self._buffer) >= self._max_size:
self._dropped_count += 1
return False # Backpressure: drop oldest
self._buffer.append(event)
self._seen_hashes.add(event["content_hash"])
# Evict old hashes to prevent unbounded growth
if len(self._seen_hashes) > self._max_size * 2:
# Keep only hashes for events still in buffer
active_hashes = {e["content_hash"] for e in self._buffer}
self._seen_hashes = active_hashes
return True
async def drain(self) -> List[IngestedEvent]:
"""Atomically drain all buffered events."""
async with self._lock:
events = list(self._buffer)
self._buffer.clear()
return events
def health_metrics(self) -> Dict[str, Any]:
return {
"utilization": self.utilization,
"buffered": len(self._buffer),
"dropped": self._dropped_count,
"unique_hashes": len(self._seen_hashes)
}
class BaseStreamingLoader(ABC):
"""
Abstract base for all streaming financial data loaders.
Handles async iteration, cursor persistence, and normalization.
Subclasses implement only source-specific parsing.
"""
def __init__(
self,
stream_id: str,
buffer: AsyncEventBuffer,
cursor_store: Any, # Redis/Postgres cursor persistence
vectorstore: Any,
batch_size: int = 50,
poll_interval_ms: int = 100
):
self.stream_id = stream_id
self.buffer = buffer
self.cursor_store = cursor_store
self.vectorstore = vectorstore
self.batch_size = batch_size
self.poll_interval_ms = poll_interval_ms
self._running = False
@abstractmethod
async def _fetch_batch(self, cursor: StreamCursor) -> List[Dict]:
"""Source-specific: fetch raw events since cursor position."""
...
@abstractmethod
def _normalize_event(self, raw: Dict, sequence: int) -> IngestedEvent:
"""Source-specific: normalize raw event to common schema."""
...
def _compute_content_hash(self, content: str, ticker: str, event_type: str) -> str:
"""Deterministic hash for cross-stream deduplication."""
canonical = f"{ticker}|{event_type}|{content.strip()}"
return hashlib.sha256(canonical.encode()).hexdigest()[:32]
async def get_cursor(self) -> StreamCursor:
"""Load persisted cursor or initialize."""
cursor = await self.cursor_store.get(f"cursor:{self.stream_id}")
if cursor is None:
return {
"stream_id": self.stream_id,
"last_sequence": 0,
"last_timestamp": datetime.min,
"checkpoint_hash": ""
}
return cursor
async def save_cursor(self, cursor: StreamCursor):
"""Persist cursor for resumability."""
await self.cursor_store.set(f"cursor:{self.stream_id}", cursor)
async def ingest_loop(self):
"""Main ingestion loop with backpressure awareness."""
self._running = True
cursor = await self.get_cursor()
while self._running:
try:
# Fetch new events from source
raw_events = await self._fetch_batch(cursor)
if not raw_events:
await asyncio.sleep(self.poll_interval_ms / 1000)
continue
# Normalize and buffer
accepted = 0
for i, raw in enumerate(raw_events):
seq = cursor["last_sequence"] + i + 1
event = self._normalize_event(raw, seq)
success = await self.buffer.put(event)
if success:
accepted += 1
cursor = {
"stream_id": self.stream_id,
"last_sequence": seq,
"last_timestamp": event["timestamp"],
"checkpoint_hash": event["content_hash"]
}
# Flush to vectorstore when buffer is hot
if self.buffer.should_flush:
events = await self.buffer.drain()
docs = [
Document(
page_content=e["content"],
metadata={
"event_id": e["event_id"],
"stream_id": e["stream_id"],
"ticker": e["ticker"],
"event_type": e["event_type"],
"timestamp": e["timestamp"].isoformat(),
"sequence": e["sequence"],
"content_hash": e["content_hash"]
}
)
for e in events
]
await self.vectorstore.aadd_documents(docs)
# Persist cursor after successful batch
await self.save_cursor(cursor)
except Exception as e:
# Log but don't crash; retry on next cycle
print(f"[{self.stream_id}] Ingestion error: {e}")
await asyncio.sleep(1)
async def stop(self):
self._running = False
Join the conversation! Your thoughts help the community grow.