HinterBuild logoHinterBuild
AI Systems · 19 min read

Chunking Strategies for RAG That Actually Work

Chunking strategies for RAG that fix retrieval: structure-aware, semantic, and parent-child splitting by document type, with Python code and eval metrics.

Muhammad Abdul Sami, author

Muhammad Abdul Sami

· Updated · 19 min read

  • RAG
  • Embeddings
  • Vector Databases
  • Data Pipelines
  • Python

Table of Contents:

Why Chunking Makes or Breaks RAG

Short answer: Chunking strategies for RAG determine whether retrieval returns complete, answerable text segments or fragments that force the LLM to hallucinate — making chunking the single highest-impact decision in any RAG pipeline.

Chunking is the step everyone rushes through. Teams spend weeks choosing embedding models and vector databases, then split documents with a 512-character sliding window. The result: questions split from answers, headers separated from content, tables cut in half, and retrieval that returns plausible-but-incomplete fragments.

We audited 30 RAG pipelines at HinterBuild. In 24 of them, fixing chunking alone improved retrieval precision@5 by 20-35% — without changing embeddings, vector databases, or LLMs. Chunking is where bad RAG results originate.

This guide covers chunking strategies for RAG that work in production. For what comes after chunking, see hybrid search and advanced RAG techniques.

Key Takeaways:

  • Fixed-size chunking is the #1 cause of bad RAG results — avoid it for all non-homogeneous content
  • Match chunk strategy to document type — FAQ, technical docs, legal, and code each need different approaches
  • Target 500-1000 tokens for most content with 10-20% overlap to prevent boundary loss
  • Parent-child chunking solves the precision-vs-completeness tradeoff
  • Semantic chunking splits at embedding similarity boundaries for unstructured text
  • Evaluate chunks with retrieval metrics, not visual inspection — what looks good may retrieve badly

Fixed-Size Chunking and Why It Fails

Short answer: Fixed-size chunking splits text at arbitrary character or token boundaries, destroying semantic units — the most common and most damaging chunking mistake in RAG pipelines.

The Failure Pattern

python
def naive_chunk(text: str, chunk_size: int = 512) -> list[str]:
    return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)]

Consider a real support document:

## Refund Policy

Annual subscriptions are eligible for a prorated refund within 
30 days of purchase. Monthly subscriptions are non-refundable after 
the billing period begins. Enterprise contracts follow separate 
terms outlined in Section 4.2 of the Master Service Agreement.

To request a refund, contact billing@company.com with your 
account ID and reason for cancellation.

Fixed 512-char chunking produces:

ChunkContentProblem
1"## Refund Policy\n\nAnnual subscriptions are eligible for a prorated refund within \n30 days of purchase. Monthly subscriptions are non-ref"Cuts mid-sentence
2"undable after \nthe billing period begins. Enterprise contracts follow separate \nterms outlined in Section 4.2 of the Master Service Agree"No header context
3"ment.\n\nTo request a refund, contact billing@company.com with your \naccount ID and reason for cancellation."Missing policy details

Query: "Can I get a refund on a monthly subscription?"

Retrieval returns chunk 2 — the model sees "undable after the billing period begins" without the question context and may hallucinate a refund policy.

When Fixed-Size Is Acceptable

Fixed-size chunking works only for:

  • Homogeneous text with no structure (raw logs, transcripts)
  • Very short documents that fit in a single chunk
  • Prototyping before implementing proper chunking

If you are using a framework splitter, LangChain's RecursiveCharacterTextSplitter is the closest thing to a sane default: it tries paragraph, then line, then sentence boundaries before falling back to characters. It is still not document-type aware, which is why the rest of this guide exists.

For everything else, use structure-aware or semantic chunking. Our RAG pipeline debugging guide covers how to diagnose chunking failures in existing pipelines.


Structure-Aware Chunking by Document Type

Short answer: Structure-aware chunking splits documents at natural boundaries — headers, paragraphs, question-answer pairs, code blocks — preserving semantic units that fixed-size chunking destroys.

Chunking Strategy by Document Type

