Skip to main content
Organizations bring content into an AI system for one reason: so people can ask questions in plain language and get answers grounded in that content, instead of digging through a wiki, CMS, or shared drive by hand. That only works if the content is discoverable, accurately attributed, and trustworthy. Gloo’s Data Engine handles the AI-readiness side of that: fetching content, transcribing audio or video, normalizing it into clean text, chunking it into passages, embedding, and indexing for search and grounded completions. But that pipeline can’t recover context you never gave it — it’s supplemented by a content preparation pipeline: the decisions you make about your source content before Gloo ever sees it. How you extract content, what shape you normalize it into, what stable IDs you assign, and what metadata you attach determine whether the whole system stays manageable for years or turns into broken citations and un-refreshable content down the line. This guide describes that preparation layer as one repeatable pattern, deliberately source-agnostic — the source system is the least important part of the design.
This is a design guide, not a runnable walkthrough. For a complete, runnable pipeline in six languages, follow the Build an End-to-End RAG Pipeline series — everything downstream of the adapter stage here is exactly what that series implements.

Do You Need a Custom Pipeline?

Before building anything, check whether Studio’s managed ingestion already covers your source. Gloo AI Studio provides an interface with four no-code connectors:
  • Web Scraping — point Gloo at a website or sitemap URL and it crawls and ingests the pages. The right path for public sites you don’t have API access to.
  • Files — drag-and-drop upload across all supported formats.
  • YouTube — ingest a channel or videos; Gloo handles transcription.
  • Feeds — subscribe to RSS/content feeds without writing a poller.
Use Studio managed ingestion when point-and-go is enough: a public website, a YouTube channel, a standard feed, or occasional file drops, especially when the person doing the ingesting isn’t a developer. Build a custom pipeline — the subject of this guide — when you need control the managed connectors don’t give you: stable producer_id schemes for programmatic updates and deletes, metadata attached at scale from your source system, incremental sync against a private CMS/API/database, or content that lives behind authentication.
Audio and video content (podcasts, sermon recordings, YouTube videos) is handled for you via the YouTube connector and audio/video file uploads. Those connectors transcribe content automatically so you don’t need to produce a transcript first.The preparation advice in this guide targets text content; if you already have text transcripts, they follow the same pattern as any other document.

The Pattern

Every ingestion integration, regardless of source, decomposes into the same five stages: Ingestion pipeline: Adapter and Normalize, then Upload, Edit, and Verify Adapter — source-specific code that extracts content items and their native metadata from wherever they live: paginate a CMS API, query database rows, list bucket objects, read a feed. This is the only stage that changes when the source changes, so keep it thin: its whole job is to emit items in a common shape for the rest of the pipeline. Normalize — convert each item to clean text (Markdown preferred), strip boilerplate, deduplicate within the batch, detect the content type, and assign a stable ID. This stage is where most quality problems are won or lost — see Pre-Ingestion Preparation below. Upload — send each item to POST /ingestion/v2/files with a producer_id. Because producer_id makes uploads idempotent, re-running the whole pipeline is safe: existing items are detected instead of duplicated. Edit Metadata — attach metadata (title, summary, author, tags, canonical URL) via PATCH /engine/v2/item. You can target the item by producer_id, so this stage needs no state from the upload stage. Verify — ingestion is asynchronous: the upload response means queued, not searchable. Poll GET /engine/v2/items/{item_id} until status reaches COMPLETED (you may see intermediate states such as CHUNKING along the way). The key property: everything to the right of the adapter is identical for every source. Write the normalize → upload → edit metadata → verify stages once; when a new content source appears, you write only a new adapter.

Pre-Ingestion Preparation

These are the decisions to make up front — each is cheap to decide before launch and expensive to retrofit after.

Detect the content type early

Classify each item — article, transcript, report, Q&A, devotional — as it comes out of the adapter, before normalization. The type drives everything downstream: how you clean the text, whether the item has natural boundaries worth preserving, and what metadata makes it findable. Most source systems already know the type (a CMS post type, a database column, a file extension); carry it through rather than inferring it later.

