System design¶
Eagle-RAG is an industry-agnostic, multimodal RAG data layer for Agents and LLMs. A microkernel hosts in-process industry plugins; two-layer isolation separates domain deploy binding (plugin_namespace = Milvus Database) from knowledge-base tenancy (kb_name = Milvus scalar filter within that Database). Five design principles recur in every module; this page explains the theory behind each, walks the actual code paths, and documents tuning tensions, configuration, and failure behavior.
For the full plugin contract, hook catalog, and deployment model, see Plugin architecture.
Theory and foundations¶
RAG as a layered system¶
Gao et al., 2023 decomposes RAG into:
| Layer | Function | Eagle-RAG primary modules |
|---|---|---|
| Plugins | Domain classify / embed / route / assemble | eagle_rag/plugins/, in-repo plugins/ |
| Indexing | Parse → chunk → embed → store | ingest/, index/, IngestOrchestrator, Celery tasks |
| Retrieval | Query embed → ANN → filter → expand | retrievers/, RetrieverOrchestrator, router_engine.py |
| Generation | Rerank → prompt → LLM/VLM | generation/multimodal_engine.py |
Lewis et al., 2020 established that retrieval conditioning reduces hallucination on knowledge-intensive tasks. Eagle-RAG adds multimodal indexing (dual vector spaces), plugin-namespace Milvus Database isolation, and multi-tenant kb_name scalar filtering (Milvus hybrid search).
ANN index choice¶
Visual vectors (2048-d) may exceed RAM at scale:
| Algorithm | Paper | Complexity | Eagle-RAG usage |
|---|---|---|---|
| HNSW | Malkov & Yashunin, 2016 | O(log N) search; graph in RAM | Default MILVUS_VISUAL_INDEX_TYPE=hnsw |
| DiskANN | Subramanya et al., NeurIPS 2019 | Disk-resident Vamana graph | diskann for billion-scale visual slices |
HNSW builds a hierarchy of proximity graphs: upper layers for coarse navigation, lower layers for fine search. Parameters M (neighbors per node) and efConstruction trade build time/recall for search quality.
Principle 1: Lazy initialization¶
Why¶
Import-time connections cause:
- Slow cold start (Milvus, PostgreSQL, GPU model load)
- Cascading import failures when a dependency is absent in dev
- Brittle unit tests that require full infrastructure
How — code walkthrough¶
Settings singleton (with profile overlay):
# eagle_rag/config.py
@lru_cache(maxsize=1)
def get_settings() -> Settings:
path_str = os.environ.get("EAGLE_RAG_SETTINGS_PATH", str(_DEFAULT_SETTINGS_PATH))
data = _apply_profile(_load_yaml(Path(path_str))) # EAGLE_RAG_PROFILE → profiles.<name>
return Settings(**data)
_apply_profile() deep-merges profiles.<name> when EAGLE_RAG_PROFILE (or YAML active_profile) is set. Env EAGLE_RAG_PROFILE wins over YAML. Loaded once per process; tests call get_settings.cache_clear() between cases.
Milvus text store:
get_text_vector_store() / get_text_index() in eagle_rag/index/milvus_text_store.py — construct MilvusVectorStore on first retrieval or ingest upsert, not at import eagle_rag.
Milvus client pool (per Database):
# eagle_rag/index/milvus_pool.py
class MilvusClientPool:
def get(self, db_name: str | None = None, *, plugin_namespace: str | None = None) -> MilvusClient:
if db_name is None:
db_name = milvus_db_name(plugin_namespace)
# ... pooled MilvusClient(uri=..., db_name=db_name) — never switch DB per request
MilvusClientPool binds db_name at client creation time (one pooled client per Milvus Database). Legacy module-level _client in milvus_visual_store.py still lazy-inits for backward compatibility; new paths use get_milvus_pool().
Visual encoder singleton:
get_visual_encoder() in eagle_rag/ingest/visual_encoder.py — provider=pixelrag loads local Qwen3-VL-Embedding-2B on first encode; provider=dashscope calls Bailian qwen3-vl-embedding (no local weights). Keeps API process free of GPU memory unless it handles visual tasks directly. Domain encoders (pubmedbert, molformer, medimageinsight BiomedCLIP/open_clip, uni2) lazy-load via EncoderRegistry in plugin on_load.
FastAPI app:
# eagle_rag/api/app.py
settings = get_settings() # config only — no DB connect
app = FastAPI(..., lifespan=get_combined_lifespan(mcp_app))
Routers imported after app creation; no Milvus at import. PluginManager.load_all() runs during lifespan startup.
Trade-off¶
| Pro | Con |
|---|---|
| Fast startup; testable without Docker | First request pays connection + model load latency |
| Missing dep fails at use site with clear error | Must restart process to pick up config changes |
Principle 2: Graceful degradation¶
Why¶
RAG systems depend on parsers, vector DBs, model APIs, workers, and optional domain plugins. Total failure on one outage is unacceptable for an intranet data layer.
Degradation matrix¶
| Failure | Code location | Behavior |
|---|---|---|
| Knowhere SDK unreachable | parse_with_knowhere_sdk() |
KnowhereError → task FAILED; no mock parse |
| Unknown visual embed provider | visual_encoder.get_visual_encoder() |
ValueError; no random vectors |
| DashScope visual embed missing key / API error | DashScopeQwen3VLEncoder |
Fail-fast ValueError / RuntimeError; no empty vectors |
| VLM API key missing | multimodal_engine.py |
Error string in response; no unhandled 500 |
| Milvus write fails during ingest | upsert_text_nodes / upsert_visual |
May log and continue (ingest availability) |
| Tag resolution fails | _resolve_scope_filter() |
Log warning; ignore tag dimension |
| Visual dispatch fails | dispatch_visual_chunks() |
Log error; knowhere_parse still SUCCESS |
| Keyword catalog write fails | knowhere_parse step 5.2 |
Non-blocking warning |
doc_nav persistence fails |
knowhere_parse step 5.7 |
Non-blocking warning |
| Text retriever exception | _fetch_nodes() |
logger.warning; skip text modality |
| Visual retriever exception | _fetch_nodes() |
logger.warning; skip visual modality |
QUERY_ASSEMBLE hook subscriber fails |
hookbus.invoke_all() |
Log warning; skip failed plugin hint; continue query |
| Specialized-collection retrieval plan fails | RetrieverOrchestrator.retrieve() |
Log warning; skip plan; RRF merge remaining hits (best-effort) |
Wrong plugin_namespace in request |
api/deps.py, db/namespace.py |
HTTP 403 (unless plugins.allow_namespace_override) |
| Domain encoder load / encode fails | plugins/*/encoders.py, encoder_runtime |
EncoderLoadError on affected chunk/plan; no OpenAI/Cohere fallback |
| Redis down for SSE logs | notifications router | In-memory asyncio.Queue + 5s heartbeats |
| MCP tool exception | resilient_call() in mcp_server.py |
{"error": ...} without breaking session |
Health probe semantics¶
GET /health — each dependency probed in isolated try/except with ~3s timeout:
up— probe succeededdown— probed and failedunknown— not configured (e.g. PixelRAG when visual provider inactive)
GET /health/plugins — loaded plugin manifests, Celery task modules, and namespace binding (worker consistency probe).
This distinguishes misconfiguration from outage.
Principle 3: Sync + async dual DB access¶
Why¶
FastAPI handlers are async; Celery workers are sync. A single async-only ORM would force asyncio.run() in workers or block the event loop in API handlers.
How¶
Repositories in eagle_rag/db/repositories/ expose paired APIs and force plugin_namespace on every read/write via instance_namespace():
| Pattern | Async (API) | Sync (Celery) |
|---|---|---|
| KB exists | kb_exists() |
kb_exists_sync() |
| Document register | register_document() |
register_document_sync() |
| Dedup check | check_duplicate() |
check_duplicate_sync() |
eagle_rag/db/namespace.py resolves the instance-bound namespace from settings.plugins.default_namespace and rejects explicit mismatches (403 in production).
JSONB columns: json.dumps + ::jsonb cast on write; defensive _loads on read for legacy string values.
Connection pools: separate async (asyncpg) and sync (psycopg) engines from POSTGRES_DSN.
Trade-off¶
Duplicated repository methods increase maintenance but avoid worker/event-loop coupling — standard pattern for FastAPI + Celery codebases.
Principle 4: Adapter pattern¶
Why¶
Knowhere (HTTP → ParseResult) and PixelRAG (render → tiles) produce different native outputs. Retrieval and generation must see uniform LlamaIndex TextNode / ImageNode with consistent metadata (kb_name, document_id, path).
Adapter flow¶
flowchart LR
DOC["Document"] --> ROUTER["route()"]
ROUTER -->|"knowhere"| KAD["knowhere_adapter"]
ROUTER -->|"pixelrag"| PAD["pixelrag_adapter"]
KAD -->|"HTTP :5005"| KH["Knowhere"]
KH -->|"ParseResult"| KAD
KAD --> TX["eagle_text 1536d"]
KAD -->|"extract_visual_chunks"| VQ["pixelrag_queue"]
VQ --> PAD
PAD --> VIZ["eagle_visual 2048d"]
knowhere_adapter.py key functions:
| Function | Output |
|---|---|
parse_with_knowhere_sdk() |
ParseResult |
chunks_to_text_nodes() |
list[TextNode] with connect_to, path |
sections_to_text_nodes() |
type="section_summary" nodes |
extract_visual_chunks() |
Visual dispatch descriptors |
knowhere_parse |
Celery task — full pipeline |
pixelrag_adapter.py key functions:
| Function | Output |
|---|---|
pixelrag_build |
Full visual document ingest |
knowhere_visual_chunks |
Knowhere image/table → tiles → vectors |
get_visual_encoder().embed_*() |
2048-d L2-normalized vector |
Single Knowhere document → dual index
After knowhere_parse, text chunks and section summaries land in eagle_text. Image/table chunks dispatch to knowhere_visual_chunks on pixelrag_queue with four anchor fields into eagle_visual. Domain plugins may add specialized collections via IngestOrchestrator. See Multimodal fusion.
Principle 5: Microkernel + plugins¶
Why¶
Industry-specific recall (biomedical encoders, lakehouse metadata, entity expansion) must extend Core without forking the platform or reintroducing finance-specific hardcoding.
How¶
| Component | Role |
|---|---|
PluginManager |
Loads from settings.plugins.enabled (in-repo modules); core_defaults always first |
HookBus |
invoke_first / invoke_all / invoke_transform with namespace filter |
| Hot-path hooks | PARSE / CHUNK / QUERY_ASSEMBLE via hotpath_hooks.py |
IngestOrchestrator |
CLASSIFY_* → EMBED_* → UPSERT_VECTORS per chunk |
RetrieverOrchestrator |
Multi-collection ANN plans → per-plan rerank → RRF merge (rerank_fusion.py) |
EncoderRegistry |
Domain encoders registered at plugin on_load |
mcp_registry |
@register_mcp_tool with RAG-only name guard |
Single-domain deployment: each process binds one settings.plugins.default_namespace (= Milvus Database + PG filter). Cross-industry retrieval is multiple instances, not runtime domain switching (ADR-002).
G4 routing rule: Core default never auto-queries specialized collections; only a domain QueryRouteClassifier or scope-aware catalog union may add them.
Surface split:
| Surface | Scope |
|---|---|
| Built-in Next.js UI | Core only — Knowhere + PixelRAG hybrid |
| Domain plugins (biomed experimental, lakehouse-bi under development, …) | Backend + MCP only — no domain UI in this repo |
Full hook catalog, manifest fields, and authoring guide: Plugin architecture · ADR-008.
C4 container view¶
flowchart TB
subgraph eagle["Eagle-RAG deployment"]
API["FastAPI :8000"]
PLG["eagle_rag/plugins + plugins/*"]
W1["worker-router<br/>router_queue c=4"]
W2["worker-knowhere<br/>knowhere_queue c=8"]
W3["worker-pixelrag<br/>pixelrag_queue c=1"]
PG[("PostgreSQL 16<br/>plugin_namespace filter")]
REDIS[("Redis 7")]
MINIO[("MinIO")]
MILVUS[("Milvus DB per plugin_namespace<br/>eagle_text + eagle_visual (+ specialized)")]
end
subgraph external["External"]
KH["Knowhere :5005"]
DS["DeepSeek API"]
QW["DashScope Qwen APIs"]
end
FE["Next.js :3000<br/>Core UI only"] -->|REST/SSE| API
AGENT["Agent MCP"] -->|HTTP /mcp| API
API --> PLG
PLG --> PG & MILVUS
API --> PG & REDIS & MILVUS
API --> W1
W1 --> W2 & W3
W2 -->|HTTP SDK| KH
W2 & W3 --> MILVUS & MINIO
API --> DS & QW
W3 -->|local GPU/CPU| QW
Deployment layers: infrastructure → Knowhere sub-stack → application. Details: ops/docker.
Model stack (Core: DeepSeek + Qwen)¶
| Role | Model | Dimension | Config path |
|---|---|---|---|
| LLM / routing | DeepSeek-V4-Pro | — | settings.llm |
| VLM generation | Qwen-VL-Max | — | settings.vlm |
| Text embedding | text-embedding-v4 |
1536 | settings.embedding.text |
| Visual embedding | Local Qwen3-VL-Embedding-2B or Bailian qwen3-vl-embedding |
2048 | settings.embedding.visual |
| Rerank | qwen3-rerank |
— | settings.rerank.text |
Core only — no OpenAI / Cohere adapters. New Core models via LlamaIndex integration packages.
Domain plugins may register additional encoders via EncoderRegistry (e.g. pubmedbert 768-d, molformer 768-d, medimageinsight BiomedCLIP/open_clip 1024-d, uni2 1536-d in plugins/biomed). Medical imaging encoders never fall back to Qwen3-VL; load failures raise EncoderLoadError unless test-mode deterministic is explicitly enabled.
Routing LLM: route_query() uses DeepSeek with router.llm.prompt_template when router.llm.enabled=true. Heuristic fallback: router.heuristic.rules in YAML. Domain plugins may supply QueryRouteClassifier for specialized collection plans.
MCP integration architecture¶
FastMCP mounted on FastAPI at /mcp:
# eagle_rag/api/app.py — pattern
mcp_app = build_mcp_app()
app = FastAPI(..., lifespan=get_combined_lifespan(mcp_app))
app.mount(settings.mcp.streamable_http_path, mcp_app)
get_combined_lifespan chains:
- Application startup (DB pools,
PluginManager.load_all(), telemetry) StreamableHTTPSessionManagertask group — prevents "Task group is not initialized" on MCP requests
Exposed tools (G3): Core tools core_ingest, core_query, core_retrieve_text, core_retrieve_visual, plus {namespace}_* tools from the default_namespace plugin only. Names are registered via eagle_rag/plugins/mcp_registry.py (@register_mcp_tool); bare ingest / query names are rejected (RAG-only platform boundary).
resilient_call() wraps tool execution with timeout, circuit breaker (circuit_fail_threshold), and optional Redis cache (cache_ttl).
Configuration¶
| Setting | Design impact |
|---|---|
EAGLE_RAG_PROFILE |
Overlay profiles.<name> in settings.yaml (plugins, default_namespace, encoders) |
get_settings() cache |
Restart after .env / profile change |
plugins.default_namespace |
Milvus Database + PG repository filter for this instance |
plugins.options[<namespace>] |
Per-plugin knobs via plugin_options() — not Core-typed industry settings |
milvus.visual_index_type |
HNSW vs DiskANN |
embedding.visual.provider |
pixelrag (local HF) or dashscope (Bailian); same provider for ingest+query; switch requires eagle_visual rebuild |
router.llm.enabled |
LLM vs heuristic query routing |
celery.queues.pixelrag_queue.concurrency |
Must be 1 — OOM prevention |
mcp.standalone |
Separate uvicorn on :8081 vs API mount |
telemetry.tracing_enabled |
OpenTelemetry export |
Full reference: configuration.
Failure modes and operations¶
Startup failures¶
| Symptom | Cause | Action |
|---|---|---|
| API crash on boot | Unknown EAGLE_RAG_PROFILE |
Use core, biomed, or lakehouse-bi; check profiles: in YAML |
| API starts, Milvus errors on first query | Lazy init — Milvus not ready | Wait for Milvus healthy; verify default_namespace Database exists |
| MCP 500 on first tool call | Lifespan not initialized | Ensure get_combined_lifespan used |
| Worker import error on PixelRAG | Missing GPU drivers / package | Check pixelrag install; use CPU embed_device |
| Plugin load error on boot | enabled plugin ≠ default_namespace (G3) |
Align profile plugins.enabled with default_namespace |
Runtime degradation paths¶
flowchart TD
Q[Query] --> QA[QUERY_ASSEMBLE hooks]
QA -->|ok| RQ[enriched query]
QA -->|subscriber fail| RQ
RQ --> RO[RetrieverOrchestrator]
RO --> P1{plan 1}
P1 -->|ok| N1[nodes]
P1 -->|fail| SKIP1[skip — warn]
RO --> P2{plan N}
P2 -->|ok| N2[nodes]
P2 -->|fail| SKIP2[skip — warn]
N1 & N2 & SKIP1 & SKIP2 --> RRF[RRF merge]
RRF --> G[multimodal_engine]
G -->|VLM fail| ERR[error string in answer]
G -->|ok| ANS[answer + sources]
Operator checklist¶
- Verify
EAGLE_RAG_PROFILEmatches intended domain before deploy - Check
/health/plugins— loaded namespace, manifests, Celery modules match workers - Verify lazy singletons not holding stale connections after Milvus restart
- Restart workers after
settings.yamlchange (get_settingscached) - Monitor
/health— distinguishunknownvsdown - Keep
pixelrag_queueat concurrency 1
Design tensions summary¶
| Tension | Where | Tuning |
|---|---|---|
| Cold start vs fast import | get_settings(), get_text_index(), get_visual_encoder(), domain encoders |
First query after deploy pays connection + model/API setup; warm workers with smoke retrieve |
| Config immutability per process | @lru_cache on settings |
Restart API + workers after settings.yaml / .env / profile change |
| Degradation vs silent wrong answers | Retriever [], VLM None, skipped RRF plans |
Prefer explicit error strings in generation over hallucinating without context |
| Adapter normalization cost | Knowhere ParseResult → many TextNodes |
Large docs = high embed API cost linear in chunk count |
| Index write strictness | knowhere_parse fails on text upsert error |
Visual path still best-effort — asymmetry is intentional |
| Single-domain vs multi-collection | RetrieverOrchestrator within one Milvus DB |
Specialized plans are best-effort; Core never auto-fans out to domain collections |