| # Prism β Learning Concepts |
|
|
| ## Concept Index by Product Workflow |
|
|
| Every concept in this file maps to a specific stage in Prism's request lifecycle. Use this to understand *when* each idea becomes relevant, not just *what* it is. |
|
|
| ``` |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β USER UPLOADS A DOCUMENT β |
| β β |
| β PDF/TXT/CSV β chunk β embed β store β briefing β |
| β β |
| β Concept 11 RecursiveCharacterTextSplitter β |
| β How text is split into chunks (paragraph β line β |
| β β word β char priority). Overlap bridges splits. β |
| β β |
| β Concept 1 Parent-Child Chunking β |
| β Design pattern: small child chunks for retrieval, β |
| β large parent chunks sent to LLM. β |
| β β |
| β Concept 2 InMemoryStore β |
| β Where parent chunks live (RAM only, dies on β |
| β restart). ChromaDB stores child vectors on disk. β |
| β β |
| β Concept 14 LLM at Ingest Time (Briefing) β |
| β After ingest, LLM auto-generates 5-bullet summary β |
| β + 3 suggested questions. Runs once, not per query. β |
| β β |
| β Concept 21 Contextual Retrieval β |
| β Prepend 2-sentence situating context to each chunk β |
| β before embedding. Fixes decontextualized chunks. β |
| β Measured: +18% recall (0.51β0.60), +6.6pp P@5. β |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β |
| βΌ |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β USER SENDS A QUESTION β |
| β β |
| β question β (optionally) multi-query expand β (optionally) β |
| β HyDE expand β retrieve β fuse β rerank β top-5 chunks β |
| β β |
| β Concept 17 Multi-Query Retrieval β |
| β LLM generates 3 phrasings of query β retrieve for β |
| β each β pool + deduplicate β RRF β rerank. β |
| β Widens candidate pool. ON by default. β |
| β β |
| β Concept 7 HyDE β |
| β LLM generates fake answer β embed fake answer β |
| β instead of query β closes question/answer vector β |
| β space gap β higher context recall. β |
| β Measured: +21pp recall (0.51β0.72). ON by default. β |
| β β |
| β Concept 4 BM25Okapi β |
| β Sparse keyword retrieval. Catches exact terms β |
| β (section numbers, βΉ amounts) that dense misses. β |
| β TF saturation prevents high-frequency term bias. β |
| β β |
| β Concept 3 RRF + Cross-Encoder Pipeline β |
| β Dense (0.7) + BM25 (0.3) merged via weighted RRF. β |
| β Cross-encoder reranks top-10 jointly β top-5. β |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β |
| βΌ |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β WEB SEARCH (always on) β |
| β β |
| β question β condense with history β Tavily β web results β |
| β β |
| β Concept 5 ConversationalRetrievalChain Condensation Trap β |
| β Chain's internal condense step strips prepended β |
| β web context. Fix: bypass chain entirely for web β |
| β queries. Direct LLM call preserves all context. β |
| β β |
| β Concept 12 ConversationBufferWindowMemory β |
| β Sliding k=10 window of chat history injected into β |
| β condense_question() before Tavily search, so β |
| β follow-up queries have full context. β |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β |
| βΌ |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β LLM GENERATES ANSWER β |
| β β |
| β [doc chunks] + [web results] + [chat history] β LLM β answer β |
| β β |
| β Concept 12 ConversationBufferWindowMemory (output_key trap) β |
| β Memory saves this turn for next question. β |
| β output_key="answer" required β chain returns β |
| β multiple keys, memory needs to know which to save. β |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β |
| βΌ |
| β |
| βΌ |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β EVALUATION (offline, run via scripts/run_eval_versioned.py) β |
| β β |
| β 50 eval pairs β retrieve β answer β score β versioned JSON β |
| β β |
| β Concept 8 Eval Metric Design (Faithfulness Is Circular) β |
| β Why faithfulness 1.0 was meaningless. How β |
| β answer_correctness (vs ground truth) is honest. β |
| β β |
| β Concept 9 Precision@K vs Recall Diagnostic β |
| β P@5=0.89 + recall=0.51 = pool too narrow. β |
| β The combination tells you exactly what to fix. β |
| β β |
| β Concept 15 All 7 Eval Metrics β Full Reference β |
| β Answer Correctness, Answer Relevancy, Context β |
| β Recall, Precision@5, Latency p50/p95/p99. β |
| β Includes computation steps, failure modes, β |
| β Prism v1.0.0 results, and metric interaction map. β |
| β β |
| β Concept 20 Semantic Chunking Tradeoff β |
| β Topic-boundary splits improve recall but hurt β |
| β precision when eval pairs are aligned to fixed β |
| β chunk boundaries. Measured: +9.3pp recall, β |
| β -27.3pp P@5, 5Γ latency (v1.4.0 ablation). β |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| ``` |
|
|
| --- |
|
|
| ## 1. Parent-Child Chunking β The Retrieval-Faithfulness Tradeoff |
|
|
| ### The Problem with One Chunk Size |
|
|
| | Chunk Size | Retrieval | LLM Answer Quality | |
| | --- | --- | --- | |
| | Small (200 chars) | Precise β matches exact phrase | Bad β too little context, answer is fragmented | |
| | Large (800 chars) | Imprecise β embedding averages over too much text | Good β LLM sees full context | |
|
|
| **Key insight:** Embeddings of large chunks get "diluted" β the vector represents the average meaning of 800 chars. Small chunks have sharper, more focused vectors that match queries better. |
|
|
| ### How ParentDocumentRetriever Works |
|
|
| **INGEST:** |
|
|
| - 800-char parent β stored in `InMemoryStore` (keyed by ID) |
| - 200-char children β embedded β stored in ChromaDB |
|
|
| **QUERY:** |
|
|
| 1. Query hits ChromaDB β finds best matching 200-char child chunk |
| 2. Look up `parent_id` from child metadata |
| 3. Return the full 800-char parent to LLM |
|
|
| ### Why This Works |
|
|
| - **Child chunk** = precise retrieval target (dense vector = focused meaning) |
| - **Parent chunk** = rich answer context (LLM gets surrounding sentences) |
|
|
| ### Concrete Example β RBI Circular PDF |
|
|
| **Raw text (one paragraph):** |
|
|
| > "The Reserve Bank of India has mandated that all UPI transactions above βΉ2,000 must undergo additional authentication from January 2025. This includes biometric verification or OTP-based second factor. Non-compliant PSPs will face penalties up to βΉ10 lakh per violation." |
| > |
| |
| **After chunking:** |
|
|
| - **Parent (800 chars) β InMemoryStore:** Full paragraph above |
| - **Child A (200 chars) β ChromaDB:** "The Reserve Bank of India has mandated that all UPI transactions above βΉ2,000 must undergo additional authentication from January 2025." |
| - **Child B:** "This includes biometric verification or OTP-based second factor." |
| - **Child C:** "Non-compliant PSPs will face penalties up to βΉ10 lakh per violation." |
|
|
| **Query:** `"What is the UPI transaction authentication limit?"` |
|
|
| β ChromaDB finds **Child A** (score: 0.91) β focused vector on UPI + βΉ2000 + authentication |
|
|
| β Fetches parent_id β returns **full parent paragraph** to LLM |
| |
| **Comparison Table:** |
| |
| | Approach | LLM Gets | Problem | |
| | --- | --- | --- | |
| | Dense-only, large chunks | Full paragraph (good) | Match was imprecise β wrong paragraph might score higher | |
| | Dense-only, small chunks | Child A only (38 words) | Misses penalty info β incomplete answer | |
| | ParentDocumentRetriever | Full paragraph (precise match + rich context) | β
Best of both | |
| |
| **LLM final answer:** "UPI transactions above βΉ2,000 require additional authentication (biometric or OTP) from Jan 2025. Non-compliant PSPs face up to βΉ10 lakh penalty." |
| |
| --- |
| |
| ## 2. InMemoryStore β What It Actually Is |
| |
| **Key point: InMemoryStore is NOT part of ChromaDB. It's a separate, RAM-only store.** |
| |
| Under the hood it's essentially a plain Python dict wrapped in a LangChain class: |
| |
| ```python |
| store = {} |
| store["parent_id_abc123"] = "Full 800-char parent chunk text..." |
| store["parent_id_def456"] = "Another parent chunk..." |
| ``` |
| |
| ### Two Separate Stores in Prism |
| |
| | ChromaDB (on disk) | InMemoryStore (RAM only) | |
| | --- | --- | |
| | Child chunk vectors | Parent chunk text | |
| | [vector, metadata] β find similar | parent_id β full text | |
| | β
Survives restart | β Dies on restart | |
|
|
| ### Flow |
|
|
| ``` |
| Child A metadata = { "parent_id": "abc123", "text": "short child..." } |
| β |
| InMemoryStore["abc123"] |
| β |
| "Full 800-char parent text" β LLM |
| ``` |
|
|
| ### β οΈ Known Limitation in Prism |
|
|
| Render free tier cold-starts β InMemoryStore is wiped β must **re-ingest PDFs on every cold start**. |
|
|
| ChromaDB persists to disk so child vectors survive, but parent text is gone. |
|
|
| ### β οΈ Architecture Note β Intended vs Current Implementation |
|
|
| `architecture.md` and Concept 1 describe ParentDocumentRetriever (child 200 / parent 800). The current `ingest.py` uses a single-pass `RecursiveCharacterTextSplitter` at 500 chars β no separate parent store. The ParentDocumentRetriever was the original design and is documented as such. Both chunking approaches teach the same tradeoff; the parent-child concept remains valid as a pattern even if the current code simplified to single-pass chunking. |
|
|
| --- |
|
|
| ## 3. RRF + Cross-Encoder Reranker Pipeline |
|
|
| ### 3a. RRF β Reciprocal Rank Fusion |
|
|
| **Problem:** Dense retrieval returns a ranked list. BM25 returns a ranked list. Scores are on different scales (BM25: 0β15, cosine: 0β1) β can't add them directly. |
|
|
| **RRF Solution:** Ignore raw scores. Use only rank position. |
|
|
| ``` |
| Standard RRF formula: |
| score(doc) = Ξ£ 1 / (k + rank_in_list) |
| k = 60 (constant, dampens top-rank advantage) |
| |
| Prism's weighted RRF (applied inside the formula per list): |
| score(doc) += dense_weight / (k + rank_in_dense_list) # 0.7 Γ contribution |
| score(doc) += sparse_weight / (k + rank_in_sparse_list) # 0.3 Γ contribution |
| ``` |
|
|
| **Example:** |
|
|
| | Doc | Dense (ChromaDB) | BM25 | RRF Score | |
| | --- | --- | --- | --- | |
| | Doc A | Rank 1 (0.91) | Rank 2 (9.1) | 1/61 + 1/62 = **0.0325** β
| |
| | Doc C | Rank 3 (0.71) | Rank 1 (12.3) | 1/63 + 1/61 = **0.0320** | |
| | Doc B | Rank 2 (0.87) | β | 1/62 = 0.0161 | |
| | Doc D | β | Rank 3 (7.4) | 1/63 = 0.0159 | |
|
|
| **Doc A wins** β appeared high in BOTH lists β signals true relevance. |
|
|
| > In Prism: `dense_weight=0.7`, `sparse_weight=0.3` applied **inside** the RRF formula β each list's contribution is multiplied by its weight before summing. Not "before RRF" as a pre-filter, but as a per-list scaling factor within fusion (see Concept 4 for the actual code). |
| > |
| |
| ### 3b. Cross-Encoder Reranker |
|
|
| **Problem:** RRF gives top-10 candidates. Bi-encoder (ChromaDB) encodes query and doc *separately* β approximate similarity. |
|
|
| **Cross-Encoder:** Feeds query + doc *together* into BERT β full attention across both β much more accurate relevance score. |
|
|
| | | Bi-Encoder (ChromaDB) | Cross-Encoder (TinyBERT) | |
| | --- | --- | --- | |
| | Method | embed(query) + embed(doc) β cosine(q,d) | BERT([query][SEP][doc]) β single score 0β1 | |
| | Speed | Fast | Slow | |
| | Accuracy | Approximate | Accurate | |
| | Encoding | Independent | Joint | |
|
|
| **Example β after RRF top-10:** |
|
|
| | Pair | Cross-Encoder Score | |
| | --- | --- | |
| | (query, Doc A) | 0.94 β
| |
| | (query, Doc B) | 0.88 β
| |
| | (query, Doc C) | 0.61 | |
| | (query, Doc D) | 0.23 | |
|
|
| Top-5 by cross-encoder score β LLM |
|
|
| ### Full Retrieval Pipeline |
|
|
| ``` |
| Query |
| | |
| ββ ChromaDB dense β top-10 ranked docs |
| ββ BM25 sparse β top-10 ranked docs |
| | |
| β |
| RRF fusion β merged top-10 (rank-based, scale-agnostic) |
| | |
| β |
| Cross-encoder β re-scores all 10 jointly with query |
| | |
| β |
| Top-5 parent chunks β LLM |
| ``` |
|
|
| --- |
|
|
| --- |
|
|
| ## 4. BM25Okapi β Why Keywords Beat Embeddings for Exact Terms |
|
|
| ### The Problem with Dense Retrieval on Regulatory Text |
|
|
| Embeddings capture *meaning*. But regulatory text has exact identifiers β section numbers, policy codes, rupee amounts β where the exact token matters, not the meaning. |
|
|
| **Example:** |
|
|
| Query: `"What is the penalty for UPI non-compliance?"` |
|
|
| A dense embedding model reads this as: *"something about UPI and consequences"*. |
|
|
| Two chunks in corpus: |
| - Chunk A: `"Non-compliant PSPs will face penalties up to βΉ10 lakh per violation."` β exact answer |
| - Chunk B: `"UPI has transformed digital payments in India with over 100 billion transactions."` β semantically close (UPI topic) but wrong |
|
|
| Dense retrieval might rank Chunk B high because it's heavily UPI-themed. BM25 ranks Chunk A high because "penalt" and "non-compli" are rare, high-signal tokens. |
|
|
| ### How BM25Okapi Works |
|
|
| Plain TF-IDF problem: a doc that says "UPI" 50 times gets 50Γ the score. That's unfair β one mention of "βΉ10 lakh" in the right context should beat 50 mentions of "UPI" in a generic overview. |
|
|
| BM25 fixes this with **term frequency saturation**: |
|
|
| ``` |
| BM25 score = IDF(term) Γ [ tf Γ (k1 + 1) ] / [ tf + k1 Γ (1 - b + b Γ dl/avgdl) ] |
| |
| Where: |
| tf = how many times term appears in this chunk |
| IDF = how rare the term is across all chunks (log scale) |
| k1 = saturation constant (~1.5) β controls how fast TF saturates |
| b = length normalization (~0.75) |
| dl = this chunk's length |
| avgdl = average chunk length in corpus |
| ``` |
|
|
| **Saturation in plain English:** |
|
|
| | tf (term count in chunk) | TF-IDF score | BM25 score (k1=1.5) | |
| | --- | --- | --- | |
| | 1 | 1.0 | 1.0 | |
| | 5 | 5.0 | 1.56 β plateaus | |
| | 20 | 20.0 | 1.79 β barely grows | |
| | 50 | 50.0 | 1.88 β effectively capped | |
|
|
| A chunk mentioning "UPI" 50 times scores almost the same as one mentioning it 5 times. But "βΉ10 lakh" appearing once in a chunk that has it = high IDF (rare token) Γ full TF benefit. |
|
|
| ### In Prism |
|
|
| ```python |
| # bm25_index.py |
| from rank_bm25 import BM25Okapi |
| |
| # .lower() matters β "UPI" and "upi" are different tokens without it |
| corpus = [doc["content"].lower().split() for doc in all_chunks] |
| bm25 = BM25Okapi(corpus) |
| |
| # At query time β query also lowercased to match corpus tokenization: |
| scores = bm25.get_scores(query.lower().split()) |
| top_k_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k] |
| ``` |
|
|
| BM25 operates on raw tokens (word split). No embeddings. No GPU. Rebuilds in ~1s at startup from corpus. |
|
|
| **Weight in Prism:** `sparse_weight=0.3` in RRF β BM25 is complementary, not dominant. Dense handles semantic meaning; BM25 catches exact matches dense misses. |
|
|
| --- |
|
|
| ## 5. ConversationalRetrievalChain β The Silent Context Killer |
|
|
| ### What the Chain Does Internally |
|
|
| `ConversationalRetrievalChain` has two internal steps most people don't know about: |
|
|
| ``` |
| User question + chat history |
| β |
| [STEP 1] Condense question |
| LLM rewrites "Is the price level good?" |
| β "Is Bajaj Finance stock βΉ908-924 a good buy in 2026?" |
| (standalone question for retrieval) |
| β |
| [STEP 2] Retrieve + Answer |
| Standalone question β retriever β top-5 chunks β LLM answer |
| ``` |
|
|
| Step 1 exists so follow-up questions work without full history context. Good design for RAG. |
|
|
| ### The Bug: Web Context Gets Stripped |
|
|
| When Prism added Tavily web search, the naive approach was: |
|
|
| ```python |
| web_results = tavily.search(question) |
| augmented_question = f"Web context: {web_results}\n\nQuestion: {question}" |
| chain.invoke({"question": augmented_question}) # β WRONG |
| ``` |
|
|
| What actually happens: |
|
|
| ``` |
| augmented_question (with Tavily content prepended) |
| β |
| [STEP 1] Chain's condense LLM: |
| "Rewrite this as a standalone retrieval query." |
| Output: "What is Bajaj Finance stock price?" |
| β ALL TAVILY CONTENT STRIPPED. LLM never sees it. |
| β |
| [STEP 2] Answer LLM gets: corpus chunks only. No web context. |
| ``` |
|
|
| The chain's condensation step rewrites the question for retrieval quality β and throws away everything else. |
|
|
| ### The Fix in Prism |
|
|
| Bypass the chain entirely for web queries. Direct LLM call: |
|
|
| ```python |
| # chain.py |
| def run_query_with_web(question, rag_docs, web_sources, memory): |
| history = memory.load_memory_variables({})["history"] |
| |
| prompt = f""" |
| Chat history: {history} |
| |
| Web search results: |
| {web_sources} |
| |
| Document context: |
| {rag_docs} |
| |
| Question: {question} |
| Answer:""" |
| |
| answer = llm.invoke(prompt) |
| memory.save_context({"input": question}, {"answer": answer}) |
| return answer |
| ``` |
|
|
| No condensation step. Web context reaches the LLM guaranteed. |
|
|
| **Lesson:** LangChain abstractions are powerful but opaque. When something doesn't work, read what the chain actually does internally β don't assume the abstraction is transparent. |
|
|
| --- |
|
|
| ## 7. HyDE β Hypothetical Document Embeddings |
|
|
| ### The Problem with Embedding a Question |
|
|
| When you search ChromaDB, you embed the *query* and find similar vectors. But your corpus contains *answers*, not questions. They live in different regions of vector space. |
|
|
| **Concrete example:** |
|
|
| Query: `"What is the UPI transaction limit for P2P transfers?"` |
|
|
| This embeds as a *question vector* β the model has seen millions of questions, it knows this pattern. |
|
|
| The answer in your corpus: `"P2P UPI transfers are capped at βΉ1 lakh per transaction per day as per NPCI guidelines."` |
|
|
| This embeds as an *answer vector* β declarative sentence, factual tone, different region in 1536-dimensional space. |
|
|
| They're semantically related, but the vector distance is larger than it should be. |
|
|
| ### HyDE's Solution |
|
|
| Before searching, ask the LLM to hallucinate an answer: |
|
|
| ``` |
| Step 1: LLM generates a fake answer to the query |
| Query: "What is the UPI transaction limit for P2P transfers?" |
| Fake answer: "The UPI transaction limit for P2P transfers is typically |
| set by NPCI and varies by bank, generally around βΉ1 lakh |
| per day for most PSPs." |
| |
| Step 2: Embed the FAKE ANSWER (not the query) |
| vector = embed("The UPI transaction limit for P2P transfers is...") |
| |
| Step 3: Search ChromaDB with this vector |
| β Finds real answer chunks that are semantically close to a fake answer |
| β Much closer in vector space than the original question was |
| ``` |
|
|
| ### Why "Hypothetical" Works |
|
|
| The fake answer and the real corpus chunk are both declarative, factual, answer-shaped text. They live in the same region of vector space. The query (a question) lives elsewhere. |
|
|
| ``` |
| Vector space (simplified): |
| |
| [Question zone] [Answer zone] |
| "What is UPI limit?" --- ... --- "P2P capped at βΉ1 lakh..." |
| β β |
| far from corpus close to corpus chunk |
| |
| HyDE: |
| "UPI limit is ~βΉ1 lakh..." β fake answer β close to real chunk β
|
| ``` |
|
|
| ### In Prism |
|
|
| ```python |
| # retriever.py |
| def _hyde_expand(self, query: str) -> str: |
| prompt = f"Write a 2-sentence factual answer to: {query}" |
| return self.llm.invoke(prompt).content |
| |
| def _get_relevant_documents(self, query: str): |
| dense_query = self._hyde_expand(query) if self.use_hyde else query |
| |
| # Dense search uses fake answer embedding |
| dense_docs = self.vectorstore.similarity_search(dense_query, k=10) |
| |
| # BM25 still uses original query (keyword matching needs real terms) |
| sparse_docs = self.bm25_retrieve(query, k=10) |
| |
| return self.rrf_and_rerank(dense_docs, sparse_docs) |
| ``` |
|
|
| **ON by default** (`config.yaml` `hyde_enabled: true`) β adds one Groq call per query (~200ms). Latency cost accepted. |
|
|
| **Measured lift (v1.1.0, 18 samples):** context_recall 0.51 β 0.72 (+21pp). Latency 2029ms β 4018ms p50 (2Γ). HyDE specifically helps recall β it finds chunks that keyword/direct-embed matching misses. |
| |
| --- |
| |
| ## 8. Eval Metric Design β Why Faithfulness Is Circular |
| |
| ### Four RAGAS Metrics and What They Actually Measure |
| |
| | Metric | Judge compares | Question it answers | |
| | --- | --- | --- | |
| | **faithfulness** | LLM answer vs retrieved chunks | "Did the LLM stick to what was in the retrieved docs?" | |
| | **answer_correctness** | LLM answer vs ground_truth reference | "Is the answer actually right?" | |
| | **answer_relevancy** | LLM answer vs original question | "Did the answer address what was asked?" | |
| | **context_recall** | Retrieved chunks vs ground_truth | "Did retrieval find the chunks needed to answer?" | |
| |
| ### The Faithfulness Trap |
| |
| In Prism v1 eval, faithfulness scored 1.0. Seemed great. Was meaningless. |
| |
| Here's why: |
| |
| ``` |
| Eval pair designed alongside corpus: |
| Question: "What is the UPI P2P transaction limit?" |
| Ground truth: "βΉ1 lakh per day" |
| |
| Corpus chunk (also written by us): |
| "P2P UPI transfers are capped at βΉ1 lakh per day per NPCI guidelines." |
| |
| Flow: |
| 1. LLM retrieves that chunk (of course β it's perfectly matched) |
| 2. LLM answers: "The UPI P2P limit is βΉ1 lakh per day." |
| 3. RAGAS faithfulness judge: "Does answer match retrieved chunk?" β YES β score: 1.0 |
| ``` |
| |
| The judge is comparing the answer to the chunk that was *designed to produce that answer*. Circular. Score tells you the retrieval worked, not whether the answer is correct. |
| |
| ### answer_correctness Is the Honest Metric |
|
|
| ``` |
| Flow: |
| 1. LLM answers: "The UPI P2P limit is βΉ1 lakh per day." |
| 2. Ground truth (human-written reference): "βΉ1 lakh per transaction per day" |
| 3. RAGAS judge: "Does answer match ground truth?" β mostly yes β score: 0.82 |
| ``` |
|
|
| Judge compares to an *independent human reference*. No circular dependency on retrieved chunks. Harder to game. |
|
|
| **Prism v1.0.0 Violet results:** |
|
|
| | Metric | Score | Interpretation | |
| | --- | --- | --- | |
| | answer_correctness | 0.82 | 82% of answers match ground truth β honest signal | |
| | answer_relevancy | 0.62 | RAGAS penalizes verbosity β Groq 70B tends to over-explain | |
| | context_recall | 0.51 | Only 51% of needed chunks retrieved β target for Multi-Query | |
| | P@5 | 0.89 | When chunks are retrieved, 89% are correct β precision is fine | |
| |
| ### The P@5 + Recall Diagnostic |
| |
| P@5=0.89 + recall=0.51 tells you something specific: |
| |
| ``` |
| Retrieval pool is too narrow. |
| |
| "When we retrieve something, it's usually right (0.89)." |
| "But we're missing ~half the relevant chunks (0.51)." |
| |
| Root cause: single-phrasing query misses chunks phrased differently. |
| Fix: Multi-Query Retrieval β generate 3 phrasings, retrieve for each, pool candidates. |
| ``` |
| |
| This is exactly how production ML teams diagnose retrieval systems. The combination of metrics points to the specific fix. |
| |
| --- |
| |
| ## 9. Precision@K vs Recall β The Retrieval Diagnostic Pair |
| |
| ### Definitions in Plain English |
| |
| Imagine your corpus has **5 chunks** that are genuinely relevant to a query. Your retriever returns **5 chunks** (K=5). |
| |
| ``` |
| Ground truth relevant chunks: [A, B, C, D, E] |
| Retrieved chunks: [A, B, X, Y, Z] |
| |
| Precision@5 = correct retrieved / K = 2/5 = 0.40 |
| "Of what I returned, how much was right?" |
| |
| Recall@5 = correct retrieved / total relevant = 2/5 = 0.40 |
| "Of all the right chunks, how many did I find?" |
| ``` |
| |
| ### The Four Diagnostic Combinations |
| |
| | P@5 | Recall | Diagnosis | Fix | |
| | --- | --- | --- | --- | |
| | High | High | β
Retrieval working well | Ship it | |
| | High | Low | Pool too narrow β finding right chunks but missing others | Multi-Query Retrieval, HyDE | |
| | Low | High | Too much noise β finding relevant chunks but also junk | Better reranking, stricter K | |
| | Low | Low | Retrieval fundamentally broken | Check embeddings, chunking strategy | |
| |
| ### Prism v1.0.0 Violet: High P, Low Recall |
| |
| ``` |
| P@5 = 0.89 β "When Prism retrieves a chunk, 89% of the time it's relevant." |
| Recall = 0.51 β "But Prism only finds 51% of the relevant chunks total." |
| |
| Example query: "What are the RBI guidelines on UPI merchant limits?" |
| |
| Relevant chunks in corpus: [merchant_limit_2024, merchant_kyc, limit_circular_2023, payment_cap, psp_obligations] |
|
|
| Prism retrieved: [merchant_limit_2024, merchant_kyc, some_unrelated_chunk, another_unrelated, payment_cap] |
| |
| Precision@5 = 3/5 = 0.60 (found 3 right ones, 2 noise) |
| Recall = 3/5 = 0.60 (missed limit_circular_2023 and psp_obligations) |
| ``` |
| |
| Why did it miss them? `limit_circular_2023` might use different phrasing: *"The ceiling for merchant UPI collections was revised..."* β no overlap with "merchant limits". Single-phrasing retrieval misses it. |
| |
| Multi-Query generates: `"UPI merchant payment ceiling"`, `"RBI merchant collection limit"`, `"PSP merchant UPI cap"` β retrieves from all three β pools candidates β recall rises. |
| |
| --- |
| |
| --- |
| |
| ## 11. RecursiveCharacterTextSplitter β How Text Gets Chunked |
| |
| ### The Problem with Fixed Splits |
| |
| Splitting every 500 characters naively: |
| |
| ``` |
| "...comply with Section 7(b) of the Act. |SPLIT| Payment systems must maintain..." |
| ``` |
| |
| Cut mid-sentence. "Section 7(b)" separated from "Payment systems must maintain" β the context that makes "Section 7(b)" meaningful is now in a different chunk. The embedding for that chunk is weak. |
| |
| ### How RecursiveCharacterTextSplitter Works |
| |
| It tries a priority list of separators, from most preferred to least: |
| |
| ```python |
| separators = ["\n\n", "\n", " ", ""] |
| # Priority: paragraph break > line break > word break > character break |
| ``` |
| |
| For a given `chunk_size=500`: |
| |
| 1. **Try `\n\n` first** β split at paragraph boundaries. If resulting piece β€ 500 chars: done. |
| 2. **If piece still >500** β try `\n` (line breaks within the paragraph). |
| 3. **If still >500** β try spaces (word boundaries). |
| 4. **Last resort** β split at exact character position. |
| |
| **Result:** Chunks break at natural language boundaries when possible, hard character cuts only when forced. |
| |
| ### Overlap β Why Adjacent Chunks Share Text |
| |
| ```python |
| splitter = RecursiveCharacterTextSplitter( |
| chunk_size=500, |
| chunk_overlap=50, # last 50 chars of chunk N = first 50 chars of chunk N+1 |
| ) |
| ``` |
| |
| Without overlap: |
| ``` |
| Chunk 1: "...UPI transaction limits are set by NPCI guidelines for" |
| Chunk 2: "each payment service provider based on risk assessment." |
| ``` |
| Query `"UPI limit by PSP"` β the full sentence is split. Neither chunk alone has a strong embedding. |
|
|
| With overlap=50: |
| ``` |
| Chunk 1: "...UPI transaction limits are set by NPCI guidelines for each" |
| Chunk 2: "for each payment service provider based on risk assessment." |
| ``` |
| The bridging phrase appears in both chunks. One of them will have a strong vector for the full concept. |
|
|
| ### In Prism |
|
|
| ```python |
| # ingest.py |
| splitter = RecursiveCharacterTextSplitter( |
| chunk_size=500, # from config.yaml |
| chunk_overlap=50, |
| ) |
| chunks = splitter.split_documents(documents) |
| ``` |
|
|
| **Note:** Prism originally used ParentDocumentRetriever (child 200 / parent 800). The current code uses a single-pass splitter at 500 chars. The architecture.md documents the intended design; the code reflects the current implementation. Both teach the same concept β chunk size is a precision-vs-context tradeoff. |
|
|
| --- |
|
|
| ## 12. ConversationBufferWindowMemory β Sliding Window Chat History |
|
|
| ### Why You Need Memory in a Chat System |
|
|
| Each `POST /api/chat` is a stateless HTTP request. The LLM has no memory of what was said 10 seconds ago. Without memory: |
|
|
| ``` |
| User: "Tell me about UPI transaction limits." |
| Prism: "UPI P2P limit is βΉ1 lakh per day..." |
| |
| User: "What about merchant payments?" β no context |
| LLM sees: question = "What about merchant payments?" β what is "about"? who knows? |
| Prism: "Merchant payments are a type of..." β wrong, generic answer |
| ``` |
|
|
| ### How ConversationBufferWindowMemory Works |
|
|
| Keeps last `k` conversation turns (human + AI) as a rolling window: |
|
|
| ```python |
| # memory.py |
| memory = ConversationBufferWindowMemory( |
| memory_key="chat_history", |
| return_messages=True, |
| output_key="answer", # β critical β explained below |
| k=10, # keep last 10 turns |
| ) |
| ``` |
|
|
| Turn 1: `[Human: "Tell me about UPI limits", AI: "UPI P2P is βΉ1 lakh..."]` |
| Turn 2: `[Human: "Tell me about UPI limits", AI: "...", Human: "What about merchant payments?", AI: "Merchant UPI limit is βΉ5 lakh..."]` |
|
|
| At turn 11: Turn 1 is evicted. Window always has last 10. |
|
|
| The chain sees: |
| ``` |
| Chat history: |
| Human: Tell me about UPI limits |
| AI: UPI P2P is βΉ1 lakh per day... |
| |
| Current question: What about merchant payments? |
| ``` |
| β LLM understands "What about" refers to UPI limits. Answers correctly. |
|
|
| ### The output_key Trap |
| |
| `ConversationalRetrievalChain` returns a dict with multiple keys: |
| |
| ```python |
| result = chain.invoke({"question": "..."}) |
| # result = { |
| # "answer": "UPI P2P limit is βΉ1 lakh...", |
| # "source_documents": [...], |
| # "question": "..." |
| # } |
| ``` |
| |
| Memory's `save_context` call needs to know which key is the "output" to save: |
| |
| ```python |
| # WRONG β KeyError because chain returns multiple output keys |
| memory = ConversationBufferWindowMemory(memory_key="chat_history") |
|
|
| # RIGHT β tell memory exactly which key to save |
| memory = ConversationBufferWindowMemory( |
| memory_key="chat_history", |
| output_key="answer", # save result["answer"], not the whole dict |
| ) |
| ``` |
| |
| Without `output_key="answer"`: LangChain tries to infer the output key, sees multiple candidates, raises `ValueError: Multiple keys returned`. This was a real bug hit during Prism development β subtle because the chain runs fine; the crash happens on the `save_context` call after. |
|
|
| --- |
|
|
| ## 14. LLM at Ingest Time β The Briefing Pattern |
|
|
| ### Two Places LLMs Can Run in a RAG System |
|
|
| Most tutorials show LLM running only at **query time**: user asks β retrieve β LLM answers. |
|
|
| Prism also runs LLM at **ingest time**: document uploaded β LLM summarizes β shown to user immediately. |
|
|
| ``` |
| INGEST TIME (once, when doc uploaded): |
| PDF β chunks β embed β store |
| β (also) |
| LLM reads first 6 chunks |
| β generates 5-bullet summary |
| β generates 3 suggested questions |
| β returned in upload response |
| |
| QUERY TIME (every user message): |
| question β retrieve β LLM answers |
| ``` |
|
|
| ### Why This Is Useful |
|
|
| When a user uploads an unfamiliar document, they don't know what to ask. The briefing gives them: |
|
|
| 1. **Orientation** β "what is this document about?" |
| 2. **Starting questions** β clickable prompts that immediately work |
|
|
| ```python |
| # briefing.py |
| def generate_briefing(doc_name: str, text_sample: str) -> dict: |
| prompt = f""" |
| Analyze this document excerpt and respond with valid JSON only. |
| Document: {doc_name} |
| Content: {text_sample[:3000]} |
| |
| Return exactly: {{"summary": ["5 bullet strings"], "suggested_questions": ["3 question strings"]}} |
| """ |
| response = llm.invoke([HumanMessage(content=prompt)]) |
| # Parse JSON from response β return to frontend |
| ``` |
|
|
| ```python |
| # routes/upload.py β after ingestion completes: |
| briefing = generate_briefing(doc_name, sample_text) |
| return { |
| "uploaded": [filename], |
| "documents": docs, |
| "briefing": briefing, # β frontend shows this immediately |
| } |
| ``` |
|
|
| ### Design Decisions |
|
|
| **Non-critical path** β briefing failure doesn't block upload: |
| ```python |
| try: |
| briefing = generate_briefing(...) |
| except Exception as e: |
| logger.warning("Briefing skipped: %s", e) |
| briefing = None # upload still succeeds |
| ``` |
|
|
| **JSON extraction from LLM output** β LLMs often wrap JSON in markdown fences (` ```json`). Prism strips these with regex before `json.loads()`: |
| ```python |
| raw = re.sub(r"^```(?:json)?\s*", "", raw) # strip opening fence |
| raw = re.sub(r"\s*```$", "", raw) # strip closing fence |
| match = re.search(r"\{.*\}", raw, re.DOTALL) # extract JSON object |
| data = json.loads(match.group()) |
| ``` |
|
|
| **When to use LLM at ingest time vs query time:** |
|
|
| | | Ingest time | Query time | |
| | --- | --- | --- | |
| | Runs | Once per document | Once per user message | |
| | Cost | Fixed (paid once) | Scales with usage | |
| | Best for | Document-level metadata, summaries, question suggestions | Answering specific user questions | |
| | Examples | Briefing, contextual retrieval, chunk tagging | RAG answer, faithfulness eval, HyDE | |
|
|
| The most powerful use of ingest-time LLM is **contextual retrieval** (roadmap): for every chunk, ask LLM "given this full document, write 2 sentences situating this chunk" β then prepend that context before embedding. Anthropic reports ~49% reduction in retrieval failures. Same LLM-at-ingest-time pattern, much higher impact. |
|
|
| --- |
|
|
| --- |
|
|
| ## 15. Eval Dashboard Metrics β Full Reference |
|
|
| Prism's eval dashboard shows 7 numbers per run. Each answers a different question about the system. Understanding *what* each metric measures, *how* it's computed, and *what can fool it* is the difference between blindly running evals and actually improving the system. |
|
|
| --- |
|
|
| ### Overview β What Each Metric Diagnoses |
|
|
| ``` |
| Query β [Retrieval] β top-5 chunks β [LLM] β answer |
| β β β |
| Precision@5 Context Recall Answer Correctness |
| Answer Relevancy |
| Latency p50/p95/p99 |
| ``` |
|
|
| | Metric | What it measures | Requires ground truth? | LLM call? | |
| | --- | --- | --- | --- | |
| | Answer Correctness | Is the answer factually right? | Yes | Yes (judge) | |
| | Answer Relevancy | Does answer address the question? | No | Yes + embed | |
| | Context Recall | Did retrieval find all needed chunks? | Yes | Yes (RAGAS) | |
| | Precision@5 | Are the top-5 chunks relevant? | Yes (keywords/sources) | No | |
| | Latency p50/p95/p99 | How fast is the system? | No | No | |
|
|
| --- |
|
|
| ### Metric 1 β Answer Correctness (primary) |
|
|
| **Question it answers:** "Is Prism's answer actually correct?" |
|
|
| **Why it's primary:** The only metric that directly measures answer quality vs a human-authored reference. Everything else is a proxy. |
|
|
| **How it's computed:** |
|
|
| ```python |
| CORRECTNESS_PROMPT = """ |
| You are an evaluation judge. Score the generated answer against the reference answer. |
| |
| Use a 1β5 integer scale: |
| 5 β All key facts present and correct |
| 4 β Most key facts correct, minor omissions or imprecision |
| 3 β Some key facts correct, moderate gaps |
| 2 β Few facts correct, significant errors or hallucinations |
| 1 β Completely wrong, irrelevant, or contradicts reference |
| |
| Reference answer: {ground_truth} |
| Generated answer: {answer} |
| |
| Respond with JSON: {"score": 4, "reason": "one sentence"} |
| """ |
| |
| # Normalize 1β5 β 0β1 |
| score_normalized = (raw_score - 1) / 4 |
| ``` |
|
|
| **Example:** |
|
|
| ``` |
| Ground truth: "UPI P2P limit is βΉ1 lakh per transaction per day per NPCI guidelines." |
| Answer: "You can transfer up to βΉ1 lakh daily on UPI for person-to-person transfers." |
| |
| Judge: score=4 (correct amount + correct type, slight imprecision on "per transaction") |
| Normalized: (4-1)/4 = 0.75 |
| ``` |
|
|
| **What the judge model sees:** Only the answer and ground truth. NOT the retrieved chunks. This is intentional β if the answer is wrong but the chunks were correct, that's an LLM reasoning failure. If the answer is right but the chunks were irrelevant, that's lucky hallucination. |
|
|
| **What fools it:** |
| - 8B judge is lenient β "approximately correct" often scores 4/5 |
| - Rephrased correct answers may score 3 if judge doesn't recognize equivalence |
| - Very long answers may confuse the judge β score correct facts buried in padding lower |
|
|
| **Prism v1.0.0 result:** `0.82` β 82% of answers have most key facts correct. Target: >0.85 via better retrieval (Multi-Query β more context β better answers). |
|
|
| --- |
|
|
| ### Metric 2 β Answer Relevancy |
|
|
| **Question it answers:** "Did Prism answer the question that was actually asked, or did it go off on a tangent?" |
|
|
| **How it's computed (RAGAS):** |
|
|
| ``` |
| Step 1: Given the generated answer, LLM generates N reverse questions |
| Answer: "UPI P2P limit is βΉ1 lakh per day..." |
| Reverse Q1: "What is the UPI transaction limit?" |
| Reverse Q2: "How much can you transfer via UPI P2P daily?" |
| Reverse Q3: "What is the per-day UPI P2P cap?" |
| |
| Step 2: Embed original question + all N reverse questions |
| embed("What is the UPI transaction limit for P2P transfers?") β vector_q |
| embed("What is the UPI transaction limit?") β vector_r1 |
| embed("How much can you transfer via UPI P2P daily?") β vector_r2 |
| |
| Step 3: Cosine similarity between original question and each reverse question |
| sim(vector_q, vector_r1) = 0.94 |
| sim(vector_q, vector_r2) = 0.91 |
| sim(vector_q, vector_r3) = 0.89 |
| |
| Step 4: Answer relevancy = mean similarity = (0.94 + 0.91 + 0.89) / 3 = 0.91 |
| ``` |
|
|
| **Intuition:** If the answer actually addresses the question, reverse-engineered questions from that answer will be similar to the original question. If the answer went off-topic, the reverse questions will diverge. |
|
|
| **What fools it:** |
| - **Verbosity penalty:** Groq 70B tends to over-explain. A padded answer like "Great question! UPI P2P limit is βΉ1 lakh. UPI has transformed payments in India, with many PSPs offering..." generates reverse questions about UPI history, not the limit β lower similarity β lower score. |
| - Doesn't check factual correctness β a confident wrong answer can score high if it's "on topic". |
|
|
| **Prism v1.0.0 result:** `0.62` β lower than expected. Direct cause: Groq 70B verbose responses. Fix: tighter system prompt (`"Be concise. One paragraph maximum."`). |
|
|
| --- |
|
|
| ### Metric 3 β Context Recall |
|
|
| **Question it answers:** "Did the retriever surface all the chunks that were needed to construct a correct answer?" |
|
|
| **How it's computed (RAGAS):** |
|
|
| ``` |
| Step 1: Take ground truth reference answer |
| "UPI P2P limit is βΉ1 lakh per day. Merchant UPI payments are capped at βΉ5 lakh." |
| |
| Step 2: RAGAS LLM breaks it into individual factual sentences (claims) |
| Claim A: "UPI P2P limit is βΉ1 lakh per day." |
| Claim B: "Merchant UPI payments are capped at βΉ5 lakh." |
| |
| Step 3: For each claim, check: can this claim be attributed to any retrieved chunk? |
| Claim A β search retrieved chunks β Chunk 3 contains "P2P cap βΉ1 lakh" β ATTRIBUTED β |
| Claim B β search retrieved chunks β no chunk mentions merchant cap β NOT ATTRIBUTED β |
| |
| Step 4: Context recall = attributed claims / total claims = 1/2 = 0.50 |
| ``` |
|
|
| **Intuition:** Recall measures completeness of the retrieval pool. Low recall = the retriever missed relevant chunks β LLM can't answer completely even if it tries β answer will be incomplete or hallucinated. |
|
|
| **Why this is the most important metric to improve:** |
| - Low recall (0.51 in v1.0.0) means ~half the relevant information is missing from context |
| - LLM can only work with what it's given β perfect LLM + bad retrieval = bad answer |
| - Improving recall directly improves answer quality |
|
|
| **What fools it:** |
| - Ground truth phrasing matters β if GT says "βΉ1 lakh" and the chunk says "100,000 rupees", RAGAS LLM may or may not link them |
| - Long GT answers with many claims β denominator grows β harder to achieve high recall |
|
|
| **Prism v1.0.0 result:** `0.51` β retriever finds correct chunks (P@5=0.89) but misses ~half the relevant ones. **Directly motivates Multi-Query Retrieval.** |
|
|
| --- |
|
|
| ### Metric 4 β Precision@5 |
|
|
| **Question it answers:** "Of the 5 chunks Prism returned, how many were actually relevant?" |
|
|
| **How it's computed (custom, deterministic β no LLM):** |
|
|
| ```python |
| def compute_precision_at_k(query, retrieved_chunks, ground_truth, k=5): |
| relevant_sources = ground_truth.get("relevant_sources", []) # expected source filenames |
| keywords = ground_truth.get("relevant_chunk_keywords", []) # expected keywords |
| |
| top_k = retrieved_chunks[:k] |
| relevant_count = 0 |
| |
| for chunk in top_k: |
| source_match = chunk["source"] in relevant_sources |
| keyword_match = any(kw.lower() in chunk["content"].lower() for kw in keywords) |
| |
| if source_match or keyword_match: # OR β either condition counts |
| relevant_count += 1 |
| |
| return relevant_count / k # 0.0 β 1.0 |
| ``` |
|
|
| **Example eval pair:** |
| ```json |
| { |
| "query": "What is the UPI merchant payment limit?", |
| "relevant_sources": ["npci_merchant_guidelines.pdf"], |
| "relevant_chunk_keywords": ["merchant", "βΉ5 lakh", "payment cap", "PSP ceiling"] |
| } |
| ``` |
|
|
| Retrieved chunks: |
| ``` |
| Chunk 1 (source: npci_merchant_guidelines.pdf) β source_match β β relevant |
| Chunk 2 (content: "...merchant PSP ceiling is βΉ5 lakh...") β keyword_match β β relevant |
| Chunk 3 (source: rbi_circular.pdf, content: "UPI transaction monitoring...") β neither β irrelevant |
| Chunk 4 (source: npci_merchant_guidelines.pdf) β source_match β β relevant |
| Chunk 5 (content: "...digital payment systems in India...") β neither β irrelevant |
| |
| P@5 = 3/5 = 0.60 |
| ``` |
|
|
| **Why deterministic (no LLM):** |
| - Fast β runs per-query without API calls |
| - Reproducible β same query always same score |
| - No rate limits β can evaluate 50 pairs without hitting Groq TPD |
|
|
| **What fools it:** |
| - Source OR keyword: a chunk from the wrong document that happens to mention a keyword counts as relevant |
| - Keyword list quality matters β too broad β everything matches, score inflates β too narrow β correct chunks missed, score deflates |
|
|
| **Prism v1.0.0 result:** `0.89` β 89% of retrieved chunks are relevant. High precision + low recall = pool is accurate but too narrow. The retriever is finding the right kind of chunks, just not enough of them. |
|
|
| --- |
|
|
| ### Metric 5 β Latency p50 / p95 / p99 |
|
|
| **Question it answers:** "How fast is Prism in practice?" |
|
|
| **Why three numbers instead of average:** |
|
|
| ``` |
| Query latencies (ms): [1800, 1950, 2100, 1900, 2050, 2200, 1850, 3800, 2000, 1950] |
| β one slow outlier |
| |
| Mean: 2160ms β pulled up by the outlier, misleading |
| p50: 1975ms β 50% of queries faster than this (typical experience) |
| p95: 3230ms β 95% of queries faster than this (worst normal case) |
| p99: 3740ms β 99% of queries faster than this (absolute worst) |
| ``` |
|
|
| **How it's computed:** |
|
|
| ```python |
| import numpy as np |
| |
| # Measure per query: retrieval + LLM call (no Tavily, no web search) |
| t0 = time.time() |
| answer, docs, chunks = _answer_query(llm, retriever, query) |
| latency_ms = int((time.time() - t0) * 1000) |
| latencies.append(latency_ms) |
| |
| # After all queries: |
| p50 = int(np.percentile(latencies, 50)) # median |
| p95 = int(np.percentile(latencies, 95)) # 95th percentile |
| p99 = int(np.percentile(latencies, 99)) # 99th percentile |
| ``` |
|
|
| **What's included in the latency measurement:** |
| - ChromaDB dense retrieval (cosine ANN search) |
| - BM25 sparse retrieval (in-memory token scoring) |
| - RRF fusion (pure Python, negligible) |
| - TinyBERT cross-encoder reranking (BERT forward pass Γ 10 pairs) |
| - Groq LLM call (network + inference) |
|
|
| **What's NOT included:** |
| - Tavily web search (separate, always adds 500β1000ms) |
| - HyDE expansion (adds one extra Groq call ~200ms) |
| - Python GC (`gc.collect()` after response) |
|
|
| **Prism results across versions:** |
|
|
| | Version | Stack | p50 | p95 | p99 | |
| | --- | --- | --- | --- | --- | |
| | v1.0.0 | baseline | 2029ms | β | β | |
| | v1.1.0 | HyDE | 4018ms | 6452ms | 6455ms | |
| | v1.2.0 | HyDE+MQ | 1812ms | 6136ms | 6744ms | |
| | v1.3.0 | HyDE+MQ+CTX | 2610ms | 6720ms | 6959ms | |
|
|
| HyDE adds one Groq call per query β p50 2Γ. Contextual chunks are longer β LLM processes more tokens β higher p50 vs baseline. Wide p95/p99 gap = occasional Groq congestion spikes, not retrieval. |
|
|
| **Production benchmarks for context:** |
|
|
| | System | p50 target | Notes | |
| | --- | --- | --- | |
| | Search engine | <200ms | Pre-indexed, no LLM | |
| | RAG (GPU cloud) | 500β800ms | GPU reranker + fast LLM | |
| | Prism (Render free CPU) | ~2000ms | CPU reranker + network LLM | |
| | Prism + streaming | ~300ms to first token | Same total time, perceived faster | |
|
|
| --- |
|
|
| ### How the Metrics Interact β Reading the Dashboard |
|
|
| ``` |
| High correctness + high recall + high P@5 = system is working β
|
| |
| High P@5 + low recall: |
| β Retriever finds right chunks but misses others |
| β Fix: Multi-Query Retrieval, HyDE |
| |
| Low P@5 + high recall: |
| β Retriever finds many chunks but most are noise |
| β Fix: stricter reranking, smaller retrieve_k |
| |
| High recall + low correctness: |
| β Retriever gave good context, LLM failed to use it |
| β Fix: better system prompt, larger LLM model |
| |
| Low relevancy + high correctness: |
| β LLM is verbose/padded but factually right |
| β Fix: tighter prompt ("be concise, one paragraph maximum") |
| |
| High latency p99 >> p50: |
| β Occasional slow queries (complex retrieval or Groq congestion) |
| β Monitor, add timeout handling |
| ``` |
|
|
| **Metric evolution across Prism versions:** |
|
|
| ``` |
| v1.0.0 v1.1.0 v1.2.0 v1.3.0 |
| (base) (HyDE) (HyDE+MQ)(+CTX) |
| correctness: 0.82 0.75 0.77 0.78 |
| relevancy: 0.62 0.845 0.890 0.799 |
| recall: 0.51 0.721 0.645 0.768 β primary target |
| P@5: 0.89 0.911 0.904 0.984 |
| p50: 2029ms 4018ms 1812ms 2610ms |
| |
| Verdict (v1.0.0): recall is the primary bottleneck. |
| Fix: HyDE (+21pp recall), Multi-Query (wider pool), Contextual Retrieval (+18% recall). |
| Best production stack: v1.3.0 β HyDE + Multi-Query + Contextual Retrieval. |
| ``` |
|
|
| --- |
|
|
| ## 17. Multi-Query Retrieval β Wider Net for Higher Recall |
|
|
| ### The Problem: Single Phrasing Has Blind Spots |
|
|
| A query hits ChromaDB + BM25 with one set of tokens. Corpus chunks that express the same concept differently never surface. |
|
|
| ``` |
| Query: "What is the UPI merchant limit?" |
| |
| Corpus has: |
| Chunk A: "merchant UPI transaction cap is βΉ5 lakh" β found β (keyword match) |
| Chunk B: "ceiling for PSP collections was revised..." β MISSED (different vocab) |
| Chunk C: "payment service providers may not exceed..." β MISSED (no "merchant" token) |
| ``` |
|
|
| Chunk B and C are relevant. Single-phrasing retrieval misses them. This is why Prism v1.0.0 Violet had context_recall = 0.51 β only half the needed chunks were retrieved. |
| |
| ### The Fix: Generate 3 Phrasings, Pool Results |
| |
| ``` |
| Step 1: LLM generates 3 paraphrases of the original query |
| Original: "What is the UPI merchant limit?" |
| Phrasing 2: "What is the PSP payment ceiling for UPI collections?" |
| Phrasing 3: "How much can merchants accept via UPI transactions?" |
| |
| Step 2: Run retrieval for EACH phrasing |
| Original β [Chunk A, Chunk D, Chunk E, ...] (retrieve_k per list) |
| Phrasing 2 β [Chunk B, Chunk A, Chunk F, ...] β Chunk B appears now |
| Phrasing 3 β [Chunk C, Chunk B, Chunk G, ...] β Chunk C appears now |
|
|
| Step 3: Pool + deduplicate by content key (keep best rank per chunk) |
| Combined unique pool: [A, B, C, D, E, F, G, ...] |
|
|
| Step 4: RRF fuse the deduplicated pool β rerank top-5 |
| Reranker sees 30 candidates instead of 10 β picks best 5 from wider pool |
| ``` |
| |
| ### Why Deduplication Uses Best Rank |
| |
| If Chunk A appears at rank 1 in phrasing 1 and rank 3 in phrasing 2, keep it at rank 1 β its highest-confidence rank. The merged ranked list is then sorted by best rank before RRF fusion. |
| |
| ```python |
| # retriever.py β deduplication loop |
| for rank, doc in enumerate(d_results): |
| key = doc["content"][:120] |
| if key not in dense_seen or rank < dense_seen[key][0]: |
| dense_seen[key] = (rank, doc) # keep best (lowest) rank |
|
|
| dense_pool = [doc for _, doc in sorted(dense_seen.values(), key=lambda x: x[0])] |
| ``` |
| |
| ### In Prism |
| |
| ```python |
| # retriever.py |
| def _multi_query_expand(self, query: str) -> list[str]: |
| prompt = ( |
| "Generate 3 different phrasings of the following question for document retrieval. " |
| "Each phrasing must use different vocabulary but seek the same information. " |
| "Return ONLY the 3 questions, one per line, no numbering, no preamble.\n\n" |
| f"Question: {query}" |
| ) |
| # returns [original_query, phrasing_2, phrasing_3, phrasing_4] |
| |
| def _get_relevant_documents(self, query, ...): |
| queries = self._multi_query_expand(query) if self.use_multi_query else [query] |
| |
| dense_seen, sparse_seen = {}, {} |
| for q in queries: |
| # retrieve for each phrasing, keep best rank per unique chunk |
| ... |
| |
| dense_pool = sorted_by_best_rank(dense_seen) |
| sparse_pool = sorted_by_best_rank(sparse_seen) |
| |
| fused = self._rrf_fuse(dense_pool, sparse_pool) # wider pool |
| reranked = rerank(query, fused[:retrieve_k], top_k=rerank_k) # reranker uses original query |
| ``` |
| |
| **ON by default** (`config.yaml` `multi_query_enabled: true`) β adds one Groq call per query (~200ms). |
|
|
| **Measured result (v1.2.0, 25 samples, HyDE+MQ):** recall=0.645, P@5=0.904. Multi-Query alone provided smaller-than-expected recall lift. Root cause: query-side reformulation cannot fix decontextualized chunk embeddings β chunks with weak vectors rank low regardless of how the query is phrased. The larger recall gain came from Contextual Retrieval (Concept 21), which fixes chunk quality at ingest time. |
|
|
| **Cost:** 1 extra Groq call + 3Γ retrieval calls (fast, in-memory) + 3Γ BM25 (negligible). |
|
|
| **Reranker still uses original query** β not the phrasings. The phrasings widen the candidate pool; the reranker judges relevance against what the user actually asked. |
|
|
| --- |
|
|
| ## Summary β Key Concepts Cheatsheet |
|
|
| | Concept | What it solves | Where in Prism | |
| | --- | --- | --- | |
| | ParentDocumentRetriever | Retrieval precision + answer faithfulness | Ingestion + retrieval layer | |
| | InMemoryStore | Fast parent text lookup by ID | RAM store (lost on restart) | |
| | ChromaDB | Vector similarity search for child chunks | Persistent disk store | |
| | BM25Okapi | Exact keyword matching with TF saturation | Sparse retrieval (weight 0.3) | |
| | RRF | Merging dense + sparse rankings (scale-agnostic) | Post-retrieval fusion | |
| | Cross-Encoder | Accurate joint query-doc relevance scoring | Final reranking step | |
| | Chain condensation trap | Why LangChain strips web context silently | Web query bypass in chain.py | |
| | HyDE | Closes question-answer vector space gap; +21pp recall | Dense retrieval (hyde_enabled: true) | |
| | Faithfulness is circular | Why eval metrics designed alongside corpus lie | answer_correctness chosen as primary | |
| | P@5 + Recall diagnostic pair | Identifies whether retrieval pool is narrow or noisy | v1.0.0 Violet: P=0.89, R=0.51 | |
| | RecursiveCharacterTextSplitter | Splits at paragraph/line/word/char in priority order | ingest.py chunking | |
| | ConversationBufferWindowMemory | Sliding k-window of chat history + output_key trap | memory.py, used in chain | |
| | LLM at ingest time | Briefing pattern β run LLM once per doc, not per query | briefing.py on upload | |
| | Contextual Retrieval | Fix decontextualized chunks at ingest; +18% recall | `contextualize_chunks_async()` in ingest.py | |
| | Answer Correctness | LLM judge 1β5 vs ground truth, normalized β 0β1 | Primary metric, independent of retrieved chunks | |
| | Answer Relevancy | Reverse-question cosine similarity β penalizes verbose LLM | RAGAS, no ground truth needed | |
| | Context Recall | GT sentences attributed to retrieved chunks Γ· total | RAGAS, measures retrieval completeness | |
| | Precision@5 | Relevant chunks in top-5 Γ· 5, source + keyword match | Custom deterministic, no LLM call | |
| | Latency p50/p95/p99 | Percentile timing: median / worst-normal / absolute-worst | numpy.percentile over per-query ms measurements | |
| | Multi-Query Retrieval | Wider candidate pool; best rank dedup before RRF | retriever.py `_multi_query_expand()` (multi_query_enabled: true) | |
| | Semantic Chunking Tradeoff | Recall vs precision when eval is fixed-chunk-aligned | v1.4.0 ablation: +9.3pp recall, β27.3pp P@5, 5Γ latency | |
|
|
| --- |
|
|
|
|
| --- |
|
|
| ## 20. Semantic Chunking Tradeoff β Recall vs Precision |
|
|
| ### The Problem with Fixed-Size Splits |
|
|
| `RecursiveCharacterTextSplitter` at 500 chars cuts mid-sentence, mid-table, mid-list. Embedding a truncated sentence returns a weak vector β the chunk doesn't represent a complete idea. Semantic chunking fixes this by splitting at topic boundaries (where cosine similarity between adjacent sentences drops below a threshold), producing variable-size chunks that each represent one coherent thought. |
|
|
| ### What the Ablation Showed (Prism v1.4.0, 25 samples) |
|
|
| | Stack | recall | P@5 | latency p50 | |
| |-------|--------|-----|-------------| |
| | v1.3.0 HyDE+MQ+CTX (fixed 500-char) | 0.768 | **0.984** | 2610ms | |
| | v1.4.0 + Semantic chunking | **0.861** | 0.711 | 12952ms | |
| | Delta | +9.3pp | **β27.3pp** | 5Γ slower | |
|
|
| ### Why Recall Went Up |
|
|
| Semantic chunks contain complete sentences and complete ideas. The embedding captures the full meaning β better vector β higher chance of matching the relevant query β more reference content covered β higher context recall. |
|
|
| ### Why Precision Collapsed |
|
|
| P@5 measures overlap between retrieved chunks and the ground-truth `relevant_chunks` field in `eval_pairs.json`. Those reference chunks were defined against fixed 500-char splits. Semantic chunks have different, larger boundaries β they contain the relevant content but as part of a bigger unit, so exact overlap with the reference drops. The reranker also receives a wider, noisier candidate pool (variable-size chunks = less uniform scoring surface). |
|
|
| **Key insight:** P@5 is eval-alignment-sensitive. If ground truth was labelled against fixed chunks, semantic chunks will always score lower on P@5 even when they retrieve better content. For a production system with human-labelled relevance judgements (not self-aligned eval pairs), the gap would be smaller. |
|
|
| ### Why Latency Was 5Γ |
|
|
| Semantic chunks are longer on average than 500-char fixed chunks. Longer chunks = more tokens fed to the LLM per answer generation call. Retrieval time is unchanged (semantic chunking is an ingest-time decision), but inference time scales with context length. |
|
|
| ### Decision |
|
|
| Semantic chunking rejected for Prism production. v1.3.0 (HyDE + Multi-Query + Contextual) confirmed as best stack: P@5=0.984, recall=0.768, p50=2610ms. |
|
|
| Semantic chunking would make more sense when: |
| - Ground truth eval labels are created after chunking (aligned to the actual chunk boundaries) |
| - LLM context window is not a bottleneck |
| - Recall is the primary metric (e.g. legal/compliance: never miss a relevant clause) |
|
|
| --- |
|
|
| ## 21. Contextual Retrieval β Fixing Decontextualized Chunks at Ingest |
|
|
| ### The Problem: Fixed-Size Chunks Lose Context |
|
|
| `RecursiveCharacterTextSplitter` at 500 chars cuts documents into fragments. Many fragments are decontextualized β they lack the surrounding information that gives them meaning: |
|
|
| ``` |
| Chunk from NPCI merchant guidelines PDF: |
| "The limit was revised to βΉ2 lakh." |
| |
| Problems: |
| - "The limit" β which limit? Not in this chunk. |
| - "revised" β from what? Not in this chunk. |
| - "βΉ2 lakh" β for what transaction type? Not in this chunk. |
| |
| Embedding of this chunk β weak, generic vector. |
| Query "merchant UPI transaction cap" β this chunk may not surface. |
| ``` |
|
|
| No query-side technique (HyDE, Multi-Query) can fix this β the chunk embedding is weak regardless of how the query is phrased. |
|
|
| ### The Fix: Anthropic's Contextual Retrieval |
|
|
| At ingest time, before embedding, ask the LLM to prepend 2 sentences situating each chunk in its document: |
|
|
| ``` |
| Prompt: |
| "Given this document: [full doc or representative sample] |
| Write 2 sentences situating this chunk in context. |
| |
| Chunk: 'The limit was revised to βΉ2 lakh.'" |
| |
| LLM output: |
| "In the NPCI UPI merchant guidelines (2024), Section 4.3 covers |
| transaction ceiling revisions for PSPs. The limit was revised to βΉ2 lakh." |
| ``` |
|
|
| The contextual prefix is **prepended to the chunk** before embedding: |
|
|
| ```python |
| # ingest.py |
| chunk.page_content = f"{context_prefix}\n\n{chunk.page_content}" |
| # then embed this augmented text |
| ``` |
|
|
| The chunk stored for retrieval is now specific and rich. Same chunk now surfaces for "merchant UPI transaction cap" queries. |
|
|
| ### Why This Works |
|
|
| | | Original chunk | Contextual chunk | |
| |---|---|---| |
| | Text | "The limit was revised to βΉ2 lakh." | "NPCI UPI merchant guidelines 2024, PSP ceiling. The limit was revised to βΉ2 lakh." | |
| | Embedding | Generic "revision" vector | Specific "merchant UPI PSP ceiling" vector | |
| | Retrieval | Misses merchant-related queries | Surfaces correctly | |
|
|
| The embedding now represents a complete, specific idea instead of a floating fragment. |
|
|
| ### Design Pattern: Ingest-Time vs Query-Time |
|
|
| | | Ingest-time LLM (Contextual Retrieval) | Query-time LLM (HyDE, Multi-Query) | |
| |---|---|---| |
| | Runs | Once per chunk, at upload | Every query | |
| | Cost | Paid once; benefit on every future query | Paid per query | |
| | What it fixes | Bad chunk embeddings (quality problem) | Query-corpus vocabulary gap (coverage problem) | |
| | Latency impact | Upload slower (~40s for 30 chunks) | Query slower (+200ms per technique) | |
|
|
| **Key insight:** Query-side techniques improve how well a query matches existing chunk vectors. Contextual retrieval improves the chunk vectors themselves. Both are needed for maximum recall. |
|
|
| ### Measured Results β Prism v1.3.0 (25 samples) |
|
|
| | Metric | v1.0.0 baseline | v1.3.0 (HyDE+MQ+CTX) | Delta | |
| |--------|-----------------|----------------------|-------| |
| | context_recall | 0.51 | 0.768 | **+25.8pp** | |
| | precision_at_5 | 0.89 | 0.984 | **+9.4pp** | |
| | answer_relevancy | 0.62 | 0.799 | +17.9pp | |
| | latency p50 | 2029ms | 2610ms | +28% | |
|
|
| Contextual retrieval contributes ~+18% recall (v1.3.0 vs v1.2.0 without CTX: 0.768 vs 0.645). |
|
|
| ### Production Complication: 40s Upload |
|
|
| 30 chunks Γ 1 Groq call Γ ~1.3s/call (sequential) = ~40s blocking. Solution: parallel calls with `asyncio.Semaphore(3)` β 3 concurrent Groq calls at ~3000 TPM burst (under Groq's 6000 TPM limit). Reduces to ~15s. Two-phase upload: sync non-contextual embed first (user can query in <3s), contextual replacement in background. |
|
|
| ### Distinction from Briefing (Concept 14) |
|
|
| | | Briefing | Contextual Retrieval | |
| |---|---|---| |
| | What | Document-level 5-bullet summary + 3 questions | Chunk-level 2-sentence situating context | |
| | Shown to user | Yes (in upload response) | No (prepended to chunk text, invisible) | |
| | Purpose | User orientation | Embedding quality improvement | |
| | LLM calls | 1 per document | 1 per chunk (~30 per doc) | |