Normalize to clean text

Gloo accepts a wide range of formats and detects each automatically: Markdown, plain text, rich text, reStructuredText, ORG, HTML, PDF, Word, PowerPoint, EPUB, CSV, TSV, Excel, JSON, XML — plus audio (aac, flac, m4a, mp3, ogg, wav) and video (avi, flv, mov, mp4, webm, wmv), which Gloo transcribes for you. When you control the conversion, prefer Markdown: it preserves heading structure (which helps chunking find natural boundaries) while staying free of layout noise.
Source formatGuidance
HTML (CMS pages, web content)Convert to Markdown. Strip navigation, headers/footers, cookie banners, comment sections — keep only the content a reader would consider the article. Preserve headings.
PDFExtract text and inspect the output: multi-column layouts, running headers, and page numbers often survive extraction as mid-sentence noise. Clean these before upload rather than uploading raw extractions at scale.
Transcripts (podcasts, sermons, meetings)If you already have a text transcript, keep speaker labels and section breaks that carry meaning; strip per-word timestamps and filler tokens. If you only have audio/video, upload the media itself (or use the Studio YouTube connector) and let Gloo transcribe.
Structured records (database rows, JSON, spreadsheets)Render each record as a small Markdown document — a heading plus labeled fields — rather than concatenating raw values. CSV/TSV/Excel/JSON upload directly too, but ask whether one row is really one retrievable document; if so, per-record Markdown items give each its own producer_id and metadata.
Whatever you strip, strip it consistently: the goal is that two items about the same topic differ only in their content, not in whatever noise their source format happened to leave behind.

Deduplicate — your half and Gloo’s half

Deduplication splits cleanly into two responsibilities:
  • You dedup within a run. Source systems frequently expose the same item more than once — a post that appears in two categories, a document reachable at two paths, a feed that repeats entries. Dedup on the source system’s native ID inside your adapter, before anything is uploaded.
  • Gloo dedups across runs. Uploading with the same producer_id again doesn’t create a copy — the existing item is detected. This is what makes “just re-run the pipeline” a safe recovery strategy.
If you dedup within each batch and use stable producer_ids, you never need to track “what have I already uploaded?” state yourself.

Choose a producer_id scheme before the first upload

This is the single highest-leverage decision in the whole pipeline. The producer_id is your stable handle on an item: it makes uploads idempotent, lets you update metadata without storing Gloo’s item_id, and lets a sync process map source-system changes onto Gloo items years later. The recommended scheme is {source-system}-{native-id}:
wordpress-post-4812
notion-page-9f3c2a1e-8b4d-4f6a-9c0d-1e2f3a4b5c6d
kb-row-20871
s3-sermons/2026/easter-sunday.md
Do:
  • Derive it from the source system’s own primary key — the one identifier that survives edits, renames, and moves.
  • Prefix with the source system so IDs stay unique when a second source later feeds the same publisher.
  • Treat it as permanent: the same source item must always map to the same producer_id.
Don’t:
  • Derive it from the title, URL slug, or file path if those can change — a renamed item would look like a brand-new one, leaving the old version orphaned in your publisher.
  • Derive it from position or ordering (row number, feed index).
  • Reuse a producer_id for different content — that’s an update, not a new item.
producer_id applies to single-file uploads only. If you pass it on a multi-file upload, it’s ignored. Pipelines should upload one file per request so every item gets its stable handle.

Pre-chunk vs. let Gloo chunk

Default: let Gloo chunk. After upload, Gloo automatically splits your content into retrieval-sized chunks, embeds them, and indexes them — you don’t configure any of it, and for most content types (articles, reports, transcripts, long-form prose) automatic chunking is the right answer. Split content yourself before upload only when it has hard semantic boundaries that must not be crossed — units that would be wrong to retrieve partially or merged with a neighbor:
  • Q&A content — each question-and-answer pair is one unit; upload each pair as its own item.
  • Devotionals or daily entries — each entry stands alone; a chunk spanning two days’ entries would mislead retrieval.
  • Episodic collections — if each segment has its own title, date, and canonical URL, it deserves its own item and metadata.
