RAGEmbeddings

From source system to corpus

Extraction is the bottleneck, not the embedding call - because the content still lives inside someone else's system.

WHY THIS, FOR YOU

You fixed the parser. Tables come out as tables and two-column PDFs read in the right order. Retrieval still cannot answer anything about last quarter's outages, because the postmortems live in a wiki and no PDF ever existed. The same goes for helpdesk tickets, CRM notes and a shared drive nobody can name. Either nothing pulled them out, or a script did once and quietly stopped. Getting content out of the system it lives in is a different job from reading a file, and often it is the bigger one. This lesson covers connectors versus scripts, the crawl that ends early and looks finished, what to record at extraction, and when to skip the pipeline.

A support lead asks why the assistant knows nothing about last quarter's outages. The postmortems are in the wiki. The parser is fine and the chunker is fine, but the wiki was never indexed: someone ran an export in March and nobody ran it again. This lesson is about the step before a file exists - getting content out of a system that was never built to be read by a pipeline. Parsing documents before you chunk them, next, takes the bytes from there.

1.Getting bytes out is a different job than parsing them

A PDF in a folder is the easy case. Something already put it there, and your job is only to read it correctly. Most of a real corpus does not arrive that way. It lives inside a running system with its own permissions, its own limits and its own idea of what a page is, and you get content out on that system's terms. Each kind fails in its own way, and none of it has to do with file format. The systems named here are examples, current as of October 2026.

Source systemHow you get content outWhat actually breaks
Wiki (Confluence, for example)Paginated REST API, page tree walkPermission-scoped pages, cursor pagination across large spaces
Helpdesk or chat (Slack, for example)REST API, rate-limited per minuteTight rate limits, attachments fetched separately from the text
CRMREST API or a scheduled exportFields hidden behind record-level permissions, export schemas that change without notice
Shared driveSync client or storage APIAuth tokens that expire mid-crawl, native documents that must be exported before they can be read

None of this shows up in a parsing library, because parsing runs after extraction. If the corpus is missing whole documents instead of mangling the ones it has, look here first, not at chunk size.

Rate limits are the part that surprises people, so do the arithmetic before you design anything. Slack's changelog says that since May 2025, new commercially distributed apps outside its Marketplace, and new installations of them, may read channel history at one request per minute, with at most 15 messages per request (as of October 2026). Whether older installs are covered is unclear: Slack's changelog says they are not, but other sources report a move to the same limits in March 2026, so check Slack's current page for your install. Internal apps you build for your own workspace keep much higher limits. Say you ship a distributed app, and one customer's workspace holds 100,000 messages. That count is invented, but the division is not.

Everything, once
100,000 messages at 15 per call is 6,667 calls, at one per minute. That is most of five days of waiting before the corpus exists.
  1. Calls6,667
  2. Timeabout 4.6 days
  3. Corpus holds100,000 messages
One channel history pulled by a distributed app at 15 messages per call and one call per minute, for an invented 100,000 messages. The counts come from the division, and per-channel overhead is ignored.

The third row is the failure to name. Call it the quiet partial crawl: nothing errors, the corpus is simply short, and retrieval blames the model for pages that were never there. It gets worse downstream, because a pipeline that treats a missing page as a deleted page will turn a short crawl into a mass deletion. The guard is a count: compare what you fetched with the total the source reports, or with the last run, and fail when it falls short.

2.Buy the connector, hand-roll only the gap

A connector is software built to pull data out of one kind of system, maintained by someone who watches that system's API change. Airbyte is a general example, an open-source platform whose docs list over 600 sources and destinations (as of October 2026), mostly databases and SaaS tools. Unstructured, built for preparing documents for LLM pipelines, lists Confluence, SharePoint and Slack among its source connectors (as of October 2026). Either way, pagination, retry-on-429 and incremental fetch are already solved for those systems.

A hand-rolled script is code you write against the source's API. It starts faster, does exactly the narrow thing you need, and is entirely yours to keep working.

Maintained connectorHand-rolled script
Time to first working pullSlower - setup and configurationFaster - straight to the API
Pagination and rate limitsUsually handledOnly what you write
Source's API changesSomeone else's job to fixYour job, found when it goes quiet
Best fitCommon systems, ongoing syncOne narrow, stable endpoint

Default to a connector and hand-roll only the gap: a niche internal tool, a one-off migration, an endpoint too narrow to justify adopting one. Where a script is the right call, write it for someone who has to run it without you. Log every failure, never hard-code something the source may change (a folder path, a fixed set of IDs), and fail on a short crawl.

import hashlib, json, logging, time
import requests

log = logging.getLogger("extract")

def sha(text: str) -> str:
    return hashlib.sha256(text.encode()).hexdigest()

