Batch Ingestion
You ran the import script. It ran for two hours, hit a network timeout at record 38,000, and now you have to decide: restart from zero and create duplicates, or piece together which records made it through. This is the failure mode teams underestimate. Naive for-loops don't checkpoint. They don't deduplicate. They don't rate-limit or chunk documents for recall precision. This pattern implements a production-grade batch ingestion pipeline: five stages, resumable at any point, with automatic deduplication and real-time progress monitoring.
Start Building Free →- Dakera instance running (quickstart)
- SDK installed:
pip install dakera/npm i @dakera-ai/dakera - Source data in JSON, JSONL, CSV, or database export format
- Estimated document count and average document size to plan rate limits and total runtime
The Problem
Knowledge base migration is consistently underestimated. Four failure modes teams hit when writing a naive ingestion loop:
- Rate limit crashes: A loop that sends requests faster than the server's rate limit (
DAKERA_RATE_LIMIT_RPS, 100 requests per second by default) gets429answers with aRetry-Afterheader. Without retry logic every refused request is a document silently skipped, and the memory store looks complete while records are missing. - No resume capability: A 50,000-document import runs for 2 hours, hits a network timeout at record 38,000, and the developer must restart from record 0. Without checkpoint logic, re-ingesting everything creates duplicates and wastes hours.
- Monolithic chunk size: 10-page PDFs are ingested as single memories. A recall query for a specific section returns the entire document (10,000+ characters), consuming the full context window budget. Chunking to 800 characters per memory dramatically improves recall precision.
- No deduplication: Running the ingestion script twice (common during testing) stores 100,000 memories for 50,000 documents. AutoPilot merges near-duplicates in the background, every 6 hours by default, but until then the copies compete for the same recall slots and context window space.
Architecture
This pattern implements a five-stage pipeline: Load → Chunk → Tag → Ingest (with rate limiting and retry) → Verify. A checkpoint file records how many source records are fully processed, so a crashed run resumes where it stopped. Progress is logged in real-time. A post-ingestion deduplication pass removes near-duplicate chunks.
Implementation Steps
-
Audit and prepare your source dataBefore writing code, sample 100 records from your source and answer: What is the average document length? Are there duplicates? What metadata is available (author, date, category, source system)? Which documents are highest value and should get higher importance scores? Documents over 800 characters need chunking; those under 200 characters may need merging with adjacent records to create meaningful memory units.
-
Implement sentence-aware chunking with overlapSplit long documents into chunks of up to 800 characters, breaking only at sentence boundaries (never mid-sentence; a single longer sentence stays whole). Add a 100-character overlap between adjacent chunks so that contextual information at chunk boundaries isn't lost during recall. Assign each chunk a
chunk_indexandtotal_chunksin metadata so you can reconstruct document order if needed. -
Tag every chunk with complete provenance metadataEach memory must carry enough metadata to trace it back to its source:
source(filename or system name),doc_id(unique document ID from the source system),chunk_index,ingestion_batch(timestamp of this batch run), andcategory. Also give every chunk abatch:<run timestamp>tag. The metadata traces a chunk to its source; the tag is what rolls a run back, becausebatch_forget()filters on tags, importance, type, session and creation time, not on metadata. -
Run ingestion with worker pool, rate limiting, and retryRun 20 concurrent workers behind one shared rate limiter capped at 20 requests per second, well under the server's default limit of 100. These are starting values, not a measured optimum: tune both for your server. Give each chunk 3 attempts, waiting 1 s and then 2 s between them, before declaring a permanent failure. The SDK already retries
5xxanswers and honorsRetry-Afteron a429; this retry covers what still fails. Log permanent failures to a separate file for manual review, and never skip them silently. -
Write checkpoint files for resume capabilityWork through the source in windows of 500 records. When every chunk of a window is stored or logged as failed, write the index of the next record and a timestamp to a checkpoint file. On startup, read the checkpoint and skip the records before it. A crash then costs at most one window: up to 500 records re-ingested, and the duplicates they create are removed by the deduplication pass.
Before ingesting 50,000 documents, run the pipeline on a 500-document sample: first with dry_run=True to count chunks, then for real into a test agent. Verify that chunking produces the expected number of chunks, metadata is correctly attached, recall queries return relevant results, and the estimated completion time is acceptable. A few minutes on a sample prevents hours of wasted ingestion caused by a configuration error.
Implementation
# Store one chunk (the pipeline sends one request per chunk)
curl -X POST http://localhost:3000/v1/memory/store \
-H "Authorization: Bearer dk-..." \
-H "Content-Type: application/json" \
-d '{
"agent_id": "knowledge-base",
"content": "Dakera provides persistent memory for AI agents. It stores, indexes, and retrieves memories using semantic search. Designed for production AI systems requiring cross-session context.",
"importance": 0.80,
"memory_type": "semantic",
"tags": ["source:product-documentation.md", "category:product_docs", "batch:2024-08-01T10:00:00Z"],
"metadata": {
"source": "product-documentation.md",
"doc_id": "doc_001",
"chunk_index": 0,
"total_chunks": 3,
"ingestion_batch": "2024-08-01T10:00:00Z",
"category": "product_docs"
}
}'
# Or store several chunks in one request
curl -X POST http://localhost:3000/v1/memories/store/batch \
-H "Authorization: Bearer dk-..." \
-H "Content-Type: application/json" \
-d '{
"agent_id": "knowledge-base",
"memories": [
{"content": "First chunk ...", "importance": 0.80, "memory_type": "semantic", "tags": ["batch:2024-08-01T10:00:00Z"]},
{"content": "Second chunk ...", "importance": 0.80, "memory_type": "semantic", "tags": ["batch:2024-08-01T10:00:00Z"]}
]
}'
# Verify: sample recall after the batch completes
curl -X POST http://localhost:3000/v1/memory/recall \
-H "Authorization: Bearer dk-..." \
-H "Content-Type: application/json" \
-d '{"agent_id": "knowledge-base", "query": "persistent memory for AI agents", "top_k": 5}'
# Verify: count the agent's memories (total_memories)
curl http://localhost:3000/v1/agents/knowledge-base/stats \
-H "Authorization: Bearer dk-..."
# Roll back one run by its batch tag
curl -X DELETE http://localhost:3000/v1/memories/forget/batch \
-H "Authorization: Bearer dk-..." \
-H "Content-Type: application/json" \
-d '{"agent_id": "knowledge-base", "filter": {"tags": ["batch:2024-08-01T10:00:00Z"]}}'import json
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime, timezone
from itertools import islice
from pathlib import Path
from threading import Lock
from typing import Iterator
from dakera import DakeraClient
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
logger = logging.getLogger(__name__)
client = DakeraClient(base_url="http://localhost:3000", api_key="dk-...")
# Configuration
AGENT_ID = "knowledge-base"
MAX_WORKERS = 20
RATE_LIMIT_RPS = 20 # requests per second, shared by all workers
CHUNK_SIZE = 800 # max characters per chunk (a longer sentence stays whole)
CHUNK_OVERLAP = 100 # overlap between consecutive chunks
CHECKPOINT_INTERVAL = 500 # source records between checkpoints
MAX_ATTEMPTS = 3 # attempts per chunk before a permanent failure
BATCH_ID = datetime.now(timezone.utc).isoformat()
BATCH_TAG = f"batch:{BATCH_ID}" # one tag per run, for rollback with batch_forget
@dataclass
class Chunk:
content: str
doc_id: str
source: str
chunk_index: int
total_chunks: int
category: str
importance: float = 0.80
def chunk_document(text: str, doc_id: str, source: str, category: str) -> list[Chunk]:
"""Split a document into sentence-aware overlapping chunks."""
if len(text) <= CHUNK_SIZE:
return [Chunk(text, doc_id, source, 0, 1, category)]
chunks = []
sentences = text.replace('. ', '.|').replace('! ', '!|').replace('? ', '?|').split('|')
current_chunk = ""
for sentence in sentences:
if len(current_chunk) + len(sentence) + 1 > CHUNK_SIZE and current_chunk:
chunks.append(current_chunk.strip())
# Overlap: carry the last 100 characters into the next chunk
current_chunk = current_chunk[-CHUNK_OVERLAP:] + " " + sentence
else:
current_chunk += (" " if current_chunk else "") + sentence
if current_chunk.strip():
chunks.append(current_chunk.strip())
total = len(chunks)
return [
Chunk(content=c, doc_id=doc_id, source=source, chunk_index=i,
total_chunks=total, category=category)
for i, c in enumerate(chunks)
]
def load_checkpoint(checkpoint_file: str) -> int:
"""Number of source records a previous run finished (0 if none)."""
p = Path(checkpoint_file)
if p.exists():
data = json.loads(p.read_text())
logger.info(f"Resuming from checkpoint: record {data['last_index']}")
return data["last_index"]
return 0
def save_checkpoint(checkpoint_file: str, index: int, ingested: int, failed: int) -> None:
"""Record that every source record before `index` is done."""
Path(checkpoint_file).write_text(json.dumps({
"last_index": index,
"ingested": ingested,
"failed": failed,
"timestamp": datetime.now(timezone.utc).isoformat()
}, indent=2))
class RateLimiter:
"""Spaces requests so all workers together send at most `rps` per second."""
def __init__(self, rps: float):
self.min_interval = 1.0 / rps
self.next_slot = time.monotonic()
self.lock = Lock()
def wait(self) -> None:
with self.lock:
now = time.monotonic()
slot = max(now, self.next_slot)
self.next_slot = slot + self.min_interval
time.sleep(max(0.0, slot - now))
rate_limiter = RateLimiter(RATE_LIMIT_RPS)
def ingest_chunk(chunk: Chunk) -> bool:
"""Store one chunk, retrying with exponential backoff. Returns True on success."""
for attempt in range(MAX_ATTEMPTS):
try:
rate_limiter.wait()
client.store_memory(
agent_id=AGENT_ID,
content=chunk.content,
importance=chunk.importance,
memory_type="semantic",
tags=[f"source:{chunk.source}", f"category:{chunk.category}", BATCH_TAG],
metadata={
"source": chunk.source,
"doc_id": chunk.doc_id,
"chunk_index": chunk.chunk_index,
"total_chunks": chunk.total_chunks,
"category": chunk.category,
"ingestion_batch": BATCH_ID
}
)
return True
except Exception as e:
if attempt < MAX_ATTEMPTS - 1:
backoff = 2 ** attempt # 1 s, then 2 s
logger.warning(f"Retry {attempt + 1}/{MAX_ATTEMPTS - 1} for {chunk.doc_id}[{chunk.chunk_index}]: {e}. Waiting {backoff}s")
time.sleep(backoff)
else:
logger.error(f"PERMANENT FAILURE: {chunk.doc_id}[{chunk.chunk_index}]: {e}")
return False
def load_jsonl(file_path: str) -> Iterator[dict]:
"""Stream documents from a JSONL file."""
with open(file_path) as f:
for line in f:
line = line.strip()
if line:
yield json.loads(line)
def batch_ingest(
source_file: str,
checkpoint_file: str = ".ingest_checkpoint.json",
dry_run: bool = False
) -> dict:
"""Ingest a JSONL file in windows of CHECKPOINT_INTERVAL records, resumable after a crash."""
start_index = load_checkpoint(checkpoint_file)
records = enumerate(load_jsonl(source_file))
for _ in islice(records, start_index):
pass # skip records a previous run finished
ingested, failed, planned = 0, 0, 0
failed_chunks: list[Chunk] = []
start_time = time.monotonic()
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
while window := list(islice(records, CHECKPOINT_INTERVAL)):
chunks = [
chunk
for i, doc in window
for chunk in chunk_document(
text=doc["content"],
doc_id=doc.get("id", f"doc_{i:06d}"),
source=doc.get("source", source_file),
category=doc.get("category", "general"),
)
]
planned += len(chunks)
if dry_run:
continue
# Every chunk of the window finishes (stored or failed) before the checkpoint
for chunk, ok in zip(chunks, executor.map(ingest_chunk, chunks)):
if ok:
ingested += 1
else:
failed += 1
failed_chunks.append(chunk)
done = window[-1][0] + 1
save_checkpoint(checkpoint_file, done, ingested, failed)
elapsed = time.monotonic() - start_time
logger.info(f"Progress: {done} records | {ingested} chunks ok, {failed} failed | "
f"{ingested / max(elapsed, 1):.1f} chunks/s")
if dry_run:
logger.info(f"DRY RUN: would ingest {planned} chunks. Nothing was stored.")
return {"dry_run": True, "chunks": planned}
# Write failed chunks for manual review; never skip them silently
if failed_chunks:
failed_file = f"failed_chunks_{BATCH_ID[:10]}.jsonl"
with open(failed_file, 'w') as f:
for c in failed_chunks:
f.write(json.dumps({"doc_id": c.doc_id, "chunk_index": c.chunk_index, "content": c.content[:200]}) + '\n')
logger.warning(f"Wrote {len(failed_chunks)} failed chunks to {failed_file}")
elapsed = time.monotonic() - start_time
result = {
"total_chunks": planned,
"ingested": ingested,
"failed": failed,
"elapsed_seconds": round(elapsed, 1),
"throughput_per_min": round(ingested / max(elapsed / 60, 1e-9)),
"batch_tag": BATCH_TAG
}
logger.info(f"Batch complete: {result}")
return result
def verify(sample_queries: list[str]) -> None:
"""Stage 5: count the agent's memories and spot-check recall."""
stats = client.agent_stats(AGENT_ID)
logger.info(f"Memories stored for {AGENT_ID}: {stats['total_memories']}")
for q in sample_queries:
hits = client.recall(agent_id=AGENT_ID, query=q, top_k=5)
logger.info(f"{q!r}: {[m.content[:60] for m in hits.memories]}")
# Run the ingestion, then verify
result = batch_ingest("knowledge-base.jsonl", dry_run=False)
print(f"Ingested {result['ingested']:,} chunks in {result['elapsed_seconds']}s ({result['throughput_per_min']}/min)")
verify(["How do I configure SSO?", "What does the SAML setup need?"])
# Roll the whole run back if it went wrong:
# from dakera import BatchForgetRequest, BatchMemoryFilter
# client.batch_forget(BatchForgetRequest(agent_id=AGENT_ID, filter=BatchMemoryFilter(tags=[result["batch_tag"]])))import { DakeraClient } from '@dakera-ai/dakera';
import { createReadStream, existsSync, readFileSync, writeFileSync } from 'fs';
import { createInterface } from 'readline';
const client = new DakeraClient({ baseUrl: 'http://localhost:3000', apiKey: 'dk-...' });
const CONFIG = {
agentId: 'knowledge-base',
maxWorkers: 20,
rateLimitRps: 20, // shared by all workers
chunkSize: 800,
chunkOverlap: 100,
checkpointInterval: 500, // source records between checkpoints
maxAttempts: 3,
batchId: new Date().toISOString(),
};
const BATCH_TAG = `batch:${CONFIG.batchId}`;
interface Chunk {
content: string;
docId: string;
source: string;
chunkIndex: number;
totalChunks: number;
category: string;
}
function chunkDocument(text: string, docId: string, source: string, category: string): Chunk[] {
if (text.length <= CONFIG.chunkSize) {
return [{ content: text, docId, source, chunkIndex: 0, totalChunks: 1, category }];
}
const sentences = text.replace(/\. /g, '.|').replace(/! /g, '!|').replace(/\? /g, '?|').split('|');
const rawChunks: string[] = [];
let current = '';
for (const sentence of sentences) {
if (current.length + sentence.length + 1 > CONFIG.chunkSize && current) {
rawChunks.push(current.trim());
current = current.slice(-CONFIG.chunkOverlap) + ' ' + sentence;
} else {
current += (current ? ' ' : '') + sentence;
}
}
if (current.trim()) rawChunks.push(current.trim());
return rawChunks.map((content, i) => ({
content, docId, source, chunkIndex: i, totalChunks: rawChunks.length, category,
}));
}
// Spaces requests so all workers together send at most rateLimitRps per second
let nextSlot = Date.now();
async function rateLimit(): Promise<void> {
const slot = Math.max(Date.now(), nextSlot);
nextSlot = slot + 1000 / CONFIG.rateLimitRps;
await new Promise(r => setTimeout(r, slot - Date.now()));
}
async function ingestChunk(chunk: Chunk): Promise<boolean> {
for (let attempt = 0; attempt < CONFIG.maxAttempts; attempt++) {
try {
await rateLimit();
await client.storeMemory(CONFIG.agentId, {
content: chunk.content,
importance: 0.80,
memory_type: 'semantic',
tags: [`source:${chunk.source}`, `category:${chunk.category}`, BATCH_TAG],
metadata: {
source: chunk.source,
doc_id: chunk.docId,
chunk_index: chunk.chunkIndex,
total_chunks: chunk.totalChunks,
category: chunk.category,
ingestion_batch: CONFIG.batchId,
},
});
return true;
} catch (err) {
if (attempt < CONFIG.maxAttempts - 1) {
const backoff = 2 ** attempt * 1000; // 1 s, then 2 s
console.warn(`Retry ${attempt + 1} for ${chunk.docId}[${chunk.chunkIndex}]: waiting ${backoff}ms`);
await new Promise(r => setTimeout(r, backoff));
} else {
console.error(`PERMANENT FAILURE: ${chunk.docId}[${chunk.chunkIndex}]: ${err}`);
}
}
}
return false;
}
// Run tasks with at most `limit` in flight; results keep their order
async function pool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: limit }, worker));
return results;
}
async function batchIngest(sourceFile: string, checkpointFile = '.ingest_checkpoint.json'): Promise<void> {
const startIndex = existsSync(checkpointFile)
? JSON.parse(readFileSync(checkpointFile, 'utf8')).last_index as number
: 0;
const start = Date.now();
let ingested = 0, failed = 0, index = 0;
let window: { index: number; doc: any }[] = [];
const failedChunks: Chunk[] = [];
const flush = async () => {
const chunks = window.flatMap(({ index: i, doc }) =>
chunkDocument(doc.content, doc.id ?? `doc_${i}`, doc.source ?? sourceFile, doc.category ?? 'general'));
const results = await pool(chunks, CONFIG.maxWorkers, ingestChunk);
results.forEach((ok, i) => ok ? ingested++ : (failed++, failedChunks.push(chunks[i])));
const done = window[window.length - 1].index + 1;
writeFileSync(checkpointFile, JSON.stringify({ last_index: done, ingested, failed }));
const rate = ingested / Math.max((Date.now() - start) / 1000, 1);
console.log(`Progress: ${done} records | ${ingested} chunks ok, ${failed} failed | ${rate.toFixed(1)} chunks/s`);
window = [];
};
const rl = createInterface({ input: createReadStream(sourceFile) });
for await (const line of rl) {
if (!line.trim()) continue;
const i = index++;
if (i < startIndex) continue; // finished by a previous run
window.push({ index: i, doc: JSON.parse(line) });
if (window.length === CONFIG.checkpointInterval) await flush();
}
if (window.length) await flush();
if (failedChunks.length) {
writeFileSync(`failed_chunks_${CONFIG.batchId.slice(0, 10)}.jsonl`,
failedChunks.map(c => JSON.stringify({ doc_id: c.docId, chunk_index: c.chunkIndex })).join('\n'));
}
const elapsed = (Date.now() - start) / 1000;
console.log(`Complete: ${ingested} ingested, ${failed} failed in ${elapsed.toFixed(1)}s (tag ${BATCH_TAG})`);
}
await batchIngest('knowledge-base.jsonl');
// Verify: count, then spot-check recall
const stats = await client.agentStats(CONFIG.agentId);
console.log(`Memories stored: ${stats.total_memories}`);
const { memories } = await client.recall(CONFIG.agentId, 'How do I configure SSO?', { top_k: 5 });
memories.forEach(m => console.log(m.score.toFixed(2), m.content.slice(0, 60)));use std::sync::Arc;
use dakera_client::{DakeraClient, MemoryType, StoreMemoryRequest};
use tokio::{sync::Semaphore, task::JoinSet, time::{interval, sleep, Duration}};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Arc::new(
DakeraClient::builder("http://localhost:3000").api_key("dk-...").build()?,
);
let semaphore = Arc::new(Semaphore::new(20)); // 20 concurrent workers
let mut ticker = interval(Duration::from_millis(50)); // 20 requests per second
let batch_tag = "batch:2024-08-01T10:00:00Z".to_string(); // one tag per run
// Output of the load, chunk and tag stages (see the Python tab)
let chunks: Vec<String> = load_chunks("knowledge-base.jsonl");
println!("Ingesting {} chunks", chunks.len());
let mut set = JoinSet::new();
for (i, content) in chunks.into_iter().enumerate() {
ticker.tick().await;
let permit = semaphore.clone().acquire_owned().await?;
let client = client.clone();
let tags = vec!["source:docs".to_string(), "category:general".to_string(), batch_tag.clone()];
set.spawn(async move {
let _permit = permit; // released when the task ends
for attempt in 0..3u32 {
let request = StoreMemoryRequest::new("knowledge-base", content.clone())
.with_type(MemoryType::Semantic)
.with_importance(0.80)
.with_tags(tags.clone());
match client.store_memory(request).await {
Ok(_) => return (i, true),
Err(e) if attempt < 2 => {
eprintln!("Retry {} for chunk {}: {:?}", attempt + 1, i, e);
sleep(Duration::from_secs(1 << attempt)).await; // 1 s, then 2 s
}
Err(e) => eprintln!("Permanent failure at chunk {}: {:?}", i, e),
}
}
(i, false)
});
}
let (mut ingested, mut failed) = (0u64, 0u64);
while let Some(result) = set.join_next().await {
match result {
Ok((_, true)) => ingested += 1,
_ => failed += 1,
}
}
println!("Batch complete: {} ingested, {} failed", ingested, failed);
Ok(())
}package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log"
"os"
"sync"
"sync/atomic"
"time"
dakera "github.com/dakera-ai/dakera-go"
)
const (
agentID = "knowledge-base"
maxWorkers = 20
maxAttempts = 3
rateLimit = 20 // requests per second, shared by all workers
batchTag = "batch:2024-08-01T10:00:00Z"
)
// One tick per allowed request, shared by every worker
var limiter = time.Tick(time.Second / rateLimit)
func ingestChunkWithRetry(ctx context.Context, client *dakera.Client, content, source, docID string) bool {
importance := float32(0.80)
for attempt := 0; attempt < maxAttempts; attempt++ {
<-limiter
_, err := client.StoreMemory(ctx, agentID, dakera.StoreMemoryRequest{
Content: content,
MemoryType: "semantic",
Importance: &importance,
Tags: []string{"source:" + source, "category:general", batchTag},
Metadata: map[string]interface{}{
"source": source,
"doc_id": docID,
},
})
if err == nil {
return true
}
if attempt < maxAttempts-1 {
backoff := time.Duration(1<<uint(attempt)) * time.Second // 1 s, then 2 s
log.Printf("Retry %d for %s: %v. Waiting %v", attempt+1, docID, err, backoff)
time.Sleep(backoff)
} else {
log.Printf("PERMANENT FAILURE: %s: %v", docID, err)
}
}
return false
}
// Reads one chunk per JSONL line (chunking happens upstream; see the Python tab)
func batchIngest(ctx context.Context, client *dakera.Client, sourceFile string) error {
f, err := os.Open(sourceFile)
if err != nil {
return err
}
defer f.Close()
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
var ingested, failed atomic.Int64
total := 0
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var doc map[string]interface{}
if err := json.Unmarshal([]byte(line), &doc); err != nil {
continue
}
total++
content, _ := doc["content"].(string)
docID, _ := doc["id"].(string)
if docID == "" {
docID = fmt.Sprintf("doc_%06d", total)
}
source, _ := doc["source"].(string)
sem <- struct{}{}
wg.Add(1)
go func(c, d, s string) {
defer wg.Done()
defer func() { <-sem }()
if ingestChunkWithRetry(ctx, client, c, s, d) {
ingested.Add(1)
} else {
failed.Add(1)
}
}(content, docID, source)
}
wg.Wait()
fmt.Printf("Complete: %d ingested, %d failed out of %d total\n", ingested.Load(), failed.Load(), total)
return scanner.Err()
}
func main() {
client := dakera.NewClientWithOptions(dakera.ClientOptions{
BaseURL: "http://localhost:3000",
APIKey: "dk-...",
})
if err := batchIngest(context.Background(), client, "knowledge-base.jsonl"); err != nil {
log.Fatal(err)
}
}Bootstrap your agent with 50k+ documents today
This pipeline runs on any of the Dakera SDKs or plain REST. Start your knowledge base migration in minutes.
Real-World Scenario: Knowledge Base Migration — 50,000 Documents
An enterprise software company migrates their support knowledge base to power a new AI assistant. The source is 50,243 Confluence pages averaging 2,400 characters each. Total estimated chunks: ~150,000.
- Audit phase (1 hour): Sample 200 documents. Discover that 8% are duplicates from page cloning. Identify 4 importance tiers: critical procedures (0.95), general documentation (0.80), archived content (0.60), draft pages (0.45). Tag strategy defined:
tier,space,last_modified. - Dry run (10 minutes): Run the pipeline on 500 documents. Verify chunking produces about 3 chunks per page. Confirm estimated completion: 150,000 chunks ÷ 20 chunks/s (the rate limiter's cap) = 7,500 s, about 2.1 hours.
- Ingestion run (about 2.2 hours): 149,847 chunks ingested, 153 permanent failures (0.1%). Checkpoint file saved every 500 records; after a network blip at record 32,000 the restarted run resumed from the last checkpoint. Failed records written to
failed_chunks_2024-08-01.jsonlfor manual review. - Post-ingestion dedup: AutoPilot's deduplication (cosine similarity above 0.93, every 6 hours, or at once with
POST /admin/autopilot/trigger) merges 12,400 near-duplicate chunks from the cloned Confluence pages. After dedup, effective corpus: 137,447 unique chunks. - Verification (15 minutes):
GET /v1/agents/{id}/statsconfirms the count, and 20 sample recall queries all return relevant results. Agent achieves 94% answer accuracy on support questions vs. 61% baseline (no memory).
Before / After Memory State
// Day 1: agent deployed
// Memory store: 0 records
// User: "How do I configure
// SSO for our org?"
// Agent: "I don't have specific
// information about your
// organization's SSO setup.
// Please check your docs."
// Zero-value response.
// User opens a support ticket.
// Agent provides no ROI.
// Day 1: agent deployed with
// 137,447 ingested memories
// User: "How do I configure
// SSO for our org?"
// Recall: 8 relevant chunks
// from SSO_Configuration.md
// + 3 from SAML_Setup_Guide.md
// Agent: "Here are the exact
// steps for your Okta setup..."
// 94% answer accuracy.
// Support tickets -67%.
SDK Method Reference
| Method | SDK | Purpose in this pattern |
|---|---|---|
store_memory(agent_id, content, importance, memory_type, tags, metadata) | Python | Ingest a single chunk with metadata |
store_memories_batch(BatchStoreMemoryRequest(agent_id, memories)) | Python | Store several chunks in one request (POST /v1/memories/store/batch) |
batch_forget(BatchForgetRequest(agent_id, filter)) | Python | Roll back an ingestion run by its batch tag |
recall(agent_id, query, top_k) | Python | Post-ingestion verification sampling |
agent_stats(agent_id) | Python | Count the stored memories (total_memories) |
storeMemory(agentId, {content, importance, memory_type, tags, metadata}) | TypeScript | Async parallel chunk ingestion |
batchForget({agent_id, filter}) | TypeScript | Roll back an ingestion run by its batch tag |
client.store_memory(StoreMemoryRequest::new(agent_id, content)).await? | Rust | Async chunk store with JoinSet concurrency |
client.StoreMemory(ctx, agentID, dakera.StoreMemoryRequest{...}) | Go | Goroutine-parallel chunk ingestion |
Edge Cases and Gotchas
- Silent deduplication failures: If your source data has near-duplicate documents (Confluence page clones, CRM record copies), they'll be ingested separately, consuming twice the memory and degrading recall quality. Dedupe the source data before ingestion where you can. After ingestion, AutoPilot merges near-duplicates above a cosine similarity of 0.93 every 6 hours;
POST /v1/knowledge/deduplicatemerges only exact duplicates and refuses agents with more than 5,000 memories to compare. See the Semantic Deduplication pattern. - Chunk boundary semantic loss: A sentence split across two chunks means neither chunk has full context for that sentence. Sentence-aware chunking (breaking only at sentence boundaries) with 100-character overlap minimizes this. For especially context-sensitive content (legal documents, code), consider larger overlap (200–300 characters).
- Importance score uniformity: Assigning
importance=0.80to all 150,000 chunks means everything is equally important at recall time. Differentiate by content quality: critical procedures get 0.95, general documentation 0.80, archived content 0.60. This dramatically improves recall precision for high-value queries. - Ingestion during live traffic: Running a large ingestion while the agent is serving requests can cause a temporary recall accuracy dip — the index is partially updated. For production migrations, prefer maintenance windows, or ingest under a separate
agent_id(each agent has its own namespace), verify quality, then point the agent at it. - No rollback for partial ingestion: If you ingest 80% of your corpus and discover a data quality issue, you can't easily "undo" 120,000 stored memories. Design rollback into your pipeline from the start: tag every ingested chunk with a
batch:<run timestamp>tag, sobatch_forget()with that tag as its filter rolls back the entire run cleanly.
The server allows DAKERA_RATE_LIMIT_RPS requests per second (default 100, burst 50); over it, a request is refused with 429 and a Retry-After header. Under memory pressure a batch store is refused with 503 and Retry-After. Both are transient: wait as told, then retry. The 20 requests per second used here is a starting point, not a benchmark; measure on your own server before you plan an ingestion timeline.
Performance Considerations
Advanced Configuration: Adaptive rate limiting and dynamic chunking
Implement adaptive rate limiting that backs off when 429 responses are detected, and dynamic chunking that adjusts chunk size based on content type:
class AdaptiveRateLimiter:
def __init__(self, initial_rps: float = 20.0):
self.current_rps = initial_rps
self.min_rps = 5.0
self.max_rps = 30.0
self.interval = 1.0 / initial_rps
self.last_call = 0.0
self.consecutive_errors = 0
def on_success(self):
self.consecutive_errors = 0
# Slowly ramp up if we've been error-free for a while
if self.current_rps < self.max_rps:
self.current_rps = min(self.current_rps * 1.05, self.max_rps)
self.interval = 1.0 / self.current_rps
def on_rate_limit_error(self):
self.consecutive_errors += 1
# Aggressive backoff on rate limit
self.current_rps = max(self.current_rps * 0.7, self.min_rps)
self.interval = 1.0 / self.current_rps
CHUNK_SIZES = {
"code": 400, # Code chunks: smaller for precise recall
"procedure": 600, # Step-by-step docs: medium
"general": 800, # General text: standard
"reference": 1000, # Reference docs: larger for coherence
}
def get_chunk_size(category: str) -> int:
return CHUNK_SIZES.get(category, 800)
Run this pattern against a real Dakera.
The Playground executes live API calls in your browser. No account, no install. Then run it on your own server in five minutes.