Source: http://www.poma-ai.com/docs/pipelines/paddleocr-vl-to-redis

# The Missing Link Between PaddleOCR-VL and Optimal Retrieval in Redis

<ByAuthor />

**The short answer:** PaddleOCR-VL gives you self-hosted, layout-aware parsing; Redis gives you an in-memory `VECTOR` index with exact-match `TAG`/`NUMERIC` filters as fast as the search itself. Wired together naively — embed a whole image-heavy markdown page into a hash next to the vector, query without Redis's exact return contract — they still produce mediocre RAG, on top of an avoidable RAM bill. The missing link is POMA: `PrimeCut().ingest()` consumes the raw layout-parsing JSON (either shape, auto-detected), emits hierarchy-preserving [chunksets](/learn/chunking/chunksets), and `vektoria` writes them as content-free vectors into RediSearch while the actual content — including described images — lives on a volume. Retrieval runs the KNN query Redis actually requires and hands the result to `assemble()`.

## What each end of the pipeline actually provides

**PaddleOCR-VL** pairs a layout-detection model (PP-DocLayoutV3) with a vision-language OCR model, served behind your own `/layout-parsing` HTTP API — typically vLLM, self-hosted for data locality, air-gapped deployments, or per-page cost at volume. Its preferred output, `markdown.text`, splices images in as base64 data URIs via a side-dict so they can be described; its fallback, `parsing_res_list`, is a flat labeled block array. Neither shape is small once images are inline. Details: [The Optimal Chunker for PaddleOCR-VL](/optimal-chunker-paddleocr-vl).

**Redis** provides a `VECTOR` field (`HNSW`/`FLAT`, `FLOAT32`) alongside `TAG`/`NUMERIC` metadata fields, all resident in memory, so filters run at the same speed as the vector search. What it doesn't provide: any opinion about what belongs in that hash, or a forgiving query API — the `KNN` clause has an exact syntactic contract for what comes back. Details: [Redis Chunking Strategy for RAG](/optimal-chunks-redis).

## The naive wiring, and where it breaks

The common recipe — take PaddleOCR-VL's `markdown.text` (images already spliced in as base64 data URIs for describability), embed it, and `hset` that same markdown into a Redis hash alongside the vector — breaks in a pair-specific way: Redis indexes are entirely RAM-resident, so every embedded image's base64 payload, harmless in a one-off chunking call, becomes a permanent per-key memory cost multiplied across every figure in every document. That's compounded by Redis's second, independent gotcha: a `KNN` query that doesn't alias its score `AS vector_score`, call `.return_fields("file_id", "chunkset_index", "vector_score")`, and set `.dialect(2)` returns a result shape `assemble()` cannot parse — the search succeeds, vectors match, and retrieval still comes back empty.

## The pipeline, end to end

```bash
pip install redis
```

```python
import json
import requests
import numpy as np
from redis import Redis
from redis.commands.search.field import VectorField, TagField, NumericField
from redis.commands.search.query import Query
from redis.commands.search.index_definition import IndexDefinition, IndexType
from poma import PrimeCut
from poma.vektoria import assemble, records_from_archive, Volume
from poma.embeddings import get_embedder

# 1. Your existing self-hosted call — unchanged. `/layout-parsing` is the
#    PaddleX-compatible endpoint your layout-server + PaddleOCR-VL stack exposes.
resp = requests.post(
    "http://layout-server:40111/layout-parsing",
    json={"file": "<base64-encoded PDF>", "fileType": 0},
)
resp.raise_for_status()
with open("result.paddleocr-vl.json", "w") as f:
    json.dump(resp.json(), f)

# 2. Chunk the raw result and keep the .poma archive — the volume's source of truth.
client = PrimeCut()  # reads POMA_API_KEY
client.ingest("result.paddleocr-vl.json", download_dir="archives", filename="doc.poma")

embedder = get_embedder("local:BAAI/bge-small-en-v1.5")
vol = Volume("s3://your-bucket/poma")
r = Redis(host="localhost", port=6379)

r.ft("poma_idx").create_index(
    fields=[
        VectorField("embedding", "HNSW", {"TYPE": "FLOAT32", "DIM": embedder.dims,
                                           "DISTANCE_METRIC": "COSINE"}),
        TagField("file_id"),
        NumericField("chunkset_index"),
    ],
    definition=IndexDefinition(prefix=["poma:"], index_type=IndexType.HASH),
)

# 3. Content-free ingest — vector index holds only routing metadata; content (and any
#    described images) lives on the volume, never in the RAM-resident Redis hash.
records = records_from_archive("archives/doc.poma")  # -> list[Record] (id, text, payload)
for rec in records:
    vol.write_doc(rec.payload["file_id"], {"file_id": rec.payload["file_id"],
                                            "chunks": rec.payload["chunks"], "text": rec.text})
    vector = np.asarray(embedder.embed([rec.text])[0], dtype=np.float32).tobytes()
    r.hset(f"poma:{rec.id}", mapping={
        "embedding": vector, "file_id": rec.payload["file_id"],
        "chunkset_index": rec.payload["chunkset_index"],
    })

# 4. KNN retrieval — the three-part contract Redis requires, then assemble.
qv = np.asarray(embedder.embed(["early termination conditions"])[0], dtype=np.float32).tobytes()
q = (Query("*=>[KNN 10 @embedding $vec AS vector_score]").sort_by("vector_score")
     .return_fields("file_id", "chunkset_index", "vector_score").dialect(2))
res = r.ft("poma_idx").search(q, query_params={"vec": qv})
context = assemble(res, volume=vol)  # -> [{"file_id", "content"}, ...]
```