def extract_pages(base_url: str, api_token: str, cursor: str | None = None):
    """Pull every page from an invented wiki-style API (invented response shape)."""
    headers = {"Authorization": f"Bearer {api_token}"}
    documents, retries = [], 0

    while True:
        resp = requests.get(f"{base_url}/pages", headers=headers, timeout=30,
                            params={"cursor": cursor, "limit": 100})
        if resp.status_code == 429:
            retries += 1
            if retries > 5:
                raise RuntimeError("still rate limited after 5 retries")
            # Retry-After is seconds or an HTTP date; this reads only seconds
            wait = resp.headers.get("Retry-After", "")
            log.warning("429 at cursor %s, retry %d", cursor, retries)
            time.sleep(int(wait) if wait.isdigit() else 30)
            continue
        resp.raise_for_status()
        retries = 0

        batch = resp.json()
        for page in batch["results"]:
            meta = {"title": page["title"], "acl": page["groups"]}
            documents.append({
                "source_id": page["id"],
                "source_url": page["url"],
                "body": page["body"],  # the source text, kept whole
                "text_hash": sha(page["body"]),
                "meta_hash": sha(json.dumps(meta, sort_keys=True)),
                "meta": meta,
            })
        cursor = batch.get("next_cursor")
        if not cursor:
            break

    if len(documents) < batch["total"]:  # the quiet partial crawl
        raise RuntimeError(f"fetched {len(documents)} of {batch['total']}")
    return documents

Notice what comes back besides text. A stable id and a source url are decided here, because a corpus that has forgotten where things came from cannot be repaired later. The url also lets you ask the source who may read a page, a check Permissions that travel with the data builds on. The body is kept whole, not only the chunks cut from it: a new chunker, a model that reads whole documents (Giving each chunk its context) or a new embedding model (Changing the embedding model underneath a live index) all need the original text, and by then the page may have changed or gone.

The two hashes are the other half. Hash the text and the metadata separately: the text hash decides whether a vector must be recomputed, the metadata hash whether stored fields must be patched. After chunking, keep the same pair per chunk. When a page disappears from a later crawl, record a tombstone, a marker that it is gone, instead of silently forgetting it - but only after the count check has passed, so a short crawl never turns into deletions. Keeping the index fresh without paying twice shows how the pair and the tombstone drive updates and deletes.

3.Where the ingestion budget actually goes

Many teams budget an ingestion project by putting the biggest number next to embedding, because that line has a per-token price. The first section showed where the time goes instead.

  1. Building and maintaining connectorsLarge, ongoing35% · 35%
  2. Auth, pagination, rate limits, retriesLarge, ongoing30% · 30%
  3. Normalising formats before parsingModerate, one-time per source type15% · 15%
  4. Embedding the resulting chunksSmall, predictable, scales with corpus size12% · 12%
  5. Writing vectors to the storeSmall, predictable8% · 8%
An ingestion project's engineering time100%
An ingestion project's engineering time. Building and maintaining connectors, 35%, 35 per cent. Auth, pagination, rate limits, retries, 30%, 30 per cent. Normalising formats before parsing, 15%, 15 per cent. Embedding the resulting chunks, 12%, 12 per cent. Writing vectors to the store, 8%, 8 per cent.
One illustrative split of an ingestion project's engineering time. Connectors and the auth, pagination and rate-limit handling behind them dwarf embedding and writing to the store. The figures are an illustration, not a measurement - count your own.

Embedding a chunk costs the same whether it came from a PDF, a wiki page or a ticket: one uniform call. Extraction has none of that uniformity. Every source has its own auth model, its own pagination, its own way of saying a record changed, and its own way of going quiet when something upstream shifts. That variability, not the per-token price, is what consumes an ingestion team.

The embedding call looks the same for every document. Getting the document is different every single time - that is where the budget goes.

When not to build this. If the content changes rarely and the system offers a one-time export, export once and treat the dump as your source. A sync pipeline costs more to run than it saves, and a stale snapshot you control beats a live crawl you cannot trust. The same goes for a folder of files you already hold: read them with a parser and skip the connector. Build the pipeline when the source keeps changing and the answers must keep up.

WHAT YOU TAKE AWAY

  1. Treat getting content out of the source system as its own problem, separate from parsing the file.
  2. Reach for a maintained connector before you write a scraper against someone else's API.
  3. Make a crawl that ends short fail loudly, never return what it has and exit cleanly.
  4. Store the source text, a text hash and a metadata hash for each document at extraction.
  5. Plan an ingestion budget assuming extraction may cost more than embedding, not the other way round.

RECALL NO SCROLLING BACK

  1. You already fixed layout-aware parsing for your PDFs. Retrieval is still missing entire documents. What is the most likely cause?

  2. A nightly crawl finishes with exit code 0, yet the corpus holds a fifth of the pages the wiki reports. What is the missing safeguard?

  3. When is hand-rolling extraction the right call instead of a connector?

  4. Tonight's crawl returns 3,000 pages where last night's returned 12,000, with no error. What should the pipeline do with the 9,000 pages missing from the result?

  5. Why keep the extracted source text, and not only the chunks made from it?

GO DEEPER