Quality-Aware RAG Ingestion: Routing Documents by Readability
A declarative docling-pipelines flow that scores language, duplicates and Flesch readability, then sends documents to two OpenSearch indexes for customer and developer search.
This article is published in English.
Sources: Architecting a Multi-Tier RAG Pipeline: Quality-Aware Document Routing with docling-pipelines
Moving a customer-support knowledge base into AI search usually goes wrong at the same point: every file gets the same treatment. A realistic corpus is a mixed bag. It holds tidy FAQs, structured product guides, dense developer manuals, duplicated legal text, and a few corrupted files with no readable words at all.
Pour all of that into one conversational RAG system and retrieval quality drops. A customer who wants a simple how-to gets chunks of an engineering manual. Sending garbage text or system logs to an expensive model for summaries or translation is also money thrown away.
The fix is to route documents by quality. A two-tier design looks at each file, removes empty pages, measures readability and then splits the corpus:
- Tier 1, consumer search. Highly readable documents get structural metadata, such as word counts, to support customer-facing conversational search.
- Tier 2, developer and reference search. Dense technical material goes to its own index for advanced technical questions.
docling-pipelines makes this practical. It's an open-source, MIT-licensed framework backed by IBM that builds and runs document-processing graphs straight from JSON configuration. No custom Python glue is needed, because operators hand documents to each other as in-memory Apache Arrow tables, so intermediate transforms and routing checks cost almost nothing in CPU and memory.
The shape of the pipeline
Everything lives in one JSON flow file that describes a directed acyclic graph:
ingestreads PDF, TXT, DOCX and HTML files from disk.extractpulls structured text out with the Docling library.detect_languageidentifies each document's language.dedupuses content hashes to remove duplicate rows.doc_qualitycomputes structural metrics, such as total word count.readabilitycalculates text-complexity scores.quality_branchingroutes on the Flesch Reading Ease score: above 60 goes down the high-quality branch, 60 or lower goes down the low-quality branch.- The high-quality path runs
ml_enrichment_high_quality,embeddings_high_qualityandvectordb_high_quality. It adds ML-based linguistic features, creates vector embeddings and writes into ahigh_quality_docsindex. - The low-quality path runs
sql_filter_low_quality,embeddings_low_qualityandvectordb_low_quality. It removes empty rows, embeds what's left and writes intolow_quality_docs.
Profiling before the split
Each document needs a profile before it can be routed. After ingest reads the payloads and extract turns the layout into text, every later operator works as a transformer: it inspects the table and adds new columns to the same in-memory Arrow table. Cheap CPU-only checks come first: language, duplicates and text complexity.
Language detection
Later ML models and readability formulas need to know the language, so detection comes first.
{
"name": "detect_language",
"type": "lang_detect",
"config": {
"doc_column": "content"
},
"depends_on": [
"extract"
]
}
The operator reads the content column and appends lang_name (an ISO 639-1 code) and lang_score. Placing it right after extraction means every language-sensitive step downstream can use its output.
Deduplication
Duplicate documents waste embedding compute and skew search results.
{
"name": "dedup",
"type": "ededup",
"config": {
"doc_column": "content",
"doc_id_column": "doc_id_hash"
},
"depends_on": [
"detect_language"
]
}
It refers to the content hash stored in doc_id_hash and to the raw text in content, then removes repeated rows from the table. Doing this early spares the later steps from useless work.
Document quality
Basic structural checks catch blank pages and odd layouts.
{
"name": "doc_quality",
"type": "doc_quality",
"config": {
"doc_column": "content",
"doc_content_column": "content",
"text_lang": "en"
},
"depends_on": [
"dedup"
]
}
This adds 11 structural columns, all prefixed docq_. The one that matters here is docq_total_words, the total word count for each document.
Readability
Readability formulas judge difficulty from word length, syllables and sentence structure.
{
"name": "readability",
"type": "readability",
"config": {
"doc_column": "content",
"readability_score_list": [
"flesch_reading_ease",
"flesch_kincaid_grade",
"gunning_fog",
"difficult_words",
"dale_chall_readability_score"
]
},
"depends_on": [
"doc_quality"
]
}
The readability_score_list array tells the operator which metrics to compute and append as columns. Routing relies on the Flesch Reading Ease score, which runs from 0 to 100:
- Above 60 means plain, accessible language that suits a general help-center audience.
- 60 or lower means dense, technical prose that assumes specialist knowledge.
The branching step
The branching operator cuts the Arrow dataset into separate processing paths.
{
"name": "quality_branching",
"type": "branching",
"config": {
"branches": {
"high_quality_branch": {
"link_name": "High Quality Documents",
"criteria_json": {
"criteria_list": [
{
"variable": "flesch_reading_ease",
"operator": ">",
"value": 60
}
],
"logical_operator": "AND"
}
},
"low_quality_branch": {
"link_name": "Low Quality Documents",
"criteria_json": {
"criteria_list": [
{
"variable": "flesch_reading_ease",
"operator": "<=",
"value": 60
}
],
"logical_operator": "AND"
}
}
}
},
"depends_on": [
"readability"
]
}
Routing is controlled by a declarative criteria_json block with four fields:
variablenames the column to test, for exampleflesch_reading_ease.operatorgives the comparison, such as>or<=.valueis the threshold, here60.logical_operatorjoins several conditions withANDorOR.
With flesch_reading_ease > 60 as the rule, the engine evaluates the conditions natively, splits the in-memory table and runs each downstream branch.
High-quality path: enrich, embed, index
Documents sent down high_quality_branch receive linguistic ML enrichment first.
{
"name": "ml_enrichment_high_quality",
"type": "ml_enrichment",
"config": {
"doc_column": "content",
"lang_column": "lang_name",
"output_column_prefix": "ml_",
"error_column_name": "enrichment_error"
},
"depends_on": [
"quality_branching.high_quality_branch"
]
}
ml_enrichment parses the text with language-aware models and produces metadata such as sentence counts, syllable properties and noun lists, all aimed at better retrieval. It depends on the lang_name column from the detection step. Because this is the CPU-heavy part of the flow, it runs only on readable text. The output_column_prefix setting prefixes the new columns with ml_, which includes ml_num_words.
Next, the text becomes 2560-dimensional embeddings. The model is openai/granite4:latest, served by a local Ollama server through LiteLLM:
{
"name": "embeddings_high_quality",
"type": "embeddings",
"config": {
"embeddings_column": "embeddings",
"overlap_ratio": 0.2,
"doc_column": "content",
"provider_config": {
"api_base": "http://localhost:11434/v1",
"api_key": "${OLLAMA_API_KEY}",
"model_id": "openai/granite4:latest"
},
"provider": "litellm"
},
"depends_on": [
"ml_enrichment_high_quality"
]
}
Last, documents are indexed into OpenSearch, with feature_mappings tying Arrow columns to OpenSearch fields:
{
"name": "vectordb_high_quality",
"type": "vectordb",
"config": {
"provider": "opensearch",
"doc_id_column": "doc_id_hash",
"embeddings_column": "embeddings",
"vector_dimension": 2560,
"create_index": "true",
"provider_config": {
"index_name": "high_quality_docs",
"host": "localhost",
"port": 9200,
"username": "${OPENSEARCH_USERNAME}",
"password": "${OPENSEARCH_PASSWORD}",
"use_ssl": false,
"verify_certs": false,
"batch_size": 100,
"engine": "faiss",
"algorithm": "hnsw",
"space_type": "l2"
},
"feature_mappings": [
{"feature_name": "doc_id_hash", "mapped_column_name": "pk"},
{"feature_name": "name", "mapped_column_name": "doc_name"},
{"feature_name": "content", "mapped_column_name": "text"},
{"feature_name": "embeddings", "mapped_column_name": "vector_embeddings"},
{"feature_name": "lang_name", "mapped_column_name": "language"},
{"feature_name": "flesch_reading_ease", "mapped_column_name": "readability_score"},
{"feature_name": "ml_num_words", "mapped_column_name": "word_count"}
]
},
"depends_on": [
"embeddings_high_quality"
]
}
Low-quality path: filter, embed, index
Documents on low_quality_branch skip the costly ml_enrichment step. Some of them may contain no readable words after extraction, for example corrupted files or blank scans, and embedding an empty string only adds noise to the vector space. A filter removes them:
{
"name": "sql_filter_low_quality",
"type": "sql_filter",
"config": {
"criteria_json": {
"criteria_list": [
{
"variable": "docq_total_words",
"operator": ">",
"value": 0
}
],
"logical_operator": "AND"
}
},
"depends_on": [
"quality_branching.low_quality_branch"
]
}
The filter checks the docq_total_words column from doc_quality and drops any document with zero words.
The survivors, mostly technical material, are embedded with the same granite4 model and written to a separate OpenSearch index:
{
"name": "vectordb_low_quality",
"type": "vectordb",
"config": {
"provider": "opensearch",
"doc_id_column": "doc_id_hash",
"embeddings_column": "embeddings",
"vector_dimension": 2560,
"create_index": "true",
"provider_config": {
"index_name": "low_quality_docs",
"host": "localhost",
"port": 9200,
"username": "${OPENSEARCH_USERNAME}",
"password": "${OPENSEARCH_PASSWORD}",
"use_ssl": false,
"verify_certs": false,
"batch_size": 100,
"engine": "faiss",
"algorithm": "hnsw",
"space_type": "l2"
},
"feature_mappings": [
{"feature_name": "doc_id_hash", "mapped_column_name": "pk"},
{"feature_name": "name", "mapped_column_name": "doc_name"},
{"feature_name": "content", "mapped_column_name": "text"},
{"feature_name": "embeddings", "mapped_column_name": "vector_embeddings"},
{"feature_name": "lang_name", "mapped_column_name": "language"},
{"feature_name": "flesch_reading_ease", "mapped_column_name": "readability_score"},
{"feature_name": "difficult_words", "mapped_column_name": "difficult_words"}
]
},
"depends_on": [
"embeddings_low_quality"
]
}
Since this branch skipped enrichment, word_count doesn't exist for these documents. The mapping uses the computed difficult_words count instead, which helps with technical queries.
Two indexes, two query styles
Separate indexes per readability tier let each audience search differently.
Help-center search
For end users, the query hits high_quality_docs. It keeps only easy-to-read guides (readability_score > 60) with real substance (word_count > 100):
curl -XGET "http://localhost:9200/high_quality_docs/_search" \
-H 'Content-Type: application/json' -d'
{
"query": {
"bool": {
"must": [
{
"knn": {
"vector_embeddings": {
"vector": [0.015, -0.023, 0.004],
"k": 10
}
}
}
],
"filter": [
{
"range": {
"readability_score": {
"gt": 60
}
}
},
{
"range": {
"word_count": {
"gt": 100
}
}
}
]
}
}
}'
Developer reference search
For expert users hunting specifications, the query targets low_quality_docs. It looks at the complex material and keeps segments with many hard technical terms (difficult_words > 5):
curl -XGET "http://localhost:9200/low_quality_docs/_search" \
-H 'Content-Type: application/json' -d'
{
"query": {
"bool": {
"must": [
{
"knn": {
"vector_embeddings": {
"vector": [0.015, -0.023, 0.004],
"k": 10
}
}
}
],
"filter": [
{
"range": {
"difficult_words": {
"gt": 5
}
}
}
]
}
}
}'
Running it
The repository ships a setup script that creates a virtual environment, installs dependencies, starts local OpenSearch containers and registers the Ollama models.
Step 1: set up the environment
Clone the repo and run the script:
git clone https://github.com/IBM/docling-pipelines.git
cd docling-pipelines
./scripts/setup_docling_pipelines_environment.sh
Or install straight from PyPI:
pip install docling-pipelines
Step 2: set environment variables
export OLLAMA_API_KEY=ollama
export OPENSEARCH_USERNAME=admin
export OPENSEARCH_PASSWORD=<your-password>
Step 3: run the flow
Launch the pipeline from the command line:
docling-pipelines --flow-file sample_flows/advanced/branching_quality_routing.json
What the run prints
The output traces the flow, with each transformation and routing decision logged:
$ docling-pipelines --flow-file sample_flows/advanced/branching_quality_routing.json
[INFO] [ingest] Loading files from tests/fixtures/customer_support_docs/
[INFO] [ingest] Ingested 12 files.
[INFO] [extract] Running extract_operator using docling_library...
[INFO] [extract] Extracted text from 12 documents.
[INFO] [detect_language] Running lang_detect...
[INFO] [detect_language] Complete. lang_name assigned.
[INFO] [dedup] Running ededup...
[INFO] [dedup] Removed 2 duplicate documents. 10 documents remaining.
[INFO] [doc_quality] Running doc_quality...
[INFO] [doc_quality] Computed docq_* features for 10 documents.
[INFO] [readability] Running readability...
[INFO] [readability] Computed readability features for 10 documents.
[INFO] [quality_branching] Branching 10 documents...
[INFO] [quality_branching] Routed 6 documents to link 'High Quality Documents' (flesch_reading_ease > 60).
[INFO] [quality_branching] Routed 4 documents to link 'Low Quality Documents' (flesch_reading_ease <= 60).
[INFO] [ml_enrichment_high_quality] Running ml_enrichment on 6 documents...
[INFO] [embeddings_high_quality] Generating embeddings with model 'openai/granite4:latest'...
[INFO] [vectordb_high_quality] Uploaded 6 documents to OpenSearch index 'high_quality_docs'.
[INFO] [sql_filter_low_quality] Running sql_filter on 4 documents...
[INFO] [sql_filter_low_quality] Filtered out 0 empty documents. 4 documents remaining.
[INFO] [embeddings_low_quality] Generating embeddings with model 'openai/granite4:latest'...
[INFO] [vectordb_low_quality] Uploaded 4 documents to OpenSearch index 'low_quality_docs'.
[INFO] Flow execution finished successfully in 4.23s.
Moving quality metrics and routing rules out of custom Python and into a declarative config makes ingestion fast and easy to maintain. Processing cost follows document value, and the RAG application receives clean, tailored indexes for each audience.
The branching idea works with any metric computed earlier in the flow. Change the criteria to route by language, PII risk score or a custom classifier label. Because pipeline logic stays separate from orchestration code, teams can grow the pipeline without growing the maintenance burden.