Document TypeSplit BoundariesTarget SizeOverlapSpecial Rules
FAQ / Q&AQuestion boundaries200-500 tokens0Keep Q+A together
Technical docsH1, H2, H3 headers500-1000 tokens100-200Include header in chunk
Legal contractsClause numbers, sections300-800 tokens50-100Never split mid-clause
Code documentationFunction/class blocks400-800 tokens100Keep signature + body
PDF reportsPage sections, headers500-1000 tokens100-200Use layout-aware parser
Chat transcriptsSpeaker turns300-600 tokens50Keep turn pairs together
API referenceEndpoint blocks300-600 tokens0Keep params + response
Markdown blogsH2 sections500-1000 tokens100Include title context

Structure-Aware Implementation

python
from dataclasses import dataclass, field
from typing import Optional
import re

@dataclass
class ChunkConfig:
    chunk_size: int = 1000
    chunk_overlap: int = 200
    separators: list[str] = field(default_factory=lambda: [
        "\n## ", "\n### ", "\n#### ",
        "\n\n", "\n", ". ", " ",
    ])
    min_chunk_size: int = 100
    max_chunk_size: int = 1500

@dataclass
class Chunk:
    text: str
    metadata: dict
    char_count: int
    token_estimate: int

def structure_aware_chunk(
    text: str,
    config: ChunkConfig,
    doc_metadata: Optional[dict] = None,
) -> list[Chunk]:
    """Split text at structural boundaries, respecting size limits."""
    doc_metadata = doc_metadata or {}
    sections = _split_by_separators(text, config.separators)
    chunks = []
    current_text = ""
    current_header = ""

    for section in sections:
        if section.startswith("\n## ") or section.startswith("\n### "):
            current_header = section.strip()

        if len(current_text) + len(section) <= config.chunk_size:
            current_text += section
        else:
            if current_text.strip():
                chunks.append(_make_chunk(
                    current_text, current_header, config, doc_metadata, len(chunks)
                ))
            current_text = section

    if current_text.strip():
        chunks.append(_make_chunk(
            current_text, current_header, config, doc_metadata, len(chunks)
        ))

    return _apply_overlap(chunks, config)


def _split_by_separators(text: str, separators: list[str]) -> list[str]:
    if not separators:
        return [text]
    parts = [text]
    for sep in separators:
        new_parts = []
        for part in parts:
            splits = part.split(sep)
            for i, split in enumerate(splits):
                prefix = sep if i > 0 else ""
                new_parts.append(prefix + split)
        parts = new_parts
    return [p for p in parts if p.strip()]


def _make_chunk(
    text: str,
    header: str,
    config: ChunkConfig,
    doc_metadata: dict,
    index: int,
) -> Chunk:
    if header and header not in text:
        text = f"{header}\n\n{text}"

    return Chunk(
        text=text.strip(),
        metadata={
            **doc_metadata,
            "chunk_index": index,
            "header": header,
            "has_complete_sentences": text.rstrip().endswith((".", "?", "!", "`")),
        },
        char_count=len(text),
        token_estimate=len(text) // 4,
    )


def _apply_overlap(chunks: list[Chunk], config: ChunkConfig) -> list[Chunk]:
    if config.chunk_overlap <= 0 or len(chunks) <= 1:
        return chunks

    overlapped = [chunks[0]]
    for i in range(1, len(chunks)):
        prev_text = chunks[i - 1].text
        overlap_text = prev_text[-config.chunk_overlap:]
        new_text = overlap_text + "\n" + chunks[i].text
        overlapped.append(Chunk(
            text=new_text,
            metadata={**chunks[i].metadata, "has_overlap": True},
            char_count=len(new_text),
            token_estimate=len(new_text) // 4,
        ))
    return overlapped

FAQ-Specific Chunking

FAQ content demands the strictest chunking rules — each Q&A pair must stay intact:

python
def chunk_faq(faq_text: str, doc_metadata: dict = None) -> list[Chunk]:
    """Split FAQ at question boundaries — never split Q from A."""
    qa_pattern = re.compile(
        r'((?:Q:|Question:|##\s).+?)(?=(?:Q:|Question:|##\s)|\Z)',
        re.DOTALL | re.IGNORECASE,
    )
    pairs = qa_pattern.findall(faq_text)
    
    return [
        Chunk(
            text=pair.strip(),
            metadata={
                **(doc_metadata or {}),
                "chunk_type": "faq_pair",
                "chunk_index": i,
            },
            char_count=len(pair),
            token_estimate=len(pair) // 4,
        )
        for i, pair in enumerate(pairs)
    ]

