Create an Ingestion Pipeline
Prepare, preview, submit, and monitor data with zep-ingest
What is an ingestion pipeline?
An ingestion pipeline prepares and loads existing data into Zep in a defined order. It normalizes identities and timestamps, validates records, submits them to a graph, and monitors processing.
zep-ingest is a Python package for building these pipelines. Use it to import documents, conversations, structured records, known entities, and relationships.
This guide explains the package through a company knowledge graph example. The same workflow applies to customer data, research collections, product catalogs, support archives, and other domains.
Start with Prepare Data for Ingestion. It explains the identity, context, timestamp, and rerun decisions that this walkthrough applies.
zep-ingest 0.1 is an alpha Python package and requires Python 3.11 or later. Pin the package version and review its changelog before upgrading.
The package ships runnable scripts and sample input data in its examples directory. Each script creates its own graph or user, ingests the bundled data, and ends with a search, so you can run a complete import before adapting one to your own sources.
Install and authenticate
zep-ingest depends on the Zep Cloud SDK, so one install brings in both packages:
LLM contextualization is optional. You only need an additional provider dependency when using LLMContextualizer to add document-level context to each chunk, covered at the end of the walkthrough:
Every ingestion helper takes a Zep client as its first argument. Create one from your project API key before running any of the code below. The rest of this guide assumes this client:
Example: Build a company knowledge graph
Imagine you are building an internal assistant that must answer questions such as:
- Who owns Customer Success?
- Which team maintains a product?
- What did the company decide about a launch?
- What do the handbook, Slack history, and meeting transcripts say about a policy?
The employee directory and org chart contain known entities and relationships. The handbook, Slack messages, meeting transcripts, and email contain source material for Zep to interpret.
Ingest them in this order:
- Define canonical names and aliases.
- Create the destination and set its ontology.
- Seed people and teams from the employee directory with stable UUIDs.
- Add reporting and ownership relationships from the org chart.
- Prepare and preview the employee handbook using the canonical aliases.
- Ingest the handbook and then the remaining documents and conversations in chronological order.
This gives later extraction an existing graph of typed, canonical entities to resolve against.
Choose how to ingest each source
Choose a feature according to what the source represents, not its file format alone. When your application already knows an entity or relationship, write it directly. When the source contains material Zep should interpret, ingest it as episodes or messages.
The directory and org chart should not go through extraction when they already contain the correct identities and relationships. Documents and conversations become episodes from which Zep extracts additional entities and facts.
Choose where the data lands
Every import targets either a named graph or a user graph, and that choice sets the data model for everything after it.
Use a user graph when the data belongs to one person’s memory. User graphs integrate directly with Zep’s context retrieval, so their data reaches an agent through that user’s context. Use a named graph for knowledge shared across users, such as a handbook, a product catalog, or an internal directory, which callers search explicitly. Users and user graphs and Create graph cover the difference in full.
Pass graph_id for a named graph or user_id for a user graph. Every path in the table above accepts either one, except ingest_thread_messages, which writes messages into threads and therefore takes user_id only.
This example uses a named graph, because company knowledge is not one user’s memory.
Build the pipeline step by step
1. Normalize aliases with AliasCanonicalizer
AliasCanonicalizer rewrites known aliases to one canonical entity name before Zep extracts entities. Use it when historical sources refer to the same entity in several ways.
Skip this step if you already canonicalize entity names while exporting or preprocessing the source data. In that case, pass the normalized data directly to the loader and omit AliasCanonicalizer from the pipeline.
The employee directory says the person’s full name is Sarah Brown and the official team name is Customer Success. Historical documents use several shorter forms. Define those mappings before loading documents and conversations.
Only include an alias when the mapping is valid across the source you are processing. The bare first name Sarah is deliberately absent: add it only when the source contains exactly one Sarah. The default safety guard does not catch that case. It rejects aliases shorter than three characters and a list of common words and word-like given names, but an ordinary first name passes.
2. Create the graph and set its ontology
The Zep ontology defines the entity and relationship types Zep should extract. This is a Zep Cloud SDK operation rather than a zep-ingest transform, but it must happen before the package submits data because ontology changes are not retroactive.
Prescribed types are prescriptive, not restrictive. They tell Zep which types to look for; they do not filter what is ingested. Entities that do not match a prescribed type are still ingested, typed under the ontology Zep learns from the data. Prescribing three types therefore does not reduce the graph to three types, and setting an ontology at all is optional.
This company graph needs people, teams, and leadership relationships. Create the graph and set that ontology before adding nodes, facts, or episodes.
zep-ingest writes only into a destination that already exists; it never creates one for you. Create a named graph with client.graph.create(graph_id=...), or a user graph with client.user.add(user_id=...), before any ingestion call targets it. See Create graph and Users and user graphs. This holds for ingest_thread_messages too: it raises ConfigurationError when the user does not exist, rather than creating a bare one that skips the profile Zep relies on to anchor that user’s identity. It does create the threads a backfill references, because those are containers for the import rather than a destination you provision.
Ontology changes are not retroactive. Data that Zep processed before set_ontology is not re-extracted or retyped automatically.
3. Add known entities with ingest_nodes
ingest_nodes directly creates or upserts entities through graph.add_nodes. Use it for canonical entities your application already knows, such as customers, employees, products, accounts, or teams. It bypasses entity extraction: the caller supplies the node’s name, type, attributes, and stable identity.
The directory already identifies Sarah Brown and the Customer Success team. Add them directly and persist their UUIDs so reruns upsert the same nodes.
Your source system does not need to use UUIDs already. If it identifies Sarah as EMP-1842, generate one UUIDv4 for that employee, store the EMP-1842 → UUID mapping in your application or import manifest, and reuse it on every run.
wait() polls the completion handles the operation returned. When Zep accepts a write but returns no handle for it, the result counts those items as untracked and wait() raises IngestUntrackedError instead of blocking. Catch it and confirm the data another way, as described under monitoring below.
Waiting is always a call on the result, never an argument to the helper: bind what the helper returns, then call wait() on it with an optional poll_interval and timeout. That also leaves the result in hand to inspect when IngestUntrackedError is raised.
For direct node ingestion, the UUID is the upsert and deduplication key:
- Reusing a UUID updates the existing node.
- Sending a different UUID creates a different node, even when the name is identical.
- Omitting the UUID lets the API assign one, but
ingest_nodesrequiresrequire_uuids=Falsebecause the operation is no longer safely repeatable.
Does Zep deduplicate by name when a UUID is omitted? Not in ingest_nodes. The underlying direct graph.add_nodes operation does not search by name, so rerunning the same direct-node import without persisted UUIDs can create another node with the same name. This does not disable normal entity-name resolution elsewhere: entities extracted later from documents, messages, or episodes can still resolve to an existing node by name.
4. Add known relationships with ingest_fact_triples
ingest_fact_triples writes a fact you already know between two entities through graph.add_fact_triple. Each triple contains a source entity, a target entity, the fact statement, the relationship type, and optionally when the fact became valid.
Use these fields to describe the relationship:
source_node_nameandtarget_node_nameidentify the two entities.factis the human-readable assertion, such asSarah Brown leads Customer Success.fact_nameis the relationship type inSCREAMING_SNAKE_CASE, such asLEADS.source_node_uuidandtarget_node_uuidpin the relationship to existing nodes when their UUIDs are known.valid_atrecords when the relationship became true.
The org chart states that Sarah leads Customer Success. Add that relationship directly and pin each endpoint to its seeded UUID.
As in step 3, wait() raises IngestUntrackedError if Zep returned no completion handle for a submitted triple.
Why this matters: raw dialogue does not guarantee extraction of a specific decision, action item, or relationship. Direct facts make known assertions explicit, while UUID-pinned endpoints prevent a similar name from selecting the wrong node.
See Manually updating the graph for the underlying node and fact-triple behavior.
5. Build and preview an episode Pipeline
Pipeline connects a source Loader to ordered transforms, validates the complete output, and selects a submitter. Use preview() to inspect the transformed episodes and warnings without making Zep API calls.
The episode pipeline is:
The handbook is source material Zep should interpret, so ingest it as episodes. Apply the company alias map before chunking. Pipeline.preview() runs the loader, transforms, and validation without making any Zep API calls.
TextFileLoader raises when its glob matches no files, so create the input before building the pipeline. A minimal handbook/customer-success.md that exercises the alias map:
For a larger ready-made input, the package’s examples/data directory contains a sample handbook, Slack export, transcripts, email, and chat history.
A sampled preview validates at most limit episodes, and its warning counts cover only that sample. Before a production import, run pipeline.preview(limit=None) for an exhaustive pass over the whole stream, so a problem that first appears in episode 5,000 is not missed by a 20-episode check.
Why this matters: preview surfaces missing timestamps, automatic size-limit splits, and alias replacement counts before the first write. A transformation that is safe for ten sample records may be too broad across a complete export.
6. Ingest documents and conversations
The source-specific helpers package common loaders and transforms into one-line ingestion functions. Use them for material Zep should interpret, such as documents, conversations, transcripts, email, and structured business records.
When the preview is correct, run the same pipeline against the graph that already contains the ontology, canonical nodes, and known relationships:
The pipeline validates and spools the full transformed stream before it submits the first episode. Large imports can spill to a temporary file instead of remaining in memory.
Once the handbook import behaves as expected, ingest the remaining company sources. Reuse the same alias map and preserve each source’s original timestamps:
The Slack call above ingests public channels only. A Slack export also indexes private channels, direct messages, and group direct messages, and none of them are read unless you name them in conversation_types, which accepts public_channel, private_channel, dm, and group_dm. That default depends on the export carrying those indexes. An export with none of channels.json, groups.json, dms.json, and mpims.json is typed by folder name instead, and folder names only go so far: direct-message and group-direct-message folders are still recognized and still excluded, but nothing distinguishes a private channel’s folder from a public one, so a private channel is read as a public channel, ingested under the default, and recorded in the graph with a conversation_type of public_channel.
result.warnings carries two separate signals, so read both. Coverage: what the export held that conversation_types did not select. Fidelity, for an index-less export only: that conversation types could not be determined, how many folders were read as public channels, and a request to confirm none of them are private before trusting what lands in the graph. A private channel read as a public one is ingested, so it raises the fidelity warning and never appears in the coverage one.
Start with the oldest historical source and move forward. Within each source, keep events in their original order and retain their real created_at values.
Optional: add document context with an LLM
LLMContextualizer prepends document-level context to each chunk so that a chunk read on its own still identifies what it is about. It costs one LLM call per chunk, so weigh it against the size of your import.
LLMContextualizer is provider-independent. The package includes:
AnthropicLLMfor the Anthropic API;OpenAILLMfor the OpenAI API;OpenAICompatibleLLMfor any provider, proxy, or local model that exposes an OpenAI-compatible/chat/completionsendpoint.
The openai extra installs the client library used by both OpenAILLM and OpenAICompatibleLLM. It does not restrict you to OpenAI models. For a compatible provider, supply its model name and full base URL. Include the API-version path required by that provider, such as /v1 or /v1beta/openai/.
For example, configure a Gemini model through Gemini’s OpenAI-compatible endpoint:
Pass the adapter to a document helper:
The same adapter works with OpenAI-compatible endpoints from providers and gateways such as LiteLLM, Ollama, vLLM, OpenRouter, Together, or Groq. If a provider does not offer that interface, pass a custom client that implements complete(prompt: str) -> str.
See Gemini’s OpenAI compatibility documentation for its current endpoint and model support.
Choose a submission method
The package submitter controls how episode pipelines reach Zep. auto chooses the best available path, while batch and sequential let you require a particular submission behavior.
Episode pipelines accept method="auto", method="batch", or method="sequential".
Use auto unless you need to require a specific path. Falling back is narrower than it sounds: it happens only when the deployment does not serve the Batch API at all. Any other refusal, such as a rejected key or an exhausted quota, is raised as it is, because sequential submission would meet the same refusal one call at a time. When the fallback does happen, the result reports it in warnings.
If Batch becomes unavailable after part of a multi-batch import has already been submitted, the package stops instead of resubmitting the complete source sequentially and creating duplicate episodes. See Recover from a partial import for what to do next.
ingest_thread_messages takes the same method argument and resolves it the same way: auto submits through the Batch API and falls back to sequential thread.add_messages when your deployment does not serve it. The same anti-duplication rule applies — if Batch becomes unavailable after part of a multi-batch backfill has already been submitted, the package stops rather than resubmitting and duplicating messages.
Backfill thread messages
ingest_thread_messages writes historical application conversations to threads on a user graph. It requires the user to already exist, creates any missing threads, preserves the order of messages within each thread, and splits messages that exceed the thread-message limit.
Each row must use exactly these fields, and no others:
thread_id, role, name, content, and created_at are all required; only metadata is optional. Carrying the speaker type, speaker name, and original timestamp on every row is what lets a backfill reconstruct both the conversation and its fact validity timeline. role must be one of user, assistant, system, function, tool, or norole; a source-system value such as human is rejected. created_at is RFC3339, for example 2025-03-01T10:00:00Z.
Any other column is rejected outright rather than ignored, because silently dropping a misspelled field produces an import that looks complete and is not. Real chat exports usually carry extra columns such as an internal message ID or a channel name, so strip or rename them, and map source roles to the values above, before ingesting.
created_at is required on every thread message, and the package sends it on both the Batch and sequential paths.
Thread IDs are unique across a Zep project. Use thread_id_suffix when an imported ID might collide with an existing thread. A suffix namespaces the import; it does not make reruns idempotent.
On the sequential path, messages_per_call sets how many messages go in each thread.add_messages call. It defaults to 30, which is also the maximum; a larger value is rejected when the arguments are checked, before the user and threads are touched.
Why this matters: if historical messages are dated at ingestion time, newer-looking backfill facts can invalidate facts that occurred later in the real timeline.
Monitor ingestion with IngestResult
IngestResult gives every helper a consistent interface for processing status, errors, and monitoring identifiers across Batch, episode, and task-backed operations.
status aggregates every handle in the result and reports the least-complete one: failed, partial, canceled, untracked, processing, queued, or succeeded.
Persist the returned IDs if another process will monitor the import:
wait() polls the Batch, episode, or task handles returned by the operation until they reach a terminal state.
Some accepted writes come back without a completion handle. When that happens the result counts them in untracked_items, status reports untracked, and wait() raises IngestUntrackedError immediately rather than polling forever. The submission itself succeeded; only server-side extraction is untrackable. Catch the error and confirm the data landed with your own read, such as search_when_ready:
Sequential thread ingestion tracks a task handle when thread.add_messages returns one, and counts the messages as untracked when it does not.
Search indexing can take additional time after processing succeeds. Use search_when_ready when a script must ingest and then immediately check retrieval:
search_when_ready retries until the query returns any result or reaches the timeout. It does not verify that a specific imported record produced the result. Use a query unique to the imported data when checking indexing readiness. An empty result after the timeout is returned normally.
Why this matters: a completed ingestion task and a searchable graph are separate states. Retrying the import during the indexing window creates duplicate episodes without making indexing complete sooner.
For lower-level polling and webhook options, see Check data ingestion status.
Recover from a partial import
The package stops on an unrecoverable submission error rather than resubmitting a source from the beginning, which would duplicate everything already accepted. It does not resume, so recovery is a procedure you run, not a flag you pass. It depends on the import manifest you recorded at submit time.
- Establish what was accepted. Reconstruct the result from the identifiers in your manifest with
IngestResult.from_batch_idsorIngestResult.from_task_ids, then callrefresh()and readstatus. - List what failed.
failed_items()returns the submission errors the package recorded and, for a Batch import, the per-item failures Zep reported. Each entry identifies the item, so you can map it back to a source record through your manifest. - Rebuild the input from the records your manifest does not show as accepted. Do not rerun the original source: reingesting an accepted record creates a second episode for it, because Zep does not deduplicate episodes by content.
- Submit that remainder as a normal import, and record its identifiers in the manifest as well.
If the manifest is incomplete and you cannot tell which records landed, treat the destination as unusable rather than reingesting over it. Delete the affected data and start again. Deleting data from the graph describes what deleting an episode, node, or thread removes.
Current limitations
- Rerunning the same export creates new episodes.
zep-ingestdoes not yet provide a source manifest, content-hash deduplication, or resume checkpoint. batch_ids,episode_uuids, andtask_idslet you resume status monitoring. They do not resume parsing or submission from the middle of a source.- Accepted writes are sometimes returned without a completion handle. The result counts them in
untracked_itemsandwait()raisesIngestUntrackedError, so confirming those items requires your own read. LLMContextualizersends document content to the LLM client you provide. Confirm that the provider and deployment meet your data-handling requirements.- Retrieval ranking, authority weighting, and contradiction policy are outside the package’s scope.
Review Prepare Data for Ingestion before running a production import.