When you split, each piece becomes its own upload with its own producer_id (e.g. faq-item-291, devotional-2026-03-14) and its own metadata. Note this is a decision about item granularity — what constitutes one document — not about tuning chunk sizes. Within each item, Gloo still chunks automatically.

Metadata: attach at upload time vs. edit later

The item metadata fieldsitem_title, item_summary, item_url, item_image, publication_date, author, item_tags, and friends — can be set as soon as the item exists; you don’t need to wait for ingestion to finish. The decision rule:
  • Attach immediately what the source system already knows. Title, author, publication date, canonical URL, and native tags are sitting right there in the adapter’s output — PATCH them right after each upload, as part of the pipeline.
  • Edit later what has to be derived. Editorial summaries, curated tags, and classifications that need review can land in a separate edit pass — days or weeks later — targeting items by producer_id.
The canonical URL (item_url) deserves special attention: it’s what lets applications link users back to the original content when a search result or grounded completion cites the item. Capture it in the adapter; it’s often hard to reconstruct later.
Metadata edits are eventually consistent on the read path: a GET immediately after a PATCH may return the old values. When your pipeline verifies edits, poll rather than asserting on the first read. See Part 3 of the RAG pipeline series for the polling pattern.

Source Adapters

An adapter’s contract is small: yield items in a common shape, setting exactly one of text (Markdown you extracted and normalized) or data (raw bytes of a file already in a supported format). Define that shape once:
from dataclasses import dataclass, field

@dataclass
class ContentItem:
    producer_id: str           # stable: {source}-{native-id}
    filename: str              # name for the uploaded file, e.g. "post-4812.md"
    text: str | None = None    # normalized Markdown (text you extracted)
    data: bytes | None = None  # raw file bytes (already-supported formats, passed through)
    metadata: dict = field(default_factory=dict)  # title, item_url, author, ...
interface ContentItem {
  producerId: string;        // stable: {source}-{native-id}
  filename: string;          // name for the uploaded file, e.g. "post-4812.md"
  text?: string;             // normalized Markdown (text you extracted)
  data?: Uint8Array;         // raw file bytes (already-supported formats, passed through)
  metadata: Record<string, unknown>;  // title, item_url, author, ...
}
Every adapter outlined next feeds the same code used for: normalize → upload → edit metadata → verify. The adapter sketches are illustrative, not runnable: they show the shape of each adapter, with pagination, auth, and error handling left out.
The pattern is language-neutral. The sketches use Python and TypeScript to match the RAG pipeline series; port the same structure to Go, Java, PHP, or anything else.

CMS (WordPress, Contentful, Sanity, Drupal)

Page through the CMS’s content API, convert each entry’s HTML body to Markdown, and use the CMS’s own post ID as the native ID.
def wordpress_adapter(site: str):
    page = 1
    while posts := get_json(f"{site}/wp-json/wp/v2/posts?page={page}"):
        for post in posts:
            yield ContentItem(
                producer_id=f"wordpress-post-{post['id']}",
                filename=f"post-{post['id']}.md",
                text=html_to_markdown(post["content"]["rendered"]),
                metadata={"item_title": post["title"]["rendered"],
                          "item_url": post["link"],
                          "publication_date": post["date"]},
            )
        page += 1
async function* wordpressAdapter(site: string): AsyncGenerator<ContentItem> {
  for (let page = 1; ; page++) {
    const posts = await getJson(`${site}/wp-json/wp/v2/posts?page=${page}`);
    if (posts.length === 0) return;
    for (const post of posts) {
      yield {
        producerId: `wordpress-post-${post.id}`,
        filename: `post-${post.id}.md`,
        text: htmlToMarkdown(post.content.rendered),
        metadata: { item_title: post.title.rendered,
                    item_url: post.link,
                    publication_date: post.date },
      };
    }
  }
}

API-based tools (Notion, Confluence, custom JSON)