PrimeCut detects PaddleOCR-VL from `layoutParsingResults` or `parsing_res_list` — both are the tool's own key names — strips running furniture, describes images from the `markdown.images` side-dict, and rebuilds the cross-page heading tree, all before anything reaches Redis. To force or suppress detection, set `external_ocr_source` to `"paddleocr_vl"` or `"none"`.

## Metadata mapping: POMA fields → Redis primitives

| POMA field | Source in the PaddleOCR-VL result | Redis primitive | What it enables |
| --- | --- | --- | --- |
| `to_embed` (chunkset text) | `markdown.text` when present, else assembled from `parsing_res_list` in `block_order` | `VECTOR` field (`HNSW`, `FLOAT32`) | `KNN` vector search signal |
| `file_id` | one per ingested layout-parsing JSON | `TAG` field | exact-match filter, same speed as the vector search |
| `chunkset_index` | assigned by PrimeCut while rebuilding hierarchy | `NUMERIC` field, half of the deterministic key id | dedupe + volume lookup inside `assemble()` |
| `page` (from `page_index`) | 0-based in both PaddleOCR-VL shapes, remapped 1-based | kept in the volume's chunk payload, not the hash | page-cited cheatsheets without bloating RAM |
| described images / table HTML | `markdown.images` side-dict spliced as data URIs, or inline HTML table blocks | kept in the volume's chunk payload, not the hash | full content-free retrieval without RAM-resident image bytes |

Retrieved chunksets share ancestor lineage across a document — `assemble()` deduplicates that lineage into one prompt-ready cheatsheet, the same discipline that answers our reference legal-document benchmark with **337 tokens** instead of **1,542** for a recursive-splitter baseline. [Methodology here](/document-ingestion-chunking-rag).

## Frequently asked questions

### How do I get self-hosted PaddleOCR-VL results into Redis for RAG?

Save the raw layout-parsing JSON, run `PrimeCut().ingest()` on it, then use `records_from_archive` to embed and `hset` content-free vectors with `file_id`/`chunkset_index` into a `VECTOR`+`TAG`+`NUMERIC` index. Retrieve with the three-part `KNN` contract and `assemble()`.

### Why does my Redis KNN query return nothing through POMA assemble after ingesting PaddleOCR-VL chunks?

The score must be aliased `AS vector_score`, the query needs `.return_fields("file_id", "chunkset_index", "vector_score")`, and `.dialect(2)` must be set — miss any of the three and `assemble()` gets nothing back, regardless of source parser.

### Should PaddleOCR-VL's inline base64 images be stored in a Redis hash?

No — `markdown.images` data URIs belong on the volume with the rest of the content, not in a RAM-resident hash next to the vector, which multiplies memory cost per figure for no retrieval benefit.

### What Redis fields should PaddleOCR-VL chunksets carry?

`embedding` as `VECTOR` (HNSW, `FLOAT32`, `COSINE`), `file_id` as `TAG`, `chunkset_index` as `NUMERIC`. Page and depth stay in the volume's chunk payload, not the hash.

### Does self-hosting PaddleOCR-VL change how you should query Redis?

No — RediSearch's `KNN` syntax and return contract work identically. What changes is upstream: documents never leave your infrastructure through the OCR step.

## Related recipes

Same parser, different store: [PaddleOCR-VL → Elasticsearch](/pipelines/paddleocr-vl-to-elasticsearch) · [PaddleOCR-VL → OpenSearch](/pipelines/paddleocr-vl-to-opensearch) · [PaddleOCR-VL → Qdrant](/pipelines/paddleocr-vl-to-qdrant)

Same store, different parser: [Mistral OCR → Redis](/pipelines/mistral-ocr-to-redis) · [Textract → Redis](/pipelines/textract-to-redis)

Foundations: [The Optimal Chunker for PaddleOCR-VL](/optimal-chunker-paddleocr-vl) · [Redis Chunking Strategy for RAG](/optimal-chunks-redis) · [All pipeline recipes](/pipelines/) · [RAG architecture guide](/guides/rag-architecture/)