Building a RAG Pipeline with Antigravity— Unlock Your Company's Knowledge with Vector Search and LLMs
Build a RAG pipeline with Antigravity — the three traps in migrating to gemini-embedding-001, incremental ChromaDB ingestion, and LLM-as-a-Judge evaluation.
Setup and context — What Is RAG and Why Does It Matter?
Large Language Models carry broad general knowledge, but they can't answer questions about your proprietary data — internal documentation, API specifications, knowledge bases, or customer interaction histories. Fine-tuning is expensive and requires retraining every time the data changes.
RAG (Retrieval-Augmented Generation) solves this problem elegantly. It retrieves documents relevant to a user's question via vector search, then feeds that information as context to the LLM, enabling accurate and up-to-date responses.
We'll build a complete RAG pipeline from scratch, leaning on Antigravity's AI assistance along the way. This guide is aimed at engineers with Python fundamentals who want to run an LLM over their own data. If you'd like a refresher on crafting effective prompts first, the Antigravity Prompt Engineering Advanced Guide makes the generation sections here much easier to follow.
One thing worth flagging before we start: the code below uses gemini-embedding-001 for embeddings. text-embedding-004 — the model you'll still find in most RAG tutorials — was shut down on January 14, 2026, and now returns a 404. Swapping it out looks like a one-line change, but it trips you up in three places: dimensionality, normalization, and task types. I've written up each one, in the order I hit them, in the second half of this article.
RAG Architecture Overview
A RAG system consists of three major phases.
Ingestion Phase
Documents are loaded, split into appropriately-sized chunks, vectorized using an embedding model, and stored in a vector database.
# RAG ingestion pipeline generated with Antigravity# document_ingestor.pyimport hashlibimport mathimport osfrom pathlib import Pathfrom langchain.text_splitter import RecursiveCharacterTextSplitterfrom langchain_community.document_loaders import ( DirectoryLoader, TextLoader, PyPDFLoader, UnstructuredMarkdownLoader,)from langchain_google_genai import GoogleGenerativeAIEmbeddingsimport chromadbfrom chromadb.config import Settings# --- Configuration ---CHROMA_PERSIST_DIR = "./chroma_db"COLLECTION_NAME = "company_knowledge_v2" # New dimensionality, new collection nameEMBED_MODEL = "gemini-embedding-001"EMBED_DIM = 1536 # Recommended: 768 / 1536 / 3072. Anything but 3072 needs manual normalizationCHUNK_SIZE = 800 # Character count, not tokensCHUNK_OVERLAP = 200 # Overlap between chunksdef load_documents(source_dir: str) -> list: """Load documents in multiple formats""" loaders = { "**/*.txt": TextLoader, "**/*.md": UnstructuredMarkdownLoader, "**/*.pdf": PyPDFLoader, } documents = [] for glob_pattern, loader_cls in loaders.items(): loader = DirectoryLoader( source_dir, glob=glob_pattern, loader_cls=loader_cls, show_progress=True, ) documents.extend(loader.load()) return documentsdef chunk_documents(documents: list) -> list: """Split documents into semantically meaningful chunks""" splitter = RecursiveCharacterTextSplitter( chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP, separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "], length_function=len, ) return splitter.split_documents(documents)def l2_normalize(vec: list[float]) -> list[float]: """Vectors below 3072 dimensions come back un-normalized""" norm = math.sqrt(sum(v * v for v in vec)) return [v / norm for v in vec] if norm else vecdef chunk_id(chunk) -> str: """Stable ID derived from source + content, so re-runs don't duplicate""" source = chunk.metadata.get("source", "unknown") seed = f"{source}::{chunk.page_content}".encode("utf-8") return hashlib.sha1(seed).hexdigest()def create_embeddings_and_store(chunks: list): """Generate embeddings and store in ChromaDB""" embeddings = GoogleGenerativeAIEmbeddings( model=EMBED_MODEL, google_api_key=os.getenv("GOOGLE_API_KEY"), output_dimensionality=EMBED_DIM, ) client = chromadb.PersistentClient( path=CHROMA_PERSIST_DIR, settings=Settings(anonymized_telemetry=False), ) collection = client.get_or_create_collection( name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"}, # Cosine similarity ) batch_size = 100 # Gemini API's per-request ceiling for i in range(0, len(chunks), batch_size): batch = chunks[i:i + batch_size] texts = [chunk.page_content for chunk in batch] metadatas = [chunk.metadata for chunk in batch] ids = [chunk_id(chunk) for chunk in batch] # Be explicit about the document-side task type vectors = embeddings.embed_documents( texts, task_type="RETRIEVAL_DOCUMENT", ) if EMBED_DIM != 3072: vectors = [l2_normalize(v) for v in vectors] # upsert, not add — running this twice won't duplicate anything collection.upsert( ids=ids, documents=texts, embeddings=vectors, metadatas=metadatas, ) print(f" Stored: {i + len(batch)}/{len(chunks)} chunks") print(f"✅ All {len(chunks)} chunks synced to ChromaDB")# --- Execution ---if __name__ == "__main__": docs = load_documents("./knowledge_base") print(f"📄 Loaded {len(docs)} documents") chunks = chunk_documents(docs) print(f"✂️ Split into {len(chunks)} chunks") create_embeddings_and_store(chunks)
Expected output:
📄 Loaded 47 documents
✂️ Split into 312 chunks
Stored: 100/312 chunks
Stored: 200/312 chunks
Stored: 300/312 chunks
Stored: 312/312 chunks
✅ All 312 chunks synced to ChromaDB
A quick word on why the IDs are content hashes rather than sequential numbers. With doc_0, doc_1, and so on, adding a single document upstream shifts the ID of every chunk after it. The same text gets stored twice under different IDs, and your top search results fill up with duplicates of one another. Hashing means unchanged chunks land on the same ID no matter how many times you run the ingestion. That's also why this uses upsert instead of add.
The same ID scheme becomes the foundation for incremental updates later: store the previous hash list, upsert only what's new or changed, and delete the IDs that disappeared.
Retrieval Phase
The user's question is vectorized, and the most similar chunks are retrieved. Retrieval accuracy is the single biggest factor in overall RAG quality.
Generation Phase
Retrieved results are injected into a prompt, and the LLM generates a grounded answer.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Understand the design principles and key components of RAG architecture systematically
✦Build a vector search pipeline in Antigravity that instantly incorporates your own data into LLM responses
✦Master chunking strategies, reranking techniques, and evaluation metrics essential for production RAG systems
✦Avoid the dimensionality, normalization, and task-type traps that surface when migrating to gemini-embedding-001
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Chunking Strategy — 80% of RAG Quality Is Decided Here
The single most impactful decision in a RAG pipeline is how you split your documents into chunks. When you ask Antigravity's agent to "optimize the chunking strategy," it can propose sophisticated splitting logic like the following.
Semantic Chunking
Rather than splitting at fixed character counts, semantic chunking preserves meaningful boundaries in the text.
# semantic_chunker.py — Advanced chunking that preserves semantic coherencefrom langchain.text_splitter import RecursiveCharacterTextSplitter # used by the parent-child splitter belowfrom langchain_experimental.text_splitter import SemanticChunkerfrom langchain_google_genai import GoogleGenerativeAIEmbeddingsimport osdef create_semantic_chunks(documents: list) -> list: """Embedding-based semantic chunking""" embeddings = GoogleGenerativeAIEmbeddings( model="gemini-embedding-001", google_api_key=os.getenv("GOOGLE_API_KEY"), output_dimensionality=1536, ) # Split where semantic distance between sentences exceeds threshold chunker = SemanticChunker( embeddings, breakpoint_threshold_type="percentile", breakpoint_threshold_amount=90, # Split at top 10% distance ) chunks = [] for doc in documents: split = chunker.create_documents( [doc.page_content], metadatas=[doc.metadata], ) chunks.extend(split) return chunks# --- Parent-Child Chunking Strategy ---class ParentChildChunker: """ Use large 'parent chunks' to preserve context and small 'child chunks' for precise retrieval — the best of both worlds. """ def __init__(self, parent_size=2000, child_size=400, overlap=100): self.parent_splitter = RecursiveCharacterTextSplitter( chunk_size=parent_size, chunk_overlap=0, ) self.child_splitter = RecursiveCharacterTextSplitter( chunk_size=child_size, chunk_overlap=overlap, ) def split(self, documents: list) -> tuple: """Generate parent and child chunks simultaneously""" parent_chunks = [] child_chunks = [] for doc in documents: parents = self.parent_splitter.split_documents([doc]) for idx, parent in enumerate(parents): parent.metadata["parent_id"] = f"{doc.metadata.get('source', 'unknown')}_{idx}" parent_chunks.append(parent) # Split parent into smaller child chunks children = self.child_splitter.split_documents([parent]) for child in children: child.metadata["parent_id"] = parent.metadata["parent_id"] child_chunks.append(child) return parent_chunks, child_chunks# Expected behavior:# - Search is performed against child chunks for precision# - The parent chunk of each hit is passed as context to the LLM# → Achieves both retrieval precision and rich context
Note that ParentChildChunker depends on RecursiveCharacterTextSplitter. Lift just this class into its own module and it's easy to forget the import, at which point you get NameError: name 'RecursiveCharacterTextSplitter' is not defined. That's a common way agent-generated code breaks once you start splitting files apart — which is why the import is spelled out at the top of the snippet above.
Choosing the Right Chunk Size
There's no one-size-fits-all chunk size — the optimal value depends on the nature of your data.
The best approach is to have Antigravity's agent generate benchmark code for each pattern and compare against your actual data. As a general guideline, technical documentation (API references, etc.) works well with smaller chunks of 400–600 characters, since code context tends to be self-contained within short spans. Narrative documents (internal wikis, meeting notes, etc.) benefit from slightly larger chunks of 800–1,200 characters, as shorter chunks risk losing meaning in longer-form text. Legal and contract documents requiring strict precision call for large chunks of 1,500–2,000 characters to preserve the full context of each clause.
Advanced Retrieval — Hybrid Search and Reranking
Vector search alone falls short in certain scenarios. When exact keyword matching matters — searching for error codes, identifying product model numbers — combining vector search with keyword-based BM25 in a hybrid search approach delivers significantly better results.
# hybrid_retriever.py — Hybrid search combining vector search and BM25from langchain.retrievers import EnsembleRetrieverfrom langchain_community.retrievers import BM25Retrieverfrom langchain_community.vectorstores import Chromafrom langchain_google_genai import GoogleGenerativeAIEmbeddingsimport osdef create_hybrid_retriever(chunks: list, k: int = 5): """Hybrid retriever combining vector search and BM25""" # Deliberately no task_type here. The library assigns # RETRIEVAL_DOCUMENT to embed_documents and RETRIEVAL_QUERY # to embed_query on its own — more on that later. embeddings = GoogleGenerativeAIEmbeddings( model="gemini-embedding-001", google_api_key=os.getenv("GOOGLE_API_KEY"), output_dimensionality=1536, ) # 1. Vector search retriever vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, collection_name="hybrid_search", ) vector_retriever = vectorstore.as_retriever( search_type="mmr", # Maximal Marginal Relevance search_kwargs={"k": k, "fetch_k": k * 3}, ) # 2. BM25 keyword search retriever bm25_retriever = BM25Retriever.from_documents( chunks, k=k, ) # 3. Ensemble (weighted combination) ensemble = EnsembleRetriever( retrievers=[vector_retriever, bm25_retriever], weights=[0.6, 0.4], # Slightly favor vector search ) return ensemble# --- Reranking with Cohere Reranker ---from langchain.retrievers import ContextualCompressionRetrieverfrom langchain_cohere import CohereRerankdef add_reranking(base_retriever, top_n: int = 3): """Re-score search results with a reranker""" reranker = CohereRerank( model="rerank-v3.5", cohere_api_key=os.getenv("COHERE_API_KEY"), top_n=top_n, ) return ContextualCompressionRetriever( base_compressor=reranker, base_retriever=base_retriever, )# Usage example:# retriever = create_hybrid_retriever(chunks, k=10)# reranked_retriever = add_reranking(retriever, top_n=3)# results = reranked_retriever.invoke("How do I verify Stripe webhook signatures?")## Expected behavior:# → BM25 picks up candidates matching "Stripe", "webhook", "signatures"# → Vector search also finds semantically similar documents# → Reranker narrows down to the top 3 most relevant documents
MMR (Maximal Marginal Relevance) for Result Diversity
Setting search_type="mmr" in the vector retriever considers both similarity and diversity in results. This prevents the retriever from returning near-duplicate chunks and provides the LLM with a more comprehensive view of the topic.
Generation Phase — Prompt Design and Guardrails
How you structure the prompt with retrieved context is the key to answer quality.
# rag_chain.py — Building the RAG chainfrom langchain_google_genai import ChatGoogleGenerativeAIfrom langchain_core.prompts import ChatPromptTemplatefrom langchain_core.output_parsers import StrOutputParserfrom langchain_core.runnables import RunnablePassthrough# Keep the model name in one place — a deprecation notice then costs you one lineCHAT_MODEL = "gemini-2.5-pro" # scheduled for retirement on or after October 16, 2026def build_rag_chain(retriever): """Build a RAG chain that generates answers from retrieved context""" # --- Prompt template --- template = ChatPromptTemplate.from_messages([ ("system", """You are an assistant that answers questions accurately based on internal knowledge.Follow these rules strictly:1. Base your answer ONLY on the provided context2. If the context doesn't contain the answer, explicitly state "This information was not found in the knowledge base"3. Never mix in speculation or external knowledge4. Cite the source document for each claim5. Include code examples for technical contentContext:{context}"""), ("human", "{question}"), ]) # --- LLM --- llm = ChatGoogleGenerativeAI( model=CHAT_MODEL, temperature=0.1, # Low temperature for factual responses max_output_tokens=4096, ) # --- Chain construction --- def format_docs(docs): """Format search results into a context string""" formatted = [] for i, doc in enumerate(docs, 1): source = doc.metadata.get("source", "unknown") formatted.append( f"[Source {i}: {source}]\n{doc.page_content}" ) return "\n\n---\n\n".join(formatted) chain = ( { "context": retriever | format_docs, "question": RunnablePassthrough(), } | template | llm | StrOutputParser() ) return chain# --- Streaming version ---def query_with_streaming(chain, question: str): """Display answer with streaming""" print(f"\n📝 Question: {question}\n") print("=" * 60) for chunk in chain.stream(question): print(chunk, end="", flush=True) print("\n" + "=" * 60)# Usage example:# chain = build_rag_chain(reranked_retriever)# query_with_streaming(chain, "What's the setup procedure for a new project?")## Expected output:# 📝 Question: What's the setup procedure for a new project?# ============================================================# Based on our internal knowledge base, new project setup follows# these steps:# 1. Create a repository on GitHub...# [Source: docs/setup-guide.md]# ============================================================
Evaluation and Monitoring — Quantifying RAG Quality
A RAG system that merely "works" isn't good enough. You need a way to measure answer quality quantitatively and improve it continuously.
# rag_evaluator.py — RAG pipeline evaluation frameworkfrom dataclasses import dataclassfrom langchain_google_genai import ChatGoogleGenerativeAIfrom pydantic import BaseModel, Field@dataclassclass EvalResult: question: str expected: str actual: str faithfulness: float # Is it faithful to the context? (0-1) relevance: float # Is it relevant to the question? (0-1)class Verdict(BaseModel): """Schema the judge model is required to return""" score: float = Field(description="A score between 0.0 and 1.0") reason: str = Field(description="Why that score was given")class RAGEvaluator: """Evaluate RAG answers using LLM-as-a-Judge pattern""" def __init__(self): # Calling json.loads on the raw response breaks the moment the model # wraps its JSON in a ```json fence. Structured output removes the # parsing step entirely. self.judge = ChatGoogleGenerativeAI( model="gemini-2.5-pro", temperature=0, ).with_structured_output(Verdict) def evaluate_faithfulness( self, context: str, answer: str ) -> float: """Evaluate whether the answer is faithful to the context""" prompt = f"""Evaluate whether the following answer is based solely on the provided context.Context:{context}Answer:{answer}Return a score from 0.0 (completely unsupported) to 1.0 (fully faithful).""" return self.judge.invoke(prompt).score def evaluate_relevance( self, question: str, answer: str ) -> float: """Evaluate whether the answer addresses the question""" prompt = f"""Evaluate whether the following answer appropriately addresses the question.Question: {question}Answer: {answer}Return a score from 0.0 (irrelevant) to 1.0 (perfectly on point).""" return self.judge.invoke(prompt).score def run_eval_suite( self, chain, retriever, test_cases: list[dict] ) -> list[EvalResult]: """Run evaluation across a test case suite""" results = [] for case in test_cases: # Retrieve relevant documents docs = retriever.invoke(case["question"]) context = "\n".join(d.page_content for d in docs) # Generate answer answer = chain.invoke(case["question"]) # Evaluate faithfulness = self.evaluate_faithfulness(context, answer) relevance = self.evaluate_relevance(case["question"], answer) results.append(EvalResult( question=case["question"], expected=case.get("expected", ""), actual=answer, faithfulness=faithfulness, relevance=relevance, )) # Print summary avg_faith = sum(r.faithfulness for r in results) / len(results) avg_rel = sum(r.relevance for r in results) / len(results) print(f"\n📊 Evaluation Summary ({len(results)} cases)") print(f" Faithfulness: {avg_faith:.2f}") print(f" Relevance: {avg_rel:.2f}") return results# Test case example:# test_cases = [# {"question": "What's the deploy procedure?", "expected": "Run wrangler deploy..."},# {"question": "How do I generate API keys?", "expected": "From the admin panel..."},# ]# evaluator = RAGEvaluator()# results = evaluator.run_eval_suite(chain, retriever, test_cases)
Production Best Practices
Here are the key considerations for running a RAG system reliably in production.
Incremental ingestion: When documents are updated, only re-embed the changed files. Record file hashes and selectively re-process modified documents — this dramatically reduces both cost and processing time.
Caching strategy: Cache answers for repeated questions to reduce latency and API costs. A "semantic cache" that considers queries with vector similarity above a threshold (e.g., 0.95) as cache hits is particularly effective.
Fallback design: When vector search returns low-scoring results (e.g., cosine similarity below 0.3), responding with "No relevant information was found" is far more trustworthy than generating a hallucinated answer.
Monitoring metrics: In production, continuously monitor these indicators: search latency (target P95 under 500ms), answer generation latency (target P95 under 3 seconds), average similarity score of search results (a declining trend signals stale data), and user feedback ratio (thumbs up/down rates).
Three Things That Tripped Me Up Migrating Off text-embedding-004
On paper, swapping the embedding model is a one-line change. In practice it took me three attempts to get a working pipeline. Even the few-hundred-chunk setup I run as an indie developer hit all three. Here they are, in the order I met them.
The dimensionality changes.text-embedding-004 was fixed at 768 dimensions. gemini-embedding-001 defaults to 3072. Try to append to an existing collection and ChromaDB rejects the write on a dimension mismatch. At least it fails loudly — but it still cost me time to trace back to the cause. The rule I settled on is simple: if the model changes, the collection name changes too. That's why COLLECTION_NAME carries a _v2 suffix in the code above.
Anything other than 3072 dimensions comes back un-normalized. Pass 768 or 1536 to output_dimensionality and Matryoshka Representation Learning truncates the vector to its leading N dimensions. Those truncated vectors do not have unit norm. As long as you're on hnsw:space: cosine, ranking is unaffected — cosine similarity ignores vector length. The problem surfaces the moment you switch to ip (inner product), where the norm feeds straight into the score and merely longer chunks float to the top for no good reason. If you truncate, normalize. It's easier to make that a reflex than to remember when it matters.
Sometimes the right move is to not specify a task type. This one genuinely surprised me. The embeddings API takes a task_type argument: RETRIEVAL_DOCUMENT for the document side, RETRIEVAL_QUERY for queries. So the obvious move is to set task_type="RETRIEVAL_DOCUMENT" once at construction time. That's the mistake.
Reading the langchain-google-genai 4.3.5 source, embed_documents resolves to task_type or self.task_type or "RETRIEVAL_DOCUMENT", while embed_query resolves to task_type or self.task_type or "RETRIEVAL_QUERY". Set task_type on the instance and your query embeddings get treated as documents too. Nothing raises. Retrieval quality just quietly degrades, which makes it a particularly annoying class of bug.
How you configure it
Applied to documents
Applied to queries
Leave task_type unset on the instance
RETRIEVAL_DOCUMENT
RETRIEVAL_QUERY
Set RETRIEVAL_DOCUMENT on the instance
RETRIEVAL_DOCUMENT
RETRIEVAL_DOCUMENT (not what you want)
Pass it per call
Whatever you passed
Whatever you passed
Where you call embed_documents directly, as in the ingestion script, passing the argument explicitly makes the intent readable. The instance you hand to the Chroma retriever, on the other hand, should leave task_type empty. "Trust the library default" is an unsatisfying conclusion, but it's what the implementation actually does.
Design for the Model's Lifespan
Build a RAG pipeline once and you'll likely run it for years. The models underneath it turn over far faster than that.
text-embedding-004 shut down on January 14, 2026. The gemini-2.5-pro used on the generation side is slated for retirement on or after October 16, 2026. As of this writing the generally available successor is gemini-3.7-flash (GA August 13, 2026), while the Pro-tier gemini-3.1-pro-preview is still in preview. In other words, there's no drop-in Pro replacement right now, and that gap will persist for a while.
The mitigation is unglamorous. Don't scatter model names through the codebase — collect them into constants like EMBED_MODEL and CHAT_MODEL. Then a deprecation notice turns into one line and a smoke test. That's the reason those constants exist in the code above; it isn't about saving keystrokes.
Embedding models are the exception to the one-line rule. Changing one changes the meaning of the vector space, so every document has to be re-ingested. At tens of thousands of chunks, that's real money and real wall-clock time. I now keep a full-rebuild script alongside the incremental one from day one. Writing it after the deprecation notice lands is too late.
Where to Go Next
Start small: ingest 20 to 30 documents, write out about ten representative questions, and run RAGEvaluator over them. If faithfulness lands below 0.8, the cause is almost always the chunking strategy rather than the prompt. Sweep chunk sizes of 400, 800, and 1200 against the same question set before touching anything else. Following that order alone will save you a lot of guesswork.
Getting the pipeline running is the starting line. The real work begins when you start measuring it.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.