Class 2: your agent could act — but it still didn't know your data. Today you fix that. You'll build a system that reads your PDFs, finds the right passage for any question, and answers from real text — not vibes. It's called RAG, and it's how every "chat with your docs" app on the planet actually works. 👉
1) It doesn't know your stuff — your company's docs, your PDFs, last week's release notes. 2) When it doesn't know, it makes things up (hallucinates) confidently. Today's class is the single biggest fix in AI engineering. Same agent loop — but now it can look things up in a library you built.
If the shop-assistant agent felt fuzzy last class, this class is the cleanup. Say it plainly: "Last week the model chose a tool. Today the tool is its own library — and the magic moves from which tool to how do we find the right page?." That's RAG, in one sentence.
Two clean halves: understand the four pieces of RAG (embeddings → chunking → vector DB → retrieval) and then build a real "chat with your PDF" app. Every concept gets a simulator.
The 4 problems RAG solves — knowledge cutoff, private data, hallucinations, citations
Librarian analogy + the 5-box pipeline
The "aha" moment: king − man + woman ≈ queen. Live similarity meter.
Strategies + chunk-size simulator
Chroma · Pinecone · Qdrant — which one when
Load → chunk → embed → store → retrieve → answer
Bi-encoder ✕ cross-encoder · BM25 ✕ vector
Gradio chat UI + your own doc + public link
Caption template + post — today 🎉
Where this leads next class
An LLM is a brilliant intern who finished training a year ago and was never allowed inside your office. RAG fixes both — fresh data, and your private data — by handing the intern a relevant page at question-time.
The model's training ended months ago. It doesn't know yesterday's RBI rate cut or last week's Budget numbers.
✓ RAG: fetch today's articleIt has never seen your HR policy, your contracts, your product wiki, or your customer tickets — and you can't paste them all into one prompt.
✓ RAG: search your docsWhen unsure, the model invents confident-sounding answers. Bad in customer support, dangerous in legal, fatal in healthcare.
✓ RAG: ground in real textAsk "where did you get that?" and a raw LLM has nothing. Real products need "see source: page 14".
✓ RAG: return the source chunkRAG = Retrieval-Augmented Generation. Before the model answers, you retrieve the most relevant snippets from your own data and stuff them into the prompt. The model then answers from those snippets. No retraining. No fine-tuning. Just open-book exam instead of closed-book.
Notion AI answering from your workspace, Glean searching your company's apps, Perplexity citing sources, Intercom's Fin support bot, ChatGPT's "search the web", Cursor finding code in your repo, even the new "ask about this PDF" button in your browser — same pattern, every time. Master this, and you can clone any of them.
People sometimes ask: "Why not just fine-tune the model on our data?" Three reasons it's usually wrong: (1) expensive and slow to redo every time data changes, (2) still can't cite sources, (3) still hallucinates. RAG is faster, cheaper, traceable, and almost always the right first move.
Forget the buzzwords. The whole pipeline is just a smart librarian sitting between your question and the model.
Imagine you ask your friend a question about a 800-page book they've never read. Bad idea — they'll guess. Smart move? You find the 3 most relevant pages, hand them over, and then ask. They read the pages and answer confidently with the real text. That's RAG. The "librarian" who finds those 3 pages is the only new thing we're building today.
Boxes 1, 4, 5 you already know from Class 1. Boxes 2 + 3 are the entire job of today's class.
Phase 1 — Indexing (offline, once): read your docs → break into chunks → turn each chunk into numbers (embedding) → store in a vector database. Slow, but you only do it when documents change.
Phase 2 — Querying (online, every question): turn the question into numbers → find nearest chunks in the database → paste them into the prompt → LLM answers. Milliseconds per query.
You are not retraining the model. The model stays exactly the same as Class 1's
gpt-4o-mini. We're only changing what goes into the prompt. RAG is, at its
core, very fancy prompt engineering — automated.
Before we can "search by meaning", we need a way to turn meaning into numbers. That's an embedding. And once you see what it does, every confusing thing about RAG suddenly makes sense.
An embedding is a list of numbers (a vector) that captures the meaning of a piece of text. Similar meanings ⟶ similar numbers ⟶ nearby points in space. Different meanings ⟶ far apart.
Forget AI for a second. If I plot people by height vs weight, people of similar build end up close together on the chart. Same idea, scaled up. Real embeddings have 384, 768 or even 3072 dimensions — way more than we can draw — but the principle is identical: similar ⟶ close, different ⟶ far.
Because meaning is rich. Two words can be similar in many ways at once — topic, tone, formality, language, sentiment. Each dimension captures a different axis of similarity. 768 isn't arbitrary; it's just enough to separate millions of distinct ideas.
This is the demo that turned a generation of engineers into believers. Trained on enough text, vectors carry real relationships you can do arithmetic on. Pick a side and watch:
Click a different equation. Each is a real result from GloVe / Word2Vec embeddings — no tricks. The model never saw any rule like "queen is the female king" — that pattern just emerges from the geometry of meaning.
If woman and queen can be found by simple arithmetic, then "find the chunk most similar to my question" is also just arithmetic — fast, scalable, and runs on commodity hardware. Every RAG system uses exactly this idea. Search by meaning = nearest point in vector space.
A real-world plot of word vectors squished from 768 dimensions down to 2 for visualization. Click pairs and notice the direction connecting them is the same:
"capital-of" is encoded as the direction from country → capital. Same arrow length, same angle, anywhere on the map. That direction is the relationship.
Two vectors close together = the angle between them is small. The cosine of that angle is our score. It ranges from -1 (opposite) to +1 (identical). Practically: above 0.7 means "very similar", below 0.3 means "barely related". No math needed to use it — just remember: bigger number = more similar.
Two clock hands pointing at the same time ⟶ angle = 0 ⟶ cosine = 1 ⟶ perfectly similar. Pointing at 12 and 6 ⟶ angle = 180° ⟶ cosine = −1 ⟶ opposites. We don't care how long the hands are, only the angle. That's why text length doesn't break the score.
Type any two sentences. The meter shows the cosine similarity our system would compute (this in-browser simulation uses a small built-in semantic table, so results feel realistic for common topics).
You don't have to train anything — somebody else already did. Open-source models from Hugging Face
(sentence-transformers) or APIs from OpenAI / Cohere give you embeddings in one line:
# pip install sentence-transformers from sentence_transformers import SentenceTransformer model = SentenceTransformer("all-MiniLM-L6-v2") # ① free, fast, 384 dims vec = model.encode("A cat is sleeping on the couch") # ② that's an embedding! print(vec.shape) # → (384,) — 384 numbers print(vec[:5]) # → [-0.05, 0.12, 0.41, -0.08, 0.22] (something like that) # to compare two pieces of text → encode both → take cosine similarity from numpy import dot from numpy.linalg import norm v1 = model.encode("A cat is sleeping on the couch") v2 = model.encode("A kitten is napping on the sofa") similarity = dot(v1, v2) / (norm(v1) * norm(v2)) # cosine print(similarity) # → 0.87 (very similar 🎉)
all-MiniLM-L6-v2 is a tiny but excellent open-source embedding model — runs on your
laptop, no API key needed. For production, look at BAAI/bge-large-en-v1.5 (best English) or
OpenAI's text-embedding-3-small (paid, very good multilingual).
.encode("text") returns the vector. That's the whole API surface — one call, 384 numbers back.
Now your text is searchable by meaning.
This is the "physics" of RAG. Don't rush. Spend a full 5 minutes letting them play with the meter and the king-queen equation. Once they internalize "similar meaning = nearby vector", everything else in this class is bookkeeping.
Every chunk of text gets a point in space.
Score from −1 to +1. Bigger = closer.
king − man + woman ≈ queen. Really.
.encode(text) — that's it.
Before we embed anything, we have to cut it up. You can't embed a 200-page PDF as one vector — meaning gets averaged away to mush. So we split it into chunks. How you chunk decides how good your RAG actually is. Engineers underestimate this; the best ones obsess over it.
One whole pizza is hard to share — too big. Cut it into 100 confetti-sized bits and nobody can taste anything. Slice sizes matter. Chunks are pizza slices: too big and you lose precision (the relevant bit is buried), too small and you lose context (each crumb is meaningless).
Every N characters or tokens — done. Brain-dead simple, fast, your default. Downside: chops mid-sentence, mid-table, mid-thought.
Best for: prototypes, blog postsTry to split on paragraphs first; if too big, split on sentences; if still too big, on words. Smart fallback ladder. The default in LangChain.
Best for: most real appsEmbed every sentence; group consecutive sentences that "talk about the same thing". Chunks follow meaning, not size.
Best for: long flowing proseUse the document's own structure — markdown headings, code blocks, HTML sections. Each section becomes a chunk.
Best for: docs, code, wikisSame source passage, four ways to chop it. Switch tabs and watch where the cuts land and how chunks change shape. The differences are the lesson — pure text won't make this click; the picture will.
Try this sequence in class:
start on Fixed at size 80 → count the 🪓 mid-word cuts → flip to Recursive at the same size →
they're all gone → flip to Semantic → notice three chunks emerge that align perfectly with the three
topics → flip to Structure → see how the markdown ## headers do the work for free.
Big chunks → high recall (the answer is probably in there), but low precision (lots of fluff around it). Small chunks → high precision (the chunk is exactly the answer), but low recall (the relevant bit might be split across two chunks and you only pulled one). Most teams sweep chunk size as their first RAG tuning knob.
from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=800, # aim for ~800 chars per chunk chunk_overlap=100, # adjacent chunks share 100 chars (context glue) ) chunks = splitter.split_text(your_long_document) print(len(chunks)) # → e.g. 47 chunks ready to embed
chunk_overlap?
If your question's answer sits exactly at a chunk boundary, you'd miss it. Overlap (50–100 chars) makes sure every sentence appears in at least one chunk fully. Cheap insurance.
You have thousands of embeddings. For every question, you need the top-k nearest ones — fast. That's what a vector database does, and that's all it does.
A vector database is a search engine where instead of "find documents containing this word", the query is "find vectors closest to this vector". Same idea as Google, swapped engine.
Open-source, runs in-process (no server!), one pip install. Perfect for prototypes & up to
~10M vectors. What we'll use today.
Fully managed SaaS. Zero ops, scales to billions, but paid. The boring-and-reliable choice for production.
Open-source and production-grade. Self-host or use their cloud. Great middle ground when you outgrow Chroma.
Prototyping or under 1M chunks? Chroma. Want zero ops & have a budget? Pinecone. Need to self-host at scale? Qdrant or Weaviate. The good news: LangChain wraps all of them with the same interface, so swapping later is a one-line change.
db.add(documents, embeddings) — store chunks and their vectors. (Indexing.)
db.query(query_vector, k=3) — return the 3 chunks whose vectors are closest. (Retrieval.)
db.delete(ids) — remove chunks when a doc is deleted. That's it. Three calls.
Searching billions of vectors naively means computing billions of distances per query. HNSW (Hierarchical Navigable Small World — a 2018 algorithm) builds a "ladder" graph so each query takes only ~log(N) hops. Result: sub-10ms lookups on 100M vectors. You'll never write this yourself — every vector DB ships it built-in.
We've talked about every piece. Now we wire them up. This is the whole pattern — every RAG system you'll see in production is just a fancier version of this.
from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_huggingface import HuggingFaceEmbeddings from langchain_chroma import Chroma # ① load your documents (any text — for now, hardcoded) docs = [ "Our return policy allows refunds within 30 days of purchase.", "Shipping is free for orders above ₹999 across India.", "For corporate orders above 50 units, contact sales@example.com.", "Our office is in Indiranagar, Bangalore. Open Mon-Fri 10am-7pm.", ] # ② split into chunks (small docs here, but production = thousands of pages) splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50) chunks = splitter.create_documents(docs) # ③ pick an embedding model (free, runs locally, no API key) embedder = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2") # ④ build the vector store from chunks + embeddings (saves to disk) db = Chroma.from_documents(chunks, embedder, persist_directory="./chroma_db") print(f"Indexed {len(chunks)} chunks 🎉")
from langchain_huggingface import HuggingFaceEmbeddings from langchain_chroma import Chroma from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from dotenv import load_dotenv load_dotenv() # ⑤ open the same vector store we built in step 1 embedder = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2") db = Chroma(persist_directory="./chroma_db", embedding_function=embedder) model = ChatOpenAI(model="gpt-4o-mini", temperature=0) # ⑥ the prompt: instructions + chunks + question prompt = ChatPromptTemplate.from_template(""" Answer the question using ONLY the context below. If the context doesn't contain the answer, say "I don't know." Be concise and quote facts directly. Context: {context} Question: {question} """) def rag_answer(question): chunks = db.similarity_search(question, k=3) # ⑦ retrieve top 3 context = "\n\n".join(c.page_content for c in chunks) chain = prompt | model # ⑧ same chain trick as Class 2 return chain.invoke({"context": context, "question": question}).content print(rag_answer("How long do I have to return something?")) # → "30 days from the purchase date." ✅ from your data, not a guess
Your data. In real life this is PDFs, web pages, Notion exports — anything you can turn into text.
Chunk it. Block 17 in action. chunk_size=500 is a sane default.
Pick an embedder. We're using a free local one — swap to OpenAIEmbeddings() for a paid,
slightly better version.
Chroma.from_documents embeds every chunk and stores both the text and the vector. Done once
per dataset.
Reopen the same database. The embeddings are already on disk — no re-encoding.
The magic prompt template. Notice "ONLY the context below" — this single line is the most
important hallucination-fighter in RAG.
similarity_search = embed the question, find the 3 nearest chunks, return them. The librarian.
Same LangChain pipe (prompt | model) you used in Class 2. RAG didn't replace your
previous knowledge — it slotted right in.
Press Next to see exactly what happens inside
rag_answer("How long do I have to return something?"). Each step is a card; this is the whole RAG
flow on one screen:
That's it. Every RAG system in the world — from a hobby project to Perplexity — is a variant of these 8 lines. From here we're just making it better: smarter retrieval, smarter prompts, multiple passes. The core never changes.
A vanilla RAG works. A good RAG works well. The two tricks that close that gap — and that every senior engineer asks about — are reranking and hybrid search. Both are easy to add.
Embedding similarity is fast, but it sometimes ranks shallow word-matches above deep semantic matches. So we use a two-stage retrieval — a fast first pass to narrow down, then a slow accurate pass to pick the real winners.
You can't interview 1,000 people (it would take a year). You also can't pick someone by gut feel from a stack of resumes (you'd hire badly). So you do two passes: a fast resume scan to shortlist 25, then a real 30-minute interview with each of those 25. Bi-encoder and cross-encoder are exactly these two passes.
Reads the query and the document separately, turns each into a vector, then compares the two vectors with cosine similarity. The encoder never sees them together — it judges each "card" alone.
Feeds the query and the document into one transformer together, lets the model attend to both at once, then outputs a single relevance score. The model can compare them token by token, weigh trade-offs, spot nuance.
Funnel total: ~210ms — fast enough for live chat, accurate enough to beat raw vector search by miles.
Stage 1 (bi-encoder): grab top 50-100 candidates from the vector DB. Fast.
Stage 2 (cross-encoder): re-score those candidates with a smarter model. Slow per item, but you're only re-scoring 50, not 50 million. Keep the top 3-5 for the LLM.
Same query, same candidates. Left: what cosine similarity returned. Right: what a cross-encoder reranker returns. Press the button and watch the real answer rise:
Notice doc #3 — it never says the word "return" but it's clearly the most relevant. Pure vector search ranks it #4; cross-encoder lifts it to #1. That is why we rerank.
The code to add reranking is laughably short:
from sentence_transformers import CrossEncoder reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L6-v2") # free, fast def retrieve_with_rerank(question, top_k=3): candidates = db.similarity_search(question, k=25) # grab 25 cheap candidates pairs = [(question, c.page_content) for c in candidates] scores = reranker.predict(pairs) # cross-encoder scores each pair return [c for _, c in sorted(zip(scores, candidates), reverse=True)[:top_k]]
Pure semantic search ignores keywords. Ask "what's the price of SKU-4429?" and the embedding doesn't care about that specific code — it just sees "price" and "product". For codes, names, IDs, dates — keyword search beats semantic hands down. The fix isn't to pick one or the other. It's to use both, with very different strengths.
Remember our librarian from Block 15? She has two assistants who search the stacks completely differently. One is a literalist who lives for exact words. The other is a philosopher who lives for meaning. Send your query to the right one and you get great results. Send your query to both — and let them merge their rankings — and you get magic. That's hybrid search.
Obsessed with exact words. Type "SKU-4429" or "Article 21" and she'll find every single doc containing those exact characters. But ask for "running shoes" and she'll skip the doc titled "marathon footwear" — different words, even though same meaning.
Obsessed with meaning. Ask for "running shoes" and she surfaces "marathon footwear", "jogging trainers", even "sneakers for athletes". But ask for "SKU-4429" and she shrugs — random letters and numbers are just noise to her.
Send the same query to both librarians at the same time. Each ranks the docs by their own logic. You then merge their two ranked lists into one using a tiny formula called Reciprocal Rank Fusion (RRF):
For each doc, final score = 1 / (60 + rank in BM25) + 1 / (60 + rank in Vector). Docs that
both librarians ranked highly bubble to the top. Docs only one of them liked still get a fair shot.
No tuning, no thresholds, no magic numbers (well, 60 — but it almost never matters). That's it.
Used by almost every production RAG system in the wild.
Real user queries are messy — mostly natural language, but sprinkled with technical terms, product codes, names, or jargon the embedding model has never seen. Hybrid covers both halves of every messy query automatically: the natural-language part goes to Vector, the technical-term part goes to BM25, and RRF stitches the answers together. You're not picking sides — you're using each tool for what it's actually good at.
Same query, three rankings. Watch how Librarian A and Librarian B return different top picks — and how Hybrid merges them. Press the button to see who wins:
Counts word overlap. Loves "shoes" appearing literally.
Understands "running" means jogging — even if the word "shoes" isn't there.
Reciprocal rank fusion — gives the best of both. Default for production.
Reranking + hybrid search are the two single biggest quality wins in RAG. Add them and you go from "demo works" to "production works". Almost every benchmark in the MTEB leaderboard uses some combination. You now know the playbook.
Time to ship. Upload any PDF — your resume, a research paper, an annual report, your college notes — and chat with it. Every answer cites the page it came from. This is the project that gets "how did you build this?" in your LinkedIn DMs.
# pip install langchain langchain-openai langchain-chroma langchain-huggingface \ # langchain-community pypdf sentence-transformers gradio python-dotenv from langchain_community.document_loaders import PyPDFLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_huggingface import HuggingFaceEmbeddings from langchain_chroma import Chroma def build_index(pdf_path): pages = PyPDFLoader(pdf_path).load() # ① read all pages splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100) chunks = splitter.split_documents(pages) # ② chunk them embedder = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2") db = Chroma.from_documents(chunks, embedder) # ③ in-memory store return db
from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from dotenv import load_dotenv load_dotenv() model = ChatOpenAI(model="gpt-4o-mini", temperature=0) prompt = ChatPromptTemplate.from_template(""" You are a helpful PDF assistant. Answer the question using ONLY the context below. If the context doesn't contain the answer, say "I couldn't find that in the document." After your answer, list the page numbers you used as: Sources: page X, page Y. Context: {context} Question: {question} """) def ask(db, question): chunks = db.similarity_search(question, k=4) context = "\n\n".join( f"[page {c.metadata['page']+1]}] {c.page_content}" for c in chunks) chain = prompt | model return chain.invoke({"context": context, "question": question}).content
import gradio as gr state = {"db": None} # ① remember the index across turns def upload(pdf): state["db"] = build_index(pdf.name) # ② re-index whenever a new PDF arrives return "✅ PDF indexed! Ask me anything about it." def chat(message, history): if state["db"] is None: return "Please upload a PDF first 📄" return ask(state["db"], message) with gr.Blocks(title="📄 Chat with your PDF") as demo: gr.Markdown("## 📄 Chat with your PDF (powered by RAG)") pdf = gr.File(label="Upload a PDF", file_types=[".pdf"]) status = gr.Markdown() pdf.upload(upload, inputs=pdf, outputs=status) gr.ChatInterface(fn=chat) demo.launch(share=True) # share=True → public link!
state is a plain dict that survives across Gradio events. Without it, every chat message would
re-index the PDF from scratch (slow!).
Re-indexing only on upload means typing is instant. This is the same indexing/querying split from Block 15.
We pass page from the chunk metadata into the prompt — that's how the model knows which page
to cite. Metadata is RAG's superpower.
$ python pdf_chat.py Running on local URL: http://127.0.0.1:7860 Running on public URL: https://abcd12.gradio.live # ← share this on LinkedIn!
Your resume, the Indian Constitution, your company's HR policy, last quarter's earnings call transcript. Anything text-based.
One factual ("when was X founded?"), one summary ("what's the main argument?"), one tricky ("compare X and Y"). Notice the citations.
The answer should be "I couldn't find that in the document." — that's RAG refusing to hallucinate. Record this moment. It's the whole point.
A live in-browser version with three pre-indexed sample documents — click a PDF, ask a question, see RAG retrieve the right chunk before answering:
⚙️ Simulated in-browser with a tiny
semantic-matching layer so it runs key-free. Your real pdf_chat.py uses actual embeddings + GPT —
same flow, same feel, larger brain.
"Chat with [your docs / your PDF / your codebase / your Notion]" is the single most common AI feature on the
market today — and almost all of them are this exact pattern with a fancier UI. ChatGPT's "Browse my files",
Claude Projects, Cursor's @codebase, Glean, Notion AI — every one of them. You now own the
recipe.
"I built a RAG system" is an instant credibility upgrade — most people who post about AI have never actually built one. The shipping ritual continues: clip, caption, post, reply.
Upload your PDF on screen, ask 2 questions, show the "I couldn't find that" moment. That's the magic.
Fill in the blanks. Mention what PDF you tested with — specifics make it land.
Tag #AIEngineering, #RAG, #100DaysOfCode. Replying to every comment in the first hour is the algorithm's favourite signal.
👉 When you type "@Shivank Agrawal", pick linkedin.com/in/shivankagrawal so the tag links correctly. Tagging your instructor and Scaler boosts reach & lets us amplify your post.
Same as before: 10 minutes in class for everyone to post. Then "follow-and-like" round across the cohort. Three classes in, this is becoming muscle memory — and that's exactly the point.
The recipe is the same; the data makes it interesting. Try one of these on your own:
Index the Indian Constitution PDF. Ask "what are the Fundamental Rights?" with citations.
data: indiacode.nic.inIndex TCS or Infosys' last quarterly transcript. Ask about margins, guidance, hiring.
data: investor relations sitesIndex a chapter. Ask exam-style questions. Suddenly: a personalised tutor.
data: your bookshelfGenuinely useful at your workplace. "How many leaves do I have left?" "What's the WFH policy?"
data: your HR portalRAG works. But the frontier is making it smarter — and that's where this course leads next.
Today's RAG always retrieves. But sometimes you don't need to (small talk), and sometimes one retrieval isn't enough (multi-hop questions). Agentic RAG = combine Class 2's agent loop with Class 3's library. The agent decides: "do I need to look this up? Is what I found enough? Should I rewrite my query and try again?"
Query → retrieve → grade chunks → if bad, rewrite query, search again → if still bad, ask user for clarification → answer. Same agent loop you built in Class 2, with the librarian (Class 3) as one of its tools.
Hypothetical Document Embeddings: have the LLM imagine what the answer would look like, then search for chunks that match the imagined answer. Counter-intuitive, works surprisingly well.
Before searching, ask the LLM to generalize the question. "What's the formula for compound interest in this case?" → "What is compound interest?" → broader, better retrieval.
Build a knowledge graph from your docs (entities + relationships). Now you can answer "who reports to X" by walking the graph, not just searching.
How do you measure if your RAG is good? Three metrics: relevance, faithfulness, correctness. We'll wire up a real eval pipeline.
Three classes in, you've built a chatbot, an agent, and a RAG system. You understand the four pieces of every AI product — model · prompt · tool · retrieval. Almost everything ahead is recombining these four in cleverer ways. You're past the steep part of the curve.
Text → vectors. Similar meaning = nearby.
The unglamorous knob that decides quality.
Three calls: add, query, delete.
A real "Chat with your PDF" — on LinkedIn. 🎉