Build document-type-specific chunking into ingestion pipelines with our backend API engineering team.


Semantic Chunking with Embedding Boundaries

Short answer: Semantic chunking splits text at points where embedding similarity drops — creating chunks that represent coherent topics even in unstructured documents without headers or formatting.

How Semantic Chunking Works

  1. Split text into sentences
  2. Embed each sentence
  3. Calculate cosine similarity between consecutive sentences
  4. Split where similarity drops below a threshold (topic boundary)
python
import numpy as np
from dataclasses import dataclass

@dataclass
class SemanticChunkConfig:
    similarity_threshold: float = 0.5
    min_chunk_sentences: int = 3
    max_chunk_sentences: int = 20
    buffer_size: int = 1

async def semantic_chunk(
    text: str,
    embed_pipeline,
    config: SemanticChunkConfig = SemanticChunkConfig(),
    doc_metadata: dict = None,
) -> list[Chunk]:
    """Split text at semantic boundaries using embedding similarity."""
    sentences = _split_sentences(text)
    if len(sentences) <= config.min_chunk_sentences:
        return [Chunk(
            text=text,
            metadata={**(doc_metadata or {}), "chunk_type": "semantic"},
            char_count=len(text),
            token_estimate=len(text) // 4,
        )]

    embeddings = await embed_pipeline.embed_documents(sentences)
    
    similarities = []
    for i in range(len(embeddings) - 1):
        sim = cosine_similarity(embeddings[i], embeddings[i + 1])
        similarities.append(sim)

    # Find split points where similarity drops below threshold
    split_points = [0]
    for i, sim in enumerate(similarities):
        if sim < config.similarity_threshold:
            if (i + 1 - split_points[-1]) >= config.min_chunk_sentences:
                split_points.append(i + 1)
    
    split_points.append(len(sentences))

    chunks = []
    for i in range(len(split_points) - 1):
        start = split_points[i]
        end = split_points[i + 1]
        chunk_sentences = sentences[start:end]
        
        if len(chunk_sentences) > config.max_chunk_sentences:
            sub_chunks = _split_long_semantic_chunk(
                chunk_sentences, config.max_chunk_sentences
            )
            for sub in sub_chunks:
                chunks.append(_sentences_to_chunk(sub, doc_metadata, len(chunks)))
        else:
            chunks.append(_sentences_to_chunk(
                chunk_sentences, doc_metadata, len(chunks)
            ))

    return chunks


def cosine_similarity(a: list[float], b: list[float]) -> float:
    a_np, b_np = np.array(a), np.array(b)
    return float(np.dot(a_np, b_np) / (np.linalg.norm(a_np) * np.linalg.norm(b_np)))


def _split_sentences(text: str) -> list[str]:
    return [s.strip() for s in re.split(r'(?<=[.!?])\s+', text) if s.strip()]

Semantic vs Structure-Aware

DimensionStructure-AwareSemantic
Best forFormatted docs (Markdown, HTML)Unstructured text (emails, reports)
SpeedFast (no embedding at chunk time)Slow (embeds every sentence)
CostFree~$0.01-0.05 per document
PredictabilityHigh (follows document structure)Medium (depends on threshold)
Quality on structured docsExcellentGood (may ignore headers)
Quality on unstructured docsPoor (no boundaries to follow)Excellent

Use structure-aware chunking when documents have formatting. Use semantic chunking for unstructured text without headers or sections. For embedding fundamentals, see our embeddings guide.

Contextual and Late Chunking

Two newer techniques attack the same problem from the embedding side rather than the splitting side:

  • Contextual retrieval prepends a short, LLM-generated summary of where the chunk sits in the document before embedding it. Anthropic's contextual retrieval write-up reports a 49% reduction in top-20 retrieval failure rate from contextual embeddings plus contextual BM25 on their benchmark, at a one-time cost of one LLM call per chunk.
  • Late chunking embeds the whole document through a long-context embedding model first and then pools token embeddings per chunk, so each chunk vector carries document context. See the late chunking paper and our late chunking guide.

Both are complementary to the splitting strategies here: you still need sensible boundaries, they just make each chunk's vector less myopic.


Parent-Child and Hierarchical Chunking