Traverse the tool’s page or space hierarchy, render each page’s block tree to Markdown, and use the page UUID as the native ID.
def notion_adapter(notion, root_page_id: str):
    for page in walk_pages(notion, root_page_id):   # recursive traversal
        yield ContentItem(
            producer_id=f"notion-page-{page['id']}",
            filename=f"{page['id']}.md",
            text=blocks_to_markdown(notion.blocks.children.list(page["id"])),
            metadata={"item_title": page_title(page),
                      "item_url": page["url"]},
        )
async function* notionAdapter(notion: Client, rootPageId: string): AsyncGenerator<ContentItem> {
  for await (const page of walkPages(notion, rootPageId)) {  // recursive traversal
    const blocks = await notion.blocks.children.list({ block_id: page.id });
    yield {
      producerId: `notion-page-${page.id}`,
      filename: `${page.id}.md`,
      text: blocksToMarkdown(blocks),
      metadata: { item_title: pageTitle(page), item_url: page.url },
    };
  }
}

Database (knowledge-base tables, blog posts, CMS rows)

Query the rows, render each record as a small Markdown document (heading plus labeled fields), and use the primary key as the native ID.
def kb_adapter(db):
    for row in db.execute("SELECT id, title, body, author, updated_at FROM kb_articles"):
        yield ContentItem(
            producer_id=f"kb-row-{row.id}",
            filename=f"kb-{row.id}.md",
            text=f"# {row.title}\n\n{row.body}",
            metadata={"item_title": row.title,
                      "author": [row.author]},
        )
async function* kbAdapter(db: Database): AsyncGenerator<ContentItem> {
  const rows = await db.query("SELECT id, title, body, author, updated_at FROM kb_articles");
  for (const row of rows) {
    yield {
      producerId: `kb-row-${row.id}`,
      filename: `kb-${row.id}.md`,
      text: `# ${row.title}\n\n${row.body}`,
      metadata: { item_title: row.title, author: [row.author] },
    };
  }
}

File library (S3, GCS, SharePoint)

List the objects, filter to supported formats, and use the object key as the native ID. Files can pass through as-is — Gloo detects supported formats automatically — so this adapter sets data (raw bytes) rather than text. Scanned or layout-heavy PDFs still benefit from the extraction pass described under normalization; if you do extract text yourself, set text instead.
def s3_adapter(s3, bucket: str, prefix: str):
    for obj in s3.list_objects_v2(Bucket=bucket, Prefix=prefix)["Contents"]:
        if not obj["Key"].endswith((".md", ".txt", ".pdf", ".docx")):
            continue
        body = s3.get_object(Bucket=bucket, Key=obj["Key"])["Body"].read()
        yield ContentItem(
            producer_id=f"s3-{obj['Key']}",
            filename=obj["Key"].rsplit("/", 1)[-1],
            data=body,  # raw bytes uploaded as-is; Gloo detects the format
            metadata={"item_title": obj["Key"].rsplit("/", 1)[-1]},
        )
async function* s3Adapter(s3: S3Client, bucket: string, prefix: string): AsyncGenerator<ContentItem> {
  const { Contents } = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix }));
  for (const obj of Contents ?? []) {
    if (!/\.(md|txt|pdf|docx)$/.test(obj.Key!)) continue;
    const body = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: obj.Key! }));
    yield {
      producerId: `s3-${obj.Key}`,
      filename: obj.Key!.split("/").pop()!,
      data: await body.Body!.transformToByteArray(),  // raw bytes uploaded as-is; Gloo detects the format
      metadata: { item_title: obj.Key!.split("/").pop()! },
    };
  }
}

Feeds and batch exports (RSS, weekly CMS export)

Feeds are the incremental case: track the last GUID or timestamp you’ve seen and yield only new or changed entries. This same shape handles a weekly CMS export — diff the export against the last run’s state.
def rss_adapter(feed_url: str, state: SyncState):
    for entry in parse_feed(feed_url).entries:
        if state.already_synced(entry.guid, entry.updated):
            continue
        yield ContentItem(
            producer_id=f"rss-{entry.guid}",
            filename=f"{slugify(entry.guid)}.md",
            text=html_to_markdown(entry.content),
            metadata={"item_title": entry.title,
                      "item_url": entry.link,
                      "publication_date": entry.published},
        )
        state.mark_synced(entry.guid, entry.updated)
