Implementing a RAG pipeline with docling-pipelines and OpenSearch
Introduction
In enterprise Retrieval-Augmented Generation (RAG) applications, moving from a simple proof-of-concept to a robust production system requires high-fidelity document conversion, flexible topology orchestration, and low-latency vector retrieval. Unstructured enterprise documentsβsuch as complex multi-page PDFs, technical whitepapers, and dynamic web pagesβoften break naive text splitters by scattering table structures, disrupting read order, or discarding critical metadata.
To solve these challenges at scale, this article demonstrates an end-to-end, production-ready RAG pipeline built using IBM Docling Pipelines (docling-pipeline) and OpenSearch. Docling Pipelines provides a Directed Acyclic Graph (DAG) framework for document curationβenabling declarative flows that handle document ingestion, high-accuracy text and structure extraction, language detection, readability analysis, hybrid chunking, embedding generation, and vector indexing. Combined with OpenSearch's k-Nearest Neighbors (k-NN) search capabilities and Ollama's local LLM/embedding inference, this stack provides a privacy-conscious, local-first RAG solution.
TL;DR-What is Docling-Pipelines?
Excerpt from docling-pipelines github repository.
Docling pipelines is an enterprise-grade document curation pipeline for Retrieval Augmented Generation (RAG) applications. It ingests data from unstructured sources, curates documents, and writes entities and vector embeddings to targets β enabling AI-ready pipelines at scale.
It connects to cloud document sources (S3, OneDrive, SharePoint, Google Drive, Box, and more) and extracts content and entities from PDF, DOCX, HTML, images, and other formats using Docling. Extracted content is curated for LLMs, converted into chunks and embeddings, and stored in a vector database such as Milvus or OpenSearch.
Features
- π₯ Multi-source ingestion β local filesystem, Amazon S3, IBM COS, SharePoint, OneDrive, Google Drive, Box, and web pages
- π Document extraction β PDF, DOCX, HTML, images, and more via Docling, with optional VLM and ASR pipelines
- π§ Entity extraction β LLM-based extraction via LiteLLM (100+ providers), IBM watsonx.ai, or Docling templates
- βοΈ Chunking β Docling-native and semantic chunking strategies
- π’ Embeddings β vector embedding generation for any downstream vector store
- π Quality operators β language detection, readability scoring, PII/HAP detection, deduplication, redaction, SQL filtering, document classification, and ML enrichment
- ποΈ Vector storage β write to OpenSearch or Milvus
- π DAG-based flows β define pipelines as JSON with automatic dependency resolution and parallel execution
- π Extensible β load custom operators from Python packages, local paths, or S3 without modifying core code
- π₯οΈ Multiple interfaces β CLI, Python API (
DocpipeFlowManager), and REST API (FastAPI)
Architecture Overview of the Solution
The pipeline processes unstructured documents through two execution paths: a real execution engine backed by docling-pipelines CLI/Python APIs, and a fallback simulation manager designed for zero-dependency test environments.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Document Ingestion Layer β
β (Filesystem, Web URLs, S3/COS, SharePoint, Uploaded Files) β
ββββββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β IBM Docling Curation DAG β
β β
β ββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββββββββ β
β β ingest_source ββββββΊβ extract_operator ββββββΊβ lang_detect/readabilityβ β
β ββββββββββββββββββ ββββββββββ¬ββββββββββ βββββββββββββ¬ββββββββββββ β
β β β β
β βΌ βΌ β
β ββββββββββββββββββββββββββββββββββββββββββ β
β β docling_hybrid Chunker β β
β ββββββββββββββββββ¬ββββββββββββββββββββββββ β
β β β
β βΌ β
β ββββββββββββββββββββββββββββββββββββββββββ β
β β embeddings (Ollama/LiteLLM) β β
β ββββββββββββββββββ¬ββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β OpenSearch Vector Store Index β
β (k-NN HNSW Indexing & Semantic Search) β
ββββββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β RAG Engine & Chat β
β (Context Retrieval β Grounded LLM Prompting) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The system is organized into modular components:
π Documents β π₯ Ingest β π Extract β βοΈ Chunk β π’ Embed β ποΈ VectorDB
-
UI & Control Center (
app.py): Interactively configures pipeline parameters, template topologies, document sources, and operator settings.
-
Flow Definition Builder (
flow_builder.py): Programmatically generates valid JSON DAG specifications following the official Docling Flow Authoring Format.
Pipeline Manager (
pipeline_manager.py): Orchestrates document processing, handling document parsing, step-by-step operator execution, schema evolution, and artifact export.Vector Database & RAG Engine (
opensearch_client.py&rag_engine.py): Manages OpenSearch index creation, dense vector embeddings, k-NN retrieval, and LLM chat generation.
Implementation
Components Stack
The code and project uses the following stacks;
- Docling-pipelines library
- OpenSearch as RGA implementation
- Podman (for provisionning OpenSearch locally)
-
Ollama (with
granite-embedding:278mfor embedding andgranite4.2:latestfor local chat enablement) - Python Streamlit as the application's framework
Programmatic Pipeline Authoring (flow_builder.py)
Docling Pipelines relies on declarative flow representations that enforce dependency graphs between operators. The FlowBuilder class transforms user settings into standard flow JSON objects.
Below is an excerpt showing how the Full RAG Pipeline topology is constructed, complete with LiteLLM-compatible embedding parameters and OpenSearch vector database configs:
# flow_builder.py
from __future__ import annotations
import json
from pathlib import Path
from config import AppConfig
class FlowBuilder:
"""Builds a Docling Pipelines flow JSON from a UI pipeline configuration dict."""
def __init__(self, config: AppConfig) -> None:
self.config = config
def _build_full_rag_pipeline(self, pipeline_cfg: dict) -> dict:
"""Complete RAG pipeline: Ingest β Extract β Chunk β Embed β VectorDB."""
return {
"flow_name": "full-rag-pipeline",
"description": (
"Complete RAG pipeline: ingest documents, extract text via Docling, "
"chunk for retrieval, generate embeddings with Ollama, and store in OpenSearch."
),
"flow": [
self._ingest_operator(pipeline_cfg),
self._extract_operator(["ingest"]),
self._chunker_operator(["extract"], pipeline_cfg),
self._embeddings_operator(["chunker"], pipeline_cfg),
self._vectordb_operator(["embeddings"], pipeline_cfg),
],
"global_config": {
"doc_column": "content",
"disable_validation": False,
},
}
def _embeddings_operator(self, depends_on: list[str], pipeline_cfg: dict) -> dict:
model = pipeline_cfg.get("ollama_model", self.config.ollama_embedding_model)
if not model.startswith("openai/"):
litellm_model = f"openai/{model}"
else:
litellm_model = model
base = self.config.ollama_base_url.rstrip("/")
api_base = base if base.endswith("/v1") else f"{base}/v1"
return {
"name": "embeddings",
"type": "embeddings",
"depends_on": depends_on,
"config": {
"provider": "litellm",
"provider_config": {
"model_id": litellm_model,
"api_base": api_base,
"api_key": self.config.ollama_api_key,
},
},
}
def _vectordb_operator(self, depends_on: list[str], pipeline_cfg: dict) -> dict:
return {
"name": "vectordb",
"type": "vectordb",
"depends_on": depends_on,
"config": {
"provider": "opensearch",
"provider_config": {
"index_name": pipeline_cfg.get(
"opensearch_index", self.config.opensearch_index
),
"host": self.config.opensearch_host,
"port": self.config.opensearch_port,
"user": self.config.opensearch_user,
"password": self.config.opensearch_password,
"use_ssl": self.config.opensearch_use_ssl,
},
},
}
Pipeline Execution Engine (pipeline_manager.py)
The PipelineManager resolves execution requests by dynamically choosing between the system binary docling-pipelines and a simulation mode. During document extraction, binary document types (such as PDFs or Word documents) are parsed using docling.
# pipeline_manager.py
from dataclasses import dataclass, field
import json
import logging
import os
import subprocess
import time
from pathlib import Path
from config import AppConfig
logger = logging.getLogger(__name__)
@dataclass
class PipelineResult:
"""Uniform result object returned by any backend."""
success: bool
duration_seconds: float
docs_total: int
docs_completed: int
docs_failed: int
error_message: str = ""
operator_results: list[dict] = field(default_factory=list)
output_documents: list[str] = field(default_factory=list)
chunks: list[dict] = field(default_factory=list)
schema_changes: dict[str, list[str]] = field(default_factory=dict)
metadata: dict = field(default_factory=dict)
class PipelineManager:
def __init__(self, config: AppConfig) -> None:
self.config = config
def run(self, flow_def: dict, pipeline_cfg: dict, progress_callback=None) -> PipelineResult:
simulate = pipeline_cfg.get("simulate", not self.config.docling_pipelines_enabled)
if not simulate and not self._cli_available():
logger.warning("docling-pipelines CLI not found; falling back to simulation mode.")
simulate = True
if simulate:
return self._run_simulation(flow_def, pipeline_cfg, progress_callback)
else:
return self._run_real(flow_def, pipeline_cfg, progress_callback)
def _run_real(self, flow_def: dict, pipeline_cfg: dict, progress_callback=None) -> PipelineResult:
ts = time.strftime("%Y%m%dT%H%M%S")
flow_path = Path(self.config.output_dir) / f"flow_{ts}.json"
flow_path.parent.mkdir(parents=True, exist_ok=True)
flow_path.write_text(json.dumps(flow_def, indent=2), encoding="utf-8")
cli = self._cli_path() or "docling-pipelines"
cmd = [cli, "--flow-file", str(flow_path)]
env = os.environ.copy()
env["PYTHONPATH"] = "src:" + env.get("PYTHONPATH", "")
start = time.time()
completed = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env)
duration = time.time() - start
success = (completed.returncode == 0)
error_msg = "" if success else (completed.stdout or completed.stderr)
return PipelineResult(
success=success,
duration_seconds=duration,
docs_total=len(pipeline_cfg.get("saved_paths", [])) or 1,
docs_completed=1 if success else 0,
docs_failed=0 if success else 1,
error_message=error_msg,
output_documents=self._collect_output_files(),
)
Dense Vector Ingestion & RAG Orchestration (rag_engine.py)
The RAGEngine links document processing outputs to OpenSearch vector stores. It generates text embeddings via Ollama models (e.g., granite-embedding:278m), performs k-NN retrieval, and queries chat models (e.g., granite4.2:latest).
To prevent hallucinated answers when no context matches a user query, the RAGEngine short-circuits execution and returns a deterministic fallback message without incurring unnecessary LLM generation costs.
# rag_engine.py
import logging
import requests
from config import AppConfig
from opensearch_client import OpenSearchClient
logger = logging.getLogger(__name__)
class RAGEngine:
_NO_CONTEXT_MSG = (
"No information about this topic was found in the knowledge base. "
"Please ingest relevant documents before querying."
)
def __init__(self, *, config: AppConfig, os_client: OpenSearchClient | None = None) -> None:
self.config = config
self._os = os_client or OpenSearchClient(config=config)
def ingest_chunks(self, *, chunks: list[dict], progress_callback=None) -> dict:
if not chunks:
return {"indexed": 0, "failed": 0, "message": "No chunks provided."}
self._os.ensure_index()
embedded = []
for chunk in chunks:
text = chunk.get("text", "").strip()
if not text:
continue
vec = self._embed(text)
embedded.append({**chunk, "embedding": vec})
index_result = self._os.index_chunks(chunks=embedded)
return {
"indexed": index_result["indexed"],
"failed": index_result.get("failed", 0),
"message": f"Ingested {index_result['indexed']} chunks.",
}
def chat(self, *, query: str, top_k: int = 5, source_filter: str | None = None) -> dict:
context = self.retrieve(query=query, top_k=top_k, source_filter=source_filter)
# Deterministic check when retrieval returns zero chunks
if not context:
return {
"answer": self._NO_CONTEXT_MSG,
"context": [],
"query": query,
"sources": [],
"retrieved_count": 0,
"fallback": True,
}
ctx_text = "\n\n".join(
f"[Source: {c['source_document']}, chunk {c['chunk_index']}]\n{c['text']}"
for c in context
)
system = (
"You are a helpful assistant. "
"Answer the user's question using ONLY the provided context. "
"If the context does not contain enough information, say so clearly."
)
prompt = f"Context:\n{ctx_text}\n\nQuestion: {query}\n\nAnswer:"
answer = self._chat(prompt, system)
sources = list(dict.fromkeys(c["source_document"] for c in context))
return {
"answer": answer,
"context": context,
"query": query,
"sources": sources,
"retrieved_count": len(context),
"fallback": False,
}
def _embed(self, text: str) -> list[float]:
url = f"{self.config.ollama_base_url.rstrip('/')}/api/embed"
resp = requests.post(url, json={"model": self.config.ollama_embedding_model, "input": text}, timeout=60)
data = resp.json()
embeddings = data.get("embeddings") or data.get("embedding")
return embeddings[0] if isinstance(embeddings[0], list) else embeddings
def _chat(self, prompt: str, system: str = "") -> str:
url = f"{self.config.ollama_base_url.rstrip('/')}/api/chat"
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
resp = requests.post(url, json={"model": self.config.ollama_llm_model, "messages": messages, "stream": False}, timeout=120)
return resp.json().get("message", {}).get("content", "").strip()
Conclusion
Combining IBM Docling Pipelines with OpenSearch creates a reliable, scalable foundation for enterprise RAG applications. By decoupling document curation into modular operator DAGsβcovering ingestion, high-precision layout extraction, structure-aware chunking, dense vector embedding, and indexingβorganizations can handle diverse unstructured document formats consistently.
Integrating this pipeline with vector search and local LLM runtimes ensures strict context grounding while reducing hallucination risks. As unstructured data continues to grow in enterprise environments, modular Curation-as-Code frameworks like docling-pipeline will be critical for keeping production RAG systems reliable, observable, and easy to maintain.
Thanks for reading π¦
Links
- Full code repository for this post: https://github.com/aairom/docling-pipeline-implementaion-tests
- Docling-Pipelines: https://github.com/IBM/docling-pipelines
- OpenSearch: https://opensearch.org/






Top comments (0)