# RAG Pipeline Patterns End-to-end patterns for preparing ADE-parsed documents for Retrieval Augmented Generation (RAG) systems. Covers embedding computation, chunking strategies, vector DB ingestion, and query pipelines. --- ## 1. Chunks to CSV — RAG-Ready Dataset Extract all chunks from parsed documents into a structured CSV with 19 columns including bounding boxes, sequence info, and metadata. This CSV can feed any vector DB or search index. > **Grounding-aware records:** Every record includes `page`, `box_l`, > `box_t`, `box_r`, `box_b` from ADE's grounding data. Preserve these > columns when ingesting into a vector DB — they let you trace retrieval > results back to exact document locations for highlighting or citation. ```python import re from pathlib import Path from typing import Any, Dict, List import pandas as pd def clean_chunk_text(text: str) -> str: """Remove anchor tags and strip whitespace.""" cleaned = re.sub(r"\s*", "", text) return cleaned.strip() def chunks_to_records( parse_result: Any, document_name: str, model_version: str = "unknown", ) -> List[Dict[str, Any]]: """Convert parse result chunks to flat dicts. Each dict has 19 columns suitable for CSV / DataFrame: DOCUMENT_NAME, chunk_id, chunk_sequence_number, chunk_type, chunk_content_raw, chunk_content, chunk_text_length, chunk_word_count, page, box_l, box_t, box_r, box_b, prev_chunk_id, next_chunk_id, chunk_image_path, processed_at, ade_version, model_version """ from datetime import datetime, timezone import landingai_ade chunks = parse_result.chunks or [] now = datetime.now(timezone.utc).isoformat() records: List[Dict[str, Any]] = [] for idx, ch in enumerate(chunks): raw = ch.markdown if hasattr(ch, "markdown") else "" clean = clean_chunk_text(raw) box = ( ch.grounding.box if hasattr(ch, "grounding") and hasattr(ch.grounding, "box") else None ) page = ( ch.grounding.page if hasattr(ch, "grounding") else None ) records.append({ "DOCUMENT_NAME": document_name, "chunk_id": getattr(ch, "id", None), "chunk_sequence_number": idx, "chunk_type": getattr(ch, "type", None), "chunk_content_raw": raw, "chunk_content": clean, "chunk_text_length": len(clean), "chunk_word_count": len(clean.split()) if clean else 0, "page": page, "box_l": box.left if box else None, "box_t": box.top if box else None, "box_r": box.right if box else None, "box_b": box.bottom if box else None, "prev_chunk_id": ( chunks[idx - 1].id if idx > 0 else None ), "next_chunk_id": ( chunks[idx + 1].id if idx < len(chunks) - 1 else None ), "chunk_image_path": None, "processed_at": now, "ade_version": landingai_ade.__version__, "model_version": model_version, }) return records def batch_chunks_to_csv( results: List[Dict[str, Any]], output_path: Path, ) -> pd.DataFrame: """Combine chunk records from multiple documents into one CSV. Args: results: list of dicts with keys 'name' and 'parse_result' output_path: CSV file path """ all_records: List[Dict[str, Any]] = [] for r in results: all_records.extend( chunks_to_records(r["parse_result"], r["name"]) ) df = pd.DataFrame(all_records) df.to_csv(output_path, index=False) return df ``` ### Usage ```python from landingai_ade import LandingAIADE from pathlib import Path client = LandingAIADE() results = [] for fp in Path("docs/").glob("*.pdf"): pr = client.parse(document=fp) results.append({"name": fp.name, "parse_result": pr}) df = batch_chunks_to_csv(results, Path("all_chunks.csv")) print(f"{len(df)} chunks from {df['DOCUMENT_NAME'].nunique()} docs") ``` --- ## 2. Vector DB Ingestion — ChromaDB Local persistent vector store using OpenAI embeddings. Good for prototyping and small-to-medium corpora. ```python from pathlib import Path from typing import Any, List import chromadb from chromadb.config import Settings from openai import OpenAI def ade_chunks_to_chromadb( parse_results: List[dict], collection_name: str = "ade_documents", persist_dir: str = "./chroma_db", embedding_model: str = "text-embedding-3-small", ) -> chromadb.Collection: """Ingest ADE chunks into a persistent ChromaDB collection. Args: parse_results: list of dicts with 'name' (str) and 'parse_result' (ParseResponse) collection_name: ChromaDB collection name persist_dir: directory for persistent storage embedding_model: OpenAI embedding model name Returns: The ChromaDB collection with all chunks ingested. """ openai_client = OpenAI() chroma_client = chromadb.PersistentClient( path=persist_dir ) collection = chroma_client.get_or_create_collection( name=collection_name, metadata={"hnsw:space": "cosine"}, ) for doc in parse_results: name = doc["name"] chunks = doc["parse_result"].chunks or [] texts, ids, metadatas = [], [], [] for ch in chunks: text = ch.markdown if hasattr(ch, "markdown") else "" if not text.strip(): continue chunk_id = f"{name}:{ch.id}" texts.append(text) ids.append(chunk_id) metadatas.append({ "document": name, "chunk_type": getattr(ch, "type", "unknown"), "page": ( ch.grounding.page if hasattr(ch, "grounding") else -1 ), }) if not texts: continue # Generate embeddings in batches of 100 all_embeddings: List[List[float]] = [] for i in range(0, len(texts), 100): batch = texts[i : i + 100] resp = openai_client.embeddings.create( input=batch, model=embedding_model ) all_embeddings.extend( [e.embedding for e in resp.data] ) collection.add( ids=ids, documents=texts, embeddings=all_embeddings, metadatas=metadatas, ) return collection ``` ### Query ChromaDB ```python def query_chromadb( collection: chromadb.Collection, question: str, n_results: int = 5, embedding_model: str = "text-embedding-3-small", ) -> dict: """Query the collection and return matching chunks.""" openai_client = OpenAI() resp = openai_client.embeddings.create( input=[question], model=embedding_model ) query_embedding = resp.data[0].embedding return collection.query( query_embeddings=[query_embedding], n_results=n_results, ) ``` --- ## 3. Vector DB Ingestion — FAISS + LangChain For LangChain-based RAG pipelines. Uses FAISS for in-memory vector search. ```python from typing import Any, List from langchain.docstore.document import Document from langchain_community.vectorstores import FAISS from langchain_openai import OpenAIEmbeddings def ade_to_langchain_docs( parse_results: List[dict], ) -> List[Document]: """Convert ADE parse results to LangChain Documents. Each chunk becomes one Document with metadata including source document name, chunk type, and page number. """ docs: List[Document] = [] for item in parse_results: name = item["name"] chunks = item["parse_result"].chunks or [] for ch in chunks: text = ch.markdown if hasattr(ch, "markdown") else "" if not text.strip(): continue docs.append(Document( page_content=text, metadata={ "source": name, "chunk_type": getattr(ch, "type", "unknown"), "chunk_id": getattr(ch, "id", ""), "page": ( ch.grounding.page if hasattr(ch, "grounding") else -1 ), }, )) return docs def build_faiss_index( documents: List[Document], embedding_model: str = "text-embedding-3-small", ) -> FAISS: """Build a FAISS vector store from LangChain Documents.""" embeddings = OpenAIEmbeddings(model=embedding_model) return FAISS.from_documents(documents, embeddings) ``` ### RAG Query with LangChain ```python from langchain.chains import RetrievalQA from langchain_openai import ChatOpenAI def build_rag_chain( vectorstore: FAISS, model: str = "gpt-4o-mini", k: int = 5, ) -> RetrievalQA: """Build a RetrievalQA chain from a FAISS index.""" retriever = vectorstore.as_retriever( search_kwargs={"k": k} ) return RetrievalQA.from_chain_type( llm=ChatOpenAI(model=model, temperature=0), chain_type="stuff", retriever=retriever, return_source_documents=True, ) # Usage chain = build_rag_chain(vectorstore) answer = chain.invoke({"query": "What is the total revenue?"}) print(answer["result"]) for doc in answer["source_documents"]: print(f" - {doc.metadata['source']} p{doc.metadata['page']}") ``` --- ## 4. Full RAG Pipeline — End to End Combines parsing, chunking, vector DB, and querying into one flow. ```python import asyncio from pathlib import Path from langchain.chains import RetrievalQA from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_community.vectorstores import FAISS from landingai_ade import LandingAIADE def build_rag_from_folder( input_dir: Path, embedding_model: str = "text-embedding-3-small", llm_model: str = "gpt-4o-mini", ) -> RetrievalQA: """One-shot: parse all docs in a folder and build a RAG chain ready for querying. Returns a LangChain RetrievalQA chain. """ client = LandingAIADE() exts = {".pdf", ".png", ".jpg", ".jpeg"} files = [ p for p in input_dir.glob("*") if p.suffix.lower() in exts ] # Parse all documents parse_results = [] for fp in files: pr = client.parse(document=fp) parse_results.append({"name": fp.name, "parse_result": pr}) # Convert to LangChain docs docs = ade_to_langchain_docs(parse_results) # Build vector store embeddings = OpenAIEmbeddings(model=embedding_model) vectorstore = FAISS.from_documents(docs, embeddings) # Build chain return RetrievalQA.from_chain_type( llm=ChatOpenAI(model=llm_model, temperature=0), chain_type="stuff", retriever=vectorstore.as_retriever( search_kwargs={"k": 5} ), return_source_documents=True, ) ``` ### Usage ```python chain = build_rag_from_folder(Path("10k_filings/")) result = chain.invoke( {"query": "What were the main risk factors?"} ) print(result["result"]) ``` --- ## 5. Embedding Computation Two approaches for computing embeddings from ADE chunks: **local** (free, offline, fast) and **API-based** (higher quality, paid). Choose based on your cost/quality tradeoff. ### Local Embeddings with FastEmbed Uses [FastEmbed](https://github.com/qdrant/fastembed) to run embedding models locally. No API key needed, no per-token cost, works offline. ```python from typing import Any from fastembed import TextEmbedding def compute_embeddings_local( texts: list[str], model: str = "BAAI/bge-small-en-v1.5", ) -> list[list[float]]: """Embed texts locally with FastEmbed (batched). Default model: BAAI/bge-small-en-v1.5 (384 dims, ~33M params). Other options: - BAAI/bge-base-en-v1.5 (768 dims, ~110M params) - sentence-transformers/all-MiniLM-L6-v2 (384 dims) """ embedder = TextEmbedding(model_name=model) return [v.tolist() for v in embedder.embed(texts)] ``` ### API Embeddings with OpenAI Higher quality, especially for domain-specific content. Requires `OPENAI_API_KEY` and incurs per-token cost. ```python from openai import OpenAI def compute_embeddings_openai( texts: list[str], model: str = "text-embedding-3-small", batch_size: int = 100, ) -> list[list[float]]: """Embed texts via OpenAI API in batches.""" client = OpenAI() all_vecs: list[list[float]] = [] for i in range(0, len(texts), batch_size): resp = client.embeddings.create( input=texts[i : i + batch_size], model=model, ) all_vecs.extend(e.embedding for e in resp.data) return all_vecs ``` ### Embedding Best Practices | Practice | Why | Example | |----------|-----|---------| | **Prepend title/heading** | Gives the embedding semantic context about what the chunk is about | `f"{title}\n\n{body}"` | | **Batch all texts in one call** | Faster than embedding one-by-one; both FastEmbed and OpenAI support batching | `embedder.embed(all_texts)` | | **Store model metadata** | Consumers need to know which model produced the vectors to query correctly | `{"model": "bge-small-en-v1.5", "dims": 384}` | | **Carry grounding refs** | Enables source attribution — trace retrieval hits back to page + bounding box | `{"page": 2, "box": {...}}` | | **Clean anchor tags first** | ADE chunks contain `` tags that add noise to embeddings | `re.sub(r"\s*", "", text)` | ### Self-Describing Embedding Output Always store the embedding model name and dimensions alongside the vector so downstream consumers can interpret it correctly: ```python def make_embedding_record( text: str, vector: list[float], model: str, metadata: dict | None = None, ) -> dict: """Wrap a vector with its model info and metadata.""" return { "text": text, "embedding": { "model": model, "dimensions": len(vector), "vector": vector, }, "metadata": metadata or {}, } ``` ### Model Selection Guide | Model | Dims | Cost | Quality | Best for | |-------|------|------|---------|----------| | `BAAI/bge-small-en-v1.5` | 384 | Free (local) | Good | Prototyping, cost-sensitive, offline | | `BAAI/bge-base-en-v1.5` | 768 | Free (local) | Better | Local with higher quality needs | | `text-embedding-3-small` | 1536 | ~$0.02/1M tokens | High | Production, mixed-domain content | | `text-embedding-3-large` | 3072 | ~$0.13/1M tokens | Highest | Maximum retrieval accuracy | --- ## 6. Multi-Granularity Embedding Strategy ADE chunks are the finest-grained unit, but they're not always the right unit for embedding. Choose the granularity that matches your retrieval needs. ### Granularity Levels | Level | Unit | How to build | Best for | |-------|------|-------------|----------| | **Chunk** | Raw ADE chunk | Direct from `parse_result.chunks` | Tables, figures, forms with independent fields | | **Hierarchical** | Group of consecutive chunks | Group by boundary detection (see below) | Narrative docs where answers span paragraphs | | **Document** | Full markdown or summary | `parse_result.markdown` or ADE extract summary | Classification, routing, coarse-grained search | ### Chunk-Level (default) Each ADE chunk gets its own embedding. This is what Sections 2–4 above use. Fine-grained but may split semantic units across multiple vectors. ```python # Already shown in Sections 2-4 — each chunk → one embedding texts = [ clean_chunk_text(ch.markdown) for ch in parse_result.chunks if ch.markdown.strip() and getattr(ch, "type", "") in {"text", "table", "card"} ] ``` ### Hierarchical Chunking Group consecutive ADE chunks into higher-level semantic units before embedding. The grouping boundary is **document-specific** — the pattern is always the same but the boundary detection varies: - **Heading detection** (regex or ADE extract) — for papers, reports - **Clause boundaries** (ADE split API or extract) — for contracts - **Page boundaries** — simple, works for any document - **Fixed-size sliding windows** — N consecutive chunks with overlap The abstract pattern: ```python from dataclasses import dataclass, field from typing import Any, Callable @dataclass class ChunkGroup: """A group of consecutive ADE chunks forming a semantic unit.""" label: str chunks: list[Any] = field(default_factory=list) grounding_refs: list[dict] = field(default_factory=list) @property def text(self) -> str: return "\n".join( clean_chunk_text(ch.markdown) for ch in self.chunks if ch.markdown.strip() ) @property def embedding_input(self) -> str: """Prepend label for better embedding quality.""" return f"{self.label}\n\n{self.text}" def group_chunks( chunks: list[Any], is_boundary: Callable[[Any], str | None], ) -> list[ChunkGroup]: """Group ADE chunks by a boundary detection function. Args: chunks: ADE parse_result.chunks is_boundary: function that returns a group label (str) if the chunk starts a new group, or None if it continues the current group. Returns: List of ChunkGroup with grounding refs preserved. """ groups: list[ChunkGroup] = [] current: ChunkGroup | None = None for ch in chunks: label = is_boundary(ch) if label is not None: current = ChunkGroup(label=label) groups.append(current) if current is None: current = ChunkGroup(label="(preamble)") groups.append(current) current.chunks.append(ch) if hasattr(ch, "grounding"): b = ch.grounding.box current.grounding_refs.append({ "page": ch.grounding.page, "box": { "left": b.left, "top": b.top, "right": b.right, "bottom": b.bottom, }, }) return groups ``` **Example boundary detectors:** ```python import re # Page-based: new group every page def by_page(ch: Any) -> str | None: page = ch.grounding.page if hasattr(ch, "grounding") else -1 return f"Page {page + 1}" if not hasattr(by_page, "_last") or by_page._last != page else None # (simplified — use a closure or class for production) # Heading-based: new group on ATX or numbered headings def by_heading(ch: Any) -> str | None: text = re.sub(r"\s*", "", ch.markdown or "").strip() first_line = text.split("\n")[0].strip() if re.match(r"^#{1,6}\s+", first_line): return re.sub(r"^#{1,6}\s+", "", first_line) if re.match(r"^\d+(?:\.\d+)*\.?\s+[A-Z]", first_line): return first_line return None ``` **Using groups for embedding:** ```python groups = group_chunks(parse_result.chunks, by_heading) texts = [g.embedding_input for g in groups if g.text.strip()] vectors = compute_embeddings_local(texts) # Each group carries grounding_refs for source attribution for g, vec in zip(groups, vectors): print(f"{g.label}: {len(g.grounding_refs)} chunk refs, " f"{len(vec)} dims") ``` ### Document-Level Embed the full document markdown or a summary. Useful for routing queries to the right document before doing fine-grained search. ```python # Full markdown (may be long — consider truncation) doc_text = parse_result.markdown[:8000] doc_vec = compute_embeddings_local([doc_text])[0] # Or use ADE extract to get a summary first from landingai_ade.lib import pydantic_to_json_schema from pydantic import BaseModel, Field class DocSummary(BaseModel): summary: str = Field( description="A 2-3 sentence summary of the document." ) er = client.extract( schema=pydantic_to_json_schema(DocSummary), markdown=parse_result.markdown, ) summary_vec = compute_embeddings_local( [er.extraction["summary"]] )[0] ``` ### Decision Matrix | Document Type | Recommended | Rationale | |--------------|-------------|-----------| | Academic papers, reports | Hierarchical (by heading) | Answers span paragraphs within sections | | Invoices, forms | Chunk-level | Each field is independent | | Mixed document batches | Document-level + chunk-level | Route first, then search within | | Contracts, legal docs | Hierarchical (by clause) | Clauses are the natural retrieval unit | | Slide decks | Chunk-level (by page) | Each slide is self-contained | | Long narratives (books) | Hierarchical (sliding window) | Fixed-size windows with overlap | --- ## Chunk Filtering Tips Not all chunks are useful for RAG. Filter by type to improve relevance: ```python # Keep only text and table chunks (skip logos, scan codes) RAG_CHUNK_TYPES = {"text", "table", "card"} docs = [ Document(page_content=ch.markdown, metadata={...}) for ch in parse_result.chunks if getattr(ch, "type", "") in RAG_CHUNK_TYPES and ch.markdown.strip() ] ``` --- ## Dependencies ``` # Chunks to CSV only pip install landingai-ade pandas # Local embeddings (free, offline) pip install landingai-ade fastembed # ChromaDB pipeline (API embeddings) pip install landingai-ade chromadb openai # FAISS + LangChain pipeline (API embeddings) pip install landingai-ade langchain langchain-openai langchain-community faiss-cpu # Local embeddings + ChromaDB pip install landingai-ade fastembed chromadb # Full pipeline (all options) pip install landingai-ade pandas fastembed chromadb openai langchain langchain-openai langchain-community faiss-cpu ```