async function* rssAdapter(feedUrl: string, state: SyncState): AsyncGenerator<ContentItem> {
  const feed = await parseFeed(feedUrl);
  for (const entry of feed.entries) {
    if (state.alreadySynced(entry.guid, entry.updated)) continue;
    yield {
      producerId: `rss-${entry.guid}`,
      filename: `${slugify(entry.guid)}.md`,
      text: htmlToMarkdown(entry.content),
      metadata: { item_title: entry.title,
                  item_url: entry.link,
                  publication_date: entry.published },
    };
    state.markSynced(entry.guid, entry.updated);
  }
}
Because uploads are idempotent by producer_id, incremental sync degrades gracefully: if your sync state is lost, a full re-run re-uploads everything without creating duplicates. Re-uploading a changed entry with its existing producer_id updates the item rather than duplicating it; when source items are removed, delete the corresponding Gloo items with DELETE /engine/v2/items, scoped to exactly those item IDs — see Part 2 of the RAG pipeline series for the scoped-delete pattern.
The POST /engine/v2/publisher/{publisher_id}/items/by-producer lookup (producer ID → item ID) is cached for around five minutes and isn’t invalidated by writes. Right after an upload or delete it can return stale mappings. Treat the upload response — or a GET on the item — as the source of truth in sync code.

Decision Matrix

Here is a skimmable summary of everything above:
DecisionDefaultChoose the other option when…Covered in
Custom pipeline vs. Studio managed ingestionStudio managed, if a connector fits (website, YouTube, feed, file drops)You need stable producer_id schemes, metadata at scale, incremental sync against a private system, or programmatic lifecycle managementDo you need a custom pipeline?
Pre-chunk vs. let Gloo chunkLet Gloo chunkContent has hard semantic units (Q&A pairs, devotional entries) that must never be split or mergedPre-chunk vs. let Gloo chunk
producer_id scheme{source}-{native-id}Never — always derive from the source’s primary keyChoose a producer_id scheme
Metadata at upload vs. edit laterKnown-at-source fields at uploadDerived or editorial metadata (summaries, curated tags) → separate edit passMetadata timing
Markdown vs. original formatConvert to MarkdownFiles are already clean Markdown/text/PDF/Word in a library → upload as-isNormalize to clean text
Full re-sync vs. incremental syncIncremental (track last-seen state)Catalog is small (hundreds of items) → full re-runs are simpler and idempotency makes them safeFeeds and batch exports
One publisher vs. severalOne per distinct content brand or ownerSources with different ownership, audiences, or lifecycle → separate publishers keep bulk operations safely scopedPart 2: scoping
One file per upload vs. batchOne file per requestNever for pipelines — producer_id is ignored on multi-file uploadsproducer_id scheme

What Gloo Does for You

Do not build these yourself, they’re handled automatically after upload:
  • Format detection and parsing — Markdown, text, rich text, reStructuredText, ORG, HTML, PDF, Word, PowerPoint, EPUB, CSV, TSV, Excel, JSON, and XML files are detected and processed without any format flags.
  • Transcription — audio (aac, flac, m4a, mp3, ogg, wav) and video (avi, flv, mov, mp4, webm, wmv) files are transcribed automatically, whether uploaded as files or ingested via the Studio YouTube connector.
  • Chunking — content is split into retrieval-sized chunks automatically; there’s nothing to configure.
  • Embedding — chunks are embedded for semantic search; you never manage models or vectors.
  • Vector storage and retrieval indexing — no vector database to run, no index to maintain.
  • Cross-run deduplication — re-uploading with the same producer_id updates rather than duplicates.
Your pipeline’s job ends at clean text, stable IDs, good metadata, and verified indexing. Everything between upload and search-readiness is Gloo’s job. That is why the verify stage polls a status instead of orchestrating anything.

Next Steps