rag.ingest("document.txt"), the SDK transforms your raw text into a structured knowledge graph through a 9-step sequential pipeline. Think of it as an assembly line: each step takes the output of the previous one, refines it, and passes it forward.
This document explains what each step does, why it exists, and how to tune it.
This pipeline is built for prose. For CSVs and other tabular sources, see
Structured Ingestion: you declare what the columns mean
and the graph is written deterministically, with no model in the loop, into the
same graph your documents live in.
The Big Picture
Step-by-Step Explanation
Step 1 — Load
What it does: Reads raw text from a file, URL, or string. How: TheLoaderStrategy ABC handles this. The SDK auto-detects the loader based on file extension:
.pdffiles usePdfLoader- Everything else uses
TextLoader - If you pass
text=directly, the loader step is skipped entirely
DocumentOutput containing the raw text and a DocumentInfo with a unique ID and file path.
Code: LoaderStrategy.load() in ingestion/loaders/base.py
Step 2 — Chunk
What it does: Splits the document text into smaller overlapping windows called chunks. Each chunk is small enough for the LLM to process, but large enough to contain meaningful context. How: The defaultFixedSizeChunking uses a sliding window:
- Window size: 1000 characters (configurable)
- Overlap: 100 characters between consecutive chunks
- Step size:
chunk_size - chunk_overlap= 900 characters
TextChunks — a list of TextChunk objects, each with a unique ID (uid), the text content, and an index number.
Code: ChunkingStrategy.chunk() in ingestion/chunking_strategies/base.py
Step 3 — Build Lexical Graph (Mandatory)
What it does: Creates the provenance backbone of the knowledge graph — this is how every answer traces back to its source document. Creates:- 1 Document node (with the file path and metadata)
- N Chunk nodes (one per text chunk, storing the chunk text and index)
- N PART_OF edges (Document → each Chunk)
- N-1 NEXT_CHUNK edges (Chunk → next Chunk, preserving reading order)
IngestionPipeline._build_lexical_graph() in ingestion/pipeline.py
Step 4 — Extract Entities & Relationships
What it does: The most important step — an LLM reads each chunk and extracts structured knowledge: entities (people, places, organizations, etc.) and the relationships between them. How: The defaultGraphExtraction strategy uses a 2-step process:
-
Step 1 (NER): A pluggable entity extractor identifies entities in the text. Default: GLiNER (a local transformer model, no API calls needed). Alternative:
LLMExtractor(uses the LLM for NER). - Step 2 (Verify + Relationships): The LLM receives the pre-extracted entities and the original text. It verifies the entities (fixing errors, adding missed ones) and extracts all relationships between them.
GraphData containing nodes (entities), relationships, and mention records.
Code: ExtractionStrategy.extract() in ingestion/extraction_strategies/base.py
Step 4b — Quality Filter
What it does: Removes bad data that slipped through extraction — nodes with empty orNone IDs, and relationships whose endpoints don’t exist.
Why: LLMs sometimes produce malformed output (empty entity names, references to entities that weren’t extracted). This step catches those before they reach the graph.
Code: IngestionPipeline._filter_quality() in ingestion/pipeline.py
Step 5 — Prune Against Schema
What it does: Filters extracted data to only keep entities and relationships that match your schema definition. How it works:- If your schema defines entity types (e.g., Person, Organization, Location), only entities with those labels pass through
- If your schema defines relationship types, only those relationship types pass through
- Relationships whose endpoints were pruned are also removed
- Special cases:
"Unknown"entities (low-confidence NER) and"RELATES"edges (the unified relationship type) always pass through
GraphSchema()), this step is skipped entirely — everything passes through.
Code: IngestionPipeline._prune() in ingestion/pipeline.py
Step 6 — Resolve Duplicates
What it does: Merges entities that refer to the same real-world thing. When the LLM extracts “Alice” from chunk 1 and “Alice” from chunk 5, this step recognizes they’re the same entity and merges them. Default at ingest: ExactMatchResolution- Groups by
(normalized name, label)only — same name but different labels stay separate (e.g., Person “Paris” vs Location “Paris”); the judge decides those atfinalize() - Survivor keeps every member’s description joined with
" | "(no LLM summary); all relationship endpoints are re-pointed to the survivor before the write, so no edge is lost - No embeddings, no LLM calls; zero added cost
- Ingest is per document, so a resolver here can only compare one file’s entities with each other — the cross-document duplicates that matter are handled once, at
finalize()
- Runs the same exact-match pass first, then compares the surviving names by embedding similarity within each label
- Pairs at or above
hard_threshold(0.95) merge outright; pairs down tosoft_threshold(0.65) go to the LLM for a YES/NO verdict - Useful when the same entity is named inconsistently across documents (e.g., “Acme Corp” vs “Acme Corporation”)
finalize(): the LLM-judged cross-document phase
- Runs after the exact-name dedup and the resolver pass, over every entity of every document
- Name and description embeddings plus “A’s name appears in B’s description” nominate candidate pairs (no LLM); candidates form dense sets of ≤ 8
- The judge LLM partitions each set into same-referent groups; with
judge_vote=True(default) a second pass over shuffled sets must agree, withjudge_vote=Falsethe single pass decides (a pair that reached across two sets is still linked rather than merged, withagreement=1) - Both passes agree → merge, by the same rules as every finalize merge: a table’s row survives a mention, every member’s description is kept in
descriptions(joined with" | "indescription), other names kept inaliases, edges moved first, then the loser deleted; the survivor also gains every member’s label - Only one pass agrees → link: a
SAME_ASedge (source='llm_judge'), both nodes stay - Pairs a resolver remembered as
DISTINCT_FROM, two keyed rows of a table, and a mention two such rows could own are never asked, grouped or linked finalize(judge=False)skips it (finalize(resolve=False, judge=False)runs no model at all);deduplicate_entities()on its own runs it only withjudge=True;judge_llm=picks the model (gpt-4.1-class recommended); passresolver=LLMVerifiedResolution(...)toingest()to also resolve per document
ResolutionStrategy.resolve() in ingestion/resolution_strategies/base.py
Step 7 — Write to Graph
What it does: Persists all the extracted and resolved data into FalkorDB using batched Cypher queries. How:- Nodes are written via
UNWIND $batch AS item MERGE (n:Label {id: item.id}) SET n += item.properties - Relationships are written similarly, using label hints for efficient MATCH operations
- Batch size: 500 items per query
- Entity nodes automatically get the
__Entity__secondary label (structural nodes like Chunk and Document do not)
GraphStore.upsert_nodes() and GraphStore.upsert_relationships() in storage/graph_store.py
Steps 8 & 9 — Mentions + Index Chunks (Parallel)
These two steps run simultaneously since they’re independent:Step 8 — Write Mentions
What it does: CreatesMENTIONED_IN edges linking every entity to every chunk it was extracted from. These edges are critical for retrieval — they let the system find text passages for any entity.
Details: Uncapped — every entity-chunk pair gets an edge. Duplicates are deduplicated by (entity_id, chunk_id).
Step 9 — Index Chunks
What it does: Embeds each chunk’s text into a vector and stores it on the Chunk node. These embeddings power the vector similarity search during retrieval. How:- Batch-embed all chunk texts in one API call (
aembed_documents) - Write vectors to Chunk nodes via
SET c.embedding = vecf32(vector) - Falls back to sequential embedding if the batch call fails
- Mentions:
IngestionPipeline._write_mentions()iningestion/pipeline.py - Chunk indexing:
VectorStore.index_chunks()instorage/vector_store.py
Post-Ingestion: finalize()
After all documents are ingested, callfinalize() to prepare the graph for querying. This is a separate step because some operations (like deduplication) work best when run globally across all documents, not per-document.
finalize() runs 5 steps in order:
finalize() after each document — call it once after all ingestion is complete. Entity backfill re-scans all entities and is slow when called repeatedly.
Configuration Quick Reference
Chunking
Extraction
Resolution
Performance Notes
- Slowest step: Extraction (Step 4) — involves LLM calls for every chunk. Expect ~2-5 seconds per chunk.
- Fastest step: Quality filter, prune, and resolve — all in-memory, sub-second.
- Parallelism: Steps 8-9 run in parallel. Step 1 NER uses a semaphore (default 12 concurrent calls).
- Batch size: The benchmark uses 1500-character chunks. 20 documents (~4.7 MB total) take ~47 minutes to ingest.