Short answer: Parent-child chunking indexes small child chunks for precise retrieval but returns larger parent chunks for generation — solving the fundamental tradeoff between retrieval precision and context completeness.

The Size Tradeoff

Chunk SizeRetrieval PrecisionContext Completeness
200 tokensHigh (95%+)Low (fragments)
1000 tokensMedium (70-80%)High (full sections)
2000 tokensLow (55-65%)Very high (but noisy)

Parent-child gives you both: retrieve on 300-token children, generate from 1500-token parents.

python
@dataclass
class ParentChildConfig:
    parent_size: int = 1500
    child_size: int = 300
    parent_overlap: int = 200
    child_overlap: int = 50

async def parent_child_chunk(
    text: str,
    config: ParentChildConfig,
    doc_metadata: dict = None,
) -> tuple[list[Chunk], list[Chunk]]:
    """Generate parent and child chunks with linkage metadata."""
    doc_metadata = doc_metadata or {}
    doc_id = doc_metadata.get("doc_id", "unknown")
    
    # Create parent chunks
    parent_config = ChunkConfig(
        chunk_size=config.parent_size,
        chunk_overlap=config.parent_overlap,
    )
    parents = structure_aware_chunk(text, parent_config, doc_metadata)
    
    children = []
    for p_idx, parent in enumerate(parents):
        parent_id = f"{doc_id}_parent_{p_idx}"
        parent.metadata["parent_id"] = parent_id
        parent.metadata["chunk_level"] = "parent"
        
        child_config = ChunkConfig(
            chunk_size=config.child_size,
            chunk_overlap=config.child_overlap,
        )
        parent_children = structure_aware_chunk(
            parent.text, child_config,
            {**doc_metadata, "parent_id": parent_id},
        )
        
        for c_idx, child in enumerate(parent_children):
            child.metadata["chunk_level"] = "child"
            child.metadata["parent_id"] = parent_id
            child.metadata["parent_text"] = parent.text
            child.metadata["child_index"] = c_idx
            children.append(child)
    
    return parents, children

Index only child chunks in the vector store. At retrieval time, deduplicate by parent_id and return parent text to the LLM. This pattern is covered in depth in our advanced RAG techniques guide.


Special Cases: Tables, Code, and PDFs

Short answer: Tables, code blocks, and PDF documents require specialized chunking — never run them through generic text splitters or you will destroy the structure that makes them retrievable.

Table Chunking

Never split a table across chunks. Extract the entire table as a single unit with surrounding context:

python
def chunk_with_tables(text: str, doc_metadata: dict = None) -> list[Chunk]:
    """Extract tables as atomic chunks, chunk remaining text normally."""
    table_pattern = re.compile(r'(\|.+\|[\n\|]+)', re.MULTILINE)
    
    chunks = []
    last_end = 0
    
    for match in table_pattern.finditer(text):
        # Chunk text before table
        before = text[last_end:match.start()].strip()
        if before:
            chunks.extend(structure_aware_chunk(before, ChunkConfig(), doc_metadata))
        
        # Table as single chunk with context
        table_text = match.group(0)
        context_start = max(0, match.start() - 200)
        context = text[context_start:match.start()].strip()
        
        chunks.append(Chunk(
            text=f"{context}\n\n{table_text}" if context else table_text,
            metadata={
                **(doc_metadata or {}),
                "chunk_type": "table",
                "contains_table": True,
            },
            char_count=len(table_text),
            token_estimate=len(table_text) // 4,
        ))
        last_end = match.end()
    
    # Remaining text after last table
    remaining = text[last_end:].strip()
    if remaining:
        chunks.extend(structure_aware_chunk(remaining, ChunkConfig(), doc_metadata))
    
    return chunks

PDF Chunking

Raw PDF text extraction destroys layout. Use layout-aware parsers:

ParserStrengthsBest For
Unstructured.ioTables, headers, multi-columnGeneral PDFs
Docling (IBM)Complex layouts, scientific papersTechnical PDFs
PyMuPDFFast, basic layoutSimple PDFs
Azure Document IntelligenceForms, handwritingEnterprise docs
python
from unstructured.partition.pdf import partition_pdf

