<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Priya Singh on Medium]]></title>
        <description><![CDATA[Stories by Priya Singh on Medium]]></description>
        <link>https://medium.com/@PriyaSingh325?source=rss-bb58ec7be9f0------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*227oa-rR9iGuRMZl5owypQ.png</url>
            <title>Stories by Priya Singh on Medium</title>
            <link>https://medium.com/@PriyaSingh325?source=rss-bb58ec7be9f0------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 24 May 2026 02:26:01 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@PriyaSingh325/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Building Recommendation Systems with Vector Search]]></title>
            <link>https://medium.com/@PriyaSingh325/building-recommendation-systems-with-vector-search-c285e5384013?source=rss-bb58ec7be9f0------2</link>
            <guid isPermaLink="false">https://medium.com/p/c285e5384013</guid>
            <category><![CDATA[vector-search]]></category>
            <category><![CDATA[personalization]]></category>
            <category><![CDATA[word-embeddings]]></category>
            <category><![CDATA[recommendation-system]]></category>
            <dc:creator><![CDATA[Priya Singh]]></dc:creator>
            <pubDate>Fri, 22 May 2026 07:19:47 GMT</pubDate>
            <atom:updated>2026-05-22T07:19:47.850Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nLf0I9K_CbFF1_zsKQ66QQ.png" /></figure><p>Last week I was debugging a recommendations pipeline that looked fine in a notebook and felt mediocre in the product. The model was not broken. The problem was that our retrieval layer was still thinking in keywords while the user behavior was much messier than that.</p><p>A user might read three articles about deployment latency, save one post about prompt evaluation, and ignore five posts about generic AI trends. If I reduce that behavior to tags, I lose a lot of signal. If I represent the content and user profile as vectors, I can retrieve items that are semantically close even when the words do not match exactly.</p><p>That is the part of recommendation systems I wish more teams discussed: the model is only half the story. The retrieval substrate matters.</p><h3>The Three Recommendation Modes I Actually Use</h3><p>Most recommendation systems start with one of three patterns.</p><p>The first is popularity-based recommendation. This is the “trending now” shelf. It is simple, explainable, and surprisingly hard to beat for cold-start users. If I know nothing about a visitor, showing globally popular items is a reasonable baseline.</p><p>The second is content-based recommendation. This uses item features: title, description, category, image embedding, transcript, metadata, or any other representation of the item. If a user reads an article about stream processing, I can recommend other content with similar meaning.</p><p>The third is collaborative filtering. This uses behavior. Users who clicked, watched, purchased, or saved similar things become signals for one another.</p><p>In practice, I rarely deploy only one of these. The fix was simpler than I expected: combine them, then make the retrieval layer fast enough that the product can use the system in real time.</p><h3>Why Vectors Help</h3><p>Traditional content-based systems often rely on manually assigned tags or keyword overlap. That works until the user searches for “slow checkout” and the most relevant item says “payment latency.” A keyword system sees different words. An embedding model can put both concepts near each other.</p><p><a href="https://zilliz.com/glossary/vector-embeddings?utm_campaign=mediumkoc">Vector embeddings</a> let us represent text, images, audio, and user profiles as dense numerical vectors. Once the items are embedded, recommendation becomes a similarity search problem:</p><p>1. Build an item vector from content and metadata.</p><p>2. Build a user vector from recent interactions.</p><p>3. Search for nearby item vectors.</p><p>4. Rerank with business rules, freshness, diversity, and availability.</p><p>This is where a <a href="https://zilliz.com/vector-database-use-cases/recommender-system?utm_campaign=mediumkoc">recommender system</a> starts to feel less like a static rules engine and more like a retrieval pipeline.</p><h3>A Minimal Content-Based Recommender</h3><p>Here is a stripped-down version of the pattern I usually prototype first. It averages recent item vectors to build a user vector, then searches for the nearest items.</p><pre>import numpy as np<br><br>def normalize(v):<br>    norm = np.linalg.norm(v)<br>    return v if norm == 0 else v / norm<br><br>def build_user_vector(recent_item_vectors, weights=None):<br>    if weights is None:<br>        weights = np.ones(len(recent_item_vectors))<br><br>    weighted = np.array([<br>        normalize(vec) * weight<br>        for vec, weight in zip(recent_item_vectors, weights)<br>    ])<br>    return normalize(weighted.sum(axis=0))<br><br>def recommend(vector_store, user_vector, seen_ids, limit=20):<br>    candidates = vector_store.search(<br>        vector=user_vector,<br>        top_k=limit * 3,<br>        filters={&quot;status&quot;: &quot;published&quot;}<br>    )<br><br>    results = []<br>    for item in candidates:<br>        if item[&quot;id&quot;] in seen_ids:<br>            continue<br>        results.append(item)<br>        if len(results) == limit:<br>            break<br><br>    return results</pre><p>This is not enough for production, but it is enough to test whether semantic similarity is useful for the product. The first thing I measure is not model accuracy. I measure whether a product manager can look at the results and say, “Yes, these belong together.”</p><h3>The Cold Start Problem Does Not Go Away</h3><p>One thing I learned the hard way: vector search improves retrieval, but it does not magically solve cold start.</p><p>For new users, I still need fallback shelves:</p><p>• popular items by geography or segment</p><p>• editorially curated items</p><p>• recently trending items</p><p>• onboarding interests</p><p>• context from the current page or query</p><p>For new items, I can embed the content immediately and make it eligible for content-based retrieval before it has interaction history. That is one of the nicest parts of vector-based recommendations. A new article, video, or product can be recommended based on what it is, not only who has clicked it.</p><p>Collaborative filtering still becomes more useful as interactions accumulate. My usual approach is to blend both:</p><p>• content similarity for fast item cold start</p><p>• collaborative signals for mature inventory</p><p>• business rules for availability and safety</p><p>• diversity constraints so the shelf does not show ten near-duplicates</p><h3>Where The Vector Database Fits</h3><p>A <a href="https://zilliz.com/learn/what-is-vector-database?utm_campaign=mediumkoc">vector database</a> becomes useful when the catalog is too large or dynamic for a local in-memory index. The database stores item embeddings, metadata, and sometimes multiple vectors per item. For example, a product might have one vector for title text, one for image, and one for historical user interactions.</p><p>The important production detail is filtering. Recommendation queries almost always need constraints:</p><p>• only active inventory</p><p>• only content the user is allowed to see</p><p>• exclude items already consumed</p><p>• filter by locale, category, price, or availability</p><p>• boost recent or high-quality items</p><p>If the system retrieves similar items first and filters later, it can waste most of the candidate set. That is why I care about vector search with metadata filtering, not just nearest-neighbor math.</p><h3>The Tradeoff: Personalization vs. Diversity</h3><p>My first vector recommendation prototype was too literal. If a user read one article about RAG evaluation, the next shelf became a wall of RAG evaluation posts. The similarity score was working, but the product experience was narrow.</p><p>The fix was to rerank for diversity after retrieval:</p><p>1. Retrieve 100 candidates by vector similarity.</p><p>2. Remove seen or unavailable items.</p><p>3. Group candidates by topic or source.</p><p>4. Limit how many results can come from the same cluster.</p><p>5. Mix in a few exploration items.</p><p>This reduced pure similarity a little, but the shelf became more useful. Users do not always want twenty versions of the same thing.</p><h3>Production Notes</h3><p>For real-time recommendations, latency budget matters. If the page needs to render in 300 ms, the recommendation service cannot spend 250 ms on embedding and search. I usually precompute item vectors and update user vectors asynchronously. The online path should mostly be retrieval, filtering, and reranking.</p><p>Batching also matters. Embedding every click synchronously is expensive and brittle. I prefer to stream events into a queue, aggregate recent behavior in short windows, and update user vectors every few minutes unless the product truly requires instant adaptation.</p><p>Finally, monitor the boring metrics:</p><p>• retrieval latency p95 and p99</p><p>• empty result rate</p><p>• duplicate recommendation rate</p><p>• click-through by shelf type</p><p>• diversity by category or cluster</p><p>• stale inventory returned by the service</p><p>The model team may care about offline ranking metrics, but the product will feel the operational metrics first.</p><h3>What I Would Build First</h3><p>If I were starting from scratch, I would not build the most complex recommender. I would build a content-based vector retriever, add a popularity fallback, and layer in collaborative signals later. That gives the team a working baseline, handles new items reasonably well, and creates enough logs to learn what users actually respond to.</p><p>Recommendation systems are not one algorithm. They are retrieval, ranking, feedback, and product constraints glued together. Vectors make the retrieval layer much more flexible, but the system still needs careful design around cold start, filtering, latency, and diversity.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c285e5384013" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to Evaluate RAG Applications]]></title>
            <link>https://blog.gopenai.com/how-to-evaluate-rag-applications-e9576ccd6ec8?source=rss-bb58ec7be9f0------2</link>
            <guid isPermaLink="false">https://medium.com/p/e9576ccd6ec8</guid>
            <category><![CDATA[vector-database]]></category>
            <category><![CDATA[rags]]></category>
            <category><![CDATA[retrieval-augmented-gen]]></category>
            <category><![CDATA[embedding]]></category>
            <category><![CDATA[llm]]></category>
            <dc:creator><![CDATA[Priya Singh]]></dc:creator>
            <pubDate>Fri, 15 May 2026 19:56:00 GMT</pubDate>
            <atom:updated>2026-05-15T19:56:00.447Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vfn37rnHFmhLYnzx_UIPPA.png" /></figure><h3>The Moment I Realized My RAG Was Confidently Wrong</h3><p>Last month I shipped a knowledge-base chatbot for an internal team. It looked great in demos — fluent answers, fast responses, everyone loved it. Then a product manager asked it a straightforward question about our API limits and got a response that was factually wrong but sounded completely authoritative.</p><p>The retrieved documents were correct. The answer was wrong. And I had no systematic way to tell how often this was happening across thousands of queries.</p><p>That’s when I went deep on <a href="https://zilliz.com/learn/Retrieval-Augmented-Generation?utm_campaign=mediumkoc">RAG</a> evaluation. Here’s what I learned about actually measuring whether your pipeline is working — not just whether it feels like it’s working.</p><h3>The Three Metrics That Matter</h3><p>When you treat your RAG system as a black box, you have three things to work with: the user’s query, the retrieved chunks, and the generated response. The relationship between these three elements tells you almost everything you need to know.</p><p><strong>Context Relevance</strong> measures whether the retrieval step is pulling the right documents. If your chunks don’t contain the information needed to answer the query, even the best <a href="https://zilliz.com/glossary/large-language-models-(llms)?utm_campaign=mediumkoc">LLM</a> can’t save you. Low context relevance is the single most common failure mode I’ve seen in production RAG systems — and it’s almost always a chunking or embedding problem, not a generation problem.</p><p><strong>Faithfulness</strong> checks whether the generated answer actually reflects what’s in the retrieved documents. This is your hallucination detector. A low faithfulness score means the model is making things up or mixing in information from its parametric memory instead of sticking to the provided context.</p><p><strong>Answer Relevance</strong> evaluates whether the response actually addresses the question. You can have perfect retrieval and faithful generation, but if the answer is incomplete or off-topic, the user still doesn’t get what they need.</p><p>One thing I learned the hard way: these three metrics are not independent. A problem in context relevance cascades downstream — bad retrieval leads to unfaithful generation leads to irrelevant answers. When debugging, always start with retrieval.</p><h3>How to Score These Automatically</h3><p>Here’s what actually happened when I tried to evaluate manually: I spent two hours scoring 50 query-response pairs and realized this would never scale. For a production system handling thousands of queries daily, manual evaluation is a non-starter.</p><p>The fix was simpler than I expected: use an LLM as a judge. The LLM-as-a-Judge approach, where you use a capable model like GPT-4 to score responses, reaches about 80% agreement with human raters. That sounds low until you realize that two human raters typically don’t agree much more than that on subjective assessments.</p><p>Here’s how I set this up for answer relevance:</p><pre>eval_prompt = &quot;&quot;&quot;<br>Rate how well this response answers the question.<br>Score 0-10 (0 = completely irrelevant, 10 = perfect answer).<br><br>Question: {question}<br>Response: {response}<br><br>Score:<br>&quot;&quot;&quot;<br><br>def evaluate_answer_relevance(question, response, llm_client):<br>    result = llm_client.complete(<br>        eval_prompt.format(question=question, response=response)<br>    )<br>    return int(result.strip())</pre><p>The key to making LLM-as-a-Judge work reliably is prompt engineering. Position bias is real — LLMs pay more attention to content at the beginning and end of long prompts. I use chain-of-thought prompting to force the model to reason through its score before committing to a number.</p><h3>The Ground Truth Question</h3><p>You might have noticed none of the above metrics require ground-truth answers. That’s intentional — annotating ground truth is expensive and time-consuming.</p><p>But when you do have ground truth, you unlock more precise evaluation. You can measure retrieval recall (what fraction of relevant documents did we actually retrieve?) and answer correctness (does the generated answer match the known-correct answer?).</p><p>Here’s the practical shortcut: use an LLM to generate synthetic ground truth from your knowledge documents. Tools like Ragas and LlamaIndex have built-in generators that produce question-answer pairs from your corpus. The generated questions won’t be perfect, but they’re good enough for regression testing and comparing pipeline configurations.</p><h3>White-Box Evaluation: Testing Components Separately</h3><p>When I can see inside the pipeline, I evaluate each component independently. This is where you find the actual bottleneck.</p><p><strong>[Embedding model](https://zilliz.com/blog/choosing-the-right-embedding-model-for-your-data?utm_campaign=mediumkoc) evaluation</strong>: Use information retrieval metrics — context recall (did we find the right chunks?) and context precision (are the retrieved chunks actually relevant?). The MTEB benchmark is the standard leaderboard, but be cautious: since the benchmark datasets are public, some models may be overfit. Always validate on data from your actual domain.</p><p><strong>Reranker evaluation</strong>: Measure how much the reranker improves the ordering of retrieved results. Average Precision (MAP) and Normalized Discounted Cumulative Gain (NDCG) are the key metrics here. A reranker that doesn’t meaningfully improve NDCG over the base retrieval isn’t worth the latency cost.</p><p><strong>LLM evaluation</strong>: For simple factual questions, you can use deterministic metrics like ROUGE-L and token overlap against ground truth. For open-ended questions, fall back to LLM-as-a-Judge faithfulness scoring.</p><h3>The Evaluation Tools I Actually Use</h3><p>After trying most of the options out there, here’s where I’ve landed:</p><p>• <strong>Ragas</strong>: Best for black-box RAG evaluation. Clean interface, good metric coverage, works with any RAG framework. The synthetic test data generation is a time-saver.</p><p>• <strong>TruLens-Eval</strong>: Good integration with LangChain and LlamaIndex if you’re already in those ecosystems. The browser-based monitoring is helpful for tracking evaluation over time.</p><p>• <strong>DeepEval</strong>: Lightweight, fast. Good for CI/CD integration when you want evaluation as part of your deployment pipeline.</p><p>The common pattern across all of these: they use LLM-as-a-Judge under the hood and charge you API tokens accordingly. Budget for evaluation costs — on a large test set, evaluation can cost more than the actual inference.</p><h3>What I Do Differently Now</h3><p>After going through this, I changed my RAG development workflow. Before deploying any pipeline change, I run a standardized evaluation suite: 200 synthetic questions generated from the knowledge base, scored across all three metrics. If context relevance drops below 0.7 or faithfulness drops below 0.8, the change doesn’t ship.</p><p>It’s not perfect — there are failure modes these metrics miss, especially around nuance and completeness. But it catches the catastrophic failures, which is what matters most in production.</p><p>Next, I’m looking into building a continuous evaluation loop that samples live traffic and flags quality regressions in real time. The challenge is doing this without burning through GPT-4 tokens at scale — I’m experimenting with smaller, fine-tuned judge models to bring the cost down.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e9576ccd6ec8" width="1" height="1" alt=""><hr><p><a href="https://blog.gopenai.com/how-to-evaluate-rag-applications-e9576ccd6ec8">How to Evaluate RAG Applications</a> was originally published in <a href="https://blog.gopenai.com">GoPenAI</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Why Your Vector Search Returns 10 Results From the Same Document]]></title>
            <link>https://medium.com/@PriyaSingh325/why-your-vector-search-returns-10-results-from-the-same-document-7f7cae7e81c8?source=rss-bb58ec7be9f0------2</link>
            <guid isPermaLink="false">https://medium.com/p/7f7cae7e81c8</guid>
            <category><![CDATA[vector-search]]></category>
            <category><![CDATA[milvus]]></category>
            <category><![CDATA[entity-retrieval]]></category>
            <category><![CDATA[maxsim]]></category>
            <dc:creator><![CDATA[Priya Singh]]></dc:creator>
            <pubDate>Wed, 13 May 2026 10:12:33 GMT</pubDate>
            <atom:updated>2026-05-13T10:12:33.325Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*DCUJtBsRMWIwd3Gk" /></figure><h3>Milvus Week: Array of Structs and MAX_SIM</h3><p>Last week I was building a ColBERT-style retrieval system for a client’s internal knowledge base, and I hit the exact problem that probably every ML engineer who’s worked with multi-vector search knows: the <a href="https://zilliz.com/learn/what-is-vector-database?utm_campaign=mediumkoc">vector database</a> kept returning multiple chunks from the same document instead of giving me a ranked list of unique documents. I’d fetch top-10 results, and six of them would be fragments of the same article. The whole post-processing layer I built — grouping by doc_id, deduplicating, reranking — felt exactly like what a database should be handling natively.</p><p>Then I saw the <a href="https://milvus.io/?utm_campaign=mediumkoc">Milvus</a> 2.6.4 release notes and the Array of Structs + MAX_SIM combination. I spent a weekend digging into it. Here’s what actually happened.</p><h3>The Core Problem: Embeddings Are Not Entities</h3><p>The architectural gap has always been the same. Most vector databases treat each <a href="https://zilliz.com/glossary/vector-embeddings?utm_campaign=mediumkoc">embedding</a> as an isolated row. But real applications operate on entities — documents, products, videos, scenes. When you chunk a document into 20 paragraphs and embed each one, you have 20 rows in your index. Ask for top-5, and you might get 5 rows from the same document.</p><p>I’ve patched this problem four different ways across different projects:</p><p>• Grouping by metadata field after retrieval</p><p>• Setting a max-per-document cap and re-querying if needed</p><p>• Running a separate reranking model that penalizes redundancy</p><p>• Using ColBERT’s late interaction but still handling dedup manually</p><p>All of these work. None of them are satisfying. You’re pushing application-layer logic into a problem that should be solved at retrieval time.</p><p>The use cases where this shows up are consistent:</p><p>• <a href="https://zilliz.com/learn/Retrieval-Augmented-Generation?utm_campaign=mediumkoc">RAG</a> knowledge bases: articles are chunked into paragraph embeddings, so the search engine returns scattered fragments instead of the complete document</p><p>• E-commerce recommendation: a product has multiple image embeddings, and your system returns five angles of the same item rather than five unique products</p><p>• Video platforms: videos are split into clip embeddings, but search results surface slices of the same video rather than a single consolidated entry</p><p>• ColBERT / ColPali-style retrieval: documents expand into hundreds of token or patch-level embeddings, and your results come back as tiny pieces that still require merging</p><h3>Array of Structs: One Entity, One Row</h3><p>Milvus 2.6.4 introduces an Array of Structs field type. A single record now holds an ordered list of Struct elements, where each Struct follows the same predefined schema — it can contain vectors, strings, scalar fields, whatever belongs to that sub-element.</p><p>Here’s what a document record looks like with this structure:</p><pre>{<br>  &#39;id&#39;: 0,<br>  &#39;title&#39;: &#39;Walden&#39;,<br>  &#39;title_vector&#39;: [0.1, 0.2, 0.3, 0.4, 0.5],<br>  &#39;author&#39;: &#39;Henry David Thoreau&#39;,<br>  &#39;year_of_publication&#39;: 1845,<br>  &#39;chunks&#39;: [<br>    {<br>      &#39;text&#39;: &#39;When I wrote the following pages...&#39;,<br>      &#39;text_vector&#39;: [0.3, 0.2, 0.3, 0.2, 0.5],<br>      &#39;chapter&#39;: &#39;Economy&#39;,<br>    },<br>    {<br>      &#39;text&#39;: &#39;I would fain say something, not so much...&#39;,<br>      &#39;text_vector&#39;: [0.7, 0.4, 0.2, 0.7, 0.8],<br>      &#39;chapter&#39;: &#39;Economy&#39;<br>    }<br>  ]<br>}</pre><p>The chunks field is the Array of Structs field. Every paragraph that belongs to this entity lives inside one row. No more 1:N explosion of rows per document.</p><p>This is the right data model for almost every multi-vector use case I encounter:</p><p>• RAG knowledge bases: entire document (all chunks) as one record</p><p>• E-commerce: all product images as one record</p><p>• Video search: all clip embeddings as one record</p><p>• ColPali document search: all patch embeddings as one record</p><h3>MAX_SIM: Entity-Level Scoring That Makes Sense</h3><p>The new field type alone wouldn’t be enough. You still need a scoring mechanism that operates at the entity level, not the individual-vector level. That’s what MAX_SIM provides.</p><p>When you query with MAX_SIM, Milvus compares your query vector (or token vectors) against every vector stored in the entity’s Array of Structs field, and takes the maximum similarity as that entity’s score. The entity is ranked based on that single score — no duplicate-filled result sets, no complex post-processing.</p><p>The Milvus docs walk through a concrete example worth understanding. Say you search for “Machine Learning Beginner Course,” which gets tokenized into three vectors: machine learning, beginner, course. Now you have two candidate documents:</p><p>• doc_1: “Introduction Guide to Deep Neural Networks with Python”</p><p>• doc_2: “Advanced Guide to LLM Paper Reading”</p><p>For doc_1, the per-token best matches (using cosine similarity in the [0,1] range) are:</p><p>• machine learning → deep neural networks (0.9)</p><p>• beginner → introduction (0.8)</p><p>• course → guide (0.7)</p><p>• Sum = <strong>2.4</strong></p><p>For doc_2:</p><p>• machine learning → LLM (0.9)</p><p>• beginner → guide (0.6)</p><p>• course → guide (0.8)</p><p>• Sum = <strong>2.3</strong></p><p>doc_1 wins, which is the intuitive result — it’s more of an introductory guide.</p><p>Three things to note about how MAX_SIM behaves:</p><p>1. <strong>Semantic, not lexical.</strong> “Machine learning” scores high against “deep neural networks” despite zero shared tokens. The scoring lives entirely in embedding space, making it robust to synonyms and paraphrases.</p><p>2. <strong>Length-agnostic.</strong> doc_1 has 4 vectors, doc_2 has 5. MAX_SIM doesn’t care — it matches each query vector to the best available candidate within each entity, regardless of how many exist.</p><p>3. <strong>Every query token contributes.</strong> The sum ensures that a document that matches well on some tokens but poorly on others doesn’t unfairly dominate. Lower-quality matches directly reduce the overall score.</p><h3>Setting This Up in Milvus: What the Code Looks Like</h3><p>Here’s how you’d define a collection schema with an Array of Structs field and set up retrieval with MAX_SIM:</p><pre>from pymilvus import MilvusClient, DataType, FieldSchema, CollectionSchema<br><br>client = MilvusClient(&quot;milvus.db&quot;)<br><br># Define the schema<br>schema = client.create_schema(<br>    auto_id=False,<br>    enable_dynamic_field=True<br>)<br><br># Entity-level fields<br>schema.add_field(&quot;id&quot;, DataType.INT64, is_primary=True)<br>schema.add_field(&quot;title&quot;, DataType.VARCHAR, max_length=512)<br><br># Array of Structs field for multi-vector storage<br>schema.add_field(<br>    &quot;chunks&quot;,<br>    DataType.ARRAY,<br>    element_type=DataType.STRUCT,<br>    struct_fields=[<br>        FieldSchema(&quot;text&quot;, DataType.VARCHAR, max_length=4096),<br>        FieldSchema(&quot;text_vector&quot;, DataType.FLOAT_VECTOR, dim=768),<br>        FieldSchema(&quot;chapter&quot;, DataType.VARCHAR, max_length=256),<br>    ]<br>)<br><br># Index params — HNSW index on the nested vector field<br>index_params = client.prepare_index_params()<br>index_params.add_index(<br>    field_name=&quot;chunks.text_vector&quot;,<br>    index_type=&quot;HNSW&quot;,<br>    metric_type=&quot;COSINE&quot;<br>)<br><br>client.create_collection(<br>    collection_name=&quot;documents&quot;,<br>    schema=schema,<br>    index_params=index_params<br>)</pre><p>One production consideration worth flagging: with large entities — documents with hundreds of chunks — the memory layout per record changes significantly compared to single-vector schemas. I’d recommend starting with a conservative estimate of average chunks-per-entity and monitoring memory consumption during index build, especially if you’re running Milvus on memory-constrained nodes.</p><h3>Design Tradeoffs I’m Still Thinking About</h3><p>Array of Structs + MAX_SIM solves the grouping and deduplication problem cleanly, but it’s not a universal drop-in replacement.</p><p><strong>When it works extremely well:</strong></p><p>• ColBERT and ColPali retrieval, where you’re doing late interaction across many token or patch vectors</p><p>• Document retrieval where you want entity-level ranking from the start</p><p>• E-commerce and media, where a “result” is always a single product or video</p><p><strong>Where I’d think twice:</strong></p><p>• If your chunks need to surface individually in the response (you want the specific paragraph, not just the document), you still need to identify the best matching chunk post-retrieval. MAX_SIM tells you which entity wins, not which internal vector was the best match. You’d need a second pass for chunk-level answers.</p><p>• Write-heavy pipelines where entities are frequently updated. The field type doesn’t change Milvus’s segment behavior, but it’s worth testing your specific update pattern before committing.</p><p>One thing I learned the hard way on a previous RAG project: if your chunking strategy produces wildly variable chunk counts per document — some docs have 3 chunks, others have 300 — the entity-level scores aren’t directly comparable. Normalize or filter by entity size if that matters for your recall metrics.</p><h3>Where This Fits in a Practical RAG Stack</h3><p>For the ColBERT-style setup I was building, I’m migrating to Array of Structs with MAX_SIM as the retrieval layer. The change that matters most in production: eliminating the deduplication pass that was running after every <a href="https://zilliz.com/learn/vector-similarity-search?utm_campaign=mediumkoc">vector search</a> call. In my setup, that post-processing step was adding roughly 40–80ms of latency per query depending on the result set size. With entity-level retrieval built into the database, that cost disappears.</p><p>The pattern I’m moving to:</p><p>1. At index time: one record per entity, all chunk vectors stored in the Array of Structs field</p><p>2. At query time: late-interaction scoring via MAX_SIM, entity-level ranked results returned directly</p><p>3. Final step: fetch the stored chunk text fields from the winning entity to build the LLM context window</p><p>No intermediate grouping. No dedup. No reranking middleware for deduplication purposes. Just retrieval that returns what the application actually needs.</p><p>This is the kind of database-level primitive that makes the application stack simpler. I’ll be writing a follow-up once I’ve run this in a real traffic environment and can share actual recall and latency numbers comparing it to my current post-processing approach.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7f7cae7e81c8" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Beyond the Black Box: Building Class Activation Maps in PyTorch from Scratch]]></title>
            <link>https://medium.com/@PriyaSingh325/beyond-the-black-box-building-class-activation-maps-in-pytorch-from-scratch-1199d536a2a0?source=rss-bb58ec7be9f0------2</link>
            <guid isPermaLink="false">https://medium.com/p/1199d536a2a0</guid>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[vector-database]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[llm]]></category>
            <dc:creator><![CDATA[Priya Singh]]></dc:creator>
            <pubDate>Wed, 13 May 2026 08:01:22 GMT</pubDate>
            <atom:updated>2026-05-13T08:01:22.992Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/proxy/0*_FojPg13uvN0boiJ" /></figure><p>When you’re shipping deep learning models into production — especially for high-stakes applications like medical imaging or autonomous vehicles — accuracy isn’t the only thing that matters. Interpretability becomes just as critical. The model can’t just “be right”; it needs to show its work.</p><p>This is where <a href="https://arxiv.org/pdf/2309.14304">Class Activation Mapping</a> (CAM) comes in. It’s a simple yet powerful way to make <a href="https://en.wikipedia.org/wiki/Convolutional_neural_network">convolutional neural networks</a> (CNNs) a bit less of a black box, and I’ve found it incredibly useful for both debugging and demoing models.</p><p>Let’s walk through what CAM does, why it matters, and how you can implement it from scratch using PyTorch — without relying on wrappers or high-level explainability libraries.</p><h3>Why Interpretability Matters in Vision Models</h3><p>CNNs have become the go-to tool for image classification, object detection, and segmentation. But despite their predictive power, they’re hard to trust blindly — especially in mission-critical applications. Here’s a quick example:</p><p>Imagine you’ve built a model that classifies road signs for a self-driving car. It flags a stop sign — but is it focusing on the sign, or the red car parked next to it? Without interpretability, you’d never know.</p><p>CAM addresses this by showing which parts of the image contributed most to a classification. You get a heatmap overlay on the image that essentially answers: <em>“Why did the model think this was a stop sign?”</em></p><h3>The Core Idea Behind CAM</h3><p>Let’s get a bit technical. CAM is only applicable to a specific kind of CNN architecture — where the model ends with a global average pooling (GAP) layer, followed by a fully connected (FC) layer.</p><p>Here’s how it works under the hood:</p><ol><li>The CNN processes the input image and outputs a set of feature maps from the last convolutional layer.</li><li>The GAP layer averages each feature map into a single scalar.</li><li>The FC layer multiplies these scalars by learned weights to produce class scores.</li></ol><p>The key insight: if you know the weights in the FC layer for a specific class (say, “zebra”), you can multiply those weights back into the original feature maps to see which spatial regions were most responsible for the prediction.</p><p>This gives you a class-specific heatmap — aka, the CAM.</p><h3>Visual Example</h3><p>Take this heatmap output for an image containing a zebra and a car:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*_FojPg13uvN0boiJ" /></figure><p>Heatmap: A Class Activation Map highlights the regions around the zebra and car as more significant than other image parts.</p><p>The model is clearly focusing on the zebra. That’s what we want to see: the model’s attention aligns with our human intuition.</p><h3>A Ground-Up Implementation with PyTorch</h3><p>Let’s build CAM from scratch using PyTorch and a pre-trained ResNet18. Here’s what we’ll do:</p><ul><li>Use a hook to capture the last convolutional feature maps.</li><li>Extract the weights for the predicted class from the final FC layer.</li><li>Compute a weighted sum of the feature maps using those weights.</li><li>Normalize and upsample the result to create a heatmap.</li></ul><h4>Step 1: Load and Set Up the Model</h4><pre>import numpy as np<br>import cv2<br>from torchvision import models, transforms<br>import torch<br>from torch.nn import functional as F<br>model = models.resnet18(pretrained=True)<br>model.eval()</pre><h4>Step 2: Register a Forward Hook to Grab Feature Maps</h4><pre>activation = {}<br>def get_activation(name):def hook(model, input, output):<br>        activation[name] = output.detach()return hook<br>model.layer4.register_forward_hook(get_activation(&#39;final_conv&#39;))</pre><h4>Step 3: Load and Transform the Input Image</h4><pre>image_path = &quot;path_to_your_image.png&quot;<br>image = cv2.imread(image_path)<br>orig_image = image.copy()<br>image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)<br>transform = transforms.Compose([<br>    transforms.ToPILImage(),<br>    transforms.Resize((224, 224)),<br>    transforms.ToTensor(),<br>    transforms.Normalize(mean=[0.485, 0.456, 0.406],<br>                         std=[0.229, 0.224, 0.225])<br>])<br>input_tensor = transform(image).unsqueeze(0)</pre><h4>Step 4: Forward Pass and Fetch Class Index</h4><pre>outputs = model(input_tensor)<br>class_idx = F.softmax(outputs, dim=1).argmax().item()</pre><h4>Step 5: Retrieve Feature Maps and Weights</h4><pre>feature_maps = activation[&#39;final_conv&#39;][0]<br>weights = model.fc.weight[class_idx].detach().numpy()</pre><h4>Step 6: Generate the CAM</h4><pre>def compute_cam(feature_maps, weights):<br>    nc, h, w = feature_maps.shape<br>    cam = weights.dot(feature_maps.reshape((nc, h * w)))<br>    cam = cam.reshape(h, w)<br>    cam = cam - np.min(cam)<br>    cam /= np.max(cam)<br>    cam = np.uint8(255 * cam)return cv2.resize(cam, (image.shape[1], image.shape[0]))<br>    cam = compute_cam(feature_maps.numpy(), weights)</pre><h4>Step 7: Overlay CAM on Original Image</h4><pre>heatmap = cv2.applyColorMap(cam, cv2.COLORMAP_JET)<br>overlay = heatmap * 0.3 + orig_image * 0.5<br>cv2.imshow(&#39;CAM Result&#39;, overlay.astype(np.uint8))<br>cv2.waitKey(0)</pre><p>You should see something like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*zZJtaY0Q6Il1s1Fe" /></figure><h3>CAM in the Wild: Lessons from Real Projects</h3><p>When I first added CAM to a medical image classifier, I was shocked to find that it consistently latched onto image corners — areas with hospital watermarks, not pathology. That alone saved us weeks of debugging.</p><p>In another project involving drone footage, CAM revealed that the model was biased toward shadows when identifying “moving vehicles.” Without the visualization, we wouldn’t have caught that misbehavior until it hit production.</p><p>In both cases, CAM was my early warning system.</p><h3>Limitations and When to Reach for Grad-CAM</h3><p>Now, CAM is great — but it’s not flexible. You need a GAP layer before the FC layer, which many modern architectures don’t have. If you’re using something more customized or want generalization across architectures, you’ll want Grad-CAM instead.</p><p>Grad-CAM works by computing gradients of the class score with respect to feature maps. It doesn’t require architectural changes, so it’s a drop-in solution for most use cases.</p><p>There’s also Grad-CAM++, which improves localization when multiple objects are present.</p><p>For a deeper dive:</p><ul><li><a href="https://arxiv.org/abs/1610.02391">Grad-CAM paper</a></li><li><a href="https://arxiv.org/abs/1710.11063">Grad-CAM++ paper</a></li></ul><h3>Final Thoughts: Use CAM to Build Trust</h3><p>Interpretability tools like CAM are essential when you’re putting models in production — especially in domains where debugging is hard and stakes are high.</p><p>If you’re building your own stack, self-hosting your own model inference (like with Triton) and <a href="https://zilliz.com/learn/vector-similarity-search">vector search</a> (I often reach for <a href="https://zilliz.com/what-is-milvus">Milvus</a> for scalable, GPU-accelerated retrieval), overlaying CAM visualizations can be an easy way to monitor whether your vision model is still behaving as expected post-deployment.</p><p>CAM might not be the fanciest interpretability method around anymore, but for getting started — and for injecting some transparency into your CNNs — it’s a rock-solid foundation.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1199d536a2a0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[LangChain Is Not a Framework — It’s a Wiring Diagram for LLM Systems]]></title>
            <link>https://medium.com/@PriyaSingh325/langchain-is-not-a-framework-its-a-wiring-diagram-for-llm-systems-fad4e43c6bf8?source=rss-bb58ec7be9f0------2</link>
            <guid isPermaLink="false">https://medium.com/p/fad4e43c6bf8</guid>
            <category><![CDATA[vector-database]]></category>
            <category><![CDATA[llm-applications]]></category>
            <category><![CDATA[rags]]></category>
            <category><![CDATA[langchain]]></category>
            <dc:creator><![CDATA[Priya Singh]]></dc:creator>
            <pubDate>Thu, 30 Apr 2026 13:26:01 GMT</pubDate>
            <atom:updated>2026-04-30T13:26:01.387Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*4SvxfF2JIU8tvR6Y" /></figure><p>Last week I was helping a friend prototype an internal knowledge assistant for their legal team. They had a working prompt, a decent <a href="https://zilliz.com/blog/choosing-the-right-embedding-model-for-your-data?utm_campaign=mediumkoc">embedding model</a>, and a pile of PDFs. “I just need to connect them,” they said. Twenty minutes into their codebase, I realized what they actually needed was not a better model — it was a wiring diagram. That is exactly what LangChain turned out to be for them, and for most of the production systems I have built over the past year.</p><p>There is a common misconception that LangChain is a framework you adopt wholesale, like Django or Rails. It is not. It is closer to an integration layer — a set of conventions for connecting prompts, models, retrievers, memory, and tools into something that behaves like a system instead of a notebook cell.</p><p>This post is my honest take on LangChain after shipping multiple <a href="https://zilliz.com/learn/Retrieval-Augmented-Generation?utm_campaign=mediumkoc">RAG</a> applications and agent-style systems in production. Where it helped, where it got in the way, and how I actually use it day to day.</p><h3>The Problem LangChain Solves</h3><p>Calling an <a href="https://zilliz.com/glossary/large-language-models-(llms)?utm_campaign=mediumkoc">LLM</a> is easy. Building a system around one is not.</p><p>The moment you move beyond a single prompt and a single response, you run into real architectural questions. How do you feed context from a <a href="https://zilliz.com/learn/what-is-vector-database?utm_campaign=mediumkoc">vector database</a> into your prompt? How do you chain a summarization step before a generation step? How do you let the model decide which tool to call, and then route the result back into the conversation?</p><p>You can wire all of this yourself. I have done it plenty of times. But LangChain gives you a vocabulary for these patterns. It makes implicit decisions explicit: this is the retriever, this is the prompt template, this is the chain that ties them together. Even when I end up rewriting LangChain prototypes into custom code later, the architecture it forces me to think through usually survives.</p><h3>Chains Are About Separation, Not Complexity</h3><p>The core abstraction in LangChain is the chain — a sequence of operations where the output of one step feeds into the next. This sounds trivial until you realize how many production LLM systems jam everything into a single prompt and hope for the best.</p><p>Here is what actually happens in a real system. Your user asks a question. You need to retrieve relevant documents, reformat them into context, build a prompt, send it to the model, parse the response, maybe check for <a href="https://zilliz.com/glossary/ai-hallucination?utm_campaign=mediumkoc">AI hallucination</a>, and return a structured answer. Each of those steps has different failure modes, different latency profiles, and different caching strategies.</p><p>Chains force you to separate these concerns. That separation is what makes it possible to benchmark one step without running the whole pipeline, to cache retrieval results independently of generation, and to swap out your model without touching your retrieval logic.</p><p>One thing I learned the hard way: do not over-chain. My first LangChain project had twelve steps in the chain, including three that were basically no-ops. Debugging was a nightmare. Now I keep chains to five steps or fewer and handle edge cases outside the chain.</p><h3>Retrieval Is Where LangChain Earns Its Keep</h3><p>LangChain becomes genuinely useful when retrieval enters the picture. And retrieval is where most of the actual engineering work lives in a RAG system.</p><p>The pattern is straightforward. You chunk your documents, generate <a href="https://zilliz.com/glossary/vector-embeddings?utm_campaign=mediumkoc">vector embeddings</a> for each chunk, store them in a vector database, and then at query time you embed the question, search for similar chunks, and feed those chunks into the LLM as context. LangChain wraps this entire flow in a RetrievalQA chain that handles the plumbing.</p><p>What I appreciate about this abstraction is the retriever interface. It does not care whether your backend is Milvus, Pinecone, pgvector, or a flat file. You implement get_relevant_documents() and the rest of the chain just works. In practice, I have used Milvus for most of my production systems because it handles the scale I need — millions of vectors with sub-10ms p99 latency — but the point is that LangChain does not lock you in.</p><p>Here is a minimal example that reflects how I actually start RAG prototypes:</p><pre>from langchain.embeddings import OpenAIEmbeddings<br>from langchain.vectorstores import Milvus<br>from langchain.chains import RetrievalQA<br>from langchain.llms import OpenAI<br><br>embeddings = OpenAIEmbeddings()<br>vectorstore = Milvus(<br>    collection_name=&quot;knowledge_base&quot;,<br>    embedding_function=embeddings,<br>    connection_args={&quot;host&quot;: &quot;localhost&quot;, &quot;port&quot;: &quot;19530&quot;}<br>)<br>retriever = vectorstore.as_retriever(search_kwargs={&quot;k&quot;: 5})<br><br>qa = RetrievalQA.from_chain_type(<br>    llm=OpenAI(temperature=0),<br>    retriever=retriever,<br>    chain_type=&quot;stuff&quot;<br>)<br>answer = qa.run(&quot;How does our authentication flow work?&quot;)</pre><p>Nothing fancy. But it is readable, debuggable, and easy to evolve. That matters more than cleverness when you are iterating on retrieval quality at 2am.</p><h3>Agents: Powerful but Easy to Misuse</h3><p>LangChain’s agent abstractions get a lot of attention. Agents let the model decide which tool to call, observe the result, and decide what to do next. This is genuinely powerful for certain use cases — research assistants, data exploration tools, anything where the task is not fully known upfront.</p><p>They are also one of the easiest ways to build something fragile.</p><p>Here is what actually happened the first time I deployed an agent in production: it worked perfectly on our test queries, then a customer asked a slightly ambiguous question and the agent entered a loop — calling the same search tool four times with progressively worse reformulations, burning through tokens and returning garbage. The fix was simpler than I expected: I added a max-iteration cap and a confidence check after each tool call.</p><p>I only reach for agents now when the workflow genuinely requires dynamic routing. If you know the steps upfront, use a chain. Chains are predictable. Agents are flexible. Pick the one that matches your problem.</p><h3>Memory: Less Is Usually More</h3><p>LangChain offers memory abstractions — conversation buffers, summary memory, entity memory. They are often misused.</p><p>The trap is persisting everything. In a customer support bot I built last year, we initially stored the entire conversation history in memory and fed it all back into every prompt. After about fifteen turns, the context window was full of irrelevant early messages, costs were climbing, and response quality had degraded noticeably.</p><p>Here is what I actually do now. I treat memory as a sliding window — last five turns maximum, with a separate summary buffer that condenses older context into a single paragraph. For anything that needs to persist beyond the session, I write it to the vector database and retrieve it on demand. This keeps token costs flat and retrieval relevant.</p><p>The same principle applies to <a href="https://zilliz.com/blog/multimodal-rag-expanding-beyond-text-for-smarter-ai?utm_campaign=mediumkoc">Multimodal RAG</a> systems where your context is not just text but images and tables. Memory bloat gets worse when you are dealing with <a href="https://zilliz.com/learn/introduction-to-unstructured-data?utm_campaign=mediumkoc">unstructured data</a> across multiple modalities — another reason to be aggressive about what you keep and what you discard.</p><h3>What I Actually Measure</h3><p>When evaluating a LangChain-based system, I do not care about demo impressiveness or how many components are chained together. I measure four things:</p><p>• Retrieval precision at k=5 — are the chunks actually relevant?</p><p>• End-to-end latency p95 — can a user wait this long?</p><p>• Token cost per query — can we afford this at 10x current traffic?</p><p>• Answer faithfulness — does the response actually follow the retrieved context, or is the model making things up?</p><p>LangChain helps with iteration speed on all of these, but it does not optimize any of them by default. You still need to tune your chunking strategy, pick the right embedding model, and configure your vector index parameters. The abstraction layer just makes it faster to experiment.</p><h3>When I Skip LangChain</h3><p>LangChain is not always the right tool. I skip it when:</p><p>• The application is a single prompt with no retrieval — just call the API directly.</p><p>• Latency is critical and I need full control over every network call.</p><p>• The team is experienced enough to build the plumbing themselves and LangChain’s abstractions would add indirection without adding clarity.</p><p>• I need fine-grained streaming control that LangChain’s abstractions make awkward.</p><p>LangChain shines during exploration and early system design. Mature systems sometimes outgrow it, and that is completely fine. The architecture it helped you discover is the real value — not the library itself.</p><h3>What Comes Next</h3><p>I have been experimenting with LangGraph for workflows that need conditional branching and cycles — things that plain chains cannot express cleanly. But that is a different post. For now, if you are building your first RAG system or trying to bring structure to an LLM prototype that has gotten out of hand, LangChain is still the fastest way to get from “it works in a notebook” to “it works in production.”</p><p>Just remember: it is a wiring diagram, not the system itself. The quality of your retrieval, your embeddings, and your prompts still determines whether the thing actually works.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fad4e43c6bf8" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[LangChain vs LangGraph]]></title>
            <link>https://medium.com/@PriyaSingh325/langchain-vs-langgraph-42062e6da0cb?source=rss-bb58ec7be9f0------2</link>
            <guid isPermaLink="false">https://medium.com/p/42062e6da0cb</guid>
            <category><![CDATA[langchain]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[langgraph]]></category>
            <category><![CDATA[rags]]></category>
            <category><![CDATA[ai-agent]]></category>
            <dc:creator><![CDATA[Priya Singh]]></dc:creator>
            <pubDate>Mon, 27 Apr 2026 09:16:31 GMT</pubDate>
            <atom:updated>2026-04-27T09:16:31.658Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*yu0ck1O_vl50a7fj" /></figure><p>Last week I was rebuilding a document Q&amp;A pipeline that had outgrown its original design. What started as a clean <a href="https://zilliz.com/learn/Retrieval-Augmented-Generation?utm_campaign=mediumkoc">RAG</a> chain — retrieve context, stuff it into a prompt, get an answer — had turned into something with retry logic, a verification step that called the <a href="https://zilliz.com/glossary/large-language-models-(llms">LLM</a> a second time, and a branching path where certain queries got routed to a different retriever entirely. I had duct-taped it together with nested if-statements and callbacks, and it was becoming painful to debug. That’s when I sat down and properly evaluated whether LangChain alone was still the right tool, or whether LangGraph — the graph-based orchestration layer from the same team — was what I actually needed.</p><p>If you’re at a similar crossroads, here’s what I learned from living with both in production.</p><h3>What LangChain Actually Does Well</h3><p>LangChain is middleware. It sits between your model and your application and gives you a library of connectors and abstractions so you don’t have to write boilerplate for every integration. Need to load PDFs, split them into chunks, embed them, store them in a <a href="https://zilliz.com/learn/what-is-vector-database?utm_campaign=mediumkoc">vector database</a>, and query them with <a href="https://zilliz.com/glossary/semantic-search?utm_campaign=mediumkoc">semantic search</a>? LangChain has components for each of those steps, and they plug together with a consistent interface.</p><p>The core orchestration mechanism is called LCEL (LangChain Expression Language). You pipe components together in a sequence — retriever, prompt template, model, output parser — and it handles the data flow. For straightforward pipelines, this works well. I’ve shipped several RAG services where LangChain was the right level of abstraction: connect to a <a href="https://milvus.io/?utm_campaign=mediumkoc">Milvus</a> instance for <a href="https://zilliz.com/learn/vector-similarity-search?utm_campaign=mediumkoc">vector similarity search</a>, feed results into a prompt, return the answer. Done in under a hundred lines.</p><p>Where LangChain earns its keep is the component library. Document loaders for dozens of formats. Text splitters that understand markdown structure. <a href="https://zilliz.com/glossary/vector-embeddings?utm_campaign=mediumkoc">Vector embeddings</a> wrappers for OpenAI, Cohere, HuggingFace, and others. Model interfaces that let you swap providers without rewriting your chain. If your use case fits the pattern of “connect things in a line and run them,” LangChain is genuinely productive.</p><h3>Where LangChain Starts to Creak</h3><p>The trouble starts when your workflow isn’t linear. My Q&amp;A pipeline needed to:</p><p>1. Retrieve candidate documents</p><p>2. Check whether the retrieved context was actually relevant (a lightweight classifier)</p><p>3. If not, reformulate the query and try again — up to two retries</p><p>4. If relevant, generate an answer</p><p>5. Run a fact-check pass against the original documents to reduce <a href="https://zilliz.com/glossary/ai-hallucination?utm_campaign=mediumkoc">AI hallucination</a></p><p>6. Return the answer with confidence metadata</p><p>Steps 2 and 3 are a loop. Step 5 is a conditional branch. LangChain’s Memory components can hold simple conversational context, but they weren’t designed for tracking “which retry am I on” or “did the verification pass.” I could make it work with custom callbacks and state variables stuffed into the chain’s metadata, but I was fighting the abstraction rather than using it.</p><p>This is the honest assessment: LangChain is good at pipelines, not at workflows. A pipeline is a sequence. A workflow has branches, loops, retries, and state that persists across steps. If you need the latter, you need something built for it.</p><h3>What LangGraph Brings to the Table</h3><pre>from langgraph.graph import StateGraph, END<br>from typing import TypedDict, List<br><br>class PipelineState(TypedDict):<br>    query: str<br>    documents: List[str]<br>    answer: str<br>    is_relevant: bool<br>    retry_count: int<br><br>def retrieve(state: PipelineState) -&gt; dict:<br>    # Call your vector store here — e.g., Milvus retriever via LangChain<br>    docs = retriever.invoke(state[&quot;query&quot;])<br>    return {&quot;documents&quot;: [d.page_content for d in docs]}<br><br>def check_relevance(state: PipelineState) -&gt; dict:<br>    # Lightweight classifier or LLM call to judge relevance<br>    score = relevance_classifier(state[&quot;query&quot;], state[&quot;documents&quot;])<br>    return {&quot;is_relevant&quot;: score &gt; 0.7}<br><br>def reformulate_query(state: PipelineState) -&gt; dict:<br>    new_query = query_rewriter.invoke(state[&quot;query&quot;])<br>    return {&quot;query&quot;: new_query, &quot;retry_count&quot;: state[&quot;retry_count&quot;] + 1}<br><br>def generate_answer(state: PipelineState) -&gt; dict:<br>    answer = rag_chain.invoke({<br>        &quot;context&quot;: &quot;\n&quot;.join(state[&quot;documents&quot;]),<br>        &quot;question&quot;: state[&quot;query&quot;]<br>    })<br>    return {&quot;answer&quot;: answer}<br><br>def route_after_relevance(state: PipelineState) -&gt; str:<br>    if state[&quot;is_relevant&quot;]:<br>        return &quot;generate&quot;<br>    if state[&quot;retry_count&quot;] &lt; 2:<br>        return &quot;reformulate&quot;<br>    return &quot;generate&quot;  # Give up retrying, answer with what we have<br><br>graph = StateGraph(PipelineState)<br>graph.add_node(&quot;retrieve&quot;, retrieve)<br>graph.add_node(&quot;check_relevance&quot;, check_relevance)<br>graph.add_node(&quot;reformulate&quot;, reformulate_query)<br>graph.add_node(&quot;generate&quot;, generate_answer)<br><br>graph.set_entry_point(&quot;retrieve&quot;)<br>graph.add_edge(&quot;retrieve&quot;, &quot;check_relevance&quot;)<br>graph.add_conditional_edges(&quot;check_relevance&quot;, route_after_relevance, {<br>    &quot;generate&quot;: &quot;generate&quot;,<br>    &quot;reformulate&quot;: &quot;reformulate&quot;,<br>})<br>graph.add_edge(&quot;reformulate&quot;, &quot;retrieve&quot;)<br>graph.add_edge(&quot;generate&quot;, END)<br><br>app = graph.compile()<br><br>result = app.invoke({<br>    &quot;query&quot;: &quot;How do I configure HNSW index parameters?&quot;,<br>    &quot;documents&quot;: [],<br>    &quot;answer&quot;: &quot;&quot;,<br>    &quot;is_relevant&quot;: False,<br>    &quot;retry_count&quot;: 0,<br>})</pre><p>LangGraph models your application as a directed graph. Each node is an action — calling an LLM, querying a database, running a classifier, formatting output. Edges define how control flows between nodes, and they can be conditional. You get loops, branching, retries, and parallel execution as first-class concepts rather than hacks.</p><p>The critical difference is state management. LangGraph maintains a centralized state object that every node can read from and write to. It supports rollbacks and history, so you can inspect exactly what happened at each step. When my verification node decided the retrieval was bad, it could increment a retry counter in the state, modify the query, and route back to the retrieval node — all expressed declaratively in the graph definition.</p><p>Here’s a simplified version of what the refactored pipeline looked like:</p><p>Every step is a plain function. The graph definition is separate from the logic. You can look at the graph structure and understand the flow without reading through implementation details. That separation is what I was missing.</p><h3>A Design Tradeoff That Bit Me</h3><p>One thing I didn’t expect: LangGraph’s state management adds overhead that matters at scale. Every node invocation serializes and deserializes the state object. For my pipeline, the state included retrieved document texts — sometimes 15–20 chunks of 500 tokens each. Serializing that on every node transition added measurable latency, roughly 40–60ms per step on a modestly-sized state.</p><p>My fix was to store document references (IDs) in the graph state rather than full text, and only hydrate the content when a node actually needed it. This meant adding a lightweight cache layer, but it cut per-step overhead significantly. The lesson: keep your LangGraph state lean. Treat it like you’d treat a database row, not a dumping ground for intermediate artifacts.</p><p>The other tradeoff is complexity. For a straightforward RAG pipeline — retrieve, prompt, answer — LangGraph is overkill. You’re defining nodes, edges, state schemas, and routing functions for something that LCEL handles in five lines. I’ve seen teams adopt LangGraph prematurely because it feels more “serious,” then spend days debugging graph definitions for workflows that are fundamentally linear. Use it when you actually need branching or state. Not before.</p><h3>Multi-Agent Coordination</h3><p>Where LangGraph genuinely shines is multi-agent setups. I’ve been experimenting with a system where one agent handles retrieval and answer generation, a second agent handles fact-checking, and a third handles query understanding and routing. Each agent is a subgraph with its own internal logic, and the parent graph coordinates them.</p><p>This pattern — sometimes called <a href="https://zilliz.com/blog/build-your-voice-assistant-agentic-rag-with-milvus-and-llama-3-2?utm_campaign=mediumkoc">Agentic RAG</a> — is where the graph abstraction pays for itself. Agents can run in parallel where their inputs are independent. The parent graph manages shared state, handles timeouts, and defines fallback behavior. Trying to build this with plain LangChain chains and callbacks would be a nightmare of spaghetti logic.</p><p>LangGraph also integrates with LangSmith for debugging, which becomes essential when you have multiple agents making decisions. Being able to trace which node fired, what state it saw, and what it produced is the difference between a debuggable system and a black box.</p><h3>Production Considerations</h3><p>A few things I’ve learned deploying both in production:</p><p><strong>Latency budgets matter.</strong> Each node transition in LangGraph has overhead. For user-facing applications with tight latency requirements, count your nodes carefully. A graph with eight nodes and three conditional branches will be slower than a single LCEL chain, even if the actual LLM calls are identical. Profile early.</p><p><strong>Batching is easier with LangChain.</strong> If you’re processing bulk documents — say, embedding and indexing thousands of pages into <a href="https://zilliz.com/cloud?utm_campaign=mediumkoc">Zilliz Cloud</a> — LangChain’s batch interfaces are more mature. LangGraph is designed for single-request workflows, not batch ETL.</p><p><strong>State persistence needs planning.</strong> LangGraph supports checkpointing state to external storage, which is critical for long-running conversations or multi-turn interactions. But you need to choose your backend (Redis, Postgres, etc.) and handle serialization yourself. It’s not plug-and-play.</p><p><strong>Testing graph logic separately from node logic</strong> is the single best practice I can recommend. Write unit tests for each node function in isolation, then write integration tests for the graph routing. If you mix the two, debugging becomes miserable.</p><h3>When to Use Which</h3><p>Use LangChain alone when your workflow is a pipeline: data in, steps in sequence, result out. RAG over a <a href="https://zilliz.com/learn/vector-index?utm_campaign=mediumkoc">vector index</a>, summarization chains, simple chatbots with memory. It’s productive, well-documented, and has integrations for practically everything.</p><p>Use LangGraph when your workflow has loops, branches, retries, or multiple agents making decisions. Customer service bots that escalate based on sentiment. Research assistants that iteratively refine their searches. Any system where “what happens next” depends on “what just happened.”</p><p>Use both together — and this is what I ended up doing. LangChain provides the component library: retrievers, model wrappers, document loaders, <a href="https://zilliz.com/glossary/vector-embeddings?utm_campaign=mediumkoc">embedding</a> functions. LangGraph provides the orchestration: how those components interact, when they retry, how state flows between them. The retriever node in my graph uses a LangChain retriever under the hood. The LLM calls go through LangChain’s model interface. LangGraph just manages the flow.</p><p>That’s the practical answer. Not one or the other — the right layer for the right job.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=42062e6da0cb" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building Interactive AI Chatbots with Vector Search]]></title>
            <link>https://medium.com/@PriyaSingh325/building-interactive-ai-chatbots-with-vector-search-145845303287?source=rss-bb58ec7be9f0------2</link>
            <guid isPermaLink="false">https://medium.com/p/145845303287</guid>
            <category><![CDATA[chatbots]]></category>
            <category><![CDATA[vector-search]]></category>
            <category><![CDATA[vector-database]]></category>
            <dc:creator><![CDATA[Priya Singh]]></dc:creator>
            <pubDate>Thu, 23 Apr 2026 09:06:01 GMT</pubDate>
            <atom:updated>2026-04-23T09:06:01.648Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*nChj-AzsPWrL8oBd" /></figure><p>Last week I was helping a fintech client migrate their support chatbot from a simple FAQ matcher to something that could actually hold a context-aware conversation. The old system would crumble the moment someone asked a follow-up question or phrased something slightly differently than the training data. “Why can’t I see my recent transaction?” would work fine, but “Where did my money go?” would send it into a loop of generic responses.</p><p>The breakthrough came when I stopped thinking about it as a keyword-matching problem and started treating it as a <a href="https://zilliz.com/learn/vector-similarity-search?utm_campaign=mediumkoc">vector search</a> problem. Here’s what actually happened when I rebuilt it.</p><h3>The Core Architecture: Why Vector Databases Changed Everything</h3><p>Traditional chatbots rely on exact matches or simple pattern recognition. You build a library of intents, map keywords to responses, and hope users phrase things the way you anticipated. It breaks down fast in production.</p><p>A <a href="https://zilliz.com/learn/what-is-vector-database?utm_campaign=mediumkoc">vector database</a> solves this by converting both your knowledge base and user queries into numerical representations — vectors — that capture semantic meaning. When someone asks “Where did my money go?”, the system doesn’t look for the word “transaction”. It looks for *concepts* that are mathematically similar in high-dimensional space.</p><p>Here’s the simplest example I can show. Let’s say a user asks about Vietnamese restaurants nearby. The flow looks like this:</p><pre>from pymilvus import connections, Collection<br>import openai<br># Connect to vector database<br>connections.connect(host=&quot;localhost&quot;, port=&quot;19530&quot;)<br>collection = Collection(&quot;restaurant_kb&quot;)<br># User query<br>user_query = &quot;What are the best Vietnamese restaurants near me?&quot;<br># Generate embedding for query<br>query_embedding = openai.Embedding.create(<br>    input=user_query,<br>    model=&quot;text-embedding-ada-002&quot;<br>)[&quot;data&quot;][0][&quot;embedding&quot;]<br># Search for similar vectors<br>search_params = {&quot;metric_type&quot;: &quot;COSINE&quot;, &quot;params&quot;: {&quot;nprobe&quot;: 10}}<br>results = collection.search(<br>    data=[query_embedding],<br>    anns_field=&quot;embedding&quot;,<br>    param=search_params,<br>    limit=5,<br>    output_fields=[&quot;name&quot;, &quot;cuisine&quot;, &quot;location&quot;, &quot;rating&quot;]<br>)<br># Extract top results<br>for hit in results[0]:<br>    print(f&quot;{hit.entity.get(&#39;name&#39;)} - {hit.entity.get(&#39;cuisine&#39;)} - {hit.distance}&quot;)</pre><p>The query gets vectorized, the database finds the five closest matches in vector space, and the chatbot can now synthesize a response like “Based on your location and preferences, Pho 79 and Saigon Bistro are highly rated options within 2 miles.”</p><p>What makes this powerful isn’t just the search — it’s the *memory*. The chatbot can store previous interactions as vectors too, so when the user follows up with “What about Italian instead?”, the system understands the context without needing explicit session management.</p><h3>What I Learned Building This in Production</h3><p>The first version I deployed had terrible latency. Generating <a href="https://zilliz.com/glossary/vector-embeddings?utm_campaign=mediumkoc">embeddings</a> on every query was eating 200–300ms per request, and our SLA was 500ms total. I ended up batching non-urgent updates and caching common queries, but the real fix was simpler than I expected: pre-computing embeddings for the knowledge base and only generating them on-the-fly for user input.</p><p>One thing I learned the hard way: not all <a href="https://zilliz.com/blog/choosing-the-right-embedding-model-for-your-data?utm_campaign=mediumkoc">embedding models</a> are created equal for conversational data. I started with a general-purpose BERT model and got mediocre results because it wasn’t trained on dialogue patterns. Switching to a domain-tuned transformer (fine-tuned on customer support transcripts) cut our false positive rate in half.</p><p>The tradeoff was model size and inference cost. The general BERT model was 110MB and ran locally; the fine-tuned one was 340MB and needed GPU inference. For this client, the accuracy gain was worth it, but I’ve had other projects where a lighter model made more sense because they were optimizing for response time over precision.</p><h3>Handling the Messy Reality of User Input</h3><p>Users don’t type clean, grammatically correct sentences. They abbreviate, misspell, use slang, or paste entire error messages into the chat. I had to layer in several <a href="https://zilliz.com/learn/A-Beginner-Guide-to-Natural-Language-Processing?utm_campaign=mediumkoc">NLP</a> techniques to normalize input before vectorization:</p><p>• <strong>Text normalization</strong>: lowercase, strip extra whitespace, expand contractions</p><p>• <strong>Synonym expansion</strong>: map “acc” to “account”, “txn” to “transaction”</p><p>• <strong>Fallback mechanisms</strong>: if vector similarity score is below a threshold (I used 0.65 for <a href="https://zilliz.com/blog/similarity-metrics-for-vector-search?utm_campaign=mediumkoc">cosine similarity</a>), trigger a clarification prompt instead of guessing</p><p>Here’s a snippet of the preprocessing pipeline I used:</p><pre>import re<br>from nltk.corpus import stopwords<br>from nltk.stem import WordNetLemmatizer<br>def preprocess_query(text):<br>    # Lowercase and strip whitespace<br>    text = text.lower().strip()<br>    # Expand common abbreviations<br>    abbreviations = {<br>        &quot;acc&quot;: &quot;account&quot;,<br>        &quot;txn&quot;: &quot;transaction&quot;,<br>        &quot;bal&quot;: &quot;balance&quot;,<br>        &quot;stmt&quot;: &quot;statement&quot;<br>    }<br>    for abbr, full in abbreviations.items():<br>        text = re.sub(r&#39;\b&#39; + abbr + r&#39;\b&#39;, full, text)<br>    # Remove stopwords (optional - depends on embedding model)<br>    stop_words = set(stopwords.words(&#39;english&#39;))<br>    tokens = text.split()<br>    tokens = [w for w in tokens if w not in stop_words]<br>    # Lemmatize<br>    lemmatizer = WordNetLemmatizer()<br>    tokens = [lemmatizer.lemmatize(w) for w in tokens]<br>    return &quot; &quot;.join(tokens)<br># Example<br>raw_query = &quot;Why can&#39;t I see my recent txns?&quot;<br>clean_query = preprocess_query(raw_query)<br>print(clean_query)  # &quot;see recent transaction&quot;</pre><p>I debated whether to remove stopwords before embedding. Turns out it depends on your model — transformer-based embeddings often handle stopwords fine, but older bag-of-words approaches benefit from stripping them out. I A/B tested both and kept stopwords in because the transformer model used positional context.</p><h3>Scaling to Real Traffic</h3><p>The prototype ran on a single <a href="https://milvus.io/?utm_campaign=mediumkoc">Milvus</a> instance on a 4-core VM. That worked fine for internal testing, but production traffic spiked unpredictably — 50 concurrent users one hour, 500 the next. I needed horizontal scaling without rewriting the entire stack.</p><p>I migrated to <a href="https://zilliz.com/cloud?utm_campaign=mediumkoc">Zilliz Cloud</a>, which is a managed version of Milvus. The main wins were:</p><p>• <strong>Auto-scaling</strong>: it spins up replicas during traffic spikes and scales down overnight</p><p>• <strong>Caching</strong>: frequently queried vectors are cached at the edge, shaving 50–100ms off latency</p><p>• <strong>No ops overhead</strong>: I don’t have to tune index parameters or manage sharding myself</p><p>The latency improvement was measurable. Median query time dropped from 280ms to 120ms, and P95 went from 600ms to 250ms. Part of that was network proximity (their cluster was closer to our app servers), but the built-in query optimization was the bigger factor.</p><h3>Retrieval-Augmented Generation: The Secret Weapon</h3><p>The real magic happened when I wired the vector search layer into a <a href="https://zilliz.com/learn/Retrieval-Augmented-Generation?utm_campaign=mediumkoc">RAG</a> pipeline. Instead of just retrieving similar documents and showing them to the user, I fed the top results into a <a href="https://zilliz.com/glossary/large-language-models-(llms">large language model</a>?utm_campaign=mediumkoc) as context for generation.</p><p>Here’s the workflow:</p><p>1. User asks: “What’s the fee for international wire transfers?”</p><p>2. Vector search pulls the top 3 relevant KB articles (fee schedules, wire transfer guide, international banking FAQ)</p><p>3. Those articles get passed as context to GPT-4</p><p>4. The model generates a natural-language answer grounded in those docs</p><p>Let me show you exactly how I wired this up:</p><pre>from pymilvus import Collection<br>import openai<br><br>def rag_chatbot(user_query):<br>    # Step 1: Vectorize query<br>    query_embedding = openai.Embedding.create(<br>        input=user_query,<br>        model=&quot;text-embedding-ada-002&quot;<br>    )[&quot;data&quot;][0][&quot;embedding&quot;]<br>    # Step 2: Search vector database<br>    collection = Collection(&quot;support_kb&quot;)<br>    results = collection.search(<br>        data=[query_embedding],<br>        anns_field=&quot;embedding&quot;,<br>        param={&quot;metric_type&quot;: &quot;COSINE&quot;, &quot;params&quot;: {&quot;nprobe&quot;: 16}},<br>        limit=3,<br>        output_fields=[&quot;title&quot;, &quot;content&quot;]<br>    )<br>    # Step 3: Build context from top results<br>    context = &quot;\n\n&quot;.join([<br>        f&quot;Document: {hit.entity.get(&#39;title&#39;)}\n{hit.entity.get(&#39;content&#39;)}&quot;<br>        for hit in results[0]<br>    ])<br>    # Step 4: Generate response with LLM<br>    response = openai.ChatCompletion.create(<br>        model=&quot;gpt-4&quot;,<br>        messages=[<br>            {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: &quot;You are a helpful banking assistant. Answer based only on the provided context.&quot;},<br>            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;Context:\n{context}\n\nQuestion: {user_query}&quot;}<br>        ],<br>        temperature=0.3<br>    )<br>    return response[&quot;choices&quot;][0][&quot;message&quot;][&quot;content&quot;]<br># Example<br>answer = rag_chatbot(&quot;What&#39;s the fee for international wire transfers?&quot;)<br>print(answer)</pre><p>The key constraint here is: <strong>answer only from the provided context</strong>. This prevents <a href="https://zilliz.com/glossary/ai-hallucination?utm_campaign=mediumkoc">AI hallucination</a> — the model won’t make up fee amounts or policies that don’t exist in your KB. I set temperature to 0.3 to keep responses factual and consistent.</p><p>One thing that didn’t work as expected: I initially passed all KB articles as context (thinking more data = better answers). The LLM got overwhelmed and started cherry-picking random sentences. Limiting to the top 3 most relevant docs gave much cleaner, more focused responses.</p><h3>Conversation Design: Making It Feel Human</h3><p>The technical stack is only half the battle. If the chatbot sounds robotic or can’t handle conversational flow, users bail.</p><p>I built in a few tricks to make interactions feel more natural:</p><p>• <strong>Context tracking</strong>: store the last 3 turns of conversation as vectors, so the model understands references like “that one” or “the second option”</p><p>• <strong>Personality tuning</strong>: adjusted the system prompt to match the brand voice (this client wanted professional but friendly)</p><p>• <strong>Empathy markers</strong>: if the user expresses frustration (“This is ridiculous”), the system detects negative sentiment and routes to a human agent instead of attempting another automated response</p><p>Here’s the sentiment check I added:</p><pre>from textblob import TextBlob<br><br>def check_sentiment(text):<br>    polarity = TextBlob(text).sentiment.polarity<br>    if polarity &lt; -0.3:  # Negative sentiment threshold<br>        return &quot;escalate&quot;<br>    return &quot;continue&quot;<br># Example<br>user_message = &quot;This is ridiculous, why can&#39;t you just tell me the fee?&quot;<br>if check_sentiment(user_message) == &quot;escalate&quot;:<br>    print(&quot;Routing to human agent...&quot;)</pre><p>Crude but effective. I’ve seen more sophisticated sentiment models (BERT-based classifiers), but for this use case the extra complexity wasn’t justified.</p><h3>Practical Challenges I Hit</h3><p><strong>Privacy and Security</strong>: Users paste sensitive info into chat — account numbers, SSNs, transaction IDs. I had to implement PII redaction before storing conversation history. I used a regex-based scrubber for obvious patterns (9-digit SSNs, 16-digit card numbers) and flagged edge cases for manual review.</p><p><strong>Multi-Modal Input</strong>: Some users tried to upload screenshots of error messages or PDFs of statements. The initial design only handled text. I extended it to extract text from images (using Tesseract OCR) and parse PDFs, then vectorized the extracted content. This added latency (OCR is slow), so I made it async — the chatbot responds with “Processing your document, one moment…” while the job runs in the background.</p><p><strong>Consistency Across Channels</strong>: The chatbot ran on web, mobile app, and SMS. Each channel had different character limits and formatting constraints. I built a response adapter layer that truncated or reformatted answers based on the channel, but it was messier than I’d like. Lesson learned: design for the most constrained channel first (SMS) and scale up from there.</p><h3>What I’d Do Differently Next Time</h3><p>If I were starting this project today, I’d prototype with a simpler rule-based fallback layer *before* jumping into vector search. There are categories of queries (password resets, account lockouts) that don’t benefit from <a href="https://zilliz.com/glossary/semantic-search?utm_campaign=mediumkoc">semantic search</a> — they just need fast, deterministic routing. I could’ve saved a week by handling those upfront and reserving vector search for ambiguous or knowledge-heavy queries.</p><p>I’d also spend more time on observability. We didn’t have good visibility into *why* certain queries performed poorly until I added logging for similarity scores, retrieved documents, and LLM token usage. Once I had that data, it was obvious where the bottlenecks were (spoiler: half of them were poorly indexed KB articles with vague titles).</p><h3>Next Steps If You’re Building This</h3><p>1. <strong>Start small</strong>: Pick one domain (support, product recommendations, internal Q&amp;A) and build a focused knowledge base. Trying to be conversational about everything at once is a recipe for mediocrity.</p><p>2. <strong>Measure retrieval quality separately from generation quality</strong>: Your chatbot might give bad answers because the vector search returned irrelevant docs, not because the LLM failed. Instrument both stages independently.</p><p>3. <strong>Invest in data preprocessing</strong>: Garbage in, garbage out. If your KB articles are full of jargon, inconsistent terminology, or outdated info, no amount of vector magic will fix it. Clean your data first.</p><p>4. <strong>Test with real users early</strong>: Internal QA teams won’t phrase questions like actual customers. Get a beta group on it ASAP and watch session replays to see where the model fumbles.</p><p>Vector-powered chatbots aren’t perfect, but they’re the closest I’ve come to building something that feels genuinely conversational at scale. The combination of semantic search and retrieval-augmented generation bridges the gap between rigid FAQ bots and fully custom <a href="https://zilliz.com/learn/generative-ai?utm_campaign=mediumkoc">generative AI</a> agents. And once you crack the architecture, the hard part shifts from “Can we do this?” to “How do we scale this sustainably?” — which is exactly where you want to be.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=145845303287" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Blog: LangChain 1.0 and Milvus: How to Build Production-Ready Agents with Real Long-Term Memory]]></title>
            <link>https://medium.com/@PriyaSingh325/blog-langchain-1-0-and-milvus-how-to-build-production-ready-agents-with-real-long-term-memory-478611b406dd?source=rss-bb58ec7be9f0------2</link>
            <guid isPermaLink="false">https://medium.com/p/478611b406dd</guid>
            <category><![CDATA[langchain]]></category>
            <category><![CDATA[agents]]></category>
            <category><![CDATA[vector-database]]></category>
            <dc:creator><![CDATA[Priya Singh]]></dc:creator>
            <pubDate>Tue, 21 Apr 2026 02:44:46 GMT</pubDate>
            <atom:updated>2026-04-21T02:44:46.821Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*UeQWZwhY4SsQxfGh" /></figure><p>Last week I was debugging an agent that kept “forgetting” things. We’d ask it to recall a decision from three sessions ago — the kind of thing that would be obvious to any human support engineer — and it confidently stated it had no record of it. The data existed somewhere. But our agent couldn’t reach it. The reasoning was fine; the memory architecture was not.</p><p>That pushed me to do a proper audit of our stack. We were running an older LangChain setup with its Chain-based patterns, and a lot of the production problems we’d been fighting — context overflows, state loss between restarts, boilerplate code every time we swapped model providers — traced back to design choices baked into that older version.</p><p>Here’s what I found when I actually dug into LangChain 1.0 and paired it with Milvus for persistent memory.</p><h3>Why the Chain-Based Design Was a Problem in Production</h3><p>The original Chain-based design in LangChain 0.x worked well for prototypes. Wire up a SimpleSequentialChain, add a prompt template and an <a href="https://zilliz.com/glossary/large-language-models-(llms">LLM</a>?utm_campaign=mediumkoc) call, and something works in half an hour. That&#39;s genuinely useful when you&#39;re validating an idea.</p><p>But chains are rigid. They define a fixed execution path. The moment your logic needs to branch — retry with different context, choose a different tool based on an intermediate result — you’re fighting the framework. I’ve seen teams end up with deeply nested custom chains that nobody could debug. Others bypassed LangChain entirely and called the API directly.</p><p>The other issue was production control. Chains had no built-in concept of middleware or execution hooks. PII redaction? Write it yourself. Token limit handling? Your problem. Human-in-the-loop approval? Figure it out and wire it manually.</p><h3>LangChain 1.0: The ReAct Loop as the Default</h3><p>The core shift in LangChain 1.0 is committing fully to the ReAct pattern: Reason → Tool Call → Observe → Decide. The team analyzed production agent implementations across the ecosystem and found that successful agents converge on this loop regardless of use case. So they made it the standard.</p><p>The entry point is create_agent():</p><pre>from langchain.agents import create_agent<br><br>agent = create_agent(<br>    model=&quot;openai:gpt-4o&quot;,<br>    tools=[search_knowledge_base, query_crm],<br>    system_prompt=&quot;You are a support agent. Use the tools to answer customer questions accurately.&quot;<br>)<br><br>result = agent.invoke({<br>    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What&#39;s the status of order #12345?&quot;}]<br>})</pre><p>Three parameters. A working agent. Under the hood this runs on LangGraph, which gives you state persistence, interruption recovery, and streaming without writing any of that infrastructure yourself.</p><p>One thing worth noting: the model parameter accepts either a string identifier or a pre-instantiated model object. In production, you&#39;ll often need the object form — you may need to configure timeouts, retry settings, or API keys that aren&#39;t the defaults. Pass the object.</p><h3>Middleware: Where Production Control Lives</h3><p>What made LangChain 1.0 immediately useful for our team was the Middleware system. It exposes hooks at strategic points in the ReAct loop — before model calls, after tool responses, at termination — so you can inject logic without modifying core agent code.</p><p><strong>PII detection</strong> is one of the prebuilt options. We use it to redact sensitive fields before they reach third-party models:</p><pre>from langchain.agents import create_agent<br>from langchain.agents.middleware import PIIMiddleware<br><br>agent = create_agent(<br>    model=&quot;gpt-4o&quot;,<br>    tools=[],<br>    middleware=[<br>        PIIMiddleware(&quot;email&quot;, strategy=&quot;redact&quot;, apply_to_input=True),<br>        PIIMiddleware(&quot;credit_card&quot;, strategy=&quot;mask&quot;, apply_to_input=True),<br>        PIIMiddleware(&quot;api_key&quot;, detector=r&quot;sk-[a-zA-Z0-9]{32}&quot;, strategy=&quot;block&quot;),<br>    ],<br>)</pre><p><strong>Summarization</strong> is the other one I use constantly. When conversation history approaches token limits, it automatically condenses older messages:</p><pre>from langchain.agents.middleware import SummarizationMiddleware<br><br>agent = create_agent(<br>    model=&quot;gpt-4o&quot;,<br>    tools=[weather_tool, crm_tool],<br>    middleware=[<br>        SummarizationMiddleware(<br>            model=&quot;gpt-4o-mini&quot;,<br>            max_tokens_before_summary=4000,<br>            messages_to_keep=20,<br>        ),<br>    ],<br>)</pre><p>Here’s the design tradeoff that matters: summarization reduces token usage but loses precision. Summaries flatten detail. For domains where specific facts matter — exact figures, previous commitments, specific case notes — you need a complementary store that preserves raw details even after context gets compressed. That’s where Milvus comes in.</p><p><strong>Tool retry</strong> with configurable exponential backoff is also worth setting up early:</p><pre>from langchain.agents.middleware import ToolRetryMiddleware<br><br>agent = create_agent(<br>    model=&quot;gpt-4o&quot;,<br>    tools=[database_tool, search_tool],<br>    middleware=[<br>        ToolRetryMiddleware(<br>            max_retries=3,<br>            backoff_factor=2.0,<br>            initial_delay=1.0,<br>            max_delay=60.0,<br>            jitter=True,<br>        ),<br>    ],<br>)</pre><p>Add jitter=True. Without it, multiple agent instances will all retry a failed service at the same moment and you&#39;ll amplify the problem instead of recovering from it.</p><h3>Wiring Up Long-Term Memory with Milvus</h3><p>The summarization tradeoff I mentioned above is real. Once you summarize a long session, detailed context — specific resolutions, exact numbers, prior decisions — gets compressed or dropped. An agent trying to recall something from a past session can’t reach it.</p><p>The fix is pairing short-term context management with a proper long-term memory layer backed by <a href="https://zilliz.com/learn/vector-similarity-search?utm_campaign=mediumkoc">vector search</a>. I used <a href="https://milvus.io/?utm_campaign=mediumkoc">Milvus</a> as the <a href="https://zilliz.com/learn/what-is-vector-database?utm_campaign=mediumkoc">vector database</a> for this. The langchain_milvus package wraps it as a standard VectorStore:</p><pre>from langchain.agents import create_agent<br>from langchain_milvus import Milvus<br>from langchain_openai import OpenAIEmbeddings<br>from langchain.agents.middleware import SummarizationMiddleware<br>from langgraph.checkpoint.memory import InMemorySaver<br><br>long_term_memory = Milvus(<br>    embedding=OpenAIEmbeddings(),<br>    collection_name=&quot;agent_memory&quot;,<br>    connection_args={&quot;uri&quot;: &quot;http://localhost:19530&quot;}<br>)<br><br>agent = create_agent(<br>    model=&quot;openai:gpt-4o&quot;,<br>    tools=[<br>        long_term_memory.as_retriever().as_tool(<br>            name=&quot;recall_memory&quot;,<br>            description=&quot;Retrieve relevant historical context and past decisions&quot;<br>        ),<br>        query_crm,<br>    ],<br>    checkpointer=InMemorySaver(),<br>    middleware=[<br>        SummarizationMiddleware(<br>            model=&quot;openai:gpt-4o-mini&quot;,<br>            max_tokens_before_summary=4000,<br>        )<br>    ],<br>    system_prompt=&quot;You have access to historical context. Use recall_memory when you need to retrieve past interactions.&quot;<br>)</pre><p>The pattern: short-term context lives in LangGraph’s checkpointer (fast, in-session), while important interactions get vectorized and stored in Milvus for cross-session recall. When the agent needs something from a past session, it calls recall_memory, which runs a <a href="https://zilliz.com/glossary/semantic-search?utm_campaign=mediumkoc">semantic search</a> against the Milvus collection and returns the most relevant chunks.</p><p>One thing I learned the hard way: be deliberate about what you write to long-term memory. We initially stored every message, which flooded retrieval with noise. The signal degraded fast. We switched to writing only resolved interactions, key decisions, and explicitly stated user preferences. Retrieval quality improved noticeably.</p><h3>Structured Output Without the Per-Provider Boilerplate</h3><p>This is a smaller win but a real one. Before LangChain 1.0, getting structured output from an agent meant writing provider-specific code. OpenAI has a native JSON mode; other models require tool-call workarounds. Every model switch meant rewriting adapters.</p><p>Now you define a Pydantic schema and pass it as response_format:</p><pre>from langchain.agents import create_agent<br>from pydantic import BaseModel, Field<br><br>class TicketSummary(BaseModel):<br>    issue_category: str = Field(description=&quot;Category of the support issue&quot;)<br>    resolution: str = Field(description=&quot;How the issue was resolved&quot;)<br>    follow_up_required: bool = Field(description=&quot;Whether follow-up action is needed&quot;)<br><br>agent = create_agent(<br>    model=&quot;openai:gpt-4o&quot;,<br>    tools=[query_crm],<br>    response_format=TicketSummary,<br>    system_prompt=&quot;After resolving an issue, return a structured summary.&quot;<br>)</pre><p>LangChain detects whether the model supports native structured output and selects the enforcement strategy automatically. Switch from GPT-4o to another model and this code doesn’t change.</p><h3>LangChain vs LangGraph: Choosing the Right Layer</h3><p>A question that comes up: when do you use create_agent() versus building directly in LangGraph?</p><p>create_agent() covers the majority of standard agent scenarios — a single agent that reasons, calls tools, and returns a result. LangGraph becomes necessary when you need custom state machines: agent A handles step one, passes state to agent B for step two, with conditional routing based on intermediate results. That&#39;s outside what create_agent() is designed for.</p><p>The practical thing is that they’re complementary. You can start with create_agent() and introduce LangGraph for the specific parts of your workflow that need finer control. There&#39;s no need to choose one and commit upfront.</p><h3>Production Considerations Before You Ship</h3><p>A few things that matter once you leave local testing:</p><p><a href="https://zilliz.com/blog/choosing-the-right-embedding-model-for-your-data?utm_campaign=mediumkoc"><strong>Embedding model</strong></a><strong> versioning</strong>: If you vectorize new documents with a different model version than what your existing index was built with, retrieval quality silently degrades. Version-lock your embedding model and record which version each Milvus collection was indexed with. This sounds obvious until you’ve lost a week debugging retrieval regressions to a model version bump.</p><p><strong>Milvus deployment mode</strong>: For development, Milvus Lite runs in-process with no server required. For production, you need a standalone or distributed deployment — or a managed option like <a href="https://zilliz.com/cloud?utm_campaign=mediumkoc">Zilliz Cloud</a> if you’d rather not handle the operational overhead. The connection args change; your application code doesn’t.</p><p><strong>Retrieval latency in the loop</strong>: Each tool call adds a round trip. If your agent calls Milvus on every turn, you’ll feel the latency accumulate. Keep frequently-needed context in short-term memory and only fall back to Milvus retrieval when in-session context doesn’t cover the query. Profile your agent’s tool call patterns early.</p><p>The combination of LangChain 1.0’s structured ReAct loop, composable middleware, and Milvus for durable long-term memory covers most of what we needed to move from reactive firefighting to building reliable features on top of a stable agent architecture. The memory problem that started this investigation is solved — and the production controls we’d been writing from scratch are now just configuration.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=478611b406dd" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Locality Sensitive Hashing in the Real World: When Approximation Beats Perfection]]></title>
            <link>https://medium.com/@PriyaSingh325/locality-sensitive-hashing-in-the-real-world-when-approximation-beats-perfection-5685453e0cc3?source=rss-bb58ec7be9f0------2</link>
            <guid isPermaLink="false">https://medium.com/p/5685453e0cc3</guid>
            <category><![CDATA[open-source]]></category>
            <category><![CDATA[vector-database]]></category>
            <dc:creator><![CDATA[Priya Singh]]></dc:creator>
            <pubDate>Thu, 16 Apr 2026 06:31:38 GMT</pubDate>
            <atom:updated>2026-04-16T06:31:38.877Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/proxy/0*FchSdgWYNJbmmfg5" /></figure><p>I didn’t learn Locality-Sensitive Hashing (LSH) from textbooks.</p><p>I learned it the hard way — when a similarity system that worked beautifully on 1 million items collapsed under 80 million.</p><p>At that scale, “exact” stops being elegant and starts being expensive.</p><p>LSH isn’t fashionable anymore. It doesn’t get the same attention as neural embeddings or large language models. But if you’ve ever built large-scale retrieval, recommendation, or deduplication systems, you know this truth:</p><p><strong>Approximation is often the only thing standing between you and a production outage.</strong></p><p>In this post, I want to talk about LSH the way engineers actually encounter it:</p><p>not as a theory, but as a trade-off — one that still matters deeply in modern AI systems, including RAG pipelines, multimodal retrieval, and hybrid search stacks.</p><h3>The Problem That Forces You to Care About LSH</h3><p>Let’s start with the problem LSH exists to solve.</p><p>You have:</p><ul><li>High-dimensional data (text embeddings, image vectors, audio features)</li><li>A distance metric (cosine similarity, Jaccard, Euclidean)</li><li>A requirement to find “similar” items quickly</li></ul><p>What you <strong>don’t</strong> have:</p><ul><li>Time to compute exact distances between everything</li></ul><p>Brute-force nearest neighbor search scales linearly. That’s fine at 100K vectors. It’s unacceptable at 100M.</p><p>This is why systems lean on approximate methods — LSH being one of the earliest and most influential.</p><p>If embeddings are new to you, this glossary entry on <a href="https://zilliz.com/glossary/vector-embeddings">vector embeddings</a> gives a good grounding before we go further.</p><h3>LSH Intuition (Without the Math Wall)</h3><p>Here’s the mental model I use.</p><p>Instead of comparing vectors directly, LSH asks:</p><blockquote><em>“Can I hash similar things into the same bucket </em>with high probability<em>?”</em></blockquote><p>The trick is that the hash functions are <strong>locality-sensitive</strong>:</p><ul><li>Similar inputs → same hash bucket (likely)</li><li>Dissimilar inputs → different buckets (likely)</li></ul><p>This flips the problem:</p><ul><li>From “search everything”</li><li>To “search only the buckets that matter”</li></ul><p>You’re trading <strong>perfect recall</strong> for <strong>speed and scalability</strong> — and doing it consciously.</p><h3>The First Time I Used LSH in Production</h3><p>My first real encounter with LSH was in a near-duplicate detection pipeline:</p><ul><li>Millions of user-generated documents</li><li>Heavy redundancy</li><li>Tight latency requirements</li></ul><p>Exact similarity was overkill. We didn’t need the <em>best</em> match — we needed a <em>good enough</em> candidate set.</p><p>LSH delivered:</p><ul><li>Massive reduction in comparison count</li><li>Predictable latency</li><li>Easy horizontal scaling</li></ul><p>But it also forced us to confront trade-offs early, which is why I still respect it as a system design tool.</p><h3>Common LSH Variants (And When I’d Actually Use Them)</h3><h4>MinHash (Set Similarity)</h4><p>If your data looks like:</p><ul><li>Token sets</li><li>Shingles</li><li>Binary features</li></ul><p>MinHash is still excellent for estimating Jaccard similarity. I’ve used it for:</p><ul><li>Document deduplication</li><li>Web crawl cleanup</li><li>Feature overlap analysis</li></ul><h4>Random Projection LSH</h4><p>This one comes up more in vector spaces:</p><ul><li>Hash via random hyperplanes</li><li>Preserve cosine similarity</li></ul><p>It’s conceptually simple and surprisingly effective when embeddings are noisy but structured.</p><h4>Why You Rarely See LSH Alone Anymore</h4><p>Modern systems often combine:</p><ul><li>LSH for coarse filtering</li><li>Dense retrieval or re-ranking for precision</li></ul><p>LSH doesn’t compete with embeddings — it <strong>complements</strong> them.</p><h3>LSH vs Modern ANN Indexes: An Honest Comparison</h3><p>Here’s the question I get a lot:</p><blockquote><em>“Why use LSH when we have HNSW, IVF, and graph-based indexes?”</em></blockquote><p>Short answer: you usually shouldn’t — <strong>unless your constraints demand it</strong>.</p><p>LSH shines when:</p><ul><li>You need extreme simplicity</li><li>Memory usage must be predictable</li><li>Data distribution changes frequently</li><li>You want fast rebuilds and stateless shards</li></ul><p>Graph-based ANN shines when:</p><ul><li>You want high recall</li><li>You can afford memory</li><li>Data is relatively stable</li></ul><p>Some systems — Milvus, for example — focus more on graph and IVF-based ANN rather than LSH, because they optimize for high-recall vector similarity at scale. That’s a design choice, not a universal rule.</p><h3>Where LSH Still Shows Up in Modern RAG Systems</h3><p>Even in Retrieval-Augmented Generation pipelines, LSH ideas sneak back in.</p><p>In large RAG systems:</p><ul><li>First-stage retrieval favors <strong>recall</strong></li><li>Later stages favor <strong>precision</strong></li></ul><p>LSH-like hashing can be used as:</p><ul><li>A pre-filter before vector search</li><li>A routing mechanism for sharded indexes</li><li>A cheap candidate generator</li></ul><p>If you’re new to RAG architectures, this overview of <a href="https://zilliz.com/learn/Retrieval-Augmented-Generation">Retrieval-Augmented Generation</a> provides useful context.</p><h3>A Small, Practical LSH Example (Python)</h3><p>Here’s a minimal example using random projection LSH for cosine similarity.</p><p>This isn’t a full system — but it captures the core idea.</p><pre>import numpy as np<br><br>def random_hyperplane_hash(vectors, num_planes=10):<br>    dim = vectors.shape[1]<br>    planes = np.random.randn(num_planes, dim)<br>    projections = np.dot(vectors, planes.T)<br>    return (projections &gt; 0).astype(int)<br><br># Example vectors<br>vectors = np.random.randn(1000, 128)<br><br># Hash into buckets<br>hash_codes = random_hyperplane_hash(vectors)<br><br># Group by bucket<br>buckets = {}<br>for idx, code in enumerate(map(tuple, hash_codes)):<br>    buckets.setdefault(code, []).append(idx)<br><br>print(f&quot;Number of buckets: {len(buckets)}&quot;)</pre><p>In practice, you’d:</p><ul><li>Use multiple hash tables</li><li>Tune the number of planes</li><li>Combine this with downstream scoring</li></ul><p>LSH alone is rarely the end of the pipeline.</p><h3>Cost, Scale, and Why Approximation Still Matters</h3><p>One lesson I’ve learned repeatedly: <strong>approximation is a cost control mechanism</strong>.</p><p>Every retrieval decision affects:</p><ul><li>CPU/GPU usage</li><li>Latency</li><li>LLM token consumption</li></ul><p>In RAG systems, poor retrieval inflates prompt size and model calls. Tools like the <a href="https://zilliz.com/rag-cost-calculator/">RAG cost calculator</a> make this painfully clear.</p><p>LSH’s philosophy — reduce the search space early — aligns well with cost-aware system design.</p><h3>Multimodal Data Makes LSH Relevant Again</h3><p>As soon as you introduce:</p><ul><li>Images</li><li>Audio</li><li>Video embeddings</li></ul><p>Your embedding distributions get messier.</p><p>In multimodal pipelines, coarse hashing can help route queries to the right subspace before expensive similarity search. This is especially relevant in systems discussed in <a href="https://zilliz.com/blog/multimodal-rag-expanding-beyond-text-for-smarter-ai">multimodal RAG</a>.</p><p>LSH won’t give you perfect results — but it can dramatically reduce waste.</p><h3>LSH in Agentic and Voice-Based Systems</h3><p>In agentic systems, especially voice assistants, latency spikes are deadly.</p><p>When building voice-driven RAG agents, you often need:</p><ul><li>Fast intent routing</li><li>Lightweight candidate generation</li><li>Predictable response times</li></ul><p>Hash-based filtering can be a quiet enabler here, especially in early decision stages. You can see how these ideas surface in more complex pipelines like those described in this guide on <a href="https://zilliz.com/blog/build-your-voice-assistant-agentic-rag-with-milvus-and-llama-3-2">building a voice assistant with agentic RAG</a>.</p><h3>When I Would Not Use LSH</h3><p>Let’s be clear — LSH is not a silver bullet.</p><p>I wouldn’t use it when:</p><ul><li>You need very high recall</li><li>Data is low-dimensional and small</li><li>Graph-based ANN is feasible</li><li>Ranking quality is more important than speed</li></ul><p>LSH trades accuracy for speed. If that trade-off doesn’t serve your system goals, skip it.</p><h3>Final Thoughts: Why LSH Still Deserves Respect</h3><p>LSH taught the industry something important long before neural embeddings were popular:</p><blockquote><em>You don’t need perfect similarity — just </em>useful<em> similarity.</em></blockquote><p>Even today, that lesson shows up everywhere:</p><ul><li>Approximate nearest neighbor search</li><li>Two-stage retrieval pipelines</li><li>Cost-aware RAG systems</li></ul><p>LSH may not be the star of modern AI stacks, but its ideas are everywhere. And if you’re designing systems under real-world constraints — latency, scale, cost — it’s still worth understanding deeply.</p><p>Not because it’s trendy.</p><p>Because it works.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5685453e0cc3" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Picking the Right Embedding Model for Your RAG Pipeline: What I’ve Learned the Hard Way]]></title>
            <link>https://blog.gopenai.com/picking-the-right-embedding-model-for-your-rag-pipeline-what-ive-learned-the-hard-way-9a2b8bdb3a32?source=rss-bb58ec7be9f0------2</link>
            <guid isPermaLink="false">https://medium.com/p/9a2b8bdb3a32</guid>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[embedding]]></category>
            <category><![CDATA[rags]]></category>
            <category><![CDATA[vector-database]]></category>
            <dc:creator><![CDATA[Priya Singh]]></dc:creator>
            <pubDate>Thu, 16 Apr 2026 05:48:24 GMT</pubDate>
            <atom:updated>2026-04-21T13:52:58.500Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tf15gx_nd--1YfU3Y1F3Jg.png" /></figure><p>Last year, I spent three weeks debugging a RAG chatbot that kept returning eerily confident but completely wrong answers. The retrieval metrics looked fine on paper. The LLM was GPT-4. The vector database was solid. So what was the problem?</p><p>The embedding model. I had grabbed the first one off a tutorial, plugged it in, and never questioned it again. Turns out it was wildly mismatched for the domain.</p><p>That experience forced me to actually understand what I was putting at the front of my RAG pipeline. In this post I want to share what I now know about embedding model selection — covering the MTEB leaderboard, SBERT models, and how I think about the tradeoffs between open-source and proprietary options.</p><h3>How Embedding Models Fit Into a RAG Pipeline</h3><p>Before getting into model comparisons, let me make sure the mental model is clear, because I’ve seen a lot of confusion here.</p><p>RAG works in three stages:</p><p><strong>Stage 1 — Ingestion:</strong> You run every document chunk through an embedding model to produce a <a href="https://zilliz.com/glossary/vector-embeddings?utm_source=mediumblog">vector embedding</a> — a fixed-size numerical representation of the chunk’s semantic content. These vectors get stored in a <a href="https://zilliz.com/learn/what-is-vector-database?utm_source=mediumblog">vector database</a> like <a href="https://github.com/milvus-io/milvus">Milvus</a>.</p><p><strong>Stage 2 — Retrieval:</strong> When a user asks a question, you embed that question using <em>the exact same model</em>, then run a nearest-neighbor search to find the most semantically similar chunks.</p><p><strong>Stage 3 — Generation:</strong> You inject the top-K retrieved chunks into an LLM prompt as context, and the model answers the question based on your domain knowledge rather than just its training data.</p><p>The critical constraint here: your query embedding and your document embeddings live in the same vector space only if they were produced by the same model. Swap one without reindexing the other and your retrieval quality collapses completely.</p><p>There’s a research-backed reason to keep your top-K retrieval focused, by the way. The <a href="https://arxiv.org/pdf/2307.03172.pdf">Lost in the Middle paper</a> showed that LLM answer quality degrades when too many retrieved chunks are stuffed into the context window. Keep retrieval tight and relevant rather than broad and noisy.</p><h3>Understanding SBERT: The Architecture Behind Most Embedding Models</h3><p>Most embedding models you’ll encounter are built on <a href="https://zilliz.com/learn/Sentence-Transformers-for-Long-Form-Text?utm_source=mediumblog">SBERT</a> — Sentence-BERT. It’s worth understanding what makes it different from vanilla BERT.</p><p>BERT was designed to understand individual tokens in context. SBERT extends this by training the model to produce a single, fixed-size vector that represents the <em>meaning of an entire sentence</em>. It does this using siamese network training: two sentences are encoded separately, and the model is trained to place semantically similar sentences near each other in vector space.</p><p>The practical consequence: SBERT understands that “the cat sat on the mat” and “the mat sat on the cat” mean different things. Basic BERT wouldn’t reliably catch that. For retrieval tasks, where you’re matching questions to semantically related passages, that sentence-level understanding is what makes SBERT work.</p><p>LLMs like GPT-4 are built on the <em>decoder</em> side of the transformer architecture — optimized for generation. Embedding models are built on the <em>encoder</em> side — optimized for representation. They are fundamentally different tools serving different purposes in the same pipeline.</p><h3>How to Actually Use the MTEB Leaderboard</h3><p>The <a href="https://huggingface.co/spaces/mteb/leaderboard">HuggingFace MTEB Leaderboard</a> is the industry standard for comparing embedding models, but most people use it wrong.</p><p>MTEB evaluates models across 8 tasks — retrieval, clustering, classification, semantic textual similarity, and more — across 58 datasets. When you’re building a RAG pipeline, you care about one column: <strong>Retrieval Average</strong>, measured as NDCG@10 (Normalized Discounted Cumulative Gain at the 10th result). This metric weights higher-ranked results more heavily, which aligns with how RAG actually works — the top few retrieved chunks carry most of the weight.</p><p>My workflow when picking a model:</p><ol><li>Sort the MTEB leaderboard descending by the Retrieval column</li><li>Filter to models that fit within my memory and latency budget</li><li>Pick the smallest model that achieves acceptable retrieval scores</li><li>Run my own evals on a sample of real domain queries — because MTEB is known to have overfitting issues for some models</li></ol><p>That last step is non-negotiable. I’ve been burned by models that score well on MTEB but perform poorly on technical or domain-specific text. The leaderboard is a starting point, not a final answer.</p><h3>The Six Models I’ve Worked With in Production</h3><p>Let me walk through the models I’ve actually used, with real notes on where each one shines and where it falls short.</p><p>Creator Model Embedding Dim Context Length Open Source MTEB Retrieval Score BAAI bge-base-en-v1.5 768 512 tokens Yes 53 BAAI bge-base-zh-v1.5 768 512 tokens Yes 69 VoyageAI voyage-2 1024 4K tokens No — VoyageAI voyage-code-2 1536 16K tokens No — OpenAI text-embedding-3-small 512–1536 8K tokens No 62 OpenAI text-embedding-3-large 256–3072 8K tokens No 65</p><h3>BAAI/bge-base-en-v1.5 and bge-base-zh-v1.5</h3><p>These are my go-to models when I’m prototyping or when I need to keep infrastructure costs near zero. They’re available on <a href="https://huggingface.co/BAAI/bge-large-en-v1.5">HuggingFace</a>, run fine on CPU, and have no API call costs.</p><p>The 512-token context window is the real constraint. If your documents chunk naturally under that limit — which most paragraph-level chunking strategies do — you won’t notice it. But if you’re working with long technical passages that are hard to split cleanly, you’ll hit truncation issues that silently degrade retrieval quality.</p><p>For bilingual deployments, bge-base-zh-v1.5 is the most practical option I&#39;ve found for Chinese-language content. The MTEB score of 69 on Chinese benchmarks is genuinely strong.</p><h3>VoyageAI’s voyage-2 and voyage-code-2</h3><p>I started using VoyageAI’s models after seeing a <a href="https://blog.voyageai.com/2023/10/29/a-case-study-of-chat-langchain/">case study</a> that showed significantly better NDCG@10 on technical documentation retrieval compared to the ada-002 generation.</p><p>voyage-2 is trained on conversational and dialog data, which means it handles question-to-passage matching better than general-purpose models for certain domains. In my experience with customer support RAG systems, it noticeably outperformed bge on short, intent-heavy queries.</p><p>voyage-code-2 is where things get interesting. It&#39;s trained specifically on code data with a 16K context window — the longest of any model in this group. For a code search or documentation RAG use case, that context window means you can embed entire functions or long docstrings without chunking at awkward boundaries. VoyageAI reports a 14% higher recall rate on code retrieval tasks, and in my own testing that number felt credible.</p><p>The downside: these are proprietary, API-only models. No self-hosting, and you’re dependent on their availability and pricing.</p><h3>OpenAI text-embedding-3-small and text-embedding-3-large</h3><p>These replaced ada-002 and the improvement is meaningful — higher MTEB retrieval scores, better multilingual performance, and lower pricing.</p><p>The most interesting engineering decision OpenAI made here is <a href="https://arxiv.org/pdf/2205.13147v4.pdf">Matryoshka Representation Learning</a>. Instead of training at a single fixed dimension, the model learns representations at multiple scales simultaneously. The practical result: you can truncate the embedding to a smaller dimension at query time with surprisingly small accuracy loss.</p><p>For text-embedding-3-large, going from 3072 dimensions to 256 drops the MTEB retrieval score from 65 to 62 — a 5% accuracy drop for a 12x reduction in storage and memory. For high-throughput applications where you&#39;re storing tens of millions of vectors, that tradeoff is often worth taking.</p><p>In my own RAG testing on Milvus technical documentation, text-embedding-3-small at dim=256 produced answers that were indistinguishable from dim=1536 for the majority of queries. The edge cases where higher dimensionality mattered were nuanced disambiguation questions — the kind that represent maybe 5% of real user traffic.</p><h3>End-to-End Code: RAG with OpenAI Embeddings and Milvus</h3><p>Here’s the full pipeline I use. This connects to <a href="https://cloud.zilliz.com/signup?utm_source=mediumblog">Zilliz Cloud</a> (managed Milvus), but the same code works with a self-hosted Milvus instance.</p><p><strong>Step 1 — Connect:</strong></p><pre>from pymilvus import connections, utility<br>import os<br>from dotenv import load_dotenv<br><br>load_dotenv()<br>TOKEN = os.getenv(&quot;ZILLIZ_API_KEY&quot;)<br>CLUSTER_ENDPOINT = &quot;https://in03-xxxx.api.gcp-us-west1.zillizcloud.com:443&quot;<br>connections.connect(<br>    alias=&#39;default&#39;,<br>    uri=CLUSTER_ENDPOINT,<br>    token=TOKEN,<br>)</pre><p><strong>Step 2 — Define your embedding model:</strong></p><pre>import openai<br>from openai import OpenAI<br><br>openai_client = OpenAI()<br>EMBEDDING_MODEL = &quot;text-embedding-3-small&quot;<br>EMBEDDING_DIM = 512  # Using reduced dim - good enough for most use cases</pre><p><strong>Step 3 — Create the collection:</strong></p><pre>from pymilvus import MilvusClient<br><br>COLLECTION_NAME = &quot;my_rag_collection&quot;<br>mc = MilvusClient(uri=CLUSTER_ENDPOINT, token=TOKEN)<br>mc.create_collection(<br>    COLLECTION_NAME,<br>    EMBEDDING_DIM,<br>    consistency_level=&quot;Eventually&quot;,<br>    auto_id=True,<br>    overwrite=True<br>)</pre><p><strong>Step 4 — Chunk, embed, and insert:</strong></p><pre># Assumes `chunks` is a list of LangChain Document objects<br>chunk_list = []<br>for chunk in chunks:<br>    response = openai_client.embeddings.create(<br>        input=chunk.page_content,<br>        model=EMBEDDING_MODEL,<br>        dimensions=EMBEDDING_DIM<br>    )<br>    embeddings = response.data[0].embedding<br><br>chunk_list.append({<br>        &#39;vector&#39;: embeddings,<br>        &#39;chunk&#39;: chunk.page_content,<br>        &#39;source&#39;: chunk.metadata.get(&#39;source&#39;, &#39;&#39;),<br>    })<br>mc.insert(COLLECTION_NAME, data=chunk_list, progress_bar=True)<br>mc.flush(COLLECTION_NAME)</pre><p><strong>Step 5 — Query and generate:</strong></p><pre>SAMPLE_QUESTION = &quot;What do the parameters for HNSW mean?&quot;<br><br>response = openai_client.embeddings.create(<br>    input=SAMPLE_QUESTION,<br>    model=EMBEDDING_MODEL,<br>    dimensions=EMBEDDING_DIM<br>)<br>query_embeddings = response.data[0].embedding<br>results = mc.search(<br>    COLLECTION_NAME,<br>    data=[query_embeddings],<br>    output_fields=[&quot;chunk&quot;, &quot;source&quot;],<br>    limit=3,<br>    consistency_level=&quot;Eventually&quot;<br>)<br>context = [r[&#39;entity&#39;][&#39;chunk&#39;] for r in results[0]]<br>contexts_combined = &#39; &#39;.join(context)<br>llm_response = openai_client.chat.completions.create(<br>    messages=[<br>        {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: f&quot;Answer using only the context below. Context: {contexts_combined}&quot;},<br>        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: SAMPLE_QUESTION}<br>    ],<br>    model=&quot;gpt-3.5-turbo&quot;,<br>    temperature=0.1,<br>)<br>print(llm_response.choices[0].message.content)</pre><p>Full working notebook is in the <a href="https://github.com/milvus-io/bootcamp/blob/master/bootcamp/RAG/readthedocs_openai_emb3.ipynb">Milvus bootcamp on GitHub</a>.</p><h3>My Decision Framework</h3><p>After running a lot of these experiments, here’s how I now decide:</p><ul><li><strong>Prototyping / cost-sensitive / self-hosted:</strong> Start with bge-base-en-v1.5. It&#39;s free, fast, and good enough to validate your pipeline architecture.</li><li><strong>Production English/multilingual chatbot:</strong> text-embedding-3-small with reduced dimensionality is hard to beat on the cost-quality curve.</li><li><strong>High-stakes domain RAG (legal, medical, technical docs):</strong> Evaluate voyage-2 seriously. The MTEB score doesn&#39;t capture everything; domain-specific retrieval quality can surprise you.</li><li><strong>Code search / developer tooling:</strong> voyage-code-2 with its 16K context window is worth the API dependency.</li><li><strong>Extreme memory constraints or billions of vectors:</strong> text-embedding-3-large at dim=256 — the Matryoshka approach is genuinely clever engineering.</li></ul><p>One thing I’ll keep repeating: always run your own evals on a slice of real production queries. MTEB is a proxy. Your actual retrieval quality on your actual data is the only number that matters.</p><p><em>I’m always interested in comparing notes on RAG infrastructure. If you’re doing something interesting with embedding model selection or fine-tuning, find me in the </em><a href="https://discord.com/invite/8uyFbECzPX"><em>Milvus Discord</em></a><em> or check out the </em><a href="https://zilliz.com/vdbbench-leaderboard?utm_source=mediumblog"><em>vector database benchmark leaderboard</em></a><em> for performance comparisons across setups.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9a2b8bdb3a32" width="1" height="1" alt=""><hr><p><a href="https://blog.gopenai.com/picking-the-right-embedding-model-for-your-rag-pipeline-what-ive-learned-the-hard-way-9a2b8bdb3a32">Picking the Right Embedding Model for Your RAG Pipeline: What I’ve Learned the Hard Way</a> was originally published in <a href="https://blog.gopenai.com">GoPenAI</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>