An async, production-ready document ingestion pipeline for RAG systems. Processes PDFs, images, arXiv papers, and documents from S3 through Docling extraction, GLiNER2 metadata enrichment & PII redaction, dual-vector embedding (dense + sparse), and stores everything in Qdrant with a NeonDB registry.
flowchart TD
subgraph Upload["Document Sources"]
U1[Local Folder] --> U2{Filter Files}
U2 --> U3[Upload to S3 aioboto3]
U3 --> U4[Return S3 Keys]
AX1[ArXiv ID] --> AX2[Download PDF + Metadata]
AX2 --> AX3[Upload to S3]
AX3 --> U4
end
subgraph Ingestion["Prefect Flows"]
U4 --> F1[ingest_batch]
U4 --> F2[ingest_arxiv]
F1 --> B[asyncio.gather + Semaphore concurrency=5]
F2 --> B
end
subgraph PerDoc["Per-Document Pipeline"]
B --> C1[S3 Download aioboto3]
C1 --> D1[Docling Convert to_thread]
D1 --> E1[Hybrid Chunker]
E1 --> E2[Base64 Extraction Picture/Table/Formula]
E2 --> E3{Has Text?}
E3 -->|Yes| E4[Continue Pipeline]
E3 -->|No (Image)| E5[NeonDB Only]
E4 --> F1M[Metadata Extraction GLiNER2 base]
E4 --> F2M[PII Detection GLiNER2 PII]
F1M --> G1[INSERT documents NeonDB]
G1 --> G2[INSERT chunks NeonDB]
F2M --> H1[Redact Chunks]
H1 --> I1{By Modality}
I1 --> J1[Embed Dense jina-omni-nano 768d]
I1 --> J2[Embed Sparse SPLADE v3 30522d]
J1 --> K1[Upsert Qdrant point.dense_vector]
J2 --> K1[Upsert Qdrant point.sparse_vector]
K1 --> L1[Update chunks qdrant_point_id]
end
E5 --> G2
L1 --> M[Flow Complete]
| Feature | Description |
|---|---|
| Multi-modal Document Support | PDFs, DOC/DOCX, JPG, JPEG, PNG, GIF, WEBP, BMP, TIFF |
| ArXiv Integration | Direct arXiv paper ingestion by ID — downloads PDF + metadata (title, authors, abstract, categories) |
| Table Extraction | Structured table extraction with markdown output via Docling |
| Formula Extraction | LaTeX formula extraction from academic/scientific documents |
| Image/Figure Enrichment | Vision model captioning for figures and images |
| Multi-Modal Base64 Extraction | Resized base64 PNG extraction (max 1024px) for images, tables, and formulas — stored in Qdrant payload and NeonDB for LLM multi-modal use |
| Hybrid Chunking | Docling HybridChunker for context-aware text segmentation |
| 3-Level Metadata Extraction | Document-level, page-level, and chunk-level metadata via GLiNER2 |
| PII Detection & Redaction | 42 PII types across 7 languages via GLiNER2 (email, phone, SSN, credit cards, etc.) |
| Dense Embeddings | jina-embeddings-v5-omni-nano (768d) — text + image in shared vector space |
| Sparse Embeddings | SPLADE v3 (30522d) — learned sparse retrieval with semantic term expansion |
| Native Hybrid Search | Qdrant RRF fusion of dense + sparse vectors at query time |
| Batch Processing | 100+ documents in one shot via Prefect 3 async flows with asyncio.gather + asyncio.Semaphore concurrency |
| Structured Logging | JSON logs with correlation IDs via structlog |
| Document Registry | Two-table NeonDB schema: documents + chunks with UUID linkage |
| Layer | Technology | Version |
|---|---|---|
| Orchestration | Prefect | 3.7.3 |
| Document Parsing | Docling | 2.96.1 |
| Metadata Extraction | GLiNER2 | 1.3.1 |
| PII Detection | GLiNER2 PII | 1.3.1 |
| Dense Embeddings | jina-embeddings-v5-omni-nano | via transformers 5.9.0 |
| Sparse Embeddings | SPLADE v3 (via FastEmbed) | latest |
| Vector Database | Qdrant | 1.18.0 |
| Relational Database | NeonDB / PostgreSQL | via asyncpg 0.31.0 |
| Object Storage | S3 | via aioboto3 15.5.0 |
| ArXiv Download | aiohttp | 3.9+ |
| Logging | structlog | 25.5.0 |
| Package Manager | UV | latest |
git clone https://github.com/your-org/prefect-dag.git
cd prefect-dag
uv sync
This installs all 200+ dependencies including PyTorch, transformers, Docling, and Prefect.
Copy the .env.example (create it from the table below) to .env and fill in your credentials:
cp .env.example .env
Run all migration scripts in order against your NeonDB / PostgreSQL instance:
psql $NEON_DATABASE_URL -f migrations/001_initial_schema.sql
psql $NEON_DATABASE_URL -f migrations/002_add_chunks_updated_at.sql
psql $NEON_DATABASE_URL -f migrations/003_add_chunk_base64.sql
# Or use the Makefile: make migrate
make start # Start Prefect server (4200) + Qdrant (6333) in Docker
make status # Check service health
make stop # Stop all services
make logs # Follow container logs
uv run python main.py
# Should print usage instructions
Create a .env file in the project root with the following variables:
| Variable | Required | Default | Description |
|---|---|---|---|
S3_BUCKET | Yes | - | S3 bucket name for document storage |
S3_PREFIX | No | documents/ | S3 key prefix for uploads |
AWS_REGION | No | us-east-1 | AWS region for S3 |
AWS_ACCESS_KEY_ID | Yes* | - | AWS IAM access key |
AWS_SECRET_ACCESS_KEY | Yes* | - | AWS IAM secret key |
QDRANT_URL | Yes | http://localhost:6333 | Qdrant server URL |
QDRANT_API_KEY | No | - | Qdrant API key (for Cloud) |
QDRANT_COLLECTION | No | rag_documents | Qdrant collection name |
NEON_DATABASE_URL | Yes | - | PostgreSQL connection string |
PREFECT_API_URL | No | - | Prefect Cloud API URL |
PREFECT_API_KEY | No | - | Prefect Cloud API key |
HF_TOKEN | Yes** | - | Hugging Face token for model downloads |
ARXIV_S3_PREFIX | No | arxiv/ | S3 prefix for downloaded arXiv PDFs |
*AWS credentials can also be provided via AWS CLI config or IAM roles. **Required for downloading jina-omni-nano and GLiNER2 models from Hugging Face.
.env# AWS S3
S3_BUCKET=my-rag-documents
S3_PREFIX=ingestion/
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=secret...
# Qdrant
QDRANT_URL=https://my-cluster.cloud.qdrant.io
QDRANT_API_KEY=eyJ...
QDRANT_COLLECTION=rag_documents
# NeonDB / PostgreSQL
NEON_DATABASE_URL=postgresql://user:pass@neon-host.aws.neon.tech/rag_pipeline
# Prefect (optional — for Prefect Cloud)
PREFECT_API_URL=https://api.prefect.cloud/api/...
PREFECT_API_KEY=pnu_...
# Hugging Face
HF_TOKEN=hf_...
Upload all supported files from a local folder to S3:
uv run python main.py upload ./my-documents/ s3://my-bucket/prefix/
Supported formats: .pdf, .doc, .docx, .jpg, .jpeg, .png, .gif, .webp, .bmp, .tiff
uv run python main.py ingest s3://my-bucket/prefix/document.pdf
uv run python main.py ingest-batch batch-001 \
s3://my-bucket/prefix/doc1.pdf \
s3://my-bucket/prefix/doc2.pdf \
s3://my-bucket/prefix/image.png
Download a paper directly from arXiv by ID and process it through the full pipeline:
# Bare arXiv ID
uv run python main.py ingest-arxiv 1706.03762
# With arxiv: prefix
uv run python main.py ingest-arxiv arxiv:1810.04805
# Full URL
uv run python main.py ingest-arxiv https://arxiv.org/abs/2109.00031
ArXiv ID formats supported:
1706.03762 — bare IDarxiv:1810.04805 — prefixedhttps://arxiv.org/abs/2109.00031 — abstract URLhttps://arxiv.org/pdf/2203.02155.pdf — PDF URLWhat happens:
ARXIV_S3_PREFIX (default: arxiv/)Clear all vectors and database records while preserving S3 data:
# Interactive mode (requires confirmation)
uv run python main.py flush
# Non-interactive mode (for scripts / CI)
uv run python main.py flush --force
# Or via Makefile:
make flush
What gets deleted:
documents tablechunks tableWhat is preserved:
uv run python main.py ingest-arxiv-batch batch-003 \
1706.03762 \
1810.04805 \
2109.00031
import asyncio
from src.pipeline.flows import ingest_batch
async def main():
s3_keys = [
"s3://my-bucket/prefix/doc1.pdf",
"s3://my-bucket/prefix/doc2.pdf",
]
doc_ids = await ingest_batch(s3_keys, batch_id="batch-001")
print(f"Processed {len(doc_ids)} documents")
asyncio.run(main())
When a retrieved chunk contains a chunk_base64 field, you can pass it directly to a multi-modal LLM (GPT-4o, Claude 3, Gemini):
import asyncio
from src.pipeline.embedder import embed_query
from src.pipeline.sparse_embedder import embed_text as embed_sparse_query
from src.pipeline.qdrant_store import hybrid_search
async def search(query: str):
dense = await embed_query(query)
sparse = (await embed_sparse_query([query]))[0]
results = await hybrid_search(dense, sparse, limit=10)
return results
results = asyncio.run(search("attention mechanism diagram"))
for r in results:
payload = r["payload"]
text = payload.get("chunk_text", "")[:200]
base64 = payload.get("chunk_base64")
chunk_type = payload.get("chunk_type")
page = payload.get("page_number")
if base64:
print(f"[{chunk_type}] Page {page} | Score: {r['score']:.4f}")
print(f" Text: {text}...")
print(f" Base64: {base64[:60]}... ({len(base64)//1024}KB)")
# Pass base64 to multi-modal LLM:
# messages = [{"role": "user", "content": [
# {"type": "text", "text": query},
# {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64}"}}
# ]}]
else:
print(f"[{chunk_type}] Page {page} | Score: {r['score']:.4f} | {text}...")
prefect-dag/
├── pyproject.toml # UV dependencies & project metadata
├── main.py # CLI entry point (upload, ingest, ingest-batch, ingest-arxiv)
├── Makefile # Docker + dev commands (start, stop, migrate, lint, test)
├── docker-compose.yml # Prefect server + Qdrant with persistent volumes
├── README.md # This file
├── .env.example # Environment variable template
├── src/
│ └── pipeline/
│ ├── __init__.py
│ ├── config.py # Pydantic settings (loads .env)
│ ├── logging.py # structlog JSON logging setup
│ ├── models.py # Pydantic models: Document, Chunk, Metadata
│ ├── arxiv_downloader.py # Async arXiv PDF download + metadata extraction
│ ├── s3_client.py # aioboto3 async S3 download/upload client
│ ├── s3_uploader.py # Folder upload utility (pre-ingestion)
│ ├── docling_processor.py # Docling convert with enrichments (tables, formulas, figures)
│ ├── chunker.py # Docling HybridChunker wrapper
│ ├── gliner_metadata.py # GLiNER2: 3-level metadata extraction
│ ├── gliner_pii.py # GLiNER2 PII: 42-type detection + redaction
│ ├── embedder.py # jina-omni-nano dense embeddings (768d)
│ ├── sparse_embedder.py # SPLADE v3 sparse embeddings (30522d)
│ ├── qdrant_store.py # AsyncQdrantClient: dense+sparse upsert, hybrid RRF search
│ ├── neondb_client.py # asyncpg: documents + chunks CRUD
│ ├── flush.py # flush_qdrant(), flush_neondb(), flush_all()
│ └── flows.py # Prefect async flows (ingest_batch, ingest_arxiv)
├── migrations/
│ ├── 001_initial_schema.sql # NeonDB schema: documents + chunks tables
│ ├── 002_add_chunks_updated_at.sql # Fix missing updated_at + relax document_type constraint
│ └── 003_add_chunk_base64.sql # Add base64 PNG column for LLM multi-modal use
└── tests/ # Test directory (add your tests here)
aioboto3) — Async streaming download from S3 to memoryasyncio.to_thread) — Parse PDF/image/DOC with enrichments:
generate_page_images=True for table/formula/picture base64 extractionasyncio.to_thread) — Tokenization-aware chunking preserving contextasyncio.to_thread) — For each chunk, scan all Docling items:
asyncio.to_thread) — GLiNER2 extracts:
asyncio.to_thread) — GLiNER2 scans 42 PII types:
[PII_TYPE] placeholdersasyncio.to_thread) — jina-omni-nano encodes text to 768d vectorsasyncio.to_thread) — SPLADE v3 encodes to 30522d sparse vectors with semantic term expansionqdrant_point_id linkage (all chunks including images)| Operation | Pattern | Limit |
|---|---|---|
| S3 downloads | aioboto3 native async | Per-task sequential |
| Docling conversion | asyncio.to_thread() | 5 docs (asyncio.gather + Semaphore) |
| Chunking + metadata + PII | asyncio.to_thread() | Sequential per doc |
| Embedding (dense + sparse) | asyncio.to_thread() | Batch 32-64 chunks |
| Qdrant upsert | AsyncQdrantClient | Batch 100 points |
| NeonDB inserts | asyncpg pool | Connection pool (max 10) |
All configuration is managed via src/pipeline/config.py using Pydantic Settings. It automatically loads from environment variables and .env files.
Key tunables:
| Setting | Env Var | Default | Description |
|---|---|---|---|
| Concurrency limit | CONCURRENCY_LIMIT | 5 | Prefect async flow concurrency via asyncio.gather + Semaphore |
| Embedding batch size | EMBEDDING_BATCH_SIZE | 32 | Chunks per embedding batch |
| Qdrant batch size | QDRANT_BATCH_SIZE | 100 | Points per Qdrant upsert batch |
| ArXiv S3 prefix | ARXIV_S3_PREFIX | arxiv/ | S3 prefix for downloaded arXiv PDFs |
| Dense model | DENSE_MODEL | jinaai/jina-embeddings-v5-omni-nano | Dense embedding model |
| Sparse model | SPARSE_MODEL | prithivida/Splade_PP_en_v1 | Sparse embedding model |
| GLiNER metadata model | GLINER_METADATA_MODEL | fastino/gliner2-base-v1 | Metadata extraction |
| GLiNER PII model | GLINER_PII_MODEL | fastino/gliner2-privacy-filter-PII-multi | PII detection |
documents Table| Column | Type | Description |
|---|---|---|
id | UUID PK | Unique document ID |
s3_key | TEXT | Source S3 path (unique) |
filename | TEXT | Original filename |
document_type | TEXT | pdf, doc, jpg, png, unknown, etc. |
size_bytes | BIGINT | File size in bytes |
page_count | INTEGER | Number of pages (for PDFs) |
meta_domain | TEXT | Extracted domain (technology, finance, etc.) |
meta_industry | TEXT | Extracted industry |
meta_companies | JSONB | Extracted companies |
meta_document_type | TEXT | research_paper, report, manual, contract, etc. |
meta_confidentiality | TEXT | public, internal, confidential, restricted |
meta_language | TEXT | Detected language (en, fr, de, etc.) |
pii_detected | BOOLEAN | Whether PII was found |
pii_types | JSONB | List of detected PII types |
pii_redaction_verified | BOOLEAN | Whether redaction was verified |
chunk_count | INTEGER | Number of chunks |
status | TEXT | pending, processing, completed, failed |
error_message | TEXT | Error details if failed |
created_at | TIMESTAMPTZ | Record creation time |
updated_at | TIMESTAMPTZ | Last update time |
chunks Table| Column | Type | Description |
|---|---|---|
id | UUID PK | Unique chunk ID |
document_id | UUID FK | Parent document |
chunk_index | INTEGER | Position in document |
chunk_type | TEXT | text, table, formula, image |
page_number | INTEGER | Source page number (for PDFs) |
chunk_text | TEXT | Redacted chunk content |
meta_chunk_topic | TEXT | Chunk topic classification |
meta_products | JSONB | Products mentioned |
meta_technologies | JSONB | Technologies mentioned |
meta_organizations | JSONB | Organizations mentioned |
meta_locations | JSONB | Locations mentioned |
meta_dates | JSONB | Dates/timelines mentioned |
meta_metrics | JSONB | Numerical metrics mentioned |
chunk_base64 | TEXT | Base64 PNG of image/table/formula (for LLM multi-modal use) |
qdrant_point_id | UUID | Link to Qdrant vector |
qdrant_collection | TEXT | Qdrant collection name |
pii_detected | BOOLEAN | Chunk-level PII flag |
pii_types | JSONB | Chunk-level detected PII types |
created_at | TIMESTAMPTZ | Record creation time |
updated_at | TIMESTAMPTZ | Last update time |
uv run pytest tests/
uv run ruff check src/ # Linting
uv run ruff format src/ # Formatting
uv run mypy src/ # Type checking
View flow runs and task status in the Prefect UI:
uv run prefect server start
# Open http://localhost:4200
make help # Show all available commands
make start # Start Prefect server + Qdrant in Docker
make stop # Stop all Docker services
make logs # Follow container logs
make status # Check container health
make migrate # Run NeonDB migration
make install # Install dependencies with UV
make lint # Run ruff linter
make format # Auto-format code
make test # Run syntax + import checks
make flush # Clear Qdrant + NeonDB (keeps S3, non-interactive)
make clean # Stop services and remove volumes
make env # Create .env from .env.example
| Decision | Rationale |
|---|---|
| GLiNER2 over Presidio | Higher accuracy (0.477 F1), single library for metadata + PII, CPU-only |
| SPLADE v3 over BM25 | Semantic term expansion, Qdrant-native sparse vectors, better BEIR scores |
| jina-omni-nano | Multi-modal (text + image), 768d, aligns with SPLADE in Qdrant |
| Qdrant RRF over client-side fusion | Simpler architecture, single collection, native hybrid search |
| NeonDB two-table design | Document registry + chunk-level metadata for fine-grained RAG filtering |
| asyncio.gather + Semaphore | Prefect 3 removed .map(); asyncio.gather with Semaphore for controlled concurrency |
| Image base64 in NeonDB only | Image chunks have no text to embed; base64 stored in NeonDB for retrieval-time multi-modal use |
| Table base64 in both Qdrant + NeonDB | Table chunks have text + rendered image; base64 included in Qdrant payload for LLM use at retrieval |
If Hugging Face model downloads fail:
export HF_TOKEN=your_token_here
# Or add to .env
For local Qdrant:
docker run -p 6333:6333 qdrant/qdrant
Check flow logs:
uv run prefect flow-run logs <flow-run-id>
git checkout -b feature/amazing-feature)git commit -m 'feat: add amazing feature')git push origin feature/amazing-feature)2 commits
Python
93.9%
Makefile
6.1%
An async, production-ready document ingestion pipeline for RAG systems. Processes PDFs, images, arXiv papers, and documents from S3 through Docling extraction, GLiNER2 metadata enrichment & PII redaction, dual-vector embedding (dense + sparse), and stores everything in Qdrant with a NeonDB registry.
flowchart TD
subgraph Upload["Document Sources"]
U1[Local Folder] --> U2{Filter Files}
U2 --> U3[Upload to S3 aioboto3]
U3 --> U4[Return S3 Keys]
AX1[ArXiv ID] --> AX2[Download PDF + Metadata]
AX2 --> AX3[Upload to S3]
AX3 --> U4
end
subgraph Ingestion["Prefect Flows"]
U4 --> F1[ingest_batch]
U4 --> F2[ingest_arxiv]
F1 --> B[asyncio.gather + Semaphore concurrency=5]
F2 --> B
end
subgraph PerDoc["Per-Document Pipeline"]
B --> C1[S3 Download aioboto3]
C1 --> D1[Docling Convert to_thread]
D1 --> E1[Hybrid Chunker]
E1 --> E2[Base64 Extraction Picture/Table/Formula]
E2 --> E3{Has Text?}
E3 -->|Yes| E4[Continue Pipeline]
E3 -->|No (Image)| E5[NeonDB Only]
E4 --> F1M[Metadata Extraction GLiNER2 base]
E4 --> F2M[PII Detection GLiNER2 PII]
F1M --> G1[INSERT documents NeonDB]
G1 --> G2[INSERT chunks NeonDB]
F2M --> H1[Redact Chunks]
H1 --> I1{By Modality}
I1 --> J1[Embed Dense jina-omni-nano 768d]
I1 --> J2[Embed Sparse SPLADE v3 30522d]
J1 --> K1[Upsert Qdrant point.dense_vector]
J2 --> K1[Upsert Qdrant point.sparse_vector]
K1 --> L1[Update chunks qdrant_point_id]
end
E5 --> G2
L1 --> M[Flow Complete]
| Feature | Description |
|---|---|
| Multi-modal Document Support | PDFs, DOC/DOCX, JPG, JPEG, PNG, GIF, WEBP, BMP, TIFF |
| ArXiv Integration | Direct arXiv paper ingestion by ID — downloads PDF + metadata (title, authors, abstract, categories) |
| Table Extraction | Structured table extraction with markdown output via Docling |
| Formula Extraction | LaTeX formula extraction from academic/scientific documents |
| Image/Figure Enrichment | Vision model captioning for figures and images |
| Multi-Modal Base64 Extraction | Resized base64 PNG extraction (max 1024px) for images, tables, and formulas — stored in Qdrant payload and NeonDB for LLM multi-modal use |
| Hybrid Chunking | Docling HybridChunker for context-aware text segmentation |
| 3-Level Metadata Extraction | Document-level, page-level, and chunk-level metadata via GLiNER2 |
| PII Detection & Redaction | 42 PII types across 7 languages via GLiNER2 (email, phone, SSN, credit cards, etc.) |
| Dense Embeddings | jina-embeddings-v5-omni-nano (768d) — text + image in shared vector space |
| Sparse Embeddings | SPLADE v3 (30522d) — learned sparse retrieval with semantic term expansion |
| Native Hybrid Search | Qdrant RRF fusion of dense + sparse vectors at query time |
| Batch Processing | 100+ documents in one shot via Prefect 3 async flows with asyncio.gather + asyncio.Semaphore concurrency |
| Structured Logging | JSON logs with correlation IDs via structlog |
| Document Registry | Two-table NeonDB schema: documents + chunks with UUID linkage |
| Layer | Technology | Version |
|---|---|---|
| Orchestration | Prefect | 3.7.3 |
| Document Parsing | Docling | 2.96.1 |
| Metadata Extraction | GLiNER2 | 1.3.1 |
| PII Detection | GLiNER2 PII | 1.3.1 |
| Dense Embeddings | jina-embeddings-v5-omni-nano | via transformers 5.9.0 |
| Sparse Embeddings | SPLADE v3 (via FastEmbed) | latest |
| Vector Database | Qdrant | 1.18.0 |
| Relational Database | NeonDB / PostgreSQL | via asyncpg 0.31.0 |
| Object Storage | S3 | via aioboto3 15.5.0 |
| ArXiv Download | aiohttp | 3.9+ |
| Logging | structlog | 25.5.0 |
| Package Manager | UV | latest |
git clone https://github.com/your-org/prefect-dag.git
cd prefect-dag
uv sync
This installs all 200+ dependencies including PyTorch, transformers, Docling, and Prefect.
Copy the .env.example (create it from the table below) to .env and fill in your credentials:
cp .env.example .env
Run all migration scripts in order against your NeonDB / PostgreSQL instance:
psql $NEON_DATABASE_URL -f migrations/001_initial_schema.sql
psql $NEON_DATABASE_URL -f migrations/002_add_chunks_updated_at.sql
psql $NEON_DATABASE_URL -f migrations/003_add_chunk_base64.sql
# Or use the Makefile: make migrate
make start # Start Prefect server (4200) + Qdrant (6333) in Docker
make status # Check service health
make stop # Stop all services
make logs # Follow container logs
uv run python main.py
# Should print usage instructions
Create a .env file in the project root with the following variables:
| Variable | Required | Default | Description |
|---|---|---|---|
S3_BUCKET | Yes | - | S3 bucket name for document storage |
S3_PREFIX | No | documents/ | S3 key prefix for uploads |
AWS_REGION | No | us-east-1 | AWS region for S3 |
AWS_ACCESS_KEY_ID | Yes* | - | AWS IAM access key |
AWS_SECRET_ACCESS_KEY | Yes* | - | AWS IAM secret key |
QDRANT_URL | Yes | http://localhost:6333 | Qdrant server URL |
QDRANT_API_KEY | No | - | Qdrant API key (for Cloud) |
QDRANT_COLLECTION | No | rag_documents | Qdrant collection name |
NEON_DATABASE_URL | Yes | - | PostgreSQL connection string |
PREFECT_API_URL | No | - | Prefect Cloud API URL |
PREFECT_API_KEY | No | - | Prefect Cloud API key |
HF_TOKEN | Yes** | - | Hugging Face token for model downloads |
ARXIV_S3_PREFIX | No | arxiv/ | S3 prefix for downloaded arXiv PDFs |
*AWS credentials can also be provided via AWS CLI config or IAM roles. **Required for downloading jina-omni-nano and GLiNER2 models from Hugging Face.
.env# AWS S3
S3_BUCKET=my-rag-documents
S3_PREFIX=ingestion/
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=secret...
# Qdrant
QDRANT_URL=https://my-cluster.cloud.qdrant.io
QDRANT_API_KEY=eyJ...
QDRANT_COLLECTION=rag_documents
# NeonDB / PostgreSQL
NEON_DATABASE_URL=postgresql://user:pass@neon-host.aws.neon.tech/rag_pipeline
# Prefect (optional — for Prefect Cloud)
PREFECT_API_URL=https://api.prefect.cloud/api/...
PREFECT_API_KEY=pnu_...
# Hugging Face
HF_TOKEN=hf_...
Upload all supported files from a local folder to S3:
uv run python main.py upload ./my-documents/ s3://my-bucket/prefix/
Supported formats: .pdf, .doc, .docx, .jpg, .jpeg, .png, .gif, .webp, .bmp, .tiff
uv run python main.py ingest s3://my-bucket/prefix/document.pdf
uv run python main.py ingest-batch batch-001 \
s3://my-bucket/prefix/doc1.pdf \
s3://my-bucket/prefix/doc2.pdf \
s3://my-bucket/prefix/image.png
Download a paper directly from arXiv by ID and process it through the full pipeline:
# Bare arXiv ID
uv run python main.py ingest-arxiv 1706.03762
# With arxiv: prefix
uv run python main.py ingest-arxiv arxiv:1810.04805
# Full URL
uv run python main.py ingest-arxiv https://arxiv.org/abs/2109.00031
ArXiv ID formats supported:
1706.03762 — bare IDarxiv:1810.04805 — prefixedhttps://arxiv.org/abs/2109.00031 — abstract URLhttps://arxiv.org/pdf/2203.02155.pdf — PDF URLWhat happens:
ARXIV_S3_PREFIX (default: arxiv/)Clear all vectors and database records while preserving S3 data:
# Interactive mode (requires confirmation)
uv run python main.py flush
# Non-interactive mode (for scripts / CI)
uv run python main.py flush --force
# Or via Makefile:
make flush
What gets deleted:
documents tablechunks tableWhat is preserved:
uv run python main.py ingest-arxiv-batch batch-003 \
1706.03762 \
1810.04805 \
2109.00031
import asyncio
from src.pipeline.flows import ingest_batch
async def main():
s3_keys = [
"s3://my-bucket/prefix/doc1.pdf",
"s3://my-bucket/prefix/doc2.pdf",
]
doc_ids = await ingest_batch(s3_keys, batch_id="batch-001")
print(f"Processed {len(doc_ids)} documents")
asyncio.run(main())
When a retrieved chunk contains a chunk_base64 field, you can pass it directly to a multi-modal LLM (GPT-4o, Claude 3, Gemini):
import asyncio
from src.pipeline.embedder import embed_query
from src.pipeline.sparse_embedder import embed_text as embed_sparse_query
from src.pipeline.qdrant_store import hybrid_search
async def search(query: str):
dense = await embed_query(query)
sparse = (await embed_sparse_query([query]))[0]
results = await hybrid_search(dense, sparse, limit=10)
return results
results = asyncio.run(search("attention mechanism diagram"))
for r in results:
payload = r["payload"]
text = payload.get("chunk_text", "")[:200]
base64 = payload.get("chunk_base64")
chunk_type = payload.get("chunk_type")
page = payload.get("page_number")
if base64:
print(f"[{chunk_type}] Page {page} | Score: {r['score']:.4f}")
print(f" Text: {text}...")
print(f" Base64: {base64[:60]}... ({len(base64)//1024}KB)")
# Pass base64 to multi-modal LLM:
# messages = [{"role": "user", "content": [
# {"type": "text", "text": query},
# {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64}"}}
# ]}]
else:
print(f"[{chunk_type}] Page {page} | Score: {r['score']:.4f} | {text}...")
prefect-dag/
├── pyproject.toml # UV dependencies & project metadata
├── main.py # CLI entry point (upload, ingest, ingest-batch, ingest-arxiv)
├── Makefile # Docker + dev commands (start, stop, migrate, lint, test)
├── docker-compose.yml # Prefect server + Qdrant with persistent volumes
├── README.md # This file
├── .env.example # Environment variable template
├── src/
│ └── pipeline/
│ ├── __init__.py
│ ├── config.py # Pydantic settings (loads .env)
│ ├── logging.py # structlog JSON logging setup
│ ├── models.py # Pydantic models: Document, Chunk, Metadata
│ ├── arxiv_downloader.py # Async arXiv PDF download + metadata extraction
│ ├── s3_client.py # aioboto3 async S3 download/upload client
│ ├── s3_uploader.py # Folder upload utility (pre-ingestion)
│ ├── docling_processor.py # Docling convert with enrichments (tables, formulas, figures)
│ ├── chunker.py # Docling HybridChunker wrapper
│ ├── gliner_metadata.py # GLiNER2: 3-level metadata extraction
│ ├── gliner_pii.py # GLiNER2 PII: 42-type detection + redaction
│ ├── embedder.py # jina-omni-nano dense embeddings (768d)
│ ├── sparse_embedder.py # SPLADE v3 sparse embeddings (30522d)
│ ├── qdrant_store.py # AsyncQdrantClient: dense+sparse upsert, hybrid RRF search
│ ├── neondb_client.py # asyncpg: documents + chunks CRUD
│ ├── flush.py # flush_qdrant(), flush_neondb(), flush_all()
│ └── flows.py # Prefect async flows (ingest_batch, ingest_arxiv)
├── migrations/
│ ├── 001_initial_schema.sql # NeonDB schema: documents + chunks tables
│ ├── 002_add_chunks_updated_at.sql # Fix missing updated_at + relax document_type constraint
│ └── 003_add_chunk_base64.sql # Add base64 PNG column for LLM multi-modal use
└── tests/ # Test directory (add your tests here)
aioboto3) — Async streaming download from S3 to memoryasyncio.to_thread) — Parse PDF/image/DOC with enrichments:
generate_page_images=True for table/formula/picture base64 extractionasyncio.to_thread) — Tokenization-aware chunking preserving contextasyncio.to_thread) — For each chunk, scan all Docling items:
asyncio.to_thread) — GLiNER2 extracts:
asyncio.to_thread) — GLiNER2 scans 42 PII types:
[PII_TYPE] placeholdersasyncio.to_thread) — jina-omni-nano encodes text to 768d vectorsasyncio.to_thread) — SPLADE v3 encodes to 30522d sparse vectors with semantic term expansionqdrant_point_id linkage (all chunks including images)| Operation | Pattern | Limit |
|---|---|---|
| S3 downloads | aioboto3 native async | Per-task sequential |
| Docling conversion | asyncio.to_thread() | 5 docs (asyncio.gather + Semaphore) |
| Chunking + metadata + PII | asyncio.to_thread() | Sequential per doc |
| Embedding (dense + sparse) | asyncio.to_thread() | Batch 32-64 chunks |
| Qdrant upsert | AsyncQdrantClient | Batch 100 points |
| NeonDB inserts | asyncpg pool | Connection pool (max 10) |
All configuration is managed via src/pipeline/config.py using Pydantic Settings. It automatically loads from environment variables and .env files.
Key tunables:
| Setting | Env Var | Default | Description |
|---|---|---|---|
| Concurrency limit | CONCURRENCY_LIMIT | 5 | Prefect async flow concurrency via asyncio.gather + Semaphore |
| Embedding batch size | EMBEDDING_BATCH_SIZE | 32 | Chunks per embedding batch |
| Qdrant batch size | QDRANT_BATCH_SIZE | 100 | Points per Qdrant upsert batch |
| ArXiv S3 prefix | ARXIV_S3_PREFIX | arxiv/ | S3 prefix for downloaded arXiv PDFs |
| Dense model | DENSE_MODEL | jinaai/jina-embeddings-v5-omni-nano | Dense embedding model |
| Sparse model | SPARSE_MODEL | prithivida/Splade_PP_en_v1 | Sparse embedding model |
| GLiNER metadata model | GLINER_METADATA_MODEL | fastino/gliner2-base-v1 | Metadata extraction |
| GLiNER PII model | GLINER_PII_MODEL | fastino/gliner2-privacy-filter-PII-multi | PII detection |
documents Table| Column | Type | Description |
|---|---|---|
id | UUID PK | Unique document ID |
s3_key | TEXT | Source S3 path (unique) |
filename | TEXT | Original filename |
document_type | TEXT | pdf, doc, jpg, png, unknown, etc. |
size_bytes | BIGINT | File size in bytes |
page_count | INTEGER | Number of pages (for PDFs) |
meta_domain | TEXT | Extracted domain (technology, finance, etc.) |
meta_industry | TEXT | Extracted industry |
meta_companies | JSONB | Extracted companies |
meta_document_type | TEXT | research_paper, report, manual, contract, etc. |
meta_confidentiality | TEXT | public, internal, confidential, restricted |
meta_language | TEXT | Detected language (en, fr, de, etc.) |
pii_detected | BOOLEAN | Whether PII was found |
pii_types | JSONB | List of detected PII types |
pii_redaction_verified | BOOLEAN | Whether redaction was verified |
chunk_count | INTEGER | Number of chunks |
status | TEXT | pending, processing, completed, failed |
error_message | TEXT | Error details if failed |
created_at | TIMESTAMPTZ | Record creation time |
updated_at | TIMESTAMPTZ | Last update time |
chunks Table| Column | Type | Description |
|---|---|---|
id | UUID PK | Unique chunk ID |
document_id | UUID FK | Parent document |
chunk_index | INTEGER | Position in document |
chunk_type | TEXT | text, table, formula, image |
page_number | INTEGER | Source page number (for PDFs) |
chunk_text | TEXT | Redacted chunk content |
meta_chunk_topic | TEXT | Chunk topic classification |
meta_products | JSONB | Products mentioned |
meta_technologies | JSONB | Technologies mentioned |
meta_organizations | JSONB | Organizations mentioned |
meta_locations | JSONB | Locations mentioned |
meta_dates | JSONB | Dates/timelines mentioned |
meta_metrics | JSONB | Numerical metrics mentioned |
chunk_base64 | TEXT | Base64 PNG of image/table/formula (for LLM multi-modal use) |
qdrant_point_id | UUID | Link to Qdrant vector |
qdrant_collection | TEXT | Qdrant collection name |
pii_detected | BOOLEAN | Chunk-level PII flag |
pii_types | JSONB | Chunk-level detected PII types |
created_at | TIMESTAMPTZ | Record creation time |
updated_at | TIMESTAMPTZ | Last update time |
uv run pytest tests/
uv run ruff check src/ # Linting
uv run ruff format src/ # Formatting
uv run mypy src/ # Type checking
View flow runs and task status in the Prefect UI:
uv run prefect server start
# Open http://localhost:4200
make help # Show all available commands
make start # Start Prefect server + Qdrant in Docker
make stop # Stop all Docker services
make logs # Follow container logs
make status # Check container health
make migrate # Run NeonDB migration
make install # Install dependencies with UV
make lint # Run ruff linter
make format # Auto-format code
make test # Run syntax + import checks
make flush # Clear Qdrant + NeonDB (keeps S3, non-interactive)
make clean # Stop services and remove volumes
make env # Create .env from .env.example
| Decision | Rationale |
|---|---|
| GLiNER2 over Presidio | Higher accuracy (0.477 F1), single library for metadata + PII, CPU-only |
| SPLADE v3 over BM25 | Semantic term expansion, Qdrant-native sparse vectors, better BEIR scores |
| jina-omni-nano | Multi-modal (text + image), 768d, aligns with SPLADE in Qdrant |
| Qdrant RRF over client-side fusion | Simpler architecture, single collection, native hybrid search |
| NeonDB two-table design | Document registry + chunk-level metadata for fine-grained RAG filtering |
| asyncio.gather + Semaphore | Prefect 3 removed .map(); asyncio.gather with Semaphore for controlled concurrency |
| Image base64 in NeonDB only | Image chunks have no text to embed; base64 stored in NeonDB for retrieval-time multi-modal use |
| Table base64 in both Qdrant + NeonDB | Table chunks have text + rendered image; base64 included in Qdrant payload for LLM use at retrieval |
If Hugging Face model downloads fail:
export HF_TOKEN=your_token_here
# Or add to .env
For local Qdrant:
docker run -p 6333:6333 qdrant/qdrant
Check flow logs:
uv run prefect flow-run logs <flow-run-id>
git checkout -b feature/amazing-feature)git commit -m 'feat: add amazing feature')git push origin feature/amazing-feature)2 commits
Python
93.9%
Makefile
6.1%