⌨ Keyboard shortcuts available
G — waiting for next key…
Backend Architecture Vector Search 16 min read

The Vector ETL: Offloading Document Chunking to dbt Python Models

Learn how to offload resource-heavy document chunking from application workers to your data warehouse using dbt Python models for RAG, ensuring lineage and scalability for AI pipelines.

P

Pradeep Bhandari

 · 2 views

Branded cover card: The Vector ETL: Offloading Document Chunking to dbt Python Models

Stop forcing Laravel or Python web workers to handle heavy-duty text splitting. When managing millions of PDFs or JSON blobs, your application layer shouldn't exhaust CPU cycles. Leverage dbt Python models for RAG to shift processing directly into your data warehouse. By keeping logic close to the raw source, you eliminate the need for separate microservices to manage character counts and paragraph breaks.

While SQL handles structured joins efficiently, it cannot recursively split long documents into meaningful chunks with overlapping context. Moving document chunking into dbt treats text transformation as a first-class data step, maintaining lineage and reducing infrastructure fragmentation. Snowflake or BigQuery compute can execute LangChain logic natively, delivering search-ready chunks to your vector database (like Vespa.ai or pgvector) without taxing your application server's RAM.

Why Your App Workers Are Screaming

The PHP/Laravel bottleneck

PHP workers are designed for API orchestration, not data science. Processing regex-heavy cleaning or recursive splitting on massive documents in Laravel Horizon leads to memory exhaustion and stalled pipelines. Moving raw text from the database to a PHP script for chunking wastes bandwidth and CPU. Instead of moving data to the code, move the code to the data to prevent choking application throughput.

Warehouse-native processing

Using dbt Python models, you can run libraries like LangChain or NLTK directly on warehouse compute. This offloads heavy lifting to specialized infrastructure and ensures transformations are scalable and version-controlled. Defining chunking logic in a dbt model allows you to leverage massive parallel compute power:

import pandas as pd
from langchain.text_splitter import RecursiveCharacterTextSplitter

def model(dbt, session):
    # Fetch raw document data directly from the warehouse
    docs_df = dbt.ref("stg_raw_documents").to_pandas()
    
    splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=150)
    
    chunks = []
    for _, row in docs_df.iterrows():
        # Processing happens on warehouse compute
        split_text = splitter.split_text(row['content'])
        for i, text in enumerate(split_text):
            chunks.append({
                'document_id': row['id'], 
                'chunk_index': i, 
                'chunk_content': text,
                'metadata': row['metadata']
            })
            
    return pd.DataFrame(chunks)

This approach completes in seconds what might take minutes in a queue worker, keeping your web server thin and your vector ETL pipeline robust.

Setting the Stage for Warehouse-Native Python

Orchestrating dbt Python models for RAG requires a warehouse acting as a compute engine. This shift requires specific environment configurations depending on your provider.

Environment requirements

On Snowflake, enable Snowpark and accept the Anaconda Terms of Service in the UI to import libraries. Ensure your dbt role has USAGE privileges on the compute pool. For BigQuery, enable the Dataproc API to allow serverless clusters to execute Python code. These steps prepare the infrastructure for the NLP machinery.

The dbt adapter check

Use dbt-core version 1.3 or higher. Verify your connection with:

dbt debug

If the Python section fails, your profiles.yml likely lacks Snowpark or Dataproc configurations. Ensure your service account has permissions to create temporary stages or internal tables, as dbt uses these to pass data between SQL and Python layers.

Configuring External Packages in dbt_project.yml

Warehouses provision the runtime environment before execution. You must declare dependencies upfront in your dbt_project.yml to ensure libraries are available in the sandbox.

The packages definition

Use the +packages block to define requirements for your Vector ETL. For chunking, include pandas, langchain-text-splitters, and tiktoken for token calculation.

models:
  your_project_name:
    intermediate:
      +packages: ["pandas", "langchain-text-splitters", "tiktoken"]

Nesting this under specific folders limits overhead to relevant models. This ensures the warehouse fetches dependencies from internal mirrors, avoiding environment mismatches during deployment.

