docling/docs/examples/rag_opensearch.ipynb

1061 lines
49 KiB
Plaintext
Raw Normal View History

{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# RAG with OpenSearch\n",
"\n",
"| Step | Tech | Execution |\n",
"| --- | --- | --- |\n",
"| Embedding | Ollama (IBM Granite Embedding 30M) | 💻 Local |\n",
"| Vector store | OpenSearch 2.19.3 | 💻 Local |\n",
"| Gen AI | Ollama (IBM Granite 3.3 8B) | 💻 Local |\n",
"\n",
"\n",
"This is a code recipe that uses [OpenSearch](https://opensearch.org/), an open-source search and analytics tool,\n",
"and the [LlamaIndex](https://github.com/run-llama/llama_index) framework to perform RAG over documents parsed by [Docling](https://docling-project.github.io/docling/).\n",
"\n",
"In this notebook, we accomplish the following:\n",
"* 📚 Parse documents using Docling's document conversion capabilities\n",
"* 🧩 Perform hierarchical chunking of the documents using Docling\n",
"* 🔢 Generate text embeddings on document chunks\n",
"* 🤖 Perform RAG using OpenSearch and the LlamaIndex framework\n",
"* 🛠️ Leverage the transformation and structure capabilities of Docling documents for RAG\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Preparation\n",
"\n",
"### Running the notebook\n",
"\n",
"For running this notebook on your machine, you can use applications like [Jupyter Notebook](https://jupyter.org/install) or [Visual Studio Code](https://code.visualstudio.com/docs/datascience/jupyter-notebooks).\n",
"\n",
"💡 For best results, please use **GPU acceleration** to run this notebook.\n",
"\n",
"### Virtual environment\n",
"\n",
"Before installing dependencies and to avoid conflicts in your environment, it is advisable to use a [virtual environment (venv)](https://docs.python.org/3/library/venv.html).\n",
"For instance, [uv](https://docs.astral.sh/uv/) is a popular tool to manage virtual environments and dependencies. You can install it with:\n",
"\n",
"\n",
"```shell\n",
"curl -LsSf https://astral.sh/uv/install.sh | sh\n",
"```\n",
"\n",
"Then create the virtual environment and activate it:\n",
"\n",
"```shell\n",
" uv venv\n",
" source .venv/bin/activate\n",
" ```\n",
"\n",
"Refer to [Installing uv](https://docs.astral.sh/uv/getting-started/installation/) for more details.\n",
"\n",
"### Dependencies\n",
"\n",
"To start, install the required dependencies by running the following command:"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"! uv pip install -q --no-progress notebook ipywidgets docling llama-index-readers-file llama-index-readers-docling llama-index-node-parser-docling llama-index-vector-stores-opensearch llama-index-embeddings-ollama llama-index-llms-ollama"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We now import all the necessary modules for this notebook:"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [],
"source": [
"import logging\n",
"from pathlib import Path\n",
"from tempfile import mkdtemp\n",
"\n",
"import requests\n",
"import torch\n",
"from docling_core.transforms.chunker import HierarchicalChunker\n",
"from docling_core.transforms.chunker.hierarchical_chunker import (\n",
" ChunkingDocSerializer,\n",
" ChunkingSerializerProvider,\n",
")\n",
"from docling_core.transforms.serializer.markdown import MarkdownTableSerializer\n",
"from llama_index.core import SimpleDirectoryReader, StorageContext, VectorStoreIndex\n",
"from llama_index.core.schema import TransformComponent\n",
"from llama_index.core.vector_stores import MetadataFilter, MetadataFilters\n",
"from llama_index.core.vector_stores.types import VectorStoreQueryMode\n",
"from llama_index.embeddings.ollama import OllamaEmbedding\n",
"from llama_index.llms.ollama import Ollama\n",
"from llama_index.node_parser.docling import DoclingNodeParser\n",
"from llama_index.readers.docling import DoclingReader\n",
"from llama_index.vector_stores.opensearch import (\n",
" OpensearchVectorClient,\n",
" OpensearchVectorStore,\n",
")\n",
"from rich.console import Console\n",
"from rich.pretty import pprint\n",
"\n",
"logging.getLogger().setLevel(logging.WARNING)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### GPU Checking"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Part of what makes Docling so remarkable is the fact that it can run on commodity hardware. This means that this notebook can be run on a local machine with GPU acceleration. If you're using a MacBook with a silicon chip, Docling integrates seamlessly with Metal Performance Shaders (MPS). MPS provides out-of-the-box GPU acceleration for macOS, seamlessly integrating with PyTorch and TensorFlow, offering energy-efficient performance on Apple Silicon, and broad compatibility with all Metal-supported GPUs.\n",
"\n",
"The code below checks if a GPU is available, either via CUDA or MPS."
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"MPS GPU is enabled.\n"
]
}
],
"source": [
"# Check if GPU or MPS is available\n",
"if torch.cuda.is_available():\n",
" device = torch.device(\"cuda\")\n",
" print(f\"CUDA GPU is enabled: {torch.cuda.get_device_name(0)}\")\n",
"elif torch.backends.mps.is_available():\n",
" device = torch.device(\"mps\")\n",
" print(\"MPS GPU is enabled.\")\n",
"else:\n",
" raise OSError(\n",
" \"No GPU or MPS device found. Please check your environment and ensure GPU or MPS support is configured.\"\n",
" )"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Local OpenSearch instance\n",
"\n",
"To run the notebook locally, we can pull an OpenSearch image and run a single node for local development.\n",
"You can use a container tool like [Podman](https://podman.io/) or [Docker](https://www.docker.com/).\n",
"In the interest of simplicity, we disable the SSL option for this example.\n",
"\n",
"💡 The version of the OpenSearch instance needs to be compatible with the version of the [OpenSearch Python Client](https://github.com/opensearch-project/opensearch-py) library,\n",
"since this library is used by the LlamaIndex framework, which we leverage in this notebook.\n",
"\n",
"On your computer terminal run:\n",
"\n",
"\n",
"```shell\n",
"podman run \\\n",
" -it \\\n",
" --pull always \\\n",
" -p 9200:9200 \\\n",
" -p 9600:9600 \\\n",
" -e \"discovery.type=single-node\" \\\n",
" -e DISABLE_INSTALL_DEMO_CONFIG=true \\\n",
" -e DISABLE_SECURITY_PLUGIN=true \\\n",
" --name opensearch-node \\\n",
" -d opensearchproject/opensearch:2.19.3\n",
"```\n",
"\n",
"Once the instance is running, verify that you can connect to OpenSearch:"
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{\n",
" \"name\" : \"b8582205a25c\",\n",
" \"cluster_name\" : \"docker-cluster\",\n",
" \"cluster_uuid\" : \"VxJ5hoxDRn68jodknsNdag\",\n",
" \"version\" : {\n",
" \"distribution\" : \"opensearch\",\n",
" \"number\" : \"2.19.3\",\n",
" \"build_type\" : \"tar\",\n",
" \"build_hash\" : \"a90f864b8524bc75570a8461ccb569d2a4bfed42\",\n",
" \"build_date\" : \"2025-07-21T22:34:54.259463448Z\",\n",
" \"build_snapshot\" : false,\n",
" \"lucene_version\" : \"9.12.2\",\n",
" \"minimum_wire_compatibility_version\" : \"7.10.0\",\n",
" \"minimum_index_compatibility_version\" : \"7.0.0\"\n",
" },\n",
" \"tagline\" : \"The OpenSearch Project: https://opensearch.org/\"\n",
"}\n",
"\n"
]
}
],
"source": [
"response = requests.get(\"http://localhost:9200\")\n",
"print(response.text)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Ollama models\n",
"\n",
"We will use [Ollama](https://ollama.com/), an open-source tool to run language models on your local computer, rather than relying on cloud services.\n",
"\n",
"In this example, we will use:\n",
"- [IBM Granite Embedding 30M English](https://huggingface.co/ibm-granite/granite-embedding-30m-english) for text embeddings\n",
"- [IBM Granite 3.3 8B Instruct](https://huggingface.co/ibm-granite/granite-3.3-8b-instruct) for model inference\n",
"\n",
"Once Ollama is installed on your computer, you can pull and run the models above from your terminal:\n",
"\n",
"```shell\n",
"ollama run granite-embedding:30m\n",
"ollama run granite3.3:8b\n",
"```"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Setup\n",
"\n",
"We setup the main variables for OpenSearch and the embedding and generation models."
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"The embedding dimension is 384.\n"
]
}
],
"source": [
"# http endpoint for your cluster\n",
"OPENSEARCH_ENDPOINT = \"http://localhost:9200\"\n",
"# index to store the Docling document vectors\n",
"OPENSEARCH_INDEX = \"docling-index\"\n",
"# the embedding model\n",
"EMBED_MODEL = OllamaEmbedding(model_name=\"granite-embedding:30m\")\n",
"# the generation model\n",
"GEN_MODEL = Ollama(\n",
" model=\"granite3.3:8b\",\n",
" request_timeout=120.0,\n",
" # Manually set the context window to limit memory usage\n",
" context_window=8000,\n",
" # Set temperature to 0 for reproducibility of the results\n",
" temperature=0.0,\n",
")\n",
"# a sample document\n",
"SOURCE = \"https://arxiv.org/pdf/2408.09869\"\n",
"# a sample query\n",
"QUERY = \"Which are the main AI models in Docling?\"\n",
"\n",
"embed_dim = len(EMBED_MODEL.get_text_embedding(\"hi\"))\n",
"print(f\"The embedding dimension is {embed_dim}.\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Process Data Using Docling\n",
"\n",
"Docling can parse various document formats into a unified representation ([DoclingDocument](https://docling-project.github.io/docling/concepts/docling_document/)), which can then be exported to different output formats. For a full list of supported input and output formats, please refer to [Supported formats](https://docling-project.github.io/docling/usage/supported_formats/) section of Docling's documentation.\n",
"\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In this recipe, we will use a single PDF file, the [Docling Technical Report](https://arxiv.org/pdf/2408.09869). We will process it using a [Hierarchical Chunker](https://docling-project.github.io/docling/concepts/chunking/#hierarchical-chunker) provided by Docling to generate structured, hierarchical chunks suitable for downstream RAG tasks.\n",
"\n",
"\n",
"💡 The [Hybrid Chunker](https://docling-project.github.io/docling/concepts/chunking/#hybrid-chunker) is an alternative with additional capabilities for an efficient segmentation of the document. Check the [Hybrid Chunking](https://docling-project.github.io/docling/examples/hybrid_chunking/) example for more details."
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"/Users/ceb/git/docling/.venv/lib/python3.12/site-packages/torch/utils/data/dataloader.py:684: UserWarning: 'pin_memory' argument is set as true but not supported on MPS now, then device pinned memory won't be used.\n",
" warnings.warn(warn_msg)\n",
"/Users/ceb/git/docling/.venv/lib/python3.12/site-packages/torch/utils/data/dataloader.py:684: UserWarning: 'pin_memory' argument is set as true but not supported on MPS now, then device pinned memory won't be used.\n",
" warnings.warn(warn_msg)\n"
]
}
],
"source": [
"tmp_dir_path = Path(mkdtemp())\n",
"req = requests.get(SOURCE)\n",
"with open(tmp_dir_path / f\"{Path(SOURCE).name}.pdf\", \"wb\") as out_file:\n",
" out_file.write(req.content)\n",
"\n",
"# create a Docling reader and a node parser with default Hierarchical chunker\n",
"reader = DoclingReader(export_type=DoclingReader.ExportType.JSON)\n",
"dir_reader = SimpleDirectoryReader(\n",
" input_dir=tmp_dir_path,\n",
" file_extractor={\".pdf\": reader},\n",
")\n",
"\n",
"# load the PDF files\n",
"documents = dir_reader.load_data()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Load Data into OpenSearch\n",
"\n",
"#### Define the Transformations\n",
"\n",
"Before the actual ingestion of data, we need to define the data transformations to apply on the `DoclingDocument`:\n",
"\n",
"- `DoclingNodeParser` executes the document-based chunking\n",
"- `MetadataTransform` is a custom transformation to ensure that generated chunk metadata is best formatted for indexing with OpenSearch"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [],
"source": [
"# create a Docling node parser\n",
"node_parser = DoclingNodeParser()\n",
"\n",
"\n",
"# create a custom transformation to avoid out-of-range integers\n",
"class MetadataTransform(TransformComponent):\n",
" def __call__(self, nodes, **kwargs):\n",
" for node in nodes:\n",
" binary_hash = node.metadata.get(\"origin\", {}).get(\"binary_hash\", None)\n",
" if binary_hash is not None:\n",
" node.metadata[\"origin\"][\"binary_hash\"] = str(binary_hash)\n",
" return nodes"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Embed and Insert the Data\n",
"\n",
"In this step, we create an `OpenSearchVectorClient`, which encapsulates the logic for a single OpenSearch index with vector search enabled.\n",
"\n",
"We then initialize the index using our sample data (a single PDF file), the Docling node parser, and the OpenSearch client that we just created.\n"
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"2025-09-10 13:16:53,752 - WARNING - GET http://localhost:9200/docling-index [status:404 request:0.015s]\n"
]
}
],
"source": [
"# OpensearchVectorClient stores text in this field by default\n",
"text_field = \"content\"\n",
"# OpensearchVectorClient stores embeddings in this field by default\n",
"embed_field = \"embedding\"\n",
"\n",
"client = OpensearchVectorClient(\n",
" endpoint=\"http://localhost:9200\",\n",
" index=OPENSEARCH_INDEX,\n",
" dim=embed_dim,\n",
" embedding_field=embed_field,\n",
" text_field=text_field,\n",
")\n",
"\n",
"vector_store = OpensearchVectorStore(client)\n",
"storage_context = StorageContext.from_defaults(vector_store=vector_store)\n",
"\n",
"index = VectorStoreIndex.from_documents(\n",
" documents=documents,\n",
" transformations=[node_parser, MetadataTransform()],\n",
" storage_context=storage_context,\n",
" embed_model=EMBED_MODEL,\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Build RAG\n",
"\n",
"In this section, we will see how to assemble a RAG system, execute a query, and get a generated response.\n",
"\n",
"We will also describe how to leverage Docling capabilities to improve RAG results.\n",
"\n",
"\n",
"### Run a query\n",
"\n",
"With LlamaIndex's query engine, we can simply run a RAG system as follows:"
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">👤: Which are the main AI models in Docling?\n",
"🤖: Docling primarily utilizes two AI models. The first one is a layout analysis model, \n",
"serving as an accurate object-detector for page elements. The second model is \n",
"TableFormer, a state-of-the-art table structure recognition model. Both models are \n",
"pre-trained and their weights are hosted on Hugging Face. They also power the \n",
"deepsearch-experience, a cloud-native service for knowledge exploration tasks.\n",
"</pre>\n"
],
"text/plain": [
"👤: Which are the main AI models in Docling?\n",
"🤖: Docling primarily utilizes two AI models. The first one is a layout analysis model, \n",
"serving as an accurate object-detector for page elements. The second model is \n",
"TableFormer, a state-of-the-art table structure recognition model. Both models are \n",
"pre-trained and their weights are hosted on Hugging Face. They also power the \n",
"deepsearch-experience, a cloud-native service for knowledge exploration tasks.\n"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"console = Console(width=88)\n",
"\n",
"QUERY = \"Which are the main AI models in Docling?\"\n",
"query_engine = index.as_query_engine(llm=GEN_MODEL)\n",
"res = query_engine.query(QUERY)\n",
"\n",
"console.print(f\"👤: {QUERY}\\n🤖: {res.response.strip()}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Custom serializers\n",
"\n",
"Docling can extract the table content and process it for chunking, like other text elements.\n",
"\n",
"In the following example, the response is generated from a retrieved chunk containing a table."
]
},
{
"cell_type": "code",
"execution_count": 10,
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">👤: What are the performance metrics of Docling-native PDF backend with <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">16</span> threads?\n",
"🤖: The Docling-native PDF backend, when utilized with <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">16</span> threads on an Apple M3 Max \n",
"system, completed the processing in approximately <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">167</span> seconds. It achieved a throughput \n",
"of about <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1.34</span> pages per second and peaked at a memory usage of <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">6.20</span> GB <span style=\"font-weight: bold\">(</span>resident set \n",
"size<span style=\"font-weight: bold\">)</span>. On an Intel Xeon E5-<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2690</span> system with the same thread count, it took around <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">244</span> \n",
"seconds to process, managed a throughput of <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.92</span> pages per second, and reached a peak \n",
"memory usage of <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">6.16</span> GB.\n",
"</pre>\n"
],
"text/plain": [
"👤: What are the performance metrics of Docling-native PDF backend with \u001b[1;36m16\u001b[0m threads?\n",
"🤖: The Docling-native PDF backend, when utilized with \u001b[1;36m16\u001b[0m threads on an Apple M3 Max \n",
"system, completed the processing in approximately \u001b[1;36m167\u001b[0m seconds. It achieved a throughput \n",
"of about \u001b[1;36m1.34\u001b[0m pages per second and peaked at a memory usage of \u001b[1;36m6.20\u001b[0m GB \u001b[1m(\u001b[0mresident set \n",
"size\u001b[1m)\u001b[0m. On an Intel Xeon E5-\u001b[1;36m2690\u001b[0m system with the same thread count, it took around \u001b[1;36m244\u001b[0m \n",
"seconds to process, managed a throughput of \u001b[1;36m0.92\u001b[0m pages per second, and reached a peak \n",
"memory usage of \u001b[1;36m6.16\u001b[0m GB.\n"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"QUERY = (\n",
" \"What are the performance metrics of Docling-native PDF backend with 16 threads?\"\n",
")\n",
"query_engine = index.as_query_engine(llm=GEN_MODEL)\n",
"res = query_engine.query(QUERY)\n",
"console.print(f\"👤: {QUERY}\\n🤖: {res.response.strip()}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The result above was generated with the table serialized in a triplet format.\n",
"Language models may perform better on complex tables if the structure is represented in a format that is widely adopted,\n",
"like [markdown](https://en.wikipedia.org/wiki/Markdown).\n",
"\n",
"For this purpose, we can leverage a custom serializer that transforms tables in markdown format:"
]
},
{
"cell_type": "code",
"execution_count": 11,
"metadata": {},
"outputs": [],
"source": [
"class MDTableSerializerProvider(ChunkingSerializerProvider):\n",
" def get_serializer(self, doc):\n",
" return ChunkingDocSerializer(\n",
" doc=doc,\n",
" # configuring a different table serializer\n",
" table_serializer=MarkdownTableSerializer(),\n",
" )\n",
"\n",
"\n",
"# clear the database from the previous chunks\n",
"client.clear()\n",
"vector_store.clear()\n",
"\n",
"chunker = HierarchicalChunker(\n",
" serializer_provider=MDTableSerializerProvider(),\n",
")\n",
"node_parser = DoclingNodeParser(chunker=chunker)\n",
"index = VectorStoreIndex.from_documents(\n",
" documents=documents,\n",
" transformations=[node_parser, MetadataTransform()],\n",
" storage_context=storage_context,\n",
" embed_model=EMBED_MODEL,\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Observe that the generated response is now more accurate. Refer to the [Advanced chunking & serialization](https://docling-project.github.io/docling/examples/advanced_chunking_and_serialization/) example for more details on serialization strategies."
]
},
{
"cell_type": "code",
"execution_count": 12,
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">👤: Which backend is faster on Intel with <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">4</span> threads?\n",
"🤖: The pypdfium backend is faster than the Docling-native PDF backend for an Intel Xeon\n",
"E5-<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2690</span> CPU with a thread budget of <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">4</span>, as indicated in Table <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>. The pypdfium backend \n",
"completes the processing in <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">239</span> seconds, achieving a throughput of <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.94</span> pages per \n",
"second, while the Docling-native PDF backend takes <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">375</span> seconds.\n",
"</pre>\n"
],
"text/plain": [
"👤: Which backend is faster on Intel with \u001b[1;36m4\u001b[0m threads?\n",
"🤖: The pypdfium backend is faster than the Docling-native PDF backend for an Intel Xeon\n",
"E5-\u001b[1;36m2690\u001b[0m CPU with a thread budget of \u001b[1;36m4\u001b[0m, as indicated in Table \u001b[1;36m1\u001b[0m. The pypdfium backend \n",
"completes the processing in \u001b[1;36m239\u001b[0m seconds, achieving a throughput of \u001b[1;36m0.94\u001b[0m pages per \n",
"second, while the Docling-native PDF backend takes \u001b[1;36m375\u001b[0m seconds.\n"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"query_engine = index.as_query_engine(llm=GEN_MODEL)\n",
"QUERY = \"Which backend is faster on Intel with 4 threads?\"\n",
"res = query_engine.query(QUERY)\n",
"console.print(f\"👤: {QUERY}\\n🤖: {res.response.strip()}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Refer to the [Advanced chunking & serialization](https://docling-project.github.io/docling/examples/advanced_chunking_and_serialization/) example for more details on serialization strategies."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Filter-context Query\n",
"\n",
"By default, the `DoclingNodeParser` will keep the hierarchical information of items when creating the chunks.\n",
"That information will be stored as metadata in the OpenSearch index. Leveraging the document structure is a powerful\n",
"feature of Docling for improving RAG systems, both for retrieval and for answer generation.\n",
"\n",
"For example, we can use chunk metadata with layout information to run queries in a filter context, for high retrieval accuracy.\n",
"\n",
"Using the previous setup, we can see that the most similar chunk corresponds to a paragraph without enough grounding for the question:"
]
},
{
"cell_type": "code",
"execution_count": 13,
"metadata": {},
"outputs": [],
"source": [
"def display_nodes(nodes):\n",
" res = []\n",
" for idx, item in enumerate(nodes):\n",
" doc_res = {\"k\": idx + 1, \"score\": item.score, \"text\": item.text, \"items\": []}\n",
" doc_items = item.metadata[\"doc_items\"]\n",
" for doc in doc_items:\n",
" doc_res[\"items\"].append({\"ref\": doc[\"self_ref\"], \"label\": doc[\"label\"]})\n",
" res.append(doc_res)\n",
" pprint(res, max_string=200)"
]
},
{
"cell_type": "code",
"execution_count": 14,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"How does pypdfium perform?\n"
]
},
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"font-weight: bold\">[</span>\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ </span><span style=\"font-weight: bold\">{</span>\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ │ </span><span style=\"color: #008000; text-decoration-color: #008000\">'k'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ │ </span><span style=\"color: #008000; text-decoration-color: #008000\">'score'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.6800267</span>,\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ │ </span><span style=\"color: #008000; text-decoration-color: #008000\">'text'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'If you need to run Docling in very low-resource environments, please consider configuring the pypdfium backend. While it is faster and more memory efficient than the default docling-parse backend, it '</span>+<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">90</span>,\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ │ </span><span style=\"color: #008000; text-decoration-color: #008000\">'items'</span>: <span style=\"font-weight: bold\">[{</span><span style=\"color: #008000; text-decoration-color: #008000\">'ref'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'#/texts/68'</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'label'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'text'</span><span style=\"font-weight: bold\">}]</span>\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ </span><span style=\"font-weight: bold\">}</span>\n",
"<span style=\"font-weight: bold\">]</span>\n",
"</pre>\n"
],
"text/plain": [
"\u001b[1m[\u001b[0m\n",
"\u001b[2;32m│ \u001b[0m\u001b[1m{\u001b[0m\n",
"\u001b[2;32m│ │ \u001b[0m\u001b[32m'k'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
"\u001b[2;32m│ │ \u001b[0m\u001b[32m'score'\u001b[0m: \u001b[1;36m0.6800267\u001b[0m,\n",
"\u001b[2;32m│ │ \u001b[0m\u001b[32m'text'\u001b[0m: \u001b[32m'If you need to run Docling in very low-resource environments, please consider configuring the pypdfium backend. While it is faster and more memory efficient than the default docling-parse backend, it '\u001b[0m+\u001b[1;36m90\u001b[0m,\n",
"\u001b[2;32m│ │ \u001b[0m\u001b[32m'items'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'ref'\u001b[0m: \u001b[32m'#/texts/68'\u001b[0m, \u001b[32m'label'\u001b[0m: \u001b[32m'text'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\n",
"\u001b[2;32m│ \u001b[0m\u001b[1m}\u001b[0m\n",
"\u001b[1m]\u001b[0m\n"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"retriever = index.as_retriever(similarity_top_k=1)\n",
"\n",
"QUERY = \"How does pypdfium perform?\"\n",
"nodes = retriever.retrieve(QUERY)\n",
"\n",
"print(QUERY)\n",
"display_nodes(nodes)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We may want to restrict the retrieval to only those chunks containing tabular data, expecting to retrieve more quantitative information for our type of question:"
]
},
{
"cell_type": "code",
"execution_count": 15,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"How does pypdfium perform?\n"
]
},
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"font-weight: bold\">[</span>\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ </span><span style=\"font-weight: bold\">{</span>\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ │ </span><span style=\"color: #008000; text-decoration-color: #008000\">'k'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ │ </span><span style=\"color: #008000; text-decoration-color: #008000\">'score'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.6078317</span>,\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ │ </span><span style=\"color: #008000; text-decoration-color: #008000\">'text'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'Table 1: Runtime characteristics of Docling with the standard model pipeline and settings, on our test dataset of 225 pages, on two different systems. OCR is disabled. We show the time-to-solution (TT'</span>+<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1014</span>,\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ │ </span><span style=\"color: #008000; text-decoration-color: #008000\">'items'</span>: <span style=\"font-weight: bold\">[{</span><span style=\"color: #008000; text-decoration-color: #008000\">'ref'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'#/texts/72'</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'label'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'caption'</span><span style=\"font-weight: bold\">}</span>, <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'ref'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'#/tables/0'</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'label'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'table'</span><span style=\"font-weight: bold\">}]</span>\n",
"<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│ </span><span style=\"font-weight: bold\">}</span>\n",
"<span style=\"font-weight: bold\">]</span>\n",
"</pre>\n"
],
"text/plain": [
"\u001b[1m[\u001b[0m\n",
"\u001b[2;32m│ \u001b[0m\u001b[1m{\u001b[0m\n",
"\u001b[2;32m│ │ \u001b[0m\u001b[32m'k'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
"\u001b[2;32m│ │ \u001b[0m\u001b[32m'score'\u001b[0m: \u001b[1;36m0.6078317\u001b[0m,\n",
"\u001b[2;32m│ │ \u001b[0m\u001b[32m'text'\u001b[0m: \u001b[32m'Table 1: Runtime characteristics of Docling with the standard model pipeline and settings, on our test dataset of 225 pages, on two different systems. OCR is disabled. We show the time-to-solution \u001b[0m\u001b[32m(\u001b[0m\u001b[32mTT'\u001b[0m+\u001b[1;36m1014\u001b[0m,\n",
"\u001b[2;32m│ │ \u001b[0m\u001b[32m'items'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'ref'\u001b[0m: \u001b[32m'#/texts/72'\u001b[0m, \u001b[32m'label'\u001b[0m: \u001b[32m'caption'\u001b[0m\u001b[1m}\u001b[0m, \u001b[1m{\u001b[0m\u001b[32m'ref'\u001b[0m: \u001b[32m'#/tables/0'\u001b[0m, \u001b[32m'label'\u001b[0m: \u001b[32m'table'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\n",
"\u001b[2;32m│ \u001b[0m\u001b[1m}\u001b[0m\n",
"\u001b[1m]\u001b[0m\n"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"filters = MetadataFilters(\n",
" filters=[MetadataFilter(key=\"doc_items.label\", value=\"table\")]\n",
")\n",
"\n",
"table_retriever = index.as_retriever(filters=filters, similarity_top_k=1)\n",
"nodes = table_retriever.retrieve(QUERY)\n",
"\n",
"print(QUERY)\n",
"display_nodes(nodes)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Hybrid Search Retrieval with RRF\n",
"\n",
"Hybrid search combines keyword and semantic search to improve search relevance. To avoid relying on traditional score normalization techniques, the reciprocal rank fusion (RRF) feature on hybrid search can significantly improve the relevance of the retrieved chunks in our RAG system.\n",
"\n",
"First, create a search pipeline and specify RRF as technique:"
]
},
{
"cell_type": "code",
"execution_count": 16,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{\"acknowledged\":true}\n"
]
}
],
"source": [
"url = f\"{OPENSEARCH_ENDPOINT}/_search/pipeline/rrf-pipeline\"\n",
"headers = {\"Content-Type\": \"application/json\"}\n",
"body = {\n",
" \"description\": \"Post processor for hybrid RRF search\",\n",
" \"phase_results_processors\": [\n",
" {\"score-ranker-processor\": {\"combination\": {\"technique\": \"rrf\"}}}\n",
" ],\n",
"}\n",
"\n",
"response = requests.put(url, json=body, headers=headers)\n",
"print(response.text)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We can then repeat the previous steps to get a `VectorStoreIndex` object, leveraging the search pipeline that we just created:"
]
},
{
"cell_type": "code",
"execution_count": 17,
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"2025-09-10 13:17:10,104 - WARNING - GET http://localhost:9200/docling-index-rrf [status:404 request:0.001s]\n"
]
}
],
"source": [
"client_rrf = OpensearchVectorClient(\n",
" endpoint=OPENSEARCH_ENDPOINT,\n",
" index=f\"{OPENSEARCH_INDEX}-rrf\",\n",
" dim=embed_dim,\n",
" embedding_field=embed_field,\n",
" text_field=text_field,\n",
" search_pipeline=\"rrf-pipeline\",\n",
")\n",
"\n",
"vector_store_rrf = OpensearchVectorStore(client_rrf)\n",
"storage_context_rrf = StorageContext.from_defaults(vector_store=vector_store_rrf)\n",
"index_hybrid = VectorStoreIndex.from_documents(\n",
" documents=documents,\n",
" transformations=[node_parser, MetadataTransform()],\n",
" storage_context=storage_context_rrf,\n",
" embed_model=EMBED_MODEL,\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The first retriever, which entirely relies on semantic (vector) search, fails to catch the supporting chunk for the given question in the top 1 position.\n",
"Note that we highlight few expected keywords for illustration purposes.\n"
]
},
{
"cell_type": "code",
"execution_count": 18,
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">*** <span style=\"color: #808000; text-decoration-color: #808000\">k</span>=<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span> ***\n",
"We encourage everyone to propose or implement additional features and models, and will \n",
"gladly take your inputs and contributions under review . The codebase of Docling is open\n",
"for use and contribution, under the MIT license agreement and in alignment with our \n",
"contributing guidelines included in the Docling repository. If you use Docling in your \n",
"projects, please consider citing this technical report.\n",
"</pre>\n"
],
"text/plain": [
"*** \u001b[33mk\u001b[0m=\u001b[1;36m1\u001b[0m ***\n",
"We encourage everyone to propose or implement additional features and models, and will \n",
"gladly take your inputs and contributions under review . The codebase of Docling is open\n",
"for use and contribution, under the MIT license agreement and in alignment with our \n",
"contributing guidelines included in the Docling repository. If you use Docling in your \n",
"projects, please consider citing this technical report.\n"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">*** <span style=\"color: #808000; text-decoration-color: #808000\">k</span>=<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span> ***\n",
"Optionally, you can configure custom pipeline features and runtime options, such as \n",
"turning on or off features <span style=\"font-weight: bold\">(</span>e.g. OCR, table structure recognition<span style=\"font-weight: bold\">)</span>, enforcing limits on \n",
"the input document size, and defining the budget of CPU threads. Advanced usage examples\n",
"and options are documented in the README file. <span style=\"color: #808000; text-decoration-color: #808000; font-weight: bold\">Docling also provides a Dockerfile</span> to \n",
"demonstrate how to install and run it inside a container.\n",
"</pre>\n"
],
"text/plain": [
"*** \u001b[33mk\u001b[0m=\u001b[1;36m2\u001b[0m ***\n",
"Optionally, you can configure custom pipeline features and runtime options, such as \n",
"turning on or off features \u001b[1m(\u001b[0me.g. OCR, table structure recognition\u001b[1m)\u001b[0m, enforcing limits on \n",
"the input document size, and defining the budget of CPU threads. Advanced usage examples\n",
"and options are documented in the README file. \u001b[1;33mDocling also provides a Dockerfile\u001b[0m to \n",
"demonstrate how to install and run it inside a container.\n"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">*** <span style=\"color: #808000; text-decoration-color: #808000\">k</span>=<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">3</span> ***\n",
"Docling is designed to allow easy extension of the model library and pipelines. In the \n",
"future, we plan to extend Docling with several more models, such as a figure-classifier \n",
"model, an equationrecognition model, a code-recognition model and more. This will help \n",
"improve the quality of conversion for specific types of content, as well as augment \n",
"extracted document metadata with additional information. Further investment into testing\n",
"and optimizing GPU acceleration as well as improving the Docling-native PDF backend are \n",
"on our roadmap, too.\n",
"</pre>\n"
],
"text/plain": [
"*** \u001b[33mk\u001b[0m=\u001b[1;36m3\u001b[0m ***\n",
"Docling is designed to allow easy extension of the model library and pipelines. In the \n",
"future, we plan to extend Docling with several more models, such as a figure-classifier \n",
"model, an equationrecognition model, a code-recognition model and more. This will help \n",
"improve the quality of conversion for specific types of content, as well as augment \n",
"extracted document metadata with additional information. Further investment into testing\n",
"and optimizing GPU acceleration as well as improving the Docling-native PDF backend are \n",
"on our roadmap, too.\n"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"QUERY = \"Does Docling project provide a Dockerfile?\"\n",
"retriever = index.as_retriever(similarity_top_k=3)\n",
"nodes = retriever.retrieve(QUERY)\n",
"exp = \"Docling also provides a Dockerfile\"\n",
"start = \"[bold yellow]\"\n",
"end = \"[/]\"\n",
"for idx, item in enumerate(nodes):\n",
" console.print(\n",
" f\"*** k={idx + 1} ***\\n{item.text.strip().replace(exp, f'{start}{exp}{end}')}\"\n",
" )"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"However, the retriever with the hybrid search pipeline effectively recognizes the key paragraph in the first position:"
]
},
{
"cell_type": "code",
"execution_count": 19,
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">*** <span style=\"color: #808000; text-decoration-color: #808000\">k</span>=<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span> ***\n",
"Optionally, you can configure custom pipeline features and runtime options, such as \n",
"turning on or off features <span style=\"font-weight: bold\">(</span>e.g. OCR, table structure recognition<span style=\"font-weight: bold\">)</span>, enforcing limits on \n",
"the input document size, and defining the budget of CPU threads. Advanced usage examples\n",
"and options are documented in the README file. <span style=\"color: #808000; text-decoration-color: #808000; font-weight: bold\">Docling also provides a Dockerfile</span> to \n",
"demonstrate how to install and run it inside a container.\n",
"</pre>\n"
],
"text/plain": [
"*** \u001b[33mk\u001b[0m=\u001b[1;36m1\u001b[0m ***\n",
"Optionally, you can configure custom pipeline features and runtime options, such as \n",
"turning on or off features \u001b[1m(\u001b[0me.g. OCR, table structure recognition\u001b[1m)\u001b[0m, enforcing limits on \n",
"the input document size, and defining the budget of CPU threads. Advanced usage examples\n",
"and options are documented in the README file. \u001b[1;33mDocling also provides a Dockerfile\u001b[0m to \n",
"demonstrate how to install and run it inside a container.\n"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">*** <span style=\"color: #808000; text-decoration-color: #808000\">k</span>=<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span> ***\n",
"We therefore decided to provide multiple backend choices, and additionally open-source a\n",
"custombuilt PDF parser, which is based on the low-level qpdf <span style=\"font-weight: bold\">[</span><span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">4</span><span style=\"font-weight: bold\">]</span> library. It is made \n",
"available in a separate package named docling-parse and powers the default PDF backend \n",
"in Docling. As an alternative, we provide a PDF backend relying on pypdfium , which may \n",
"be a safe backup choice in certain cases, e.g. if issues are seen with particular font \n",
"encodings.\n",
"</pre>\n"
],
"text/plain": [
"*** \u001b[33mk\u001b[0m=\u001b[1;36m2\u001b[0m ***\n",
"We therefore decided to provide multiple backend choices, and additionally open-source a\n",
"custombuilt PDF parser, which is based on the low-level qpdf \u001b[1m[\u001b[0m\u001b[1;36m4\u001b[0m\u001b[1m]\u001b[0m library. It is made \n",
"available in a separate package named docling-parse and powers the default PDF backend \n",
"in Docling. As an alternative, we provide a PDF backend relying on pypdfium , which may \n",
"be a safe backup choice in certain cases, e.g. if issues are seen with particular font \n",
"encodings.\n"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"text/html": [
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">*** <span style=\"color: #808000; text-decoration-color: #808000\">k</span>=<span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">3</span> ***\n",
"We encourage everyone to propose or implement additional features and models, and will \n",
"gladly take your inputs and contributions under review . The codebase of Docling is open\n",
"for use and contribution, under the MIT license agreement and in alignment with our \n",
"contributing guidelines included in the Docling repository. If you use Docling in your \n",
"projects, please consider citing this technical report.\n",
"</pre>\n"
],
"text/plain": [
"*** \u001b[33mk\u001b[0m=\u001b[1;36m3\u001b[0m ***\n",
"We encourage everyone to propose or implement additional features and models, and will \n",
"gladly take your inputs and contributions under review . The codebase of Docling is open\n",
"for use and contribution, under the MIT license agreement and in alignment with our \n",
"contributing guidelines included in the Docling repository. If you use Docling in your \n",
"projects, please consider citing this technical report.\n"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"retriever_rrf = index_hybrid.as_retriever(\n",
" vector_store_query_mode=VectorStoreQueryMode.HYBRID, similarity_top_k=3\n",
")\n",
"nodes = retriever_rrf.retrieve(QUERY)\n",
"for idx, item in enumerate(nodes):\n",
" console.print(\n",
" f\"*** k={idx + 1} ***\\n{item.text.strip().replace(exp, f'{start}{exp}{end}')}\"\n",
" )"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.10"
}
},
"nbformat": 4,
"nbformat_minor": 4
}