Docs

Examples

RAG Ingestion

A complete ingestion flow from source documents to Chroma-backed retrieval.

RAG ingestion turns source material into searchable evidence. A useful ingestion example should show the whole path: load source documents, OCR scanned files, normalize text, chunk with stable ids, embed, write to a vector store, and record what changed.

Scenario

Support knowledge comes from three places: published CMS articles, text-based PDFs, and scanned warranty forms. The support agent should retrieve only published checkout policy for the caller’s tenant and cite the source title or page.

Flow

Step Anvia surface App-owned boundary
load text sources CMS client, PdfFileLoader source permissions, draft filtering, source ids
OCR scanned files MistralClient().ocrModel() upload validation, source metadata, retry policy
normalize and chunk app code stable chunk ids, deleted-source handling
embed OpenAI embeddingModel("text-embedding-3-small") batching, rate limits, redaction
store ChromaVectorStore.upsertDocuments(...) collection ownership and refresh policy
serve retrieval store.index(embeddings) tenant/product filters and freshness checks

Provider Setup

import { readFile } from "node:fs/promises";
import { embedDocuments } from "@anvia/core/embeddings";
import { PdfFileLoader, pdfPageLoaderToDocuments } from "@anvia/core/loaders";
import { ChromaVectorStore } from "@anvia/chroma";
import { MistralClient } from "@anvia/mistral";
import { OpenAIClient } from "@anvia/openai";

const openai = new OpenAIClient({ apiKey: process.env.OPENAI_API_KEY });
const mistral = new MistralClient({ apiKey: process.env.MISTRAL_API_KEY });

const embeddings = openai.embeddingModel("text-embedding-3-small");
const ocr = mistral.ocrModel();

OCR Scanned Files

async function loadScannedFile(input: RefreshKnowledgeInput, file: ScannedKnowledgeFile) {
  const result = await ocr.ocr({
    source: {
      type: "bytes",
      data: await readFile(file.path),
      filename: file.filename,
    },
    tableFormat: "markdown",
    includeImageBase64: false,
  });

  return result.pages.map((page) => ({
    id: `${file.id}#page=${page.index}`,
    title: file.title,
    text: page.markdown,
    source: file.path,
    pageNumber: page.index,
    tenantId: input.tenantId,
    productArea: "checkout",
    visibility: "public",
    updatedAt: file.updatedAt,
  }));
}

Load Sources

export async function loadSupportSources(input: RefreshKnowledgeInput) {
  const cmsDocs = await input.cms.listPublishedArticles({
    tenantId: input.tenantId,
    productArea: "checkout",
  });

  const pdfPages = await pdfPageLoaderToDocuments(
    PdfFileLoader.withGlob("knowledge/support/**/*.pdf").readWithPath().byPage().ignoreErrors(),
  );

  const scannedDocs = await Promise.all(
    input.scannedFiles.map((file) => loadScannedFile(input, file)),
  );

  return [
    ...cmsDocs.map((article) => ({
      id: article.id,
      title: article.title,
      text: `${article.title}\n${article.body}`,
      source: `cms:${article.id}`,
      tenantId: article.tenantId,
      productArea: article.productArea,
      visibility: article.visibility,
      updatedAt: article.updatedAt,
    })),
    ...pdfPages.map((page) => ({
      id: page.id,
      title: page.additionalProps?.source ?? page.id,
      text: page.text,
      source: page.additionalProps?.source ?? page.id,
      pageNumber: Number(page.additionalProps?.pageNumber ?? 0),
      tenantId: input.tenantId,
      productArea: "checkout",
      visibility: "public",
      updatedAt: input.refreshStartedAt,
    })),
    ...scannedDocs.flat(),
  ];
}

Embed And Store

export async function refreshSupportKnowledge(input: RefreshKnowledgeInput) {
  const sourceDocuments = await loadSupportSources(input);
  const chunks = sourceDocuments.flatMap((document) =>
    splitIntoChunks(document.text).map((chunk, index) => ({
      id: `${document.id}#chunk=${index}`,
      text: chunk.text,
      sourceId: document.id,
      title: document.title,
      source: document.source,
      pageNumber: document.pageNumber,
      tenantId: document.tenantId,
      productArea: document.productArea,
      visibility: document.visibility,
      updatedAt: document.updatedAt,
    })),
  );

  const embedded = await embedDocuments(embeddings, chunks, {
    id: (chunk) => chunk.id,
    content: (chunk) => chunk.text,
    metadata: (chunk) => ({
      sourceId: chunk.sourceId,
      title: chunk.title,
      source: chunk.source,
      tenantId: chunk.tenantId,
      productArea: chunk.productArea,
      visibility: chunk.visibility,
      updatedAt: chunk.updatedAt,
      ...(chunk.pageNumber !== undefined ? { pageNumber: chunk.pageNumber } : {}),
    }),
    concurrency: 2,
  });

  const store = await ChromaVectorStore.connect({
    collectionName: "support_knowledge",
  });

  await store.upsertDocuments(embedded);

  await input.ingestionLog.record({
    tenantId: input.tenantId,
    sourceCount: sourceDocuments.length,
    chunkCount: chunks.length,
    collection: "support_knowledge",
    refreshedAt: input.refreshStartedAt,
  });

  return store.index(embeddings);
}

splitIntoChunks(...) is application code. Keep chunk ids stable across refreshes so updates replace old chunks instead of leaving duplicate stale evidence.

Metadata That Matters

Metadata Why
tenantId prevents cross-tenant retrieval
visibility keeps drafts and private docs out of public agents
productArea limits checkout questions to checkout policy
sourceId and source connects answers to source records and files
title and pageNumber supports citations and debugging
updatedAt helps detect stale answers

Swap Points

Part Default in this example Common swaps
OCR Mistral OCR provider OCR, custom parser, manual review queue
embeddings OpenAI text-embedding-3-small Gemini, Mistral, FastEmbed, Transformers
vector store Chroma pgvector, Qdrant, Pinecone, Redis, LanceDB, Milvus, Weaviate
source loading CMS + PDF loader + OCR object storage, uploads, database records

Failure Modes

  • OCR output is embedded without source/page metadata.
  • Draft, private, or cross-tenant documents are embedded into the public index.
  • Deleted sources are not removed or tombstoned in the retrieval collection.
  • Chunk ids change every refresh, causing duplicate stale chunks.
  • Ingestion runs in the prompt path instead of a worker or admin refresh.

Next Patterns