async def chunk_pdf(pdf_path: str, doc_metadata: dict = None) -> list[Chunk]:
    """Layout-aware PDF chunking preserving structure."""
    elements = partition_pdf(
        filename=pdf_path,
        strategy="hi_res",
        infer_table_structure=True,
    )
    
    chunks = []
    current_section = []
    current_header = ""
    
    for element in elements:
        if element.category in ("Title", "Header"):
            if current_section:
                chunks.extend(_finalize_section(
                    current_section, current_header, doc_metadata, len(chunks)
                ))
            current_header = str(element)
            current_section = [str(element)]
        elif element.category == "Table":
            if current_section:
                chunks.extend(_finalize_section(
                    current_section, current_header, doc_metadata, len(chunks)
                ))
                current_section = []
            chunks.append(Chunk(
                text=str(element),
                metadata={
                    **(doc_metadata or {}),
                    "chunk_type": "table",
                    "header": current_header,
                },
                char_count=len(str(element)),
                token_estimate=len(str(element)) // 4,
            ))
        else:
            current_section.append(str(element))
    
    if current_section:
        chunks.extend(_finalize_section(
            current_section, current_header, doc_metadata, len(chunks)
        ))
    
    return chunks

For multimodal PDFs with images and diagrams, see our multimodal RAG guide.


Evaluating Chunk Quality

Short answer: Evaluate chunking quality with retrieval metrics — precision@k and MRR on a test query set — not by reading chunks and judging them visually.

Chunk Quality Metrics

python
async def evaluate_chunking(
    test_cases: list[dict],
    chunks: list[Chunk],
    embed_pipeline,
    vector_store,
) -> dict:
    """Evaluate whether chunking strategy produces retrievable chunks."""
    # Index all chunks
    texts = [c.text for c in chunks]
    embeddings = await embed_pipeline.embed_documents(texts)
    
    for chunk, embedding in zip(chunks, embeddings):
        await vector_store.upsert({
            "id": chunk.metadata.get("chunk_index", 0),
            "embedding": embedding,
            "text": chunk.text,
            "metadata": chunk.metadata,
        })
    
    precisions = []
    fragment_failures = []
    
    for case in test_cases:
        query_emb = await embed_pipeline.embed_query(case["query"])
        results = await vector_store.similarity_search(query_emb, top_k=5)
        
        retrieved_ids = {r["metadata"].get("doc_id") for r in results}
        expected_ids = set(case["expected_doc_ids"])
        hits = len(retrieved_ids & expected_ids)
        precisions.append(hits / min(len(results), 5))
        
        # Check for fragment failures
        for r in results:
            if not r["metadata"].get("has_complete_sentences", True):
                fragment_failures.append({
                    "query": case["query"],
                    "chunk": r["text"][:100],
                })
    
    return {
        "precision_at_5": sum(precisions) / len(precisions),
        "total_chunks": len(chunks),
        "avg_chunk_tokens": sum(c.token_estimate for c in chunks) / len(chunks),
        "fragment_failures": fragment_failures,
        "fragment_rate": len(fragment_failures) / (len(test_cases) * 5),
    }

Red Flags in Chunk Quality

SignalProblemFix
Fragment rate > 10%Chunks split mid-sentenceReduce chunk size or improve separators
Avg chunk < 200 tokensOver-chunkingIncrease target size
Avg chunk > 1500 tokensUnder-chunkingDecrease target size
Precision@5 < 0.60Wrong strategy for content typeSwitch chunking approach
High overlap redundancyToo much overlapReduce overlap to 10%

Track chunk quality over time with observability and monitoring — alert when new documents produce anomalous chunk sizes or fragment rates.


Production Chunking Pipeline

Short answer: A production chunking pipeline auto-detects document type, applies the right strategy, validates chunk quality, and tracks metadata for reindexing — not a single splitter for all content.

python
from enum import Enum

class DocumentType(str, Enum):
    FAQ = "faq"
    TECHNICAL = "technical"
    LEGAL = "legal"
    PDF = "pdf"
    CODE = "code"
    GENERAL = "general"