Managing the Conda/PyPI bridge

Snowflake pulls primarily from the Anaconda channel; verify library availability in the Snowflake Anaconda repository before committing. For BigQuery, ensure the environment is ready before processing. Keep dependencies lean to minimize node startup latency, as overhead matters when processing millions of chunks.

Building the Document Chunking Model

Shifting compute to the warehouse provides the memory needed for large-scale text processing. A dbt Python model replaces fragile application scripts with structured transformation logic.

The Python model structure

Define a function model(dbt, session). The dbt object accesses project configuration and upstream models via dbt.ref(), while session connects to warehouse compute. Start by pulling cleaned data from stg_raw_documents into a Pandas DataFrame.

Implementing RecursiveCharacterTextSplitter

LangChain's RecursiveCharacterTextSplitter preserves semantic structure by respecting double newlines, single newlines, and spaces. This improves RAG accuracy compared to arbitrary string slicing.

import pandas as pd
from langchain.text_splitter import RecursiveCharacterTextSplitter

def model(dbt, session):
    dbt.config(
        packages=["pandas", "langchain"],
        materialized="table"
    )

    # Fetch upstream raw text
    df = dbt.ref("stg_raw_vault_docs").to_pandas()

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=1200,
        chunk_overlap=150,
        separators=["\n\n", "\n", " ", ""]
    )

    chunked_data = []

    for _, row in df.iterrows():
        fragments = splitter.split_text(row['raw_content'])
        for i, fragment in enumerate(fragments):
            chunked_data.append({
                'doc_id': row['id'],
                'chunk_index': i,
                'chunk_content': fragment,
                'source_url': row['source_url']
            })

    return pd.DataFrame(chunked_data)

Run this with dbt run --select stg_chunked_documents. The warehouse processes the text and writes a structured table of fragments, creating a clean foundation for your vector database.

Preserving Metadata and Lineage

To avoid debugging nightmares, document chunks must inherit the "DNA" of their parent records. If a retriever finds an answer but lacks the source document ID, the system is useless.

Tracking the source_id

Every chunked row requires a foreign key (UUID or primary key) back to the source. Using dbt.ref() ensures lineage is visible in dbt documentation, making the relationship between chunks and original data explicit.

import pandas as pd
from langchain.text_splitter import RecursiveCharacterTextSplitter

def model(dbt, session):
    df = dbt.ref("stg_internal_docs").to_pandas()
    splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=150)

    chunked_data = []
    for _, row in df.iterrows():
        chunks = splitter.split_text(row['raw_content'])
        for i, text in enumerate(chunks):
            chunked_data.append({
                "doc_id": row['id'], # The link back to source
                "chunk_id": f"{row['id']}_{i}",
                "content": text,
                "token_count": len(text.split())
            })
            
    return pd.DataFrame(chunked_data)

Chunk numbering and overlap

Storing chunk_index and overlap allows the retriever to look at neighboring chunks, providing context if a search hit is too narrow. These attributes should be stored in dedicated columns to act as filters in your vector store.

Verifying Semantic Integrity with dbt Tests

Embedding models like OpenAI's text-embedding-3-small have strict token limits. Overlong chunks cause truncation or API errors, while empty chunks waste storage.

Checking for empty chunks

Edge cases (e.g., image-only PDFs) can produce nulls. Use not_null and length tests in your schema.yml to catch these before they hit your vector database.

models:
  - name: stg_vector_chunks
    columns:
      - name: chunk_text
        tests:
          - not_null
          - dbt_expectations.expect_column_value_lengths_to_be_between:
              min_value: 10

Validating chunk distribution

Unicode characters or metadata can inflate character counts beyond your chunk_size setting. Use SQL tests to flag records exceeding your embedding model’s constraints, preventing "silent search killers" where context is lost mid-sentence.

Scaling with Incremental Materialization

Re-tokenizing 100,000 documents every run is inefficient. dbt Python models should use incremental materialization to process only new or changed data.

Avoiding full refreshes

Explicitly set the materialization strategy in the model's config block. This prevents the warehouse from overwriting the entire table during every run.

def model(dbt, session):
    dbt.config(
        materialized="incremental",
        unique_key="chunk_id",
        packages=["langchain", "pandas"]
    )
    
    df = dbt.ref("stg_raw_documents")

    if dbt.is_incremental:
        max_ts_query = f"select max(updated_at) from {dbt.this}"
        max_ts = session.sql(max_ts_query).collect()[0][0]
        df = df.filter(df.updated_at > max_ts)

    return chunk_documents(df)

Handling new documents

Filter your input dataframe using updated_at timestamps. If is_incremental is true, the process only handles the delta. This keeps the RAG pipeline lean and fast as your document library grows.

Your dbt Python model creates a version-controlled table that serves as the single source of truth for embeddings.

Feeding Kafka or Vespa

Point ingestion services directly at the warehouse table. High-volume systems can use Kafka producers triggered by dbt completions to keep search indexes synchronized without taxing web workers.

The reverse ETL handoff

Ship chunk_text and metadata to embedding providers via Reverse ETL before landing in the index. Sync IDs back to your application to maintain the AI feedback loop.

# Push dbt chunks to Vespa
import pandas as pd
from vespa.application import Vespa

def sync_to_vespa(df):
    app = Vespa(url="https://your-vespa-instance")
    for _, row in df.iterrows():
        app.feed_data_point(
            schema="doc_chunk",
            data_id=row['chunk_id'],
            fields={
                "text": row['chunk_text'],
                "metadata": row['metadata_json'],
                "embedding": generate_vector(row['chunk_text'])
            }
        )

Troubleshooting Python Models in Production

Warehouse sandboxes have tight limits. If a 200MB string causes a timeout, slice data into smaller batches using upstream filters. Package conflicts usually stem from version mismatches; check the warehouse provider's native package list before pinning versions in dbt_project.yml.

Wrap splitters in try-except blocks to prevent a single malformed document from crashing the entire run:

def model(dbt, session):
    df = dbt.ref("stg_docs").to_pandas()
    
    def process_text(text):
        try:
            return splitter.split_text(text)
        except Exception:
            return [] # Log errors and keep pipeline moving

    df['chunks'] = df['text'].apply(process_text)
    return session.create_dataframe(df)

If "Memory limit exceeded" errors occur, reduce the DataFrame size by selecting only essential columns before processing.

Is Your Warehouse Ready for the Heavy Lifting?

Moving document processing into the warehouse treats RAG data with the same rigor as financial reports, providing version control, lineage, and tests. This architecture eliminates data siloing and stops taxing application workers with NLP tasks.

The journey continues at the vector store; maintain guardrails for indexing resources (like max-links-per-node in HNSW) to avoid OOM crashes. By wiring up these warehouse-native tools, you transform your data store from cold storage into a production-ready AI asset.

Sources & Further Reading

Frequently Asked Questions

Why should I move document chunking to dbt instead of using Laravel workers?

Processing large documents in PHP or Python web workers often leads to memory exhaustion and stalls. By moving chunking to dbt Python models, you offload the heavy lifting to warehouse compute like Snowflake or BigQuery. This keeps your application layer thin, reduces bandwidth costs from moving raw text, and allows you to process millions of chunks in parallel using dedicated, scalable data infrastructure.

How do I handle dependencies like LangChain in a dbt Python model?

To use libraries like LangChain or NLTK, you must declare them in your dbt_project.yml file using the packages configuration block. Warehouses like Snowflake pull these from the Anaconda channel, while BigQuery uses Dataproc environments. This ensures that the specialized NLP logic is available within the warehouse's secure sandbox during execution, allowing for seamless integration of sophisticated text-splitting strategies directly within your data transformation pipeline.

Share this article

Related Articles

Discussion

No comments yet — be the first to share your thoughts.

Leave a comment

Comments are moderated before appearing.

Max 2,000 characters · not published

We respect your privacy