Ingestion Pipelines & Freshness
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-09-30 against langchain-classic 1.0.8, langchain-core 1.6.6, langchain-postgres
0.0.18, langchain-text-splitters 1.1.2, Presidio 2.2.364 and confluent-kafka 2.15.1 on Python 3.12, Spring AI 2.0.1
with Spring for Apache Kafka on Spring Boot 4.1.1, LangChain4j 1.20.2, and Debezium’s PostgreSQL connector (see the
version baseline). The indexing code was run against the released
packages with an in-memory vector store and a SQLite record manager (add, update, skip and delete behaved as described);
the redaction code was checked against the released Presidio packages but not run, because no spaCy model was
installed; Kafka, Debezium and PostgreSQL were not run; the Java classes were compiled against the released jars.
An index is a cache of the sources, and like every cache it goes stale. A page is edited and the index still returns the old paragraph; a document is deleted and the assistant keeps quoting it; a nightly job crashes halfway and the next run duplicates every chunk it had already written. Freshness is not a feature added later — it is a property of the ingestion design. The chunking and loading basics are in Document Ingestion & Chunking; this page is about running ingestion continuously and correctly.
Batch and streaming ingestion
| Mode | When | Shape |
|---|---|---|
Batch |
Initial loads, re-embedding after a model change, sources without change events (a shared drive, an export). |
A scheduled job lists sources, compares hashes with the last run and processes the differences; parallelised with a job queue, Spark, Ray, Dask or Airflow when the corpus is large. |
Streaming |
Sources that emit change events (a database, a CMS webhook, Git pushes) and freshness targets of minutes. |
A consumer reads change events from Kafka and re-indexes one document per event. |
Hybrid |
Most production systems. |
Streaming for day-to-day changes, a periodic batch reconciliation that catches missed events and deletes orphans. |
Whatever the mode, ingestion should acknowledge asynchronously: the API that accepts a new document returns
202 Accepted with a job ID, and the client polls or subscribes for completion (see
asynchronous ingestion endpoints).
Parsing a 300-page PDF with OCR can take minutes; nobody should hold an HTTP request open for it.
The freshness a user experiences is the sum of the delays along the path:
For a streaming pipeline on pgvector, is the commit (HNSW inserts are visible immediately); some managed vector stores index asynchronously and add seconds. Measure end to end with a canary document edited on a schedule, and alert on it.
Idempotent, restartable jobs
Ingestion jobs fail: a parser crashes on one file, the embedding API rate-limits, a pod is evicted. A job is safe to re-run only if processing the same input twice leaves the same index. Four rules make it so:
-
Deterministic IDs. Derive every chunk ID from its source and position (or content), for example
sha256(source_id + ":" + chunk_index), and upsert by that ID. A retry overwrites instead of duplicating. -
Content hashes. Store a hash of each chunk’s text (and of the parser version) and skip embedding when it has not changed. Re-embedding is the expensive step.
-
Per-document transactions. Replace all chunks of a document in one operation (delete by
sourcethen insert, in one transaction where the store allows it), so that a crash never leaves half a document. -
A job ledger. Record per document: status (
queued,parsing,embedded,failed), attempts, last error, parser used and timestamps. Stalled documents (inparsingfor longer than the timeout) are retried or surfaced on a dashboard; poison documents go to a dead-letter queue after N attempts.
LangChain implements the first three rules in the indexing API: index() hashes every document (content plus
metadata), stores the hashes in a RecordManager, writes only new or changed documents, and — with cleanup — deletes documents from the vector store that no longer come from a source. In LangChain 1.x the index() helper and
SQLRecordManager live in langchain-classic:
langchain-classic 1.0.8)import os
from langchain_classic.indexes import SQLRecordManager, index
from langchain_core.documents import Document
from langchain_core.vectorstores import VectorStore
record_manager = SQLRecordManager(namespace="pgvector/docsassistant", db_url=os.environ["RECORD_MANAGER_DB_URL"])
record_manager.create_schema()
def sync(documents: list[Document], vector_store: VectorStore) -> dict:
"""Write new and changed chunks, delete stale chunks of the same sources, skip unchanged ones."""
result = index(
documents,
record_manager,
vector_store,
cleanup="incremental", # delete old chunks of every source present in this batch
source_id_key="source", # the metadata key that identifies the source document
key_encoder="sha256",
)
return dict(result) # {'num_added': .., 'num_updated': .., 'num_skipped': .., 'num_deleted': ..}
def delete_source(source: str, vector_store: VectorStore) -> int:
"""Remove every chunk of a deleted source document."""
keys = record_manager.list_keys(group_ids=[source])
if keys:
vector_store.delete(ids=keys)
record_manager.delete_keys(keys)
return len(keys)
cleanup="incremental" deletes the old chunks of a source as soon as a new version of that source is indexed, so it is
safe for streaming updates. cleanup="full" deletes everything that was not in this call, so it belongs only in a
batch reconciliation that sees the whole corpus. The record manager must live in a database that survives restarts;
SQLite is for tests. The vector store must support deleting by ID, which PGVector and PGVectorStore do.
Change data capture with Debezium and Kafka
When the source of truth is a database — DocsAssistant’s CMS stores pages in a PostgreSQL documents table — change data capture (CDC) turns every committed change into an event without touching the application. Debezium’s
PostgreSQL connector reads the write-ahead log through logical replication and publishes one event per row change to a
Kafka topic (docs.public.documents). Each event carries an envelope with op (c create, u update, d delete, r
snapshot read), before and after row images, and source metadata. After a delete, Debezium also emits a tombstone
(a record with the same key and a null value) so that log compaction can remove the key.
Keying the topic by document ID matters: Kafka orders events per partition, so all changes of one document arrive in order at one consumer, and a re-index never races with a later delete of the same document. The worker commits its Kafka offset only after the index write succeeded, so a crash replays the event and the idempotent write absorbs it. The Kafka side — partitions, consumer groups, delivery guarantees, Kafka Connect — is covered in Kafka Connect, Kafka Consumers and Delivery Guarantees.
confluent-kafka 2.15.1)import json
import os
from confluent_kafka import Consumer
from langchain_core.documents import Document
from docsassistant.ingest.indexing import delete_source, sync
from docsassistant.ingest.pipeline import to_chunks, vector_store # ladder + redact() + splitter
consumer = Consumer({
"bootstrap.servers": os.environ["KAFKA_BOOTSTRAP_SERVERS"],
"group.id": "docsassistant-reindex",
"enable.auto.commit": False, # commit only after the index write
"auto.offset.reset": "earliest",
})
consumer.subscribe(["docs.public.documents"])
while True:
msg = consumer.poll(1.0)
if msg is None or msg.error():
continue
if msg.value() is None: # tombstone after a delete: nothing left to do
consumer.commit(message=msg)
continue
payload = json.loads(msg.value())["payload"]
row = payload["after"] or payload["before"]
source = f"cms/{row['id']}"
if payload["op"] == "d":
delete_source(source, vector_store)
else: # c, u, r
doc = Document(page_content=row["body"], metadata={"source": source, "acl": row["acl"]})
sync(to_chunks(doc), vector_store)
consumer.commit(message=msg)
Source: Debezium — PostgreSQL connector (change event values, tombstone events) and confluent-kafka Python client.
The same worker in Spring uses a @KafkaListener with manual acknowledgement and replaces a document’s chunks through
the portable VectorStore.delete(Filter.Expression) and add(List<Document>). Deterministic, name-based UUIDs make the
write idempotent (the pgvector store’s default ID column is a UUID):
package com.example.docsassistant.rag.ingest;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.IntStream;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
@Component
public class CdcReindexListener {
private final VectorStore vectorStore;
private final TokenTextSplitter splitter = TokenTextSplitter.builder().withChunkSize(512).build();
private final JsonMapper json = JsonMapper.builder().build();
public CdcReindexListener(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
// requires spring.kafka.listener.ack-mode=manual
@KafkaListener(topics = "docs.public.documents", groupId = "docsassistant-reindex")
public void onChange(ConsumerRecord<String, String> record, Acknowledgment ack) {
if (record.value() == null) { // tombstone
ack.acknowledge();
return;
}
JsonNode payload = json.readTree(record.value()).path("payload");
JsonNode row = payload.path("after").isNull() ? payload.path("before") : payload.path("after");
String source = "cms/" + row.path("id").asString();
var filter = new FilterExpressionBuilder().eq("source", source).build();
vectorStore.delete(filter); // remove the old version (or the deleted doc)
if (!"d".equals(payload.path("op").asString())) {
Document doc = new Document(row.path("body").asString(),
Map.of("source", source, "acl", row.path("acl").asString()));
List<Document> chunks = splitter.apply(List.of(doc));
List<Document> withIds = IntStream.range(0, chunks.size())
.mapToObj(i -> chunks.get(i).mutate()
.id(UUID.nameUUIDFromBytes((source + ":" + i).getBytes(StandardCharsets.UTF_8)).toString())
.build())
.toList();
vectorStore.add(withIds);
}
ack.acknowledge(); // commit only after the write succeeded
}
}
Source: Spring AI — Vector Databases (deleting by filter expression) and Spring for Apache Kafka — @KafkaListener.
The delete-then-add pair is not atomic across the two calls. If readers must never see a document with zero chunks,
write the new chunks under a new version metadata value first, switch the read filter, then delete the old version — or use a store that supports transactional replacement.
Deletes and tombstones
Deletes are the part of freshness most pipelines get wrong, and the most dangerous to get wrong: a deleted document may have been deleted because it must no longer be shown. Handle three cases explicitly:
-
Hard deletes at the source (a CDC
devent, a file removed from the bucket): delete every chunk with thatsourcefrom every index, cache and graph (GraphRAG & Knowledge Graph Construction covers tombstoning in the graph), and invalidate cached answers that cited it (Caching, Latency & Cost). -
Missed deletes (a consumer was down, a source without events): the periodic reconciliation lists all source IDs, compares them with the IDs in the index and deletes orphans.
index(…, cleanup="full")does this for LangChain. -
Soft deletes and retention (a document archived, a legal hold): keep the chunks but set
status=archivedin metadata and filter on it at query time; delete for real when retention expires.
PII redaction at ingest
Personal data in the corpus flows everywhere downstream: into embeddings, into prompts sent to a model provider, into
answers, logs and caches. Redacting it once, at ingestion, is cheaper and safer than filtering every output. Presidio
detects entities with a mix of named-entity recognition, regular expressions and checksums, and replaces them with typed
placeholders, so the text stays readable and searchable ("contact <PERSON> at `<EMAIL_ADDRESS>`"):
from langchain_core.documents import Document
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig
analyzer = AnalyzerEngine() # needs a spaCy model, e.g. en_core_web_lg, installed
anonymizer = AnonymizerEngine()
ENTITIES = ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "IBAN_CODE", "CREDIT_CARD", "IP_ADDRESS"]
def redact(doc: Document) -> Document:
findings = analyzer.analyze(text=doc.page_content, entities=ENTITIES, language="en", score_threshold=0.6)
result = anonymizer.anonymize(
text=doc.page_content,
analyzer_results=findings,
operators={"DEFAULT": OperatorConfig("replace")}, # default replacement is "<ENTITY_TYPE>"
)
counts: dict[str, int] = {}
for item in result.items:
counts[item.entity_type] = counts.get(item.entity_type, 0) + 1
return Document(page_content=result.text, metadata={**doc.metadata, "pii_redacted": counts})
Source: Presidio — Analyzer and Presidio — Anonymizer.
Typed placeholders keep answers useful ("the owner of this service is `<PERSON>`") without leaking the value. When some users are entitled to see the value, do not redact the index; instead keep the entities and enforce access at query time, as described in Access Control & Privacy. Store the redaction counts, not the values, so that auditors can see what was removed.
Provenance hashes
Every chunk should answer "where did this come from, and is it still what the source says?". DocsAssistant stores four provenance fields in chunk metadata:
| Field | Content |
|---|---|
|
The stable source ID ( |
|
SHA-256 of the original file bytes or row, so a reconciliation can detect a changed source without re-parsing it. |
|
SHA-256 of the chunk text after parsing and redaction, so unchanged chunks are not re-embedded. |
|
The parser, parser version, chunker settings and embedding model version, so a pipeline change can be targeted. |
The hashes also give tamper evidence: if a chunk’s text no longer matches its content_hash, something wrote to the
index outside the pipeline.
The Spring AI ETL pipeline
Spring AI structures ingestion as extract, transform, load: a DocumentReader (a Supplier<List<Document>>), one or
more DocumentTransformer instances (each a Function<List<Document>, List<Document>>) and a DocumentWriter (a
Consumer<List<Document>>, which every VectorStore implements). The functional shapes make a pipeline a composition:
package com.example.docsassistant.rag.ingest;
import java.util.List;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentReader;
import org.springframework.ai.model.transformer.KeywordMetadataEnricher;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@Service
public class EtlPipeline {
private final VectorStore vectorStore;
private final KeywordMetadataEnricher keywords;
private final TokenTextSplitter splitter = TokenTextSplitter.builder().withChunkSize(512).build();
public EtlPipeline(VectorStore vectorStore, ChatModel chatModel) {
this.vectorStore = vectorStore;
this.keywords = KeywordMetadataEnricher.builder(chatModel).keywordCount(5).build();
}
public int ingest(Resource resource, String source, String acl) {
DocumentReader reader = new TikaDocumentReader(resource); // extract
List<Document> docs = reader.get().stream()
.map(d -> d.mutate().metadata("source", source).metadata("acl", acl).build())
.toList();
List<Document> chunks = keywords.apply(splitter.apply(docs)); // transform
vectorStore.accept(chunks); // load
return chunks.size();
}
}
Source: Spring AI — ETL Pipeline.
The vector-store side of the ETL pipeline (writers, batching strategies, filter expressions) is on Integrating with Spring AI: ETL. For large batch loads, run the pipeline from a Spring Batch job so that restarts, chunk-oriented processing and the job ledger come for free (see Spring Batch).
The LangChain4j EmbeddingStoreIngestor
LangChain4j’s EmbeddingStoreIngestor runs a document transformer, a splitter, a segment transformer, the embedding
model and the store write in one call. Removing the previous version of the source first with removeAll(Filter)
makes the ingest idempotent per document:
package com.example.docsassistant.l4j.ingest;
import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;
import dev.langchain4j.store.embedding.IngestionResult;
public class Ingestion {
private final EmbeddingStore<TextSegment> store;
private final EmbeddingStoreIngestor ingestor;
public Ingestion(EmbeddingStore<TextSegment> store, EmbeddingModel embeddingModel) {
this.store = store;
this.ingestor = EmbeddingStoreIngestor.builder()
.documentSplitter(DocumentSplitters.recursive(1500, 150))
.textSegmentTransformer(segment -> TextSegment.from(
segment.metadata().getString("title") + "\n\n" + segment.text(), // context header
segment.metadata()))
.embeddingModel(embeddingModel)
.embeddingStore(store)
.build();
}
public IngestionResult replace(Document document, String source, String acl) {
document.metadata().put("source", source).put("acl", acl);
store.removeAll(metadataKey("source").isEqualTo(source)); // delete the previous version
return ingestor.ingest(document);
}
public void delete(String source) {
store.removeAll(metadataKey("source").isEqualTo(source));
}
}
Source: LangChain4j — RAG (Embedding Store Ingestor) and LangChain4j — PGVector.
removeAll(Filter) is implemented by the pgvector, Neo4j and most other stores; check the store’s page before relying
on it. The segment transformer above prepends the document title to every segment before embedding, a cheap form of the
contextual retrieval described in Advanced Indexing Patterns.
Ingestion at scale (parallel frameworks, idempotent restartable jobs, stalled-document observability), freshness with CDC, and redaction and provenance hashing follow the concepts of Mendelevitch and Bao, Hands-On RAG for Production (Chapters 3 and 4), paraphrased; see the bibliography.