class ProductionChunkingPipeline:
    STRATEGIES = {
        DocumentType.FAQ: chunk_faq,
        DocumentType.PDF: chunk_pdf,
        DocumentType.GENERAL: structure_aware_chunk,
    }

    async def process_document(
        self,
        doc_id: str,
        content: str,
        doc_type: DocumentType,
        metadata: dict,
    ) -> list[Chunk]:
        metadata["doc_id"] = doc_id
        metadata["doc_type"] = doc_type.value
        
        if doc_type == DocumentType.PDF:
            chunks = await self.STRATEGIES[doc_type](content, metadata)
        elif doc_type in (DocumentType.TECHNICAL, DocumentType.LEGAL):
            config = ChunkConfig(
                chunk_size=800 if doc_type == DocumentType.LEGAL else 1000,
                chunk_overlap=100,
            )
            chunks = structure_aware_chunk(content, config, metadata)
        else:
            strategy = self.STRATEGIES.get(doc_type, structure_aware_chunk)
            chunks = strategy(content, metadata) if doc_type == DocumentType.FAQ \
                else structure_aware_chunk(content, ChunkConfig(), metadata)
        
        # Validate
        for chunk in chunks:
            assert chunk.token_estimate >= 50, f"Chunk too small: {chunk.token_estimate} tokens"
            assert chunk.token_estimate <= 2000, f"Chunk too large: {chunk.token_estimate} tokens"
        
        return chunks

Deploy chunking pipelines as part of RAG & LLM systems with async ingestion workers on cloud infrastructure.

Understand when chunking fixes are sufficient vs when you need RAG vs fine-tuning for output quality.

Contact us to audit your chunking strategy.


Frequently Asked Questions

What is the best chunk size for RAG?

There is no universal best size. Use 500-1000 tokens for technical docs, 200-500 for FAQ/Q&A, and whole-unit extraction for tables and code blocks. Match chunk size to document type and validate with retrieval precision@5 on your evaluation set.

Should I use fixed-size or semantic chunking?

Never use fixed-size chunking for structured or semi-structured content. Use structure-aware chunking for documents with headers, sections, and formatting. Use semantic chunking for unstructured text without clear boundaries. Fixed-size is only acceptable for homogeneous raw text.

How much overlap should chunks have?

Use 10-20% overlap (100-200 tokens for 1000-token chunks). Overlap prevents boundary information loss — facts that span chunk edges get captured in both adjacent chunks. FAQ/Q&A pairs need zero overlap (keep pairs intact). Tables need zero overlap (keep tables whole).

What is parent-child chunking?

Parent-child chunking creates small child chunks (300 tokens) for precise retrieval and larger parent chunks (1500 tokens) for LLM generation. Search indexes child chunks; when a child matches, return its parent for context. This solves the precision-vs-completeness tradeoff.

How do I chunk PDF documents for RAG?

Use layout-aware PDF parsers (Unstructured, Docling, Azure Document Intelligence) — never raw text extraction. Preserve tables as single chunks, split on detected headers, and maintain page/section metadata. For PDFs with images, see multimodal RAG.

Can bad chunking cause LLM hallucinations?

Yes — bad chunking is the #1 cause of RAG hallucinations. When retrieval returns incomplete fragments, the LLM fills gaps with plausible but incorrect information. Fix chunking before blaming the model. See LLM hallucination causes.

How do I evaluate if my chunking strategy works?

Build a test set of 50-100 queries with known source documents. Index your chunks, run retrieval, and measure precision@5 and MRR. Check fragment rate — chunks ending mid-sentence indicate bad splitting. Target precision@5 > 0.75 before tuning anything else.

Should I re-chunk when changing embedding models?

Re-chunking is not required when changing embedding models — re-embedding existing chunks is sufficient. Re-chunk only when changing chunking strategy, document types, or chunk size parameters. Track chunking strategy version in metadata for audit trails.


Conclusion

Chunking strategies for RAG are the foundation everything else builds on:

  1. Abandon fixed-size chunking for any structured content
  2. Match strategy to document type — FAQ, technical, legal, PDF, code
  3. Use parent-child patterns when precision and completeness both matter
  4. Handle special cases — tables, code blocks, and PDFs need dedicated logic
  5. Evaluate with retrieval metrics — not visual inspection
  6. Fix chunking before embeddings, retrieval, or LLM tuning

A well-chunked pipeline with a small model outperforms a poorly chunked pipeline with a frontier model in our experience, because the model can only answer from what retrieval hands it.

Schedule a chunking audit with our RAG & LLM systems team — it is the fastest RAG quality win.

Free consultation

Book a free consultation call on RAG chunking & document processing

30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.

Book a meeting

Keep reading