<?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 M K Pavan Kumar on Medium]]></title>
        <description><![CDATA[Stories by M K Pavan Kumar on Medium]]></description>
        <link>https://medium.com/@manthapavankumar11?source=rss-934e21252654------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*hm4EMU6eAUOoFTldl_OzNg.jpeg</url>
            <title>Stories by M K Pavan Kumar on Medium</title>
            <link>https://medium.com/@manthapavankumar11?source=rss-934e21252654------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 12:45:05 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@manthapavankumar11/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[Does Index-Native BM25 Change Retrieval Quality? A Two-Collection Experiment in Qdrant 1.20]]></title>
            <link>https://blog.stackademic.com/does-index-native-bm25-change-retrieval-quality-a-two-collection-experiment-in-qdrant-1-20-feeef7dd9fff?source=rss-934e21252654------2</link>
            <guid isPermaLink="false">https://medium.com/p/feeef7dd9fff</guid>
            <category><![CDATA[migrate-from-sparse-bm25]]></category>
            <category><![CDATA[bm25-on-full-text-indexes]]></category>
            <category><![CDATA[bm25-scoring-on-full-text]]></category>
            <category><![CDATA[qdrant]]></category>
            <category><![CDATA[full-text-search]]></category>
            <dc:creator><![CDATA[M K Pavan Kumar]]></dc:creator>
            <pubDate>Fri, 30 Oct 2026 18:16:30 GMT</pubDate>
            <atom:updated>2026-10-30T18:16:30.699Z</atom:updated>
            <content:encoded><![CDATA[<p>What if you could remove the separate BM25 sparse-encoding step from your hybrid RAG pipeline? Would you still retrieve the evidence that matters? Qdrant 1.20’s native BM25 scoring made me curious enough to test it. Two collections. The same documents and dense embeddings. One change: how lexical retrieval works. A simpler pipeline sounds promising. <strong>But what happens to retrieval quality, and the answers built on top of it?</strong></p><p>I ran the experiment. Let’s look at what changed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/994/1*5X9fo8bz0KMiZrXZIfB2IA.png" /><figcaption>created by author M K Pavan Kumar</figcaption></figure><h3>The Architecture</h3><p>To understand what changes when BM25 moves into the text index, I split the experiment into two flows: ingestion and query evaluation. Both collections share the same source content and dense embeddings, while each takes a different route for lexical retrieval.</p><h4>Ingestion: one source, two collections</h4><p>The PDF is parsed into Markdown and divided into sections, preserving section numbers, titles, and parent relationships. Each section becomes a chunk with a stable point ID and a dense embedding generated once and reused across both collections. This shared foundation keeps differences in document preparation from influencing the comparison.</p><p><strong>Collection A follows the sparse-vector approach.</strong> Each chunk passes through the BM25 sparse encoder, with average document length calculated from the prepared chunks. The collection stores dense vectors, sparse vectors, and the text payload.</p><p><strong>Collection B uses index-native BM25.</strong> It receives the same dense vectors and payloads, then builds a full-text index on the text field with BM25 scoring enabled. This removes the separate sparse-vector encoding step from this ingestion path.</p><p>Both lexical paths use English stemming and stopword handling, with BM25 parameters set to (k = 1.2) and (b = 0.75). These settings help align the comparison, although they do not guarantee identical tokenization or rankings across implementations.</p><h4>Querying: a shared dense path, a different lexical path</h4><p>Each question is embedded once, and that dense query vector is reused for both collections. Collection A also generates a BM25 sparse query vector. Collection B sends the question text directly to its full-text index.</p><p>Each collection retrieves 20 dense candidates and 20 lexical candidates. Reciprocal Rank Fusion combines the two ranked lists and returns the top five results. The candidate limits and fusion method stay the same, so the comparison focuses on the lexical retrieval path.</p><h4>Evaluation: from retrieved sections to generated answers</h4><p>The retrieved sections are compared with the reference sections using Recall@k, Precision@k, MRR, and nDCG@k. This checks whether the expected evidence appears and how highly it ranks.</p><p>The flow then continues beyond the retrieval diagram. Each collection’s context is passed to the same Ollama-hosted generation model with the same prompt and settings. DeepEval evaluates the resulting answers for relevancy, faithfulness, contextual precision, contextual recall, and correctness.</p><p>Together, these checks connect the architectural change to its practical consequence: whether a different lexical path changes the evidence retrieved and the answers produced.</p><h3>What Is This Feature All About?</h3><p>With Qdrant 1.20, a full-text index can do more than identify matching text. It can <strong>rank that text using BM25 directly</strong>, giving hybrid retrieval a lexical search path without separately generated BM25 sparse vectors.</p><p>Previously, the sparse-vector approach required encoding documents during ingestion and encoding questions at query time. With index-native BM25, you enable scoring on a text payload index and query it using plain text. Qdrant maintains the corpus statistics needed for scoring, including average document length.</p><p>The distinction matters as a collection grows. In the sparse approach described by Qdrant, average document length is incorporated during encoding, so refreshing it requires re-encoding documents. The native index manages that statistic internally. It also lets you adjust the BM25 parameters k and b per query without rebuilding the index.</p><p>For this experiment, the useful part is how naturally the text query fits into hybrid search. It can sit alongside a dense-vector query in the same Query API, with Reciprocal Rank Fusion combining their results. Dense embeddings still handle semantic retrieval; the full-text index supplies the BM25-ranked lexical candidates.</p><p><strong>The opportunity is a simpler lexical pipeline.</strong> Whether it preserves retrieval quality depends on the documents, questions, text processing, and dense model paired with it. That is the question the implementation puts to the test.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KLJ_yXIZN2Xh4iXRkM9zRg.png" /><figcaption>created by author M K Pavan kumar</figcaption></figure><h3>The Implementation</h3><h4>Document Parsing (doc_parser.py)</h4><p>The experiment starts by turning the source PDF into structured text. This module provides parsing options through Surya OCR, Indra OCR, LiteParse, and Docling’s vision-language pipeline. The ingestion entry point reads the Markdown output produced through the Surya OCR path.</p><p>Once the Markdown is available, MarkdownSectionReader identifies numbered headings and extracts each section’s number, title, body, and parent relationship. It also removes matching document footers to keep repeated page information out of the retrieval content. Each non-empty section becomes a chunk, preserving the document’s logical boundaries and making it possible to trace retrieved evidence back to a specific section.</p><pre>from docling.datamodel import vlm_model_specs<br>from docling.datamodel.pipeline_options_vlm_model import InlineVlmOptions<br>import re<br>from dataclasses import dataclass<br><br>def parse_using_indra_ocr():<br>    import indra_ocr<br><br>    conv = indra_ocr.load(threads=4, mode=&quot;ocr&quot;)          # mode=&quot;auto&quot; | &quot;ocr&quot; | &quot;text&quot;<br>    markdown_string = &quot;&quot;<br>    for i, page in conv.convert(&quot;./data/information-security-and-business-resiliency-supplement_0.pdf&quot;):<br>        # print(page.source, page.timings[&quot;total_ms&quot;])<br>        print(page.markdown)                 # headings, lists, tables as HTML<br>        # print(page.blocks)                    # [{&quot;label&quot;, &quot;bbox&quot;, &quot;text&quot;}] in reading order<br>        # print(page.lines)<br>        markdown_string += page.markdown<br>    return markdown_string<br><br>def parse_using_surya_ocr(pdf_path=&quot;./data/information-security-and-business-resiliency-supplement_0.pdf&quot;, dpi=96, batch_size=8):<br>    import logging<br>    import time<br>    import pypdfium2 as pdfium<br>    from markdownify import markdownify<br>    from surya.inference import SuryaInferenceManager<br>    from surya.recognition import RecognitionPredictor<br><br>    logging.basicConfig(level=logging.INFO, format=&quot;%(asctime)s %(levelname)s %(message)s&quot;)<br>    log = logging.getLogger(&quot;surya_parse&quot;)<br><br>    log.info(&quot;starting inference backend&quot;)<br>    recognition_predictor = RecognitionPredictor(SuryaInferenceManager())<br><br>    pdf = pdfium.PdfDocument(pdf_path)<br>    total = len(pdf)<br>    log.info(&quot;rendering %d pages at %d dpi&quot;, total, dpi)<br>    images = [page.render(scale=dpi / 72).to_pil() for page in pdf]<br><br>    markdown_string = &quot;&quot;<br>    for start in range(0, total, batch_size):<br>        batch = images[start:start + batch_size]<br>        t0 = time.perf_counter()<br>        predictions = recognition_predictor(batch)<br>        elapsed = time.perf_counter() - t0<br>        for offset, page in enumerate(predictions):<br>            page_html = &quot;\n&quot;.join(<br>                block.html for block in page.blocks if block.html and not block.skipped<br>            )<br>            page_markdown = markdownify(<br>                page_html, heading_style=&quot;ATX&quot;, keep_inline_images_in=[], strip=[&quot;div&quot;]<br>            )<br>            markdown_string += page_markdown + &quot;\n\n&quot;<br>        done = min(start + batch_size, total)<br>        log.info(&quot;pages %d-%d/%d done in %.1fs (%.2f s/page)&quot;, start + 1, done, total, elapsed, elapsed / len(batch))<br>    log.info(&quot;finished %d pages&quot;, total)<br>    return markdown_string<br><br>def parse_using_liteparse():<br>    from liteparse import LiteParse<br><br>    lp = LiteParse(output_format=&quot;markdown&quot;)<br>    _result = lp.parse(&quot;./data/information-security-and-business-resiliency-supplement_0.pdf&quot;)<br>    return _result.text<br><br>def parse_using_docling_vlm(vlm_model: InlineVlmOptions, input_file: str):<br>    import datetime<br>    from docling.datamodel.base_models import InputFormat<br>    from docling.datamodel.pipeline_options import VlmPipelineOptions<br>    from docling.document_converter import DocumentConverter, PdfFormatOption<br>    from docling.pipeline.vlm_pipeline import VlmPipeline<br><br>    start_time = datetime.datetime.now()<br>    pipeline_options = VlmPipelineOptions(<br>        vlm_options=vlm_model,  # &lt;-- change the model here<br>    )<br><br>    converter = DocumentConverter(<br>        format_options={<br>            InputFormat.PDF: PdfFormatOption(<br>                pipeline_cls=VlmPipeline,<br>                pipeline_options=pipeline_options,<br>            ),<br>        }<br>    )<br><br>    doc = converter.convert(source=input_file).document<br>    end_time = datetime.datetime.now()<br>    print(f&quot;Elapsed time: {end_time - start_time}&quot;)<br>    md = doc.export_to_markdown()<br>    print(md)<br>    return md<br><br>def save_output(markdown: str, output_file: str = &quot;output.md&quot;):<br>    with open(output_file, &quot;w&quot;) as f:<br>        f.write(markdown)<br><br><br>@dataclass<br>class Section:<br>    number: str<br>    title: str<br>    text: str<br>    parent: str<br><br><br>class MarkdownSectionReader:<br>    HEADING = re.compile(r&quot;^#{1,6}\s*\**\s*(\d+(?:\.\d+)*)\.?\s+\**(.+?)\**\s*$&quot;, re.M)<br>    FOOTER = re.compile(r&quot;^©\s+\d{4}\s+Fannie Mae\s+Page\s+\d+\s+of\s+\d+\s*$&quot;, re.M)<br><br>    def __init__(self, markdown: str, strip_footers: bool = True):<br>        self.markdown = self.FOOTER.sub(&quot;&quot;, markdown) if strip_footers else markdown<br><br>    def __iter__(self):<br>        matches = list(self.HEADING.finditer(self.markdown))<br>        for i, m in enumerate(matches):<br>            start = m.end()<br>            end = matches[i + 1].start() if i + 1 &lt; len(matches) else len(self.markdown)<br>            number = m.group(1)<br>            yield Section(<br>                number=number,<br>                title=m.group(2).strip(),<br>                text=self.markdown[start:end].strip(),<br>                parent=number.rsplit(&quot;.&quot;, 1)[0] if &quot;.&quot; in number else &quot;&quot;,<br>            )<br><br>    def front_matter(self) -&gt; str:<br>        first = self.HEADING.search(self.markdown)<br>        return self.markdown[: first.start()].strip() if first else self.markdown.strip()<br><br><br>if __name__ == &quot;__main__&quot;:<br>    # parse_using_qwen38()<br>    # markdown = parse_using_liteparse()<br>    # markdown = parse_using_indra_ocr()<br>    markdown = parse_using_surya_ocr()<br>    # markdown = parse_using_docling_vlm(vlm_model_specs.LIGHTONOCR_MLX,<br>    #                                    input_file=&#39;./data/information-security-and-business-resiliency-supplement_0.pdf&#39;)<br>    save_output(markdown)<br><br>    with open(&quot;output_surya_ocr.md&quot;, encoding=&quot;utf-8&quot;) as f:<br>        reader = MarkdownSectionReader(f.read())<br><br>    for section in reader:<br>        print(section.number, section.title, len(section.text))</pre><h4>Collection Ingestion (doc_processor.py)</h4><p>QdrantIngestor creates the shared foundation for the comparison. It combines each section’s title and body, generates dense embeddings through Ollama in batches, and assigns deterministic point IDs using the document ID and section number. Both collections receive these same embeddings, IDs, and metadata, including section titles, parent sections, positions, and source text.</p><p>The two ingestion paths then diverge. For Collection A, the module calculates average chunk length using the sparse model’s text-processing pipeline and supplies it to FastEmbed’s BM25 encoder with k = 1.2 and b = 0.75. The resulting sparse vectors are uploaded alongside the dense vectors, with Qdrant’s IDF modifier enabled on the sparse field.</p><pre>import time<br>import uuid<br>from statistics import mean<br><br>from fastembed import SparseTextEmbedding<br>from ollama import Client as OllamaClient<br>from qdrant_client import QdrantClient, models<br><br>from doc_parser import MarkdownSectionReader, Section<br><br><br>class QdrantIngestor:<br>    NAMESPACE = uuid.UUID(&quot;6f1c2a4e-9b3d-4c7a-8e5f-2d1b0a9c8e7f&quot;)<br>    K, B = 1.2, 0.75<br><br>    def __init__(<br>            self,<br>            client: QdrantClient,<br>            doc_id: str,<br>            collection_a: str = &quot;fnma_hybrid_sparse&quot;,<br>            collection_b: str = &quot;fnma_hybrid_text_index&quot;,<br>            dense_model: str = &quot;embeddinggemma-2:latest&quot;,<br>            ollama_host: str = &quot;http://localhost:11434&quot;,<br>            sparse_model: str = &quot;Qdrant/bm25&quot;,<br>            batch_size: int = 64,<br>    ):<br>        self.client = client<br>        self.doc_id = doc_id<br>        self.collection_a = collection_a<br>        self.collection_b = collection_b<br>        self.batch_size = batch_size<br>        self.dense_model = dense_model<br>        self.ollama = OllamaClient(host=ollama_host)<br>        self.sparse_model = sparse_model<br>        self.dense_dim = len(self._embed_dense([&quot;dim probe&quot;])[0])<br>        self.report = {}<br><br>    def run(self, reader: MarkdownSectionReader) -&gt; dict:<br>        sections = [s for s in reader if s.text]<br>        chunks = self._build_chunks(sections)<br>        avg_len = self._avg_len(chunks)<br>        sparse = SparseTextEmbedding(self.sparse_model, k=self.K, b=self.B, avg_len=avg_len)<br>        self.report[&quot;chunks&quot;] = len(chunks)<br>        self.report[&quot;avg_len&quot;] = avg_len<br>        self._ingest_a(chunks, sparse)<br>        self._ingest_b(chunks)<br>        return self.report<br><br>    def _embed_dense(self, texts: list[str]) -&gt; list[list[float]]:<br>        out = []<br>        for i in range(0, len(texts), self.batch_size):<br>            out.extend(self.ollama.embed(model=self.dense_model, input=texts[i : i + self.batch_size])[&quot;embeddings&quot;])<br>        return out<br><br>    def _build_chunks(self, sections: list[Section]) -&gt; list[dict]:<br>        texts = [f&quot;{s.title}\n{s.text}&quot; for s in sections]<br>        t0 = time.perf_counter()<br>        vectors = self._embed_dense(texts)<br>        self.report[&quot;dense_encode_s&quot;] = time.perf_counter() - t0<br>        return [<br>            {<br>                &quot;id&quot;: str(uuid.uuid5(self.NAMESPACE, f&quot;{self.doc_id}:{s.number}&quot;)),<br>                &quot;text&quot;: t,<br>                &quot;dense&quot;: v,<br>                &quot;payload&quot;: {<br>                    &quot;doc_id&quot;: self.doc_id,<br>                    &quot;section&quot;: s.number,<br>                    &quot;title&quot;: s.title,<br>                    &quot;parent&quot;: s.parent,<br>                    &quot;position&quot;: i,<br>                    &quot;text&quot;: t,<br>                },<br>            }<br>            for i, (s, t, v) in enumerate(zip(sections, texts, vectors))<br>        ]<br><br>    def _avg_len(self, chunks: list[dict]) -&gt; float:<br>        probe = SparseTextEmbedding(self.sparse_model).model<br>        return mean(len(probe._stem(probe.tokenizer.tokenize(c[&quot;text&quot;]))) for c in chunks)<br><br>    # creates the collection if not exist else the existing collection is directly used<br>    def _recreate(self, name: str, with_sparse: bool):<br>        if not self.client.collection_exists(name):<br>            # self.client.delete_collection(name)<br>            self.client.create_collection(<br>                collection_name=name,<br>                vectors_config={&quot;dense&quot;: models.VectorParams(size=self.dense_dim, distance=models.Distance.COSINE)},<br>                sparse_vectors_config={&quot;sparse&quot;: models.SparseVectorParams(modifier=models.Modifier.IDF)} if with_sparse else None,<br>            )<br><br>    def _upsert(self, name: str, points: list[models.PointStruct]):<br>        for i in range(0, len(points), self.batch_size):<br>            self.client.upsert(collection_name=name, points=points[i : i + self.batch_size], wait=True)<br><br>    def _ingest_a(self, chunks: list[dict], sparse: SparseTextEmbedding):<br>        t0 = time.perf_counter()<br>        self._recreate(self.collection_a, with_sparse=True)<br>        sparse_vectors = list(sparse.embed([c[&quot;text&quot;] for c in chunks], batch_size=self.batch_size))<br>        t_encode = time.perf_counter() - t0<br>        points = [<br>            models.PointStruct(<br>                id=c[&quot;id&quot;],<br>                vector={&quot;dense&quot;: c[&quot;dense&quot;], &quot;sparse&quot;: models.SparseVector(indices=sv.indices.tolist(), values=sv.values.tolist())},<br>                payload=c[&quot;payload&quot;],<br>            )<br>            for c, sv in zip(chunks, sparse_vectors)<br>        ]<br>        self._upsert(self.collection_a, points)<br>        self.report[&quot;a_sparse_encode_s&quot;] = t_encode<br>        self.report[&quot;a_total_s&quot;] = time.perf_counter() - t0<br><br>    def _ingest_b(self, chunks: list[dict]):<br>        t0 = time.perf_counter()<br>        self._recreate(self.collection_b, with_sparse=False)<br>        points = [models.PointStruct(id=c[&quot;id&quot;], vector={&quot;dense&quot;: c[&quot;dense&quot;]}, payload=c[&quot;payload&quot;]) for c in chunks]<br>        self._upsert(self.collection_b, points)<br>        t_upsert = time.perf_counter() - t0<br>        self.client.create_payload_index(<br>            collection_name=self.collection_b,<br>            field_name=&quot;text&quot;,<br>            field_schema=models.TextIndexParams(<br>                type=models.TextIndexType.TEXT,<br>                tokenizer=models.TokenizerType.WORD,<br>                lowercase=True,<br>                stemmer=models.SnowballParams(type=models.Snowball.SNOWBALL, language=models.SnowballLanguage.ENGLISH),<br>                stopwords=models.Language.ENGLISH,<br>                scoring=True,<br>            ),<br>            wait=True,<br>        )<br>        self.report[&quot;b_upsert_s&quot;] = t_upsert<br>        self.report[&quot;b_index_build_s&quot;] = time.perf_counter() - t0 - t_upsert<br>        self.report[&quot;b_total_s&quot;] = time.perf_counter() - t0<br><br><br>if __name__ == &quot;__main__&quot;:<br>    with open(&quot;output_surya_ocr.md&quot;, encoding=&quot;utf-8&quot;) as f:<br>        reader = MarkdownSectionReader(f.read())<br>    ingestor = QdrantIngestor(QdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;),<br>                              doc_id=&quot;fnma-information-security-and-business-resiliency-supplement&quot;)<br>    print(ingestor.run(reader))</pre><p>Collection B receives only the dense vectors and shared payloads. After uploading the points, the module creates a scored full-text index on the text field, configured with word tokenization, lowercasing, English stemming, and English stopwords. This is where the separate sparse-encoding step is replaced by index-native BM25. The module records dense encoding time, ingestion timings, and the time spent building Collection B’s text index.</p><h4>Hybrid Retrieval and Answer Generation (doc_ai.py)</h4><p>QdrantRetriever reads questions from the golden dataset and generates one dense query embedding per question. That embedding is reused across both collections, keeping the semantic input consistent throughout the comparison.</p><p>For Collection A, the question also passes through FastEmbed to produce a BM25 sparse query vector. Collection B instead receives the question as a text query against its indexed text field, with BM25 parameters supplied at query time. Both paths retrieve up to 20 dense candidates and 20 lexical candidates, then use Reciprocal Rank Fusion to select the final five results.</p><p>The module formats the retrieved passages with their section references and sends each collection’s context to the same Ollama-hosted generation model. A shared prompt instructs the model to answer strictly from that context, acknowledge missing information, and cite section numbers where available. The question, generated answer, reference answer, retrieved sections, and context are saved separately for each collection.</p><p>Retrieval timing is measured around each query path. It includes sparse query encoding for Collection A but excludes the shared dense embedding step and subsequent answer generation, which matters when interpreting the latency comparison.</p><pre>import json<br>import logging<br>import time<br><br>from fastembed import SparseTextEmbedding<br>from ollama import Client as OllamaClient<br>from qdrant_client import QdrantClient, models<br><br>logging.basicConfig(level=logging.INFO, format=&quot;%(asctime)s %(levelname)s %(message)s&quot;, datefmt=&quot;%H:%M:%S&quot;)<br>log = logging.getLogger(&quot;retriever&quot;)<br><br><br>class QdrantRetriever:<br>    K, B = 1.2, 0.75<br>    SYSTEM = (<br>        &quot;You answer strictly from the provided context. &quot;<br>        &quot;If the context does not contain the answer, say so. Cite section numbers where present.&quot;<br>    )<br><br>    def __init__(<br>            self,<br>            client: QdrantClient,<br>            collection_a: str = &quot;fnma_hybrid_sparse&quot;,<br>            collection_b: str = &quot;fnma_hybrid_text_index&quot;,<br>            dense_model: str = &quot;embeddinggemma-2:latest&quot;,<br>            llm_model: str = &quot;gemma3:latest&quot;,<br>            ollama_host: str = &quot;http://localhost:11434&quot;,<br>            sparse_model: str = &quot;Qdrant/bm25&quot;,<br>            prefetch_limit: int = 20,<br>            top_k: int = 5,<br>            num_ctx: int = 16384,<br>    ):<br>        self.client = client<br>        self.collection_a = collection_a<br>        self.collection_b = collection_b<br>        self.dense_model = dense_model<br>        self.llm_model = llm_model<br>        self.ollama = OllamaClient(host=ollama_host)<br>        self.sparse = SparseTextEmbedding(sparse_model)<br>        self.prefetch_limit = prefetch_limit<br>        self.top_k = top_k<br>        self.num_ctx = num_ctx<br><br>    def run(self, gold_path: str, out_a: str, out_b: str, limit: int | None = None) -&gt; dict:<br>        rows = [json.loads(line) for line in open(gold_path, encoding=&quot;utf-8&quot;) if line.strip()][:limit]<br>        log.info(&quot;processing %d question(s) against %s and %s&quot;, len(rows), self.collection_a, self.collection_b)<br>        results_a, results_b, timings = [], [], {&quot;a&quot;: [], &quot;b&quot;: []}<br>        for i, row in enumerate(rows, 1):<br>            q = row[&quot;question&quot;]<br>            log.info(&quot;Q%d/%d: %s&quot;, i, len(rows), q[:80])<br>            dense = self._embed_query(q)<br>            t0 = time.perf_counter()<br>            hits_a = self._query_a(q, dense)<br>            timings[&quot;a&quot;].append(time.perf_counter() - t0)<br>            t0 = time.perf_counter()<br>            hits_b = self._query_b(q, dense)<br>            timings[&quot;b&quot;].append(time.perf_counter() - t0)<br>            log.info(&quot;  A sections=%s  B sections=%s&quot;, [h.payload[&quot;section&quot;] for h in hits_a], [h.payload[&quot;section&quot;] for h in hits_b])<br>            results_a.append(self._record(q, hits_a, row[&quot;answer&quot;], &quot;A&quot;))<br>            results_b.append(self._record(q, hits_b, row[&quot;answer&quot;], &quot;B&quot;))<br>        self._write(out_a, results_a)<br>        self._write(out_b, results_b)<br>        log.info(&quot;wrote %s and %s&quot;, out_a, out_b)<br>        return {<br>            &quot;questions&quot;: len(rows),<br>            &quot;a_query_mean_s&quot;: sum(timings[&quot;a&quot;]) / len(rows),<br>            &quot;b_query_mean_s&quot;: sum(timings[&quot;b&quot;]) / len(rows),<br>        }<br><br>    def _embed_query(self, text: str) -&gt; list[float]:<br>        return self.ollama.embed(model=self.dense_model, input=text)[&quot;embeddings&quot;][0]<br><br>    def _query_a(self, text: str, dense: list[float]) -&gt; list[models.ScoredPoint]:<br>        sv = next(self.sparse.query_embed(text))<br>        return self.client.query_points(<br>            collection_name=self.collection_a,<br>            prefetch=[<br>                models.Prefetch(query=dense, using=&quot;dense&quot;, limit=self.prefetch_limit),<br>                models.Prefetch(<br>                    query=models.SparseVector(indices=sv.indices.tolist(), values=sv.values.tolist()),<br>                    using=&quot;sparse&quot;,<br>                    limit=self.prefetch_limit,<br>                ),<br>            ],<br>            query=models.FusionQuery(fusion=models.Fusion.RRF),<br>            limit=self.top_k,<br>            with_payload=True,<br>        ).points<br><br>    def _query_b(self, text: str, dense: list[float]) -&gt; list[models.ScoredPoint]:<br>        return self.client.query_points(<br>            collection_name=self.collection_b,<br>            prefetch=[<br>                models.Prefetch(query=dense, using=&quot;dense&quot;, limit=self.prefetch_limit),<br>                models.Prefetch(<br>                    query=models.TextQuery(<br>                        text=models.TextQueryInput(<br>                            query=text,<br>                            scoring=models.Bm25Scoring(bm25=models.Bm25Params(k=self.K, b=self.B)),<br>                        )<br>                    ),<br>                    using=&quot;text&quot;,<br>                    limit=self.prefetch_limit,<br>                ),<br>            ],<br>            query=models.FusionQuery(fusion=models.Fusion.RRF),<br>            limit=self.top_k,<br>            with_payload=True,<br>        ).points<br><br>    def _generate(self, question: str, context: list[str]) -&gt; str:<br>        joined = &quot;\n\n&quot;.join(context)<br>        response = self.ollama.chat(<br>            model=self.llm_model,<br>            messages=[<br>                {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: self.SYSTEM},<br>                {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: f&quot;Context:\n{joined}\n\nQuestion: {question}&quot;},<br>            ],<br>            options={&quot;temperature&quot;: 0, &quot;num_ctx&quot;: self.num_ctx},<br>        )<br>        return response[&quot;message&quot;][&quot;content&quot;].strip()<br><br>    def _record(self, question: str, hits: list[models.ScoredPoint], ground_truth: str, tag: str) -&gt; dict:<br>        context = [f&quot;[Section {h.payload[&#39;section&#39;]}] {h.payload[&#39;text&#39;]}&quot; for h in hits]<br>        t0 = time.perf_counter()<br>        answer = self._generate(question, context)<br>        log.info(&quot;  %s generated in %.1fs (%d chars)&quot;, tag, time.perf_counter() - t0, len(answer))<br>        return {<br>            &quot;question&quot;: question,<br>            &quot;llm_response&quot;: answer,<br>            &quot;ground_truth&quot;: ground_truth,<br>            &quot;retrieved_sections&quot;: [h.payload[&quot;section&quot;] for h in hits],<br>            &quot;context&quot;: context,<br>        }<br><br>    @staticmethod<br>    def _write(path: str, rows: list[dict]):<br>        with open(path, &quot;w&quot;, encoding=&quot;utf-8&quot;) as f:<br>            for r in rows:<br>                f.write(json.dumps(r, ensure_ascii=False) + &quot;\n&quot;)<br><br><br>if __name__ == &quot;__main__&quot;:<br>    retriever = QdrantRetriever(QdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;))<br>    print(retriever.run(&quot;golden_dataset.jsonl&quot;, &quot;responses_collection_a.jsonl&quot;, &quot;responses_collection_b.jsonl&quot;))</pre><h4>Retrieval and Answer Evaluation (doc_evaluator.py)</h4><p>RagEvaluator checks both the evidence retrieved and the answers produced from it. It first extracts reference section numbers from the golden dataset’s context and compares them with the retrieved sections. Recall@k and Precision@k measure coverage and relevance, while MRR and nDCG@k capture where relevant sections appear in the ranking.</p><p>The module then constructs a DeepEval test case using the question, generated answer, reference answer, and retrieved context. Answer relevancy, faithfulness, contextual precision, and contextual recall assess different aspects of the RAG output. A separate correctness criterion checks whether the answer agrees with the reference and covers its key points, explicitly penalizing incorrect section numbers and missing obligations.</p><pre>import json<br>import logging<br>import math<br>import os<br>import re<br>import time<br>from statistics import mean<br>from dotenv import load_dotenv, find_dotenv<br><br>from deepeval.metrics import (<br>    AnswerRelevancyMetric,<br>    ContextualPrecisionMetric,<br>    ContextualRecallMetric,<br>    FaithfulnessMetric,<br>    GEval,<br>)<br>from deepeval.models import GPTModel<br>from deepeval.test_case import LLMTestCase, SingleTurnParams<br><br>load_dotenv(find_dotenv())<br><br>logging.basicConfig(level=logging.INFO, format=&quot;%(asctime)s %(levelname)s %(message)s&quot;, datefmt=&quot;%H:%M:%S&quot;)<br>log = logging.getLogger(&quot;evaluator&quot;)<br><br><br>class RagEvaluator:<br>    SECTION = re.compile(r&quot;Section\s+(\d+(?:\.\d+)*)&quot;)<br><br>    def __init__(self, collection: str, judge_model: str | None = None, threshold: float = 0.5):<br>        self.collection = collection<br>        self.judge = GPTModel(model=judge_model) if judge_model else None<br>        self.metrics = [<br>            AnswerRelevancyMetric(threshold=threshold, model=self.judge, include_reason=False),<br>            FaithfulnessMetric(threshold=threshold, model=self.judge, include_reason=False),<br>            ContextualPrecisionMetric(threshold=threshold, model=self.judge, include_reason=False),<br>            ContextualRecallMetric(threshold=threshold, model=self.judge, include_reason=False),<br>            GEval(<br>                name=&quot;Correctness&quot;,<br>                criteria=&quot;Judge whether the actual output is factually consistent with and covers the key points of the expected output. Penalise wrong section numbers and missing obligations.&quot;,<br>                evaluation_params=[SingleTurnParams.INPUT, SingleTurnParams.ACTUAL_OUTPUT, SingleTurnParams.EXPECTED_OUTPUT],<br>                model=self.judge,<br>                threshold=threshold,<br>            ),<br>        ]<br><br>    def run(self, responses_path: str, gold_path: str, out_path: str, limit: int | None = None) -&gt; dict:<br>        gold = {r[&quot;question&quot;]: self._gold_sections(r[&quot;context&quot;]) for r in self._read(gold_path)}<br>        responses = self._read(responses_path)[:limit]<br>        done = {r[&quot;question&quot;] for r in self._read(out_path)} if os.path.exists(out_path) else set()<br>        log.info(&quot;[%s] evaluating %d question(s), %d already done&quot;, self.collection, len(responses), len(done))<br>        for i, r in enumerate(responses, 1):<br>            if r[&quot;question&quot;] in done:<br>                continue<br>            t0 = time.perf_counter()<br>            log.info(&quot;[%s] Q%d/%d: %s&quot;, self.collection, i, len(responses), r[&quot;question&quot;][:80])<br>            retrieved = r[&quot;retrieved_sections&quot;]<br>            case = LLMTestCase(<br>                input=r[&quot;question&quot;],<br>                actual_output=r[&quot;llm_response&quot;],<br>                expected_output=r[&quot;ground_truth&quot;],<br>                retrieval_context=r[&quot;context&quot;],<br>            )<br>            scores = {m.__name__: self._measure(m, case) for m in self.metrics}<br>            scores.update(self._retrieval_scores(retrieved, gold.get(r[&quot;question&quot;], [])))<br>            self._append(out_path, {&quot;question&quot;: r[&quot;question&quot;], &quot;retrieved_sections&quot;: retrieved, &quot;gold_sections&quot;: gold.get(r[&quot;question&quot;], []), **scores})<br>            log.info(&quot;[%s] Q%d done in %.1fs: %s&quot;, self.collection, i, time.perf_counter() - t0, {k: round(v, 3) for k, v in scores.items()})<br>        rows = self._read(out_path)<br>        summary = {k: mean(row[k] for row in rows) for k in rows[0] if isinstance(rows[0][k], (int, float))}<br>        summary[&quot;collection&quot;] = self.collection<br>        summary[&quot;questions&quot;] = len(rows)<br>        return summary<br><br>    def _measure(self, metric, case: LLMTestCase) -&gt; float:<br>        t0 = time.perf_counter()<br>        metric.measure(case)<br>        log.info(&quot;    %-26s %.3f  (%.1fs)&quot;, metric.__name__, metric.score, time.perf_counter() - t0)<br>        return float(metric.score)<br><br>    def _gold_sections(self, context: str) -&gt; list[str]:<br>        seen = []<br>        for s in self.SECTION.findall(context):<br>            if s not in seen:<br>                seen.append(s)<br>        return seen<br><br>    def _retrieval_scores(self, retrieved: list[str], gold: list[str]) -&gt; dict:<br>        if not gold:<br>            return {&quot;recall_at_k&quot;: 0.0, &quot;precision_at_k&quot;: 0.0, &quot;mrr&quot;: 0.0, &quot;ndcg_at_k&quot;: 0.0}<br>        hits = [1 if s in gold else 0 for s in retrieved]<br>        recall = sum(hits) / len(gold)<br>        precision = sum(hits) / len(retrieved)<br>        mrr = next((1 / (i + 1) for i, h in enumerate(hits) if h), 0.0)<br>        dcg = sum(h / math.log2(i + 2) for i, h in enumerate(hits))<br>        idcg = sum(1 / math.log2(i + 2) for i in range(min(len(gold), len(retrieved))))<br>        return {&quot;recall_at_k&quot;: recall, &quot;precision_at_k&quot;: precision, &quot;mrr&quot;: mrr, &quot;ndcg_at_k&quot;: dcg / idcg}<br><br>    @staticmethod<br>    def _read(path: str) -&gt; list[dict]:<br>        with open(path, encoding=&quot;utf-8&quot;) as f:<br>            return [json.loads(line) for line in f if line.strip()]<br><br>    @staticmethod<br>    def _append(path: str, row: dict):<br>        with open(path, &quot;a&quot;, encoding=&quot;utf-8&quot;) as f:<br>            f.write(json.dumps(row, ensure_ascii=False) + &quot;\n&quot;)<br><br><br>if __name__ == &quot;__main__&quot;:<br>    gold = &quot;golden_dataset.jsonl&quot;<br>    summary_a = RagEvaluator(&quot;fnma_hybrid_sparse&quot;).run(&quot;responses_collection_a.jsonl&quot;, gold,<br>                                                       &quot;eval_collection_b.jsonl&quot;)<br>    summary_b = RagEvaluator(&quot;fnma_hybrid_text_index&quot;).run(&quot;responses_collection_b.jsonl&quot;, gold,<br>                                                           &quot;eval_collection_a.jsonl&quot;)<br>    print(json.dumps({&quot;A&quot;: summary_a, &quot;B&quot;: summary_b}, indent=2))</pre><p>Each question’s scores are appended to an output file, allowing an interrupted evaluation to resume by skipping questions already completed. Finally, the module averages the recorded scores to produce a collection-level summary. This lets the comparison follow the change from lexical retrieval through to the quality of the final answer.</p><h4>The Evaluation Dataset (golden_dataset.jsonl)</h4><p>This dataset contains <strong>20 unique, scenario-based questions</strong> about Fannie Mae’s Information Security and Business Resiliency Supplement. It serves as the shared evaluation set for both collections, allowing their retrieval and answer quality to be compared against the same reference material.</p><p>Each record contains three fields: question, answer, and context. The question presents a practical situation, the answer describes the expected response, and the context provides supporting policy excerpts with section references. The evaluator uses these references to identify which sections should be retrieved.</p><p>The questions cover cybersecurity incidents, notification deadlines, access management, third-party responsibilities, data protection, cloud adoption, and business continuity. For example, one asks what happens when a vendor suffers a ransomware attack, while another examines the separate notification obligations triggered by an incident and a regulatory filing.</p><p><strong>Many questions require evidence from multiple sections.</strong> Retrieving a passage that mentions ransomware is only part of the task; the system may also need clauses covering access restoration, security assessments, and exceptions to explain the full obligation.</p><p>This makes the dataset useful for a focused comparison of evidence retrieval and answer completeness. With 20 questions drawn from one document, however, the findings describe this particular workload and should be validated on broader datasets before being generalized.</p><pre>{&quot;question&quot;: &quot;A Company&#39;s third-party vendor is hit by ransomware, and Fannie Mae responds by disabling the Company&#39;s integration interfaces. Does this event qualify as a Cybersecurity Incident under the Supplement, what must the Company provide before its access is restored, and how does this affect its obligation to perform an independent security assessment?&quot;, &quot;answer&quot;: &quot;Yes. The definition of Cybersecurity Incident expressly lists ransomware as an example and states that the incident may occur at the Company or at the Company&#39;s third party(ies), so a vendor-side ransomware event qualifies. Under Section 4.1, once access is disabled it is restored only after the Company provides an attestation as to the security and safety of its systems and satisfies any other conditions Fannie Mae imposes; that attestation may come either from an independent third-party investigative service provider the Company retained or as a signed statement by two officers of the Company. Under Section 3.3, the Company would normally have to perform an independent security assessment upon any Cybersecurity Incident, but because access was suspended under Section 4.1, an attestation may be substituted for the Independent Security Assessment if mutually agreed upon by both parties.&quot;, &quot;context&quot;: &quot;Section 2 (Definitions) - Cybersecurity Incident: examples include ransomware, denial of service, BEC and vulnerabilities, occurring at the Company or at the Company&#39;s third party(ies). Section 4.1 (Actions by Fannie Mae): Fannie Mae may disable or terminate Company user or system access to Fannie Mae systems, including suspending integration interfaces and APIs; if disabled, access and integration will be restored only after the Company provides an attestation as to the security and safety of its systems and satisfies any other conditions Fannie Mae may impose; such attestation may be provided by an independent third-party investigative service provider the Company has retained or be a signed statement by two officers of the Company. Section 3.3 (Audit and Accountability): perform an independent security assessment of the control environment upon the occurrence of any Cybersecurity Incident and at least annually; following a Cybersecurity Incident where access to Fannie Mae has been suspended pursuant to Section 4.1, an attestation may be substituted in place of an Independent Security Assessment if mutually agreed upon by both parties.&quot;}<br>{&quot;question&quot;: &quot;A publicly traded Company identifies a Cybersecurity Incident on Monday at 9:00 AM and files a Form 8-K about it on Wednesday. Describe the two separate 36-hour notification obligations that apply, when each clock starts, to what address notice must go, and whether satisfying one discharges the other.&quot;, &quot;answer&quot;: &quot;Two distinct 36-hour obligations apply. First, under Section 4, without undue delay and no later than 36 hours after identification of the Cybersecurity Incident (or the reasonable conclusion one may have occurred), the Company must provide Fannie Mae all known details of the incident via e-mail at privacy_office@fanniemae.com, so this clock starts Monday at 9:00 AM. Second, under Section 4.2, because the Company filed a Form 8-K with the SEC relating to the incident, it must provide a link to or copy of that filing to the same e-mail address within 36 hours of the day on which the form was filed, so this second clock is tied to Wednesday&#39;s filing. Section 4.2 states this filing obligation is in addition to the general notification obligation, so satisfying one does not discharge the other.&quot;, &quot;context&quot;: &quot;Section 4 (Cybersecurity Incident Management): without undue delay and no later than 36 hours after identification of the Cybersecurity Incident, or the reasonable conclusion a Cybersecurity Incident may have occurred, and promptly thereafter as requested, provide Fannie Mae via e-mail at privacy_office@fanniemae.com all known details of the Cybersecurity Incident. Section 4.2 (Filings by the Company): if a Company has filed a Form 8-K, Form 6-K, or other Cybersecurity Incident disclosure with the Securities and Exchange Commission, or any other filings with a regulatory body relating to a Cybersecurity Incident, the Company must provide a link to or copy of such filing to Fannie Mae via e-mail at privacy_office@fanniemae.com within 36 hours of the day on which the form was filed; this obligation is in addition to the general obligation to provide notification under this section.&quot;}<br>{&quot;question&quot;: &quot;A Company discovers that a mailing containing borrowers&#39; nonpublic personal information was sent to the wrong recipient. Which sections of the Supplement govern this event, what specific details must be sent to the Fannie Mae Privacy Office, what must be done for the affected borrowers, what permission must be sought before naming Fannie Mae, and who bears the resulting third-party losses?&quot;, &quot;answer&quot;: &quot;A misdirected mailing is expressly cited in Section 4.3 as an example of a Cybersecurity Incident impacting Fannie Mae Confidential Information, and because it involves physical information incorrectly routed outside the Company, Section 4.4 also applies, requiring investigation, correction and mitigation at the Company&#39;s expense and notification to privacy_office@fanniemae.com within 36 hours. Under Section 4.3 the Company must inform the Privacy Office of the number of individuals impacted, the jurisdictions involved, the company(ies) involved if more than one entity, a description of the related NPI, and the root cause. It must provide timely written notice to affected borrowers, other impacted parties and any state or federal agencies in accordance with applicable privacy and notification laws, and keep a copy of the notice in the individual loan file. It must request permission from Fannie Mae&#39;s Privacy Office before referring to Fannie Mae in any notices to borrowers or regulators. Because the incident relates to NPI and was caused by the Company, the Company must pay all Losses relating to third-party claims and all Losses Fannie Mae incurs complying with its legal obligations.&quot;, &quot;context&quot;: &quot;Section 4.3 (Additional Company Requirements): while investigating a Cybersecurity Incident that impacts or could potentially impact Fannie Mae Confidential Information (e.g., lost/stolen file; misdirected mailing; Company or third-party Cybersecurity Incident), the Company must inform the Fannie Mae Privacy Office via e-mail at privacy_office@fanniemae.com of the number of individuals impacted, from what jurisdictions, the company(ies) involved if there is more than one entity, description of the related nonpublic personal information (NPI), and root cause; provide timely written notice to affected borrowers, other impacted parties and any state or federal agencies in accordance with applicable privacy, data security and notification laws and maintain a copy of the notice in the individual loan file; request permission from Fannie Mae&#39;s Privacy Office to use Fannie Mae&#39;s name in any notices; and in connection with any Cybersecurity Incident relating to Nonpublic Personal Information caused by Company, its subcontractors, technology, networks or personnel, Company shall pay and be responsible for all Losses relating to third party claims and all Losses incurred by Fannie Mae in complying with its legal obligations. Section 4.4 (Lost/Stolen/Incorrectly Routed Physical Information): if Company incorrectly routes outside of Company physical information such as paper files or other media which includes Fannie Mae Confidential Information, it must promptly investigate, correct and mitigate at its expense and notify Fannie Mae within 36 hours via privacy_office@fanniemae.com.&quot;}<br>{&quot;question&quot;: &quot;A third-party solution provider builds a tool that connects to a Fannie Mae system through an API and incorporates open-source libraries, and it hires contractors to maintain that tool. Is this provider subject to the Supplement, and what specific obligations across the Human Resource Security, System Development, and Vulnerability Management sections apply to it?&quot;, &quot;answer&quot;: &quot;Yes. The definition of Company explicitly includes integrators, also referred to as third-party solution providers, so the Supplement applies. Under Section 3.2, because the contractors will have access to Fannie Mae systems through indirect means such as an API, the provider must run a formal on-boarding process with background verifications for them, require them to attest to a code of conduct before access, and give them annual security awareness training. Under Section 3.8, because the tool connects to a Fannie Mae system, the provider must maintain a formal SDLC policy that includes secure coding requirements, pre- and post-deployment code scanning, separation of development/testing from production, requirements for documenting and keeping current a software bill of materials, and requirements for assessing Vulnerabilities in third-party code including open-source code. Under Section 3.4, an independent third-party penetration test must be conducted at least annually on the systems or components that connect to a Fannie Mae system, alongside regular vulnerability scanning.&quot;, &quot;context&quot;: &quot;Section 2 (Definitions) - Company: any entity expressly identified as being subject to this Supplement, including SF Lenders, MF Lenders, document custodians and integrators (also referred to as third-party solution providers). Section 3.2 (Human Resource Security): formal on-boarding process including background verifications for all employee candidates and contractors that will have access to Fannie Mae Confidential Information or Fannie Mae systems, either directly or through indirect means such as an application programming interface; code of conduct attestation prior to gaining access; at least annual information security awareness training. Section 3.8 (System Development and Change Management): if the Company develops, deploys, updates or maintains applications or tools that will access, store or process Confidential Information or connect to a Fannie Mae system, it must maintain a formal SDLC policy including secure coding, code scanning pre- and post-deployment, separation between development/testing and production, testing prior to production, defect management, documenting and assuring currency of software bill of materials, and assessing Vulnerabilities in third party code including open-source code. Section 3.4 (Vulnerability Management): an independent third-party penetration test conducted at least annually on systems or components used to store, access, process or transmit Confidential Information or connect to a Fannie Mae system, and regular vulnerability scanning/testing.&quot;}<br>{&quot;question&quot;: &quot;Compare the two attestations referenced in the Supplement: the annual attestation about the Information Security Program and the attestation required to restore access after a Cybersecurity Incident. Who may execute each, what does each attest to, and what alternative mechanism is available to SF Lenders for one of them?&quot;, &quot;answer&quot;: &quot;The annual attestation under Section 3 must be executed by a duly authorized corporate officer and attests that the Information Security Program and its associated programs, plans, policies, standards and procedures meet the requirements of the Supplement; for SF Lenders, this attestation may be provided through the annual Form 582 process described in the SF Guides. The post-incident attestation under Section 4.1 is required only after Fannie Mae has disabled the Company&#39;s access, attests to the security and safety of the Company&#39;s systems, and may be provided either by an independent third-party investigative service provider the Company has retained or as a signed statement by two officers of the Company. The annual attestation needs one officer and is recurring; the restoration attestation needs two officers or an independent investigator and is event-driven, with no Form 582 alternative.&quot;, &quot;context&quot;: &quot;Section 3 (Information Security Program): annually provide a written attestation executed by a duly authorized corporate officer that the Information Security Program and its associated programs, plans, policies, standards and procedures meet the requirements of this Supplement; for SF Lenders, this attestation may be provided through the annual Form 582 process described in the SF Guides. Section 4.1 (Actions by Fannie Mae): if disabled, the Company&#39;s access and integration to Fannie Mae systems will be restored only after the Company provides an attestation as to the security and safety of its systems; such an attestation may be provided to Fannie Mae either by an independent third-party investigative service provider the Company has retained or be a signed statement by two officers of the Company attesting to the safety and security of its systems.&quot;}<br>{&quot;question&quot;: &quot;A Company outsources document storage to a vendor that keeps Fannie Mae Confidential Information unencrypted at rest, and the vendor is breached. Under the Supplement, who is responsible for this failure, what should the Company&#39;s contract with the vendor have addressed, and what encryption-related control was violated?&quot;, &quot;answer&quot;: &quot;Under Section 3.14, the Company is responsible for its vendors&#39; compliance with the Supplement and for all failures by a vendor to comply to the same extent as if the Company itself had committed the failure, so the Company bears responsibility. Section 3.14 also requires the Company to have an agreement in place with each vendor covering adequate considerations for security and resiliency consistent and in compliance with the Supplement, and to run a vendor risk management program ensuring vendor controls are at least as protective as the Company&#39;s own Information Security Program. The violated control is in Section 3.10, which requires encrypting data in-transit and at-rest as part of the controls to identify, classify and protect Confidential Information. Because the vendor breach involves unauthorized access to Confidential Information, it is a Cybersecurity Incident at the Company&#39;s third party, triggering the Section 4 reporting duties on the Company.&quot;, &quot;context&quot;: &quot;Section 3.14 (Supply Chain Risk Management): the Company must develop, document and implement a formal vendor risk management program to ensure the controls of new and existing vendors align with and are at least as protective as the Company&#39;s Information Security Program and those required in this Supplement; the Company must have an agreement in place with each vendor that covers adequate considerations for security and resiliency consistent and in compliance with the requirements noted in this Supplement; the Company is responsible for its vendors&#39; compliance with the obligations in this Supplement and for all failures by a vendor to comply to the same extent as if such failure were committed by the Company. Section 3.10 (Data Protection and System Security): controls include encrypting end user devices and encrypting data in-transit and at-rest. Section 2 (Definitions) - Cybersecurity Incident includes accidental or unauthorized access to Confidential Information occurring at the Company or at the Company&#39;s third party(ies).&quot;}<br>{&quot;question&quot;: &quot;Fannie Mae issues an electronic bulletin amending the Supplement, and the amended term conflicts with a provision in the Company&#39;s Lender Contract. Walk through the timeline for the bulletin taking effect, the Company&#39;s window and consequence for rejecting it, what happens if the Company says nothing but keeps selling loans, and which document controls when the conflict arises.&quot;, &quot;answer&quot;: &quot;Under Section 6, Fannie Mae must issue each bulletin at least 20 calendar days before its effective date, and the amendment applies prospectively on the date specified in the bulletin. The Company may reject the bulletin by written notice to Fannie Mae within 15 calendar days after receipt, but in that case Fannie Mae may terminate the Company&#39;s Underlying Agreements effective as of the bulletin&#39;s effective date or the written notice. If the Company provides no rejection notice within that period, its continued business engagements and continued sale to or servicing of loans for Fannie Mae constitute acknowledgment of acceptance. When the amended term conflicts with the Lender Contract, which is one of the Underlying Agreements, the Conflicts provision states that the Supplement&#39;s term takes precedence with respect to its subject matter, while all non-conflicting terms of the Underlying Agreements remain in full force.&quot;, &quot;context&quot;: &quot;Section 6 (General Requirements) - Issued Bulletins; Amendments: Fannie Mae may issue hard-copy or electronic bulletins amending the Supplement on a prospective basis, effective on the date specified by Fannie Mae in the bulletin; Fannie Mae will issue each bulletin at least 20 calendar days before its effective date; the Company may reject any bulletin by providing written notice to Fannie Mae within 15 calendar days after receipt, in which case Fannie Mae may terminate the Company&#39;s Underlying Agreements effective as of the effective date of the bulletin or written notice; unless the Company provides such rejection notice, continued business engagements and continued sale to or servicing of loans for Fannie Mae is an acknowledgment of acceptance. Conflicts: if there is any conflict between a term in this Supplement and the Underlying Agreements, the term in this Supplement takes precedence with respect to the subject matter stated in this Supplement; all other terms of the Underlying Agreements not in conflict remain in full force and effect. Section 2 (Definitions) - Underlying Agreements include the Integration Agreement, Lender Contract, MF Guide, Requirements for Document Custodians and SF Guides.&quot;}<br>{&quot;question&quot;: &quot;An employee with a privileged administrator account resigns, and six months later it is discovered that the account was still active and had been used from a remote location. Identify every control in the Access Management and Physical and Environmental sections that this scenario implicates, and explain which authentication requirement is mandatory rather than conditional for such an account.&quot;, &quot;answer&quot;: &quot;Section 3.1 requires access procedures that remove access upon termination or transfer, evaluate inactivity, and determine when access is no longer needed, and it requires privileged account processes that monitor role changes and enforce revocation to ensure timely removal of dormant accounts and access no longer required, such as when a user has terminated employment. It also requires that all human and non-human accounts be reviewed and certified by the entitlement owner at least annually, which would have caught a six-month-old orphaned account, and requires remote access requirements with mechanisms to monitor and control remote access. Section 3.5 separately requires steps to remove physical and logical access immediately upon the voluntary or involuntary departure of an authorized individual. On authentication, Section 3.1 states that MFA requirements apply &#39;where applicable&#39; in general, but it is unconditional that MFA must be implemented for access to privileged accounts, so this administrator account was required to have MFA.&quot;, &quot;context&quot;: &quot;Section 3.1 (Access Management): procedures must include steps for approving access, removing access upon termination or transfer, evaluating inactivity, and determining when access is otherwise no longer needed; access for all human and non-human (system) user accounts should be reviewed and certified by the entitlement owner or user manager at least annually; requirements and processes for managing privileged accounts, including monitoring changes to roles or attributes and enforcing an access revocation process to ensure timely removal of dormant accounts and access no longer required such as when the user has terminated employment; requirements for the use of Multi-Factor Authentication where applicable; remote access requirements including mechanisms to monitor and control remote access; MFA must be implemented for access to privileged accounts. Section 3.5 (Physical and Environmental Controls): steps to remove physical and logical access immediately upon the voluntary or involuntary departure of an authorized individual.&quot;}<br>{&quot;question&quot;: &quot;A Company&#39;s contract with its subservicer, which handles servicing of Fannie Mae loans, is about to expire without renewal. Using the definition of Business Continuity Procedures together with Sections 5.1 and 5.2, explain why this is a business continuity event under the Supplement and what the Company must already have in place to address it.&quot;, &quot;answer&quot;: &quot;The definition of Business Continuity Procedures explicitly covers continuing operations upon the termination or expiration of a contract that is material to the Company&#39;s ability to service Fannie Mae loans or comply with the Lender Contract, so a lapsing subservicer contract is a covered disruption, not merely a commercial matter. Section 5.1 requires the Business Continuity Plan to ensure the Company can recover critical business operations specifically in the event of the expiration or termination of any contract material to the sale or servicing of Fannie Mae loans. Section 5.2 then requires the Company to establish and document contingencies for third-party and fourth-party vendor relationships and to confirm it can recover critical operations if third-party vendors such as subservicers experience the expiration or termination of such a material contract, suffer business failure, or fail to maintain their own Business Continuity or Disaster Recovery Procedures. The plan must also include third-party dependency information and a crisis communication strategy with points of contact for vendor dependencies.&quot;, &quot;context&quot;: &quot;Section 2 (Definitions) - Business Continuity Procedures: procedures to continue operations if adverse conditions occur, such as a storm, a fire, a crime, a disruption of critical servicing functions, or the termination or expiration of a contract that is material to Company&#39;s ability to originate loans for sale to Fannie Mae, service Fannie Mae loans, or comply with the Lender Contract. Section 5.1 (Business Continuity Plan): ensure the Company&#39;s ability to recover critical business operations if (A) there is a disruption or disaster, including to back-up systems, and (B) in the event of the expiration or termination of any contract that is material for the Company&#39;s sale or servicing of Fannie Mae loans; the plan must include third party dependency information and a crisis management plan with points of contact for Fannie Mae and vendor dependencies. Section 5.2 (Additional Requirements): establish and document contingencies for third party and fourth party vendor and service provider relationships; confirm the Company can recover critical business operations if any third party vendors such as subservicers, third-party originators and outsourcing firms fail to maintain Business Continuity or Disaster Recovery Procedures, suffer complete business failure or dissolution, or experience the expiration or termination of a material contract.&quot;}<br>{&quot;question&quot;: &quot;A Company decides to move its loan origination system, which stores Fannie Mae Confidential Information, to a public cloud provider. Which three distinct sections impose obligations on this decision, and how do the identity-and-access requirements for the cloud relate back to the Access Management section?&quot;, &quot;answer&quot;: &quot;Three sections apply. Section 3.13 is triggered because the Company leverages Cloud Computing to store Confidential Information, so it must establish cloud computing policies defining its cloud strategy, understand its security posture, concentration risk and dependencies, use providers on current regularly upgraded technology, use secure-by-design principles, and secure access to the cloud environment consistent with the Supplement. Section 3.14 applies because the cloud provider is a vendor, so it must be assessed under the formal vendor risk management program, covered by an agreement addressing security and resiliency, and the Company remains responsible for the provider&#39;s compliance. Section 3.10 continues to apply, requiring encryption in-transit and at-rest and data loss prevention over public communications. For identity and access, Section 3.13 requires the Company to implement identity access management procedures consistent with the Supplement for accessing cloud data and services, which means the Section 3.1 requirements such as unique user IDs, least privilege, annual access certification, lockout and timeout rules, and mandatory MFA for privileged accounts extend to the cloud environment.&quot;, &quot;context&quot;: &quot;Section 3.13 (Cloud Computing): if the Company leverages Cloud Computing to store, process, access or transmit Confidential Information or connect to a Fannie Mae system, it must establish cloud computing policies defining the Company&#39;s cloud strategy; understand its security posture, concentration risk and related dependencies; use cloud providers that base their services on current technology with regular upgrades tested for security; implement identity access management procedures consistent with this Supplement for accessing data and services in the cloud; secure access to the cloud environment consistent with this Supplement; and utilize secure-by-design principles. Section 3.14 (Supply Chain Risk Management): formal vendor risk management program, agreements with each vendor covering security and resiliency, Company responsible for vendor failures. Section 3.10 (Data Protection and System Security): encrypting data in-transit and at-rest; data loss prevention for outgoing transmissions over public communication. Section 3.1 (Access Management): unique user IDs, least privilege, annual access review, lockout and timeout, MFA for privileged accounts.&quot;}<br>{&quot;question&quot;: &quot;List every recurring activity in the Supplement that must occur at least annually, grouping them by whether they belong to the Information Security Program, the Cybersecurity Incident program, or the Business Continuity program, and identify which of them also have an additional event-driven trigger beyond the annual cycle.&quot;, &quot;answer&quot;: &quot;Information Security Program (Section 3): review of the Information Security Program itself; the written attestation by a corporate officer; certification of access for all human and non-human accounts; security awareness training; the independent security assessment, which is also event-triggered upon any Cybersecurity Incident; the independent third-party penetration test; review of individuals with physical access to facilities; and, if wireless is offered, review of approved wireless networks. Cybersecurity Incident program (Section 3.6): the test of the incident response plan with lessons learned incorporated. Business Continuity program (Section 5): testing of the Business Continuity Plan, which is also triggered when major changes occur to the plan; the annual threat risk assessment; review and approval of the BCP by the governing body, which is also triggered when material changes occur; and the annual tabletop or similar exercise of business and technology continuity documentation. The three with additional event-driven triggers are the independent security assessment (any Cybersecurity Incident), BCP testing (major changes), and governing-body approval of the BCP (material changes).&quot;, &quot;context&quot;: &quot;Section 3: at least annually review the Information Security Program; annually provide a written attestation. Section 3.1: access reviewed and certified at least annually. Section 3.2: at least annually conduct information security awareness training. Section 3.3: independent security assessment upon any Cybersecurity Incident and at least annually. Section 3.4: independent third-party penetration test at least annually. Section 3.5: periodic review of physical access at least annually. Section 3.12: at least annually review approved wireless networks. Section 3.6: complete on at least an annual basis a test of the incident response plan and incorporate lessons learned. Section 5.1: testing of the Business Continuity Plan annually at a minimum or when major changes occur. Section 5.2: conduct an annual threat risk assessment; governing body ensures the BCP is reviewed and approved at least annually or when material changes occur; exercise continuity planning documentation annually through tabletop or similar exercises.&quot;}<br>{&quot;question&quot;: &quot;A denial-of-service attack against one of the Company&#39;s hosting providers degrades the Company&#39;s ability to deliver loan files to Fannie Mae, but no Confidential Information is accessed or exfiltrated. Does the Supplement treat this as a reportable event, and under which two separate notification requirements, with what content and timing?&quot;, &quot;answer&quot;: &quot;Yes. The definition of Cybersecurity Incident covers any occurrence affecting the availability of Confidential Information, and it lists a denial of service attack that may affect delivery of services to Fannie Mae as an example, explicitly including events at the Company&#39;s third parties, so exfiltration is not required. Two notification requirements apply. Under Section 3.6, the Company must notify Fannie Mae of any disruptions, including technology-related ones, that could impair business conducted with Fannie Mae or the Company&#39;s ability to perform its contractual obligations, and it states that disruptions rising to the level of a Cybersecurity Incident must be reported per Section 4. Under Section 4, within 36 hours of identification the Company must e-mail privacy_office@fanniemae.com all known details, including investigations and technical indicators of compromise, tactics, techniques and procedures and timing, whether Fannie Mae systems or Confidential Information were accessed, names of third-party incident response providers, any law enforcement engaged, contact information for the investigation lead, and the extent to which Confidential Information was accessed, taken or exposed, which in this case would be reported as none.&quot;, &quot;context&quot;: &quot;Section 2 (Definitions) - Cybersecurity Incident: any occurrence affecting the confidentiality, integrity, or availability of Confidential Information; examples include a denial of service attack which may affect the delivery of the services to Fannie Mae, occurring at the Company or at the Company&#39;s third party(ies). Section 3.6 (Cyber Incident Management and Response): provide notification to Fannie Mae of any disruptions, including those related to technology, which may impact operations that could potentially impair or impact business conducted with Fannie Mae or the Company&#39;s ability to perform its obligations; disruptions rising to the level of a Cybersecurity Incident must be reported in accordance with Section 4. Section 4: within 36 hours provide all known details including related investigations and technical indicators of compromise, all tactics, techniques and procedures, timing, whether Fannie Mae systems or Confidential Information have been accessed, names of third-party incident response providers, law enforcement agencies engaged, contact information for the investigation lead, and the extent to which Fannie Mae Confidential Information was accessed, taken or exposed.&quot;}<br>{&quot;question&quot;: &quot;A Company runs a batch processing service account that several operations staff use interchangeably to run jobs touching Fannie Mae data. Reconcile the Supplement&#39;s unique-user-ID rule with its allowance for system accounts, and explain what the Audit and Accountability section requires that determines whether this arrangement is permissible.&quot;, &quot;answer&quot;: &quot;Section 3.1 requires that every user have a unique user ID and prohibits shared accounts, but carves out built-in and system accounts where individual usage can be tracked. A non-human service account is therefore permitted only if the Company can attribute each use to the specific individual who invoked it. Section 3.3 supplies the test: the Company must maintain written logging and monitoring requirements that ensure logs retain relevant, usable and timely information sufficient to identify user access and system activities, protect those logs from unauthorized modification or deletion, and monitor them for unauthorized disclosure or modification of Confidential Information. If staff share the service account&#39;s credentials without any mechanism that ties each job run to an identified person, individual usage cannot be tracked and the arrangement violates Section 3.1. Additionally, Section 3.1 requires non-human IDs to authenticate through a private and controlled method and to be included in the annual access certification by the entitlement owner.&quot;, &quot;context&quot;: &quot;Section 3.1 (Access Management): every user must have a unique user ID; no shared accounts may be used beyond built-in and system accounts where individual usage can be tracked; access for all human and non-human (system) user accounts should be reviewed and certified by the entitlement owner or user manager at least annually; the Company must require employees and system accounts (non-human ids) to authenticate through a private and controlled method. Section 3.3 (Audit and Accountability): develop and maintain written requirements for logging and monitoring of activities within information systems, including log retention periods, to ensure logs retain relevant, usable and timely information sufficient to identify user access and system activities; implement controls to protect logs from access, modification or deletion by unauthorized personnel; establish processes to monitor logs for unauthorized disclosure, modification, deletion or replication of Confidential Information.&quot;}<br>{&quot;question&quot;: &quot;An employee&#39;s laptop containing Fannie Mae borrower data is stolen from a car; the laptop had full-disk encryption disabled. Trace this event through the definition of Cybersecurity Incident, the specific Data Protection control that was not followed, the general reporting obligation, and the additional obligations that arise because the data is NPI.&quot;, &quot;answer&quot;: &quot;The definition of Cybersecurity Incident includes the loss of, or unauthorized access to, Confidential Information, and Fannie Mae Confidential Information expressly includes information about Fannie Mae borrowers, so the stolen laptop is a Cybersecurity Incident. The control not followed is in Section 3.10, which requires encrypting end user devices such as laptops, tablets and cell phones specifically to protect data if devices are lost or stolen, under a formal data management and Encryption use policy. Under Section 4, the Company must promptly investigate, correct and mitigate at its own expense, identify all Fannie Mae Confidential Information affected, and within 36 hours e-mail privacy_office@fanniemae.com all known details including law enforcement engaged and the extent of exposure. Because borrower data is nonpublic personal information, Section 4.3 adds obligations: report the number of individuals and jurisdictions, a description of the NPI and the root cause; provide timely written notice to affected borrowers and regulators under applicable law and retain a copy in each loan file; obtain Privacy Office permission before naming Fannie Mae in notices; and bear all Losses from third-party claims and Fannie Mae&#39;s compliance costs since the incident was caused by Company personnel.&quot;, &quot;context&quot;: &quot;Section 2 (Definitions) - Cybersecurity Incident: loss of, or accidental or unauthorized access to, Confidential Information; Fannie Mae Confidential Information means Confidential Information about Fannie Mae borrowers, collateral, Fannie Mae data and servicing data. Section 3.10 (Data Protection and System Security): maintaining a formal data management and Encryption use policy; encrypting end user devices (laptops, tablets, cell phones) to protect data if devices are lost or stolen. Section 4: promptly investigate, correct and mitigate at the Company&#39;s expense, identifying all Fannie Mae Confidential Information affected; within 36 hours provide all known details via privacy_office@fanniemae.com. Section 4.3: inform the Privacy Office of number of individuals impacted, jurisdictions, description of NPI and root cause; timely written notice to affected borrowers and agencies, copy in loan file; request permission to use Fannie Mae&#39;s name; Company pays all Losses for incidents relating to NPI caused by Company personnel.&quot;}<br>{&quot;question&quot;: &quot;Explain how the Supplement determines what counts as Fannie Mae Confidential Information for an MF Lender versus an SF Lender, and trace the chain of defined terms that makes the Consolidated Technology Guide relevant to both.&quot;, &quot;answer&quot;: &quot;The definition of Fannie Mae Confidential Information gives a common core covering information about Fannie Mae borrowers, collateral, Fannie Mae data and servicing data, Fannie Mae technologies, products and services, and then adds that it includes anything defined as Confidential Information under the SF Guides for SF Lenders and under the MF Guide for MF Lenders. The term SF Guides is defined as the SF Selling Guide, SF Servicing Guide and the Consolidated Technology Guide collectively, while MF Guide is defined as the Multifamily Selling and Servicing Guide, Multifamily Program Rules and the Consolidated Technology Guide. The Consolidated Technology Guide, defined as the Fannie Mae Consolidated Technology Guide as amended from time to time, therefore sits inside both guide definitions, so confidentiality definitions from it feed into Fannie Mae Confidential Information for both lender types. The SF Lender and MF Lender terms themselves are defined by reference to the SF Selling Guide and MF Guide respectively, and both guides are also Underlying Agreements over which the Supplement takes precedence in a conflict.&quot;, &quot;context&quot;: &quot;Section 2 (Definitions) - Fannie Mae Confidential Information: Confidential Information about Fannie Mae borrowers, collateral securing a Fannie Mae loan, Fannie Mae data including servicing data, existing or proposed Fannie Mae technologies, products or services and Fannie Mae&#39;s business; Fannie Mae Confidential Information includes anything that would be defined as Confidential Information under the SF Guides for SF Lenders and under the MF Guide for MF Lenders. SF Guides: collectively the SF Selling Guide, SF Servicing Guide and Consolidated Technology Guide. MF Guide: collectively the Multifamily Selling and Servicing Guide, Multifamily Program Rules and the Consolidated Technology Guide. Consolidated Technology Guide: the Fannie Mae Consolidated Technology Guide, as amended, restated, supplemented or otherwise modified from time to time. SF Lender: a lender as described in the SF Selling Guide, including sellers, servicers and seller/servicers. MF Lender: a Lender as defined in the MF Guide. Underlying Agreements include the Lender Contract, MF Guide and SF Guides; Section 6 Conflicts: Supplement takes precedence.&quot;}<br>{&quot;question&quot;: &quot;A Company provides a guest Wi-Fi network in its offices and allows underwriters to work remotely over that network and from home. Combine the Network Security and Access Management requirements to describe the full set of controls that must be in place, and identify which requirement is conditional on the Company offering wireless at all.&quot;, &quot;answer&quot;: &quot;From Section 3.12, the Company must have firewalls blocking inbound and outbound public traffic unless permitted by policy, restrict ports, protocols and services to approved ones, monitor for anomalous traffic, review firewall rules on a defined frequency and on notable network changes, and control, secure and monitor wireless access points. Because the Company offers wireless networks, the conditional block of Section 3.12 also applies: a WLAN authentication method meeting or exceeding current industry best practice on encryption strength, at least annual reviews of approved wireless networks to verify authorized users and access points, and password-protected restriction of router and network device access to authorized administrators. From Section 3.1, remote work by underwriters triggers remote access requirements with mechanisms to monitor and control remote access, MFA where applicable, account lockout after failed logins, inactivity timeouts, and least-privilege access. Section 3.10 additionally requires encrypting data in transit and encrypting the end user devices used remotely. The WLAN authentication, annual wireless review and router-protection requirements are the ones conditional on the Company offering wireless networks.&quot;, &quot;context&quot;: &quot;Section 3.12 (Network Security and Management): implement firewalls to block inbound traffic from and outbound traffic to public networks unless permitted; manage and restrict ports, protocols and services to those required and approved; monitor network traffic and detect anomalous traffic; review and approve firewall rules on a defined frequency and whenever a notable change in network technology occurs; control, secure and monitor wireless access points. In addition, if the Company offers wireless networks for network users, it must implement a WLAN Authentication method meeting or exceeding current industry best practices on Encryption strength; at least annually review approved wireless networks to validate authorized users and access points; and implement controls including password protection to restrict router and network device access to authorized users and administrators. Section 3.1 (Access Management): remote access requirements including mechanisms to monitor and control remote access; MFA where applicable; locked accounts after multiple failed logins and timeout for inactivity; least privilege. Section 3.10: encrypting end user devices and data in-transit.&quot;}<br>{&quot;question&quot;: &quot;An independent auditor&#39;s annual security assessment identifies a critical vulnerability in a server that transmits loan data to Fannie Mae, and the vendor patch has been available for two months. Which three sections together define what the Company must do, what the standard for timing is, and how the finding must be tracked?&quot;, &quot;answer&quot;: &quot;Section 3.3 obliges the Company to remediate findings raised in any independent security assessment that are relevant to services provided to or on behalf of Fannie Mae or loans sold to or serviced for Fannie Mae, with remediation completed within a commercially reasonable timeframe consistent with standard industry practices. Section 3.4 requires the finding to be entered into the vulnerability management program&#39;s inventory of all identified Vulnerabilities with a risk rating and remediation status tracked against a risk-based schedule, so a critical rating should drive priority. Section 3.9 requires the Company&#39;s written patch management program to perform timely software updates and patches based on the vulnerability management program and to maintain a process for testing and consistently applying patches as they become available without compromising the confidentiality, integrity and availability of Fannie Mae Confidential Information; a two-month-old unapplied patch on a system transmitting Fannie Mae data is exactly what this provision targets. Section 3.8 further requires that applying the patch follow the formal change management process with tracking, testing and documented back-out procedures.&quot;, &quot;context&quot;: &quot;Section 3.3 (Audit and Accountability): the Company must remediate findings relevant to the services provided to or on behalf of Fannie Mae or loans being sold to or serviced for Fannie Mae raised in any independent security assessment; remediation must be completed within a commercially reasonable timeframe and consistent with standard industry practices. Section 3.4 (Vulnerability Management): a process to identify, analyze and remediate identified Vulnerabilities within commercially reasonable timelines, including an inventory of all identified Vulnerabilities, a risk rating of their level of risk, and their remediation status based on a risk-based schedule. Section 3.9 (Patch Management): implement and maintain a written patch management program; perform timely software updates and patches based on the Company&#39;s Vulnerability management program, and maintain a process for testing and ensuring updates and patches are consistently applied as they become available without compromising the confidentiality, integrity and availability of Fannie Mae Confidential Information. Section 3.8: all changes to the technology environment including patches must follow a formal change management process including tracking, testing and documented back-out procedures.&quot;}<br>{&quot;question&quot;: &quot;A Fannie Mae relationship manager verbally tells a Company during a call that it need not perform the annual penetration test this year; the Company skips it, and later Fannie Mae raises the omission as a breach. Using the General Requirements, explain whether the verbal statement protects the Company, what form a valid waiver must take, and whether a prior written waiver of a different breach would help.&quot;, &quot;answer&quot;: &quot;The verbal statement does not protect the Company. The No Implied Waiver provision in Section 6 states that no term of the Supplement will be deemed waived and no breach excused unless the waiver or excuse is in writing and executed by a duly authorized representative of Fannie Mae, so an oral assurance from a relationship manager has no effect on the Section 3.4 obligation to conduct an independent third-party penetration test at least annually. A valid waiver would need to be a written instrument signed by an authorized Fannie Mae representative; Section 6 also notes that outside the bulletin process, amendments require a writing executed by a duly authorized representative of each party. A prior written waiver of a different breach would not help either, because the same provision states that any waiver by Fannie Mae of a breach does not constitute consent to, waiver of, or excuse for any different or subsequent breach.&quot;, &quot;context&quot;: &quot;Section 6 (General Requirements) - No Implied Waiver: no term, provision or clause of this Supplement will be deemed waived, and no breach excused unless such waiver or excuse is in writing and executed by a duly authorized representative of Fannie Mae; any waiver by Fannie Mae of a breach by the Company does not constitute a consent to, waiver of, or excuse for any different or subsequent breach. Amendments to this Supplement: Fannie Mae may at any time alter or waive any requirements; if a bulletin is not used, the terms may be amended by a writing executed by a duly authorized representative of each party. Section 3.4 (Vulnerability Management): an independent third-party penetration test conducted at least annually on systems used to store, access, process or transmit Confidential Information or connect to a Fannie Mae system.&quot;}<br>{&quot;question&quot;: &quot;After a tabletop exercise reveals that the Company&#39;s Business Continuity Plan was unknown to the operations staff expected to execute it and that no one had approved the current version, which requirements in Sections 5.1 and 5.2 were not met, and what must happen with the exercise results afterward?&quot;, &quot;answer&quot;: &quot;Section 5.1 requires the Business Continuity Plan to be available to all resources or staff required to support execution of the plan, so staff being unaware of it is a direct failure, and it requires governance to ensure compliance with BCP requirements. Section 5.2 requires a governing body to provide guidance and support of the plan and to ensure it is reviewed, formally socialized with leadership, and approved at least annually or when material changes occur, so an unapproved current version breaches this. The exercise itself satisfies the Section 5.2 requirement to exercise continuity documentation annually through tabletop or similar exercises, but Section 5.2 then requires the Company to document lessons learned and after actions, share results with Company senior management, and track the after actions to resolution. Section 5.1 additionally requires the plan to be tested annually or when major changes occur, so the governance and communication fixes themselves should be reflected in the plan&#39;s crisis management and communication strategy.&quot;, &quot;context&quot;: &quot;Section 5.1 (Business Continuity Plan): the Business Continuity Plan must include governance to ensure compliance with Business Continuity Plan requirements, a crisis management plan and communication strategy, and testing annually at a minimum or when major changes occur; the Business Continuity Plan must be available to all resources or staff that are required to support the execution of the plan. Section 5.2 (Additional Requirements): have a governing body in place to provide guidance and support of the Business Continuity Plan, ensuring the plan is reviewed, formally socialized with leadership, and approved at least annually or when material changes occur; exercise its business and technology continuity planning documentation annually through tabletop or other similar exercises; document lessons learned and after actions when plans are tested or activated; results should be shared with Company senior management and after actions should be tracked to resolution.&quot;}<br>{&quot;question&quot;: &quot;A Company&#39;s parent affiliate runs the group-wide Information Security Program, designates the senior security executive, and signs the annual attestation, while the Company itself only sells loans to Fannie Mae. Under Sections 1 and 3, is this structure permitted, what condition applies, and which standard must the affiliate&#39;s program align with?&quot;, &quot;answer&quot;: &quot;The structure is permitted. Section 1 states that all obligations required of the Company under the Supplement may be performed by a Company affiliate on the Company&#39;s behalf, and Section 3 restates this for the Information Security Program specifically, allowing the Company or its affiliate to implement the program to the extent the affiliate&#39;s program covers the Company. The condition is coverage: the affiliate&#39;s program must actually encompass the Company&#39;s systems, personnel and Fannie Mae Confidential Information. Regardless of who operates it, the program must at a minimum include the Supplement&#39;s requirements, be aligned with or exceed a current industry standard such as the NIST Framework or ISO 27001, have a designated senior executive responsible for it, be reviewed at least annually, be communicated to all applicable personnel, be made available to Fannie Mae on request, and be covered by an annual written attestation executed by a duly authorized corporate officer, which an SF Lender may deliver through the Form 582 process. The Company also remains bound to exercise at least the same care with Fannie Mae systems and Confidential Information as with its own.&quot;, &quot;context&quot;: &quot;Section 1 (Introduction): all obligations required to be performed by the Company under this Supplement may be performed by a Company affiliate on the Company&#39;s behalf. Section 3 (Information Security Program): the Company, or its affiliate (to the extent the affiliate&#39;s program covers the Company), at its own cost and expense, must implement an information security program that at a minimum includes the requirements of this Supplement; align it with or exceed a current industry standard such as the NIST Framework or ISO 27001; designate and keep a senior executive responsible for the program; at least annually review the program; communicate requirements to all applicable employees, contractors and personnel; make the program available to Fannie Mae upon request; and annually provide a written attestation executed by a duly authorized corporate officer, which for SF Lenders may be provided through the annual Form 582 process; the Company must exercise at least the same level of care with Fannie Mae systems and Fannie Mae Confidential Information as it does for its own.&quot;}</pre><h3>The Results</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MPNVZXwsklQbvUqLV3OY9g.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7ZahBuf99fwnfbMgfEpC0w.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7DhYFZ_VgJb3PYIkb47gRQ.png" /></figure><h3>The Conclusion</h3><p>This experiment started with a practical question: can we simplify the lexical side of hybrid RAG without losing the evidence needed to answer questions?</p><p>For this workload, the results were encouraging. Both collections retrieved the same five sections for 19 of the 20 questions, with identical ordering for 15. Retrieval scores remained close, while the answer-level differences could not be confidently attributed to the change in BM25 implementation.</p><p>Qdrant’s index-native BM25 offers a simpler path when the requirement is classical BM25 over text already stored in the payload. It removes separate sparse-vector encoding and average-document-length bookkeeping from that path. Learned sparse representations such as SPLADE or miniCOIL serve different needs and still require sparse vectors.</p><p><strong>My takeaway is to treat simplification as something to validate.</strong> The dataset, embedding model, text processing, and questions all influence the outcome. Before making the switch in your own RAG system, compare the retrieved evidence and evaluate the answers built from it.</p><p>In this experiment, the simpler path held up. Your workload deserves the same check.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=feeef7dd9fff" width="1" height="1" alt=""><hr><p><a href="https://blog.stackademic.com/does-index-native-bm25-change-retrieval-quality-a-two-collection-experiment-in-qdrant-1-20-feeef7dd9fff">Does Index-Native BM25 Change Retrieval Quality? A Two-Collection Experiment in Qdrant 1.20</a> was originally published in <a href="https://blog.stackademic.com/">Stackademic</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Evaluating Medical RAG with Qdrant, Jev and MLflow — A Production Ready Setup]]></title>
            <link>https://blog.devops.dev/evaluating-medical-rag-with-qdrant-jev-and-mlflow-a-production-ready-setup-091a80fb1540?source=rss-934e21252654------2</link>
            <guid isPermaLink="false">https://medium.com/p/091a80fb1540</guid>
            <category><![CDATA[jevevals]]></category>
            <category><![CDATA[production-rag]]></category>
            <category><![CDATA[qdrant]]></category>
            <category><![CDATA[tracedetection]]></category>
            <category><![CDATA[mlflow]]></category>
            <dc:creator><![CDATA[M K Pavan Kumar]]></dc:creator>
            <pubDate>Sat, 17 Oct 2026 02:51:14 GMT</pubDate>
            <atom:updated>2026-10-20T13:25:30.239Z</atom:updated>
            <content:encoded><![CDATA[<p>We often focus on retrieval accuracy and response quality, but what happens when we need to evaluate every answer against ground truth, detect contradictions, and understand where things went wrong? In this hands-on blog, we will build a <strong>complete medical RAG pipeline from scratch</strong> using <strong>LlamaIndex, Qdrant, and Ollama</strong>, then bring in <strong>MLflow and Jev</strong> to evaluate responses, compare them against ground-truth answers, inspect traces, and generate meaningful evaluation reports. 💡 <strong>The interesting part?</strong> We will put a traditional LLM judge and Jev side by side to explore how they evaluate the same RAG responses. No theory alone. Real datasets, real code, real evaluations, and measurable results. 🔬</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FV6w3mDqn_tgsz4FvDKaRg.png" /><figcaption>created by author <strong>M K Pavan Kumar</strong></figcaption></figure><h4>The Architecture</h4><p>Our architecture brings together <strong>LlamaIndex, Qdrant, Ollama, MLflow, and Jev</strong> to build a complete medical RAG pipeline with hybrid retrieval, automated evaluation, trace monitoring, and result analysis. The workflow is divided into four major components.</p><p><strong>1. Data Preparation and Ingestion</strong></p><p>We start with the <strong>MedQuAD Context GroupedQA</strong> dataset from Hugging Face, which contains medical contexts and their corresponding question-answer pairs. A Python script (create_testset.py) randomly selects 50 question-answer pairs and creates a JSONL test set containing questions, ground-truth answers, and context IDs.</p><p>A separate ingestion script (ingest_contexts.py) processes the medical contexts without chunking. <strong>Ollama&#39;s EmbeddingGemma</strong> generates dense embeddings, while <strong>FastEmbed BM25</strong> generates sparse vectors. Both representations are stored in <strong>Qdrant</strong>, enabling hybrid retrieval with context-level traceability.</p><p><strong>2. RAG Pipeline</strong></p><p>The RAG pipeline is built using <strong>LlamaIndex</strong>, which handles retrieval, response generation, and integration with Qdrant. When a question arrives, hybrid retrieval combines dense semantic search and sparse keyword matching using <strong>Reciprocal Rank Fusion (RRF)</strong>.</p><p>The retrieved contexts are passed to <strong>Ollama’s Gemma 3</strong>, which generates the final answer. Each response includes the generated answer, retrieved context IDs, and source references, allowing us to evaluate both retrieval quality and answer correctness.</p><p><strong>3. Evaluation with Jev and LLM Judges</strong></p><p>This is where the architecture becomes interesting. Using <strong>MLflow GenAI Evaluation</strong>, we evaluate every RAG response against its ground-truth answer and retrieved contexts.</p><p>We use <strong>Jev</strong> to evaluate answer coverage and contradictions, while <strong>Gemma 3</strong> serves as the traditional LLM judge for the same criteria. Custom retrieval scorers check whether the expected medical context was retrieved and at what rank.</p><p>This allows us to compare <strong>Jev and LLM-based evaluation</strong> on the same questions, responses, and evidence.</p><p><strong>4. MLflow Tracking, Monitoring, and Analysis</strong></p><p><strong>MLflow</strong> acts as the central observability, AI Gateway and evaluation platform. It records experiment configurations, Model Routing, RAG execution traces, model interactions, retrieval results, evaluation scores, and judge feedback.</p><p>Jev is accessed through the <strong>MLflow AI Gateway</strong>, while the Analyzer processes evaluation traces to measure judge agreement, identify disagreements, and produce structured results. The architecture also includes <strong>GPT-4o-mini</strong> for trace-level issue detection.</p><p>Finally, the results are exported into a JSONL report, giving us a clear picture of retrieval performance, answer quality, and how Jev compares with the traditional LLM judge.</p><p><strong>5. What Makes This Architecture Interesting?</strong></p><p>Instead of evaluating RAG using a single LLM judge, we introduce <strong>Jev as an alternative evaluation mechanism</strong> and compare both approaches against the same ground-truth medical dataset.</p><p>The goal is not just to generate accurate medical answers, but to understand <strong>how reliably we can evaluate those answers, detect failures, and trace problems back to retrieval or generation</strong>, all within a reproducible MLflow experiment.</p><h4>The Implementation</h4><p>create_test_set.py — building the test set</p><p><strong>TestsetConfig.</strong> Every knob (dataset name, split, sample size, seed, output path) is read from .env into a frozen dataclass. The script has no hard-coded choices, and freezing the config means a run is fully described by one immutable object, which is what you want when you later ask “which test set did this run use?”</p><p><strong>context_id.</strong> Each MedQuAD context gets a 16-character SHA-1 of its text. This is the join key for the whole experiment: the same function is reused by the ingestion script, so a question in the test set and a document in Qdrant can be matched without ever comparing text.</p><p><strong>flatten.</strong> The dataset groups several question–answer pairs under one context. We explode that grouping so every QA object becomes its own record, carrying its parent context and context id. Malformed or empty rows are skipped rather than failing the run; a test set should be built from clean examples only.</p><p><strong>sample.</strong> We shuffle with a fixed seed and take the first N records, allowing at most one record per context. The seed makes the set reproducible; the one-per-context rule spreads the 50 questions across 50 distinct documents so retrieval is tested on breadth, not on five questions about the same page.</p><p><strong>write.</strong> Records are written as flat JSONL (id, context_id, question, answer, context) with sequential ids. Keeping the shape flat keeps the file reusable; mapping to MLflow’s inputs/expectations structure is the evaluation script’s job.</p><pre>from __future__ import annotations<br><br>import hashlib<br>import json<br>import os<br>import random<br>from dataclasses import dataclass<br>from pathlib import Path<br><br>from datasets import load_dataset<br>from dotenv import load_dotenv<br><br><br>@dataclass(frozen=True)<br>class TestsetConfig:<br>    dataset: str<br>    split: str<br>    size: int<br>    seed: int<br>    path: Path<br><br>    @classmethod<br>    def from_env(cls) -&gt; &quot;TestsetConfig&quot;:<br>        load_dotenv()<br>        return cls(<br>            dataset=os.getenv(&quot;HF_DATASET&quot;, &quot;pavanmantha/MedQuAD_Context_GroupedQA&quot;),<br>            split=os.getenv(&quot;HF_SPLIT&quot;, &quot;train&quot;),<br>            size=int(os.getenv(&quot;TESTSET_SIZE&quot;, &quot;50&quot;)),<br>            seed=int(os.getenv(&quot;TESTSET_SEED&quot;, &quot;42&quot;)),<br>            path=Path(os.getenv(&quot;TESTSET_PATH&quot;, &quot;src/data/testset.jsonl&quot;)),<br>        )<br><br><br>class TestsetBuilder:<br>    def __init__(self, config: TestsetConfig) -&gt; None:<br>        self.config = config<br><br>    @staticmethod<br>    def context_id(context: str) -&gt; str:<br>        return hashlib.sha1(context.encode(&quot;utf-8&quot;)).hexdigest()[:16]<br><br>    def load(self):<br>        return load_dataset(self.config.dataset, split=self.config.split)<br><br>    def flatten(self, dataset) -&gt; list[dict]:<br>        records: list[dict] = []<br>        for row in dataset:<br>            context = (row.get(&quot;context&quot;) or &quot;&quot;).strip()<br>            raw_pairs = row.get(&quot;question_answer_pairs&quot;)<br>            if not context or not raw_pairs:<br>                continue<br>            try:<br>                pairs = json.loads(raw_pairs) if isinstance(raw_pairs, str) else raw_pairs<br>            except json.JSONDecodeError:<br>                continue<br>            if not isinstance(pairs, list):<br>                continue<br>            cid = self.context_id(context)<br>            for pair in pairs:<br>                question = (pair.get(&quot;question&quot;) or &quot;&quot;).strip()<br>                answer = (pair.get(&quot;answer&quot;) or &quot;&quot;).strip()<br>                if question and answer:<br>                    records.append({&quot;context_id&quot;: cid, &quot;question&quot;: question, &quot;answer&quot;: answer, &quot;context&quot;: context})<br>        return records<br><br>    def sample(self, records: list[dict]) -&gt; list[dict]:<br>        rng = random.Random(self.config.seed)<br>        shuffled = records[:]<br>        rng.shuffle(shuffled)<br>        seen: set[str] = set()<br>        chosen: list[dict] = []<br>        for rec in shuffled:<br>            if rec[&quot;context_id&quot;] in seen:<br>                continue<br>            seen.add(rec[&quot;context_id&quot;])<br>            chosen.append(rec)<br>            if len(chosen) == self.config.size:<br>                break<br>        if len(chosen) &lt; self.config.size:<br>            raise RuntimeError(f&quot;Only {len(chosen)} distinct contexts; cannot sample {self.config.size}.&quot;)<br>        return chosen<br><br>    def write(self, records: list[dict]) -&gt; None:<br>        self.config.path.parent.mkdir(parents=True, exist_ok=True)<br>        with self.config.path.open(&quot;w&quot;, encoding=&quot;utf-8&quot;) as fh:<br>            for i, rec in enumerate(records, start=1):<br>                fh.write(json.dumps({&quot;id&quot;: f&quot;q{i:03d}&quot;, **rec}, ensure_ascii=False) + &quot;\n&quot;)<br><br>    def run(self) -&gt; None:<br>        dataset = self.load()<br>        records = self.flatten(dataset)<br>        chosen = self.sample(records)<br>        self.write(chosen)<br>        print(f&quot;{len(dataset)} rows -&gt; {len(records)} QA records -&gt; {len(chosen)} written to {self.config.path} (seed={self.config.seed})&quot;)<br><br><br>if __name__ == &quot;__main__&quot;:<br>    TestsetBuilder(TestsetConfig.from_env()).run()</pre><p>qdrant_ingestion.py — populating the hybrid collection</p><p><strong>IngestConfig.</strong> Qdrant, Ollama, the sparse model and batch sizes come from .env, plus a RECREATE_COLLECTION flag so a rebuild is an explicit decision rather than a default.</p><p><strong>ContextIngestor.init.</strong> Three clients are wired once: the Qdrant client, an Ollama embedding model for dense vectors, and a QdrantVectorStore with enable_hybrid=True so LlamaIndex also computes a BM25 sparse vector through fastembed. Hybrid is deliberate for medical text, where exact terms (drug names, gene symbols) matter as much as semantic similarity.</p><p><strong>node_id.</strong> The Qdrant point id is a UUID5 derived from the context id. Deterministic ids make ingestion idempotent: re-running the script upserts the same points instead of duplicating them.</p><p><strong>load_nodes.</strong> The full dataset is read, deduplicated by context id, and each context becomes a single TextNode. There is no chunking: each row is already one coherent document. The context_id and source go into payload but are excluded from the embedded text, so metadata never contaminates the vector.</p><p><strong>ingest.</strong> Nodes are handed to VectorStoreIndex with a Qdrant-backed storage context. LlamaIndex embeds in batches, computes the sparse vectors, and upserts to the collection; we never touch the Qdrant API directly for writes.</p><p><strong>run.</strong> Optionally drop the collection, load nodes, report the point count before and after. The before/after print is the quickest sanity check that the upsert path behaved.</p><pre>from __future__ import annotations<br><br>import os<br>import uuid<br>from dataclasses import dataclass<br><br>from datasets import load_dataset<br>from dotenv import load_dotenv<br>from llama_index.core import StorageContext, VectorStoreIndex<br>from llama_index.core.schema import TextNode<br>from llama_index.embeddings.ollama import OllamaEmbedding<br>from llama_index.vector_stores.qdrant import QdrantVectorStore<br>from qdrant_client import QdrantClient<br><br>from create_test_set import TestsetBuilder<br><br>NODE_NAMESPACE = uuid.UUID(&quot;6f2b9d4e-8a1c-4c7e-9f3a-2d5e7b1c0a44&quot;)<br><br><br>@dataclass(frozen=True)<br>class IngestConfig:<br>    dataset: str<br>    split: str<br>    qdrant_url: str<br>    qdrant_api_key: str | None<br>    collection: str<br>    ollama_base_url: str<br>    embed_model: str<br>    sparse_model: str<br>    embed_batch_size: int<br>    insert_batch_size: int<br>    recreate: bool<br><br>    @classmethod<br>    def from_env(cls) -&gt; &quot;IngestConfig&quot;:<br>        load_dotenv()<br>        return cls(<br>            dataset=os.getenv(&quot;HF_DATASET&quot;, &quot;pavanmantha/MedQuAD_Context_GroupedQA&quot;),<br>            split=os.getenv(&quot;HF_SPLIT&quot;, &quot;train&quot;),<br>            qdrant_url=os.getenv(&quot;QDRANT_URL&quot;, &quot;http://localhost:6333&quot;),<br>            qdrant_api_key=os.getenv(&quot;QDRANT_API_KEY&quot;) or None,<br>            collection=os.getenv(&quot;QDRANT_COLLECTION&quot;, &quot;medquad_contexts&quot;),<br>            ollama_base_url=os.getenv(&quot;OLLAMA_BASE_URL&quot;, &quot;http://localhost:11434&quot;),<br>            embed_model=os.getenv(&quot;OLLAMA_EMBED_MODEL&quot;, &quot;embeddinggemma-2:latest&quot;),<br>            sparse_model=os.getenv(&quot;SPARSE_MODEL&quot;, &quot;Qdrant/bm25&quot;),<br>            embed_batch_size=int(os.getenv(&quot;EMBED_BATCH_SIZE&quot;, &quot;16&quot;)),<br>            insert_batch_size=int(os.getenv(&quot;INSERT_BATCH_SIZE&quot;, &quot;64&quot;)),<br>            recreate=os.getenv(&quot;RECREATE_COLLECTION&quot;, &quot;false&quot;).lower() == &quot;true&quot;,<br>        )<br><br><br>class ContextIngestor:<br>    def __init__(self, config: IngestConfig) -&gt; None:<br>        self.config = config<br>        self.client = QdrantClient(url=config.qdrant_url, api_key=config.qdrant_api_key)<br>        self.embed_model = OllamaEmbedding(<br>            model_name=config.embed_model,<br>            base_url=config.ollama_base_url,<br>            embed_batch_size=config.embed_batch_size,<br>        )<br>        self.vector_store = QdrantVectorStore(<br>            client=self.client,<br>            collection_name=config.collection,<br>            enable_hybrid=True,<br>            fastembed_sparse_model=config.sparse_model,<br>            batch_size=config.insert_batch_size,<br>        )<br><br>    @staticmethod<br>    def node_id(context_id: str) -&gt; str:<br>        return str(uuid.uuid5(NODE_NAMESPACE, context_id))<br><br>    def load_nodes(self) -&gt; list[TextNode]:<br>        dataset = load_dataset(self.config.dataset, split=self.config.split)<br>        seen: set[str] = set()<br>        nodes: list[TextNode] = []<br>        for row in dataset:<br>            context = (row.get(&quot;context&quot;) or &quot;&quot;).strip()<br>            if not context:<br>                continue<br>            cid = TestsetBuilder.context_id(context)<br>            if cid in seen:<br>                continue<br>            seen.add(cid)<br>            nodes.append(<br>                TextNode(<br>                    id_=self.node_id(cid),<br>                    text=context,<br>                    metadata={&quot;context_id&quot;: cid, &quot;source&quot;: self.config.dataset},<br>                    excluded_embed_metadata_keys=[&quot;context_id&quot;, &quot;source&quot;],<br>                    excluded_llm_metadata_keys=[&quot;source&quot;],<br>                )<br>            )<br>        return nodes<br><br>    def prepare_collection(self) -&gt; None:<br>        if self.config.recreate and self.client.collection_exists(self.config.collection):<br>            self.client.delete_collection(self.config.collection)<br><br>    def existing_count(self) -&gt; int:<br>        if not self.client.collection_exists(self.config.collection):<br>            return 0<br>        return self.client.count(self.config.collection, exact=True).count<br><br>    def ingest(self, nodes: list[TextNode]) -&gt; None:<br>        storage_context = StorageContext.from_defaults(vector_store=self.vector_store)<br>        VectorStoreIndex(<br>            nodes=nodes,<br>            storage_context=storage_context,<br>            embed_model=self.embed_model,<br>            insert_batch_size=self.config.insert_batch_size,<br>            show_progress=True,<br>        )<br><br>    def run(self) -&gt; None:<br>        self.prepare_collection()<br>        nodes = self.load_nodes()<br>        before = self.existing_count()<br>        print(f&quot;{len(nodes)} unique contexts | collection &#39;{self.config.collection}&#39; has {before} points&quot;)<br>        self.ingest(nodes)<br>        print(f&quot;done | collection now has {self.existing_count()} points | dense={self.config.embed_model} sparse={self.config.sparse_model}&quot;)<br><br><br>if __name__ == &quot;__main__&quot;:<br>    ContextIngestor(IngestConfig.from_env()).run()</pre><p>rag_index.py — the index as an MLflow model</p><p>This file is a <em>model-from-code</em> definition: MLflow executes it both when logging and when loading the model, so the whole RAG configuration lives in one reproducible place.</p><p><strong>IndexConfig.</strong> The same Qdrant/Ollama settings as ingestion, plus the generator LLM, temperature, timeout and an optional thinking level. thinking is None when blank, because Ollama rejects the option outright for models that have no thinking mode.</p><p><strong>IndexFactory.configure_settings.</strong> The global LlamaIndex Settings are set to the Ollama embedding model and Ollama LLM. MLflow serialises Settings with the model, so the logged artifact records exactly which models answered the questions.</p><p><strong>IndexFactory.build.</strong> Rather than re-indexing, the index is opened from the existing hybrid Qdrant collection with from_vector_store. The last two lines build it and call mlflow.models.set_model, which is how MLflow knows which object this file produces.</p><pre>from __future__ import annotations<br><br>import os<br>from dataclasses import dataclass<br><br>import mlflow<br>from dotenv import load_dotenv<br>from llama_index.core import Settings, VectorStoreIndex<br>from llama_index.embeddings.ollama import OllamaEmbedding<br>from llama_index.llms.ollama import Ollama<br>from llama_index.vector_stores.qdrant import QdrantVectorStore<br>from qdrant_client import QdrantClient<br><br><br>@dataclass(frozen=True)<br>class IndexConfig:<br>    qdrant_url: str<br>    qdrant_api_key: str | None<br>    collection: str<br>    ollama_base_url: str<br>    embed_model: str<br>    llm_model: str<br>    thinking: str | None<br>    temperature: float<br>    request_timeout: float<br>    sparse_model: str<br><br>    @classmethod<br>    def from_env(cls) -&gt; &quot;IndexConfig&quot;:<br>        load_dotenv()<br>        return cls(<br>            qdrant_url=os.getenv(&quot;QDRANT_URL&quot;, &quot;http://localhost:6333&quot;),<br>            qdrant_api_key=os.getenv(&quot;QDRANT_API_KEY&quot;) or None,<br>            collection=os.getenv(&quot;QDRANT_COLLECTION&quot;, &quot;medquad_contexts&quot;),<br>            ollama_base_url=os.getenv(&quot;OLLAMA_BASE_URL&quot;, &quot;http://localhost:11434&quot;),<br>            embed_model=os.getenv(&quot;OLLAMA_EMBED_MODEL&quot;, &quot;embeddinggemma-2:latest&quot;),<br>            llm_model=os.getenv(&quot;OLLAMA_LLM_MODEL&quot;, &quot;granite4.2:latest&quot;),<br>            thinking=os.getenv(&quot;OLLAMA_THINKING&quot;, &quot;&quot;).strip().lower() or None,<br>            temperature=float(os.getenv(&quot;OLLAMA_TEMPERATURE&quot;, &quot;0.0&quot;)),<br>            request_timeout=float(os.getenv(&quot;OLLAMA_REQUEST_TIMEOUT&quot;, &quot;300&quot;)),<br>            sparse_model=os.getenv(&quot;SPARSE_MODEL&quot;, &quot;Qdrant/bm25&quot;),<br>        )<br><br><br>class IndexFactory:<br>    def __init__(self, config: IndexConfig) -&gt; None:<br>        self.config = config<br><br>    def configure_settings(self) -&gt; None:<br>        Settings.embed_model = OllamaEmbedding(model_name=self.config.embed_model, base_url=self.config.ollama_base_url)<br>        llm_kwargs = dict(<br>            model=self.config.llm_model,<br>            base_url=self.config.ollama_base_url,<br>            temperature=self.config.temperature,<br>            request_timeout=self.config.request_timeout,<br>        )<br>        if self.config.thinking:<br>            llm_kwargs[&quot;thinking&quot;] = self.config.thinking<br>        Settings.llm = Ollama(**llm_kwargs)<br><br>    def build(self) -&gt; VectorStoreIndex:<br>        self.configure_settings()<br>        client = QdrantClient(url=self.config.qdrant_url, api_key=self.config.qdrant_api_key)<br>        store = QdrantVectorStore(<br>            client=client,<br>            collection_name=self.config.collection,<br>            enable_hybrid=True,<br>            fastembed_sparse_model=self.config.sparse_model,<br>        )<br>        return VectorStoreIndex.from_vector_store(vector_store=store)<br><br><br>index = IndexFactory(IndexConfig.from_env()).build()<br>mlflow.models.set_model(index)</pre><p>rag_eval.py — answering, judging, analysing</p><p><strong>The two instruction strings.</strong> Both judges receive the question (inputs), the candidate answer (outputs) and the reference answer (expectations). <em>No-contradiction</em> asks whether anything in the candidate conflicts with the reference and explicitly says not to penalise omissions. <em>Coverage</em> asks the opposite question: are the essential points present. Splitting correctness into these two signals matters because MedQuAD references are long, and a concise RAG answer can be perfectly correct yet incomplete.</p><p><strong>EvalConfig.</strong> Tracking URI, experiment name, retrieval depths, the Jev endpoint (addressed as gateway:/&lt;endpoint&gt; so the TypeSafe key stays in the MLflow AI Gateway), the Ollama text judge (ollama:/&lt;model&gt;), the routing band, and an optional EVAL_LIMIT for smoke runs.</p><p><strong>HybridRag.</strong> Wraps the index as a hybrid query engine (dense top-k plus sparse top-k, fused by Qdrant). answer returns a dict rather than a string: the answer text, the ordered list of retrieved context ids, and their scores. Returning the ids is what lets us measure retrieval separately from generation.</p><p><strong>Judges.build.</strong> Four make_judge scorers from two instruction strings and two models, so Jev and the text judge see <em>identical</em> prompts. Two code scorers sit beside them: retrieval_hit (was the gold context among the retrieved ones) and retrieval_rank (at which position, 0 for a miss). If a judge fails an answer, these tell you whether the retriever or the generator is to blame.</p><p><strong>Testset.</strong> Loads the JSONL and maps each row to MLflow’s shape: the question goes into inputs, the reference answer and gold context id into expectations. The reference is visible to judges but never to the RAG.</p><p><strong>Analyzer.</strong> After evaluation, every trace of the run is pulled back from MLflow. For each assessment we keep the verdict, Jev’s typesafe.probability from the feedback metadata, and the text judge’s rationale. Then, per criterion, three metrics: raw Jev-vs-text agreement, the fraction of Jev verdicts inside the uncertain band, and agreement after routing banded cases to the text judge. This reproduces the “accept when confident, escalate when unsure” experiment from the MLflow post, with the text judge standing in for human labels. Per-example rows are written to JSONL for the report.</p><p><strong>RagEvaluation.run.</strong> One MLflow run wraps everything: parameters are logged, the index is logged from rag_index.py as a query engine, mlflow.genai.evaluate drives predict_fn across the test set with all six scorers and links the results to the logged model, and the Analyzer’s metrics are logged on top. The run page in MLflow then holds the data, the model, the traces and every judgment together.</p><pre>from __future__ import annotations<br><br>import json<br>import os<br>from dataclasses import dataclass<br>from pathlib import Path<br><br>import mlflow<br>from dotenv import load_dotenv<br>from llama_index.core import VectorStoreIndex<br>from mlflow.genai import scorer<br>from mlflow.genai.judges import make_judge<br><br>from rag_index import index as rag_index<br><br>NO_CONTRADICTION_INSTRUCTIONS = (<br>    &quot;The user asked the question in {{ inputs }}. A trusted reference answer is in {{ expectations }} &quot;<br>    &quot;under &#39;expected_response&#39;. The candidate answer is the &#39;answer&#39; field in {{ outputs }}. &quot;<br>    &quot;Is the candidate answer free of any claim that contradicts or is materially inconsistent with the reference answer? &quot;<br>    &quot;Answer yes if every claim in the candidate is consistent with the reference. &quot;<br>    &quot;Answer no if at least one claim is contradicted by the reference or would mislead the user. &quot;<br>    &quot;Do not penalize omissions, brevity, or stylistic differences.&quot;<br>)<br><br>COVERAGE_INSTRUCTIONS = (<br>    &quot;The user asked the question in {{ inputs }}. A trusted reference answer is in {{ expectations }} &quot;<br>    &quot;under &#39;expected_response&#39;. The candidate answer is the &#39;answer&#39; field in {{ outputs }}. &quot;<br>    &quot;Does the candidate answer cover the key points of the reference answer needed to answer the question? &quot;<br>    &quot;Answer yes if the essential facts are present in substance, even if worded differently or more briefly. &quot;<br>    &quot;Answer no if an essential point is missing or replaced by unrelated content.&quot;<br>)<br><br><br>@dataclass(frozen=True)<br>class EvalConfig:<br>    tracking_uri: str<br>    experiment: str<br>    testset_path: Path<br>    results_path: Path<br>    top_k: int<br>    sparse_top_k: int<br>    jev_model: str<br>    llm_judge_model: str<br>    band_low: float<br>    band_high: float<br>    limit: int | None<br><br>    @classmethod<br>    def from_env(cls) -&gt; &quot;EvalConfig&quot;:<br>        load_dotenv()<br>        limit = os.getenv(&quot;EVAL_LIMIT&quot;)<br>        return cls(<br>            tracking_uri=os.getenv(&quot;MLFLOW_TRACKING_URI&quot;, &quot;http://127.0.0.1:5000&quot;),<br>            experiment=os.getenv(&quot;MLFLOW_EXPERIMENT&quot;, &quot;jev-rag-medquad&quot;),<br>            testset_path=Path(os.getenv(&quot;TESTSET_PATH&quot;, &quot;data/testset.jsonl&quot;)),<br>            results_path=Path(os.getenv(&quot;RESULTS_PATH&quot;, &quot;data/eval_results.jsonl&quot;)),<br>            top_k=int(os.getenv(&quot;TOP_K&quot;, &quot;5&quot;)),<br>            sparse_top_k=int(os.getenv(&quot;SPARSE_TOP_K&quot;, &quot;20&quot;)),<br>            jev_model=f&quot;gateway:/{os.getenv(&#39;JEV_GATEWAY_ENDPOINT&#39;, &#39;jev-decision-model&#39;)}&quot;,<br>            llm_judge_model=f&quot;ollama:/{os.getenv(&#39;OLLAMA_JUDGE_MODEL&#39;, &#39;granite4.2:latest&#39;)}&quot;,<br>            band_low=float(os.getenv(&quot;JEV_BAND_LOW&quot;, &quot;0.2&quot;)),<br>            band_high=float(os.getenv(&quot;JEV_BAND_HIGH&quot;, &quot;0.8&quot;)),<br>            limit=int(limit) if limit else None,<br>        )<br><br><br>class HybridRag:<br>    def __init__(self, index: VectorStoreIndex, top_k: int, sparse_top_k: int) -&gt; None:<br>        self.engine = index.as_query_engine(<br>            vector_store_query_mode=&quot;hybrid&quot;,<br>            similarity_top_k=top_k,<br>            sparse_top_k=sparse_top_k,<br>        )<br><br>    def answer(self, question: str) -&gt; dict:<br>        response = self.engine.query(question)<br>        sources = [<br>            {&quot;context_id&quot;: n.node.metadata.get(&quot;context_id&quot;), &quot;score&quot;: round(float(n.score or 0.0), 4)}<br>            for n in response.source_nodes<br>        ]<br>        return {<br>            &quot;answer&quot;: str(response).strip(),<br>            &quot;context_ids&quot;: [s[&quot;context_id&quot;] for s in sources],<br>            &quot;sources&quot;: sources,<br>        }<br><br><br>class Judges:<br>    def __init__(self, jev_model: str, llm_model: str) -&gt; None:<br>        self.jev_model = jev_model<br>        self.llm_model = llm_model<br><br>    def build(self) -&gt; list:<br>        return [<br>            make_judge(name=&quot;jev_no_contradiction&quot;, model=self.jev_model, instructions=NO_CONTRADICTION_INSTRUCTIONS, feedback_value_type=bool),<br>            make_judge(name=&quot;jev_coverage&quot;, model=self.jev_model, instructions=COVERAGE_INSTRUCTIONS, feedback_value_type=bool),<br>            make_judge(name=&quot;llm_no_contradiction&quot;, model=self.llm_model, instructions=NO_CONTRADICTION_INSTRUCTIONS, feedback_value_type=bool),<br>            make_judge(name=&quot;llm_coverage&quot;, model=self.llm_model, instructions=COVERAGE_INSTRUCTIONS, feedback_value_type=bool),<br>            self.retrieval_hit,<br>            self.retrieval_rank,<br>        ]<br><br>    @staticmethod<br>    @scorer<br>    def retrieval_hit(outputs: dict, expectations: dict) -&gt; bool:<br>        return expectations[&quot;expected_context_id&quot;] in outputs[&quot;context_ids&quot;]<br><br>    @staticmethod<br>    @scorer<br>    def retrieval_rank(outputs: dict, expectations: dict) -&gt; int:<br>        ids = outputs[&quot;context_ids&quot;]<br>        cid = expectations[&quot;expected_context_id&quot;]<br>        return ids.index(cid) + 1 if cid in ids else 0<br><br><br>class Testset:<br>    def __init__(self, path: Path, limit: int | None) -&gt; None:<br>        self.path = path<br>        self.limit = limit<br><br>    def load(self) -&gt; list[dict]:<br>        rows = [json.loads(line) for line in self.path.open(encoding=&quot;utf-8&quot;)]<br>        return rows[: self.limit] if self.limit else rows<br><br>    @staticmethod<br>    def to_eval_data(rows: list[dict]) -&gt; list[dict]:<br>        return [<br>            {<br>                &quot;inputs&quot;: {&quot;question&quot;: r[&quot;question&quot;]},<br>                &quot;expectations&quot;: {&quot;expected_response&quot;: r[&quot;answer&quot;], &quot;expected_context_id&quot;: r[&quot;context_id&quot;]},<br>            }<br>            for r in rows<br>        ]<br><br><br>class Analyzer:<br>    PAIRS = ((&quot;jev_no_contradiction&quot;, &quot;llm_no_contradiction&quot;), (&quot;jev_coverage&quot;, &quot;llm_coverage&quot;))<br><br>    def __init__(self, config: EvalConfig, rows: list[dict]) -&gt; None:<br>        self.config = config<br>        self.by_question = {r[&quot;question&quot;]: r[&quot;id&quot;] for r in rows}<br><br>    @staticmethod<br>    def _question(trace) -&gt; str:<br>        try:<br>            return json.loads(trace.data.request)[&quot;question&quot;]<br>        except Exception:<br>            return str(trace.data.request)<br><br>    def _record(self, trace) -&gt; dict:<br>        rec = {&quot;id&quot;: self.by_question.get(self._question(trace)), &quot;trace_id&quot;: trace.info.trace_id}<br>        for a in trace.info.assessments:<br>            if not hasattr(a, &quot;value&quot;) or a.name not in {n for pair in self.PAIRS for n in pair} | {&quot;retrieval_hit&quot;, &quot;retrieval_rank&quot;}:<br>                continue<br>            rec[a.name] = a.value<br>            if a.metadata and &quot;typesafe.probability&quot; in a.metadata:<br>                rec[f&quot;{a.name}_prob&quot;] = float(a.metadata[&quot;typesafe.probability&quot;])<br>            if getattr(a, &quot;rationale&quot;, None):<br>                rec[f&quot;{a.name}_rationale&quot;] = a.rationale<br>        return rec<br><br>    def run(self, run_id: str) -&gt; dict:<br>        traces = mlflow.search_traces(run_id=run_id, return_type=&quot;list&quot;, max_results=1000)<br>        records = [self._record(t) for t in traces]<br>        metrics: dict[str, float] = {}<br>        for jev, llm in self.PAIRS:<br>            scored = [r for r in records if jev in r and llm in r]<br>            if not scored:<br>                continue<br>            agree = sum(r[jev] == r[llm] for r in scored)<br>            band = [r for r in scored if f&quot;{jev}_prob&quot; in r and self.config.band_low &lt;= r[f&quot;{jev}_prob&quot;] &lt;= self.config.band_high]<br>            routed_agree = sum((r[llm] if r in band else r[jev]) == r[llm] for r in scored)<br>            metrics[f&quot;{jev}/agreement_with_llm&quot;] = agree / len(scored)<br>            metrics[f&quot;{jev}/uncertain_fraction&quot;] = len(band) / len(scored)<br>            metrics[f&quot;{jev}/agreement_after_routing&quot;] = routed_agree / len(scored)<br>        self.config.results_path.parent.mkdir(parents=True, exist_ok=True)<br>        with self.config.results_path.open(&quot;w&quot;, encoding=&quot;utf-8&quot;) as fh:<br>            for r in sorted(records, key=lambda x: x[&quot;id&quot;] or &quot;&quot;):<br>                fh.write(json.dumps(r, ensure_ascii=False) + &quot;\n&quot;)<br>        return metrics<br><br><br>class RagEvaluation:<br>    def __init__(self, config: EvalConfig) -&gt; None:<br>        self.config = config<br><br>    def run(self) -&gt; None:<br>        mlflow.set_tracking_uri(self.config.tracking_uri)<br>        mlflow.set_experiment(self.config.experiment)<br>        mlflow.llama_index.autolog()<br><br>        rows = Testset(self.config.testset_path, self.config.limit).load()<br>        rag = HybridRag(rag_index, self.config.top_k, self.config.sparse_top_k)<br><br>        def predict_fn(question: str) -&gt; dict:<br>            return rag.answer(question)<br><br>        with mlflow.start_run(run_name=&quot;hybrid-rag-jev-eval&quot;) as run:<br>            mlflow.log_params({&quot;top_k&quot;: self.config.top_k, &quot;sparse_top_k&quot;: self.config.sparse_top_k, &quot;n_examples&quot;: len(rows)})<br>            model_info = mlflow.llama_index.log_model(&quot;rag_index.py&quot;, name=&quot;rag_index&quot;, engine_type=&quot;query&quot;, input_example=rows[0][&quot;question&quot;])<br>            result = mlflow.genai.evaluate(<br>                data=Testset.to_eval_data(rows),<br>                predict_fn=predict_fn,<br>                scorers=Judges(self.config.jev_model, self.config.llm_judge_model).build(),<br>                model_id=model_info.model_id,<br>            )<br>            extra = Analyzer(self.config, rows).run(run.info.run_id)<br>            mlflow.log_metrics(extra)<br><br>        print(json.dumps({**result.metrics, **extra}, indent=2))<br>        print(f&quot;per-example results -&gt; {self.config.results_path}&quot;)<br><br><br>if __name__ == &quot;__main__&quot;:<br>    RagEvaluation(EvalConfig.from_env()).run()</pre><p>eval_report_generator.py — the HTML report</p><p><strong>TEMPLATE.</strong> A complete, self-contained page (styles, markup, a small script) with a handful of __PLACEHOLDER__ tokens. Keeping the template inside the script means the report tool is one file with one dependency.</p><p><strong>FIELDS.</strong> The whitelist of columns pulled from each JSONL row: ids, retrieval metrics, the four verdicts, the two Jev probabilities and the two rationales. Anything else in the row is dropped, so the embedded payload stays small and the page’s expectations are explicit.</p><p><strong>ReportConfig.from_args.</strong> The input path is the one positional argument; output defaults to the same name with .html. Title, eyebrow and band edges are flags with .env fallbacks, so a bare python eval_report_generator.py after each evaluation regenerates the report with the run’s own settings.</p><p><strong>EvalReport.load / intro / render.</strong> load reads and trims the rows; intro writes the header paragraph from the data (trace count, scored count, band) instead of hard-coding run facts; render embeds the rows as JSON and fills the placeholders. The only subtlety is escaping &lt;/ inside the embedded JSON so a rationale can never close the script tag early.</p><p><strong>The page itself.</strong> All analytics are computed in the browser from the embedded rows: scored vs unscored counts, Jev pass rates, retrieval rank distribution, agreement matrices, routing-band effects, probability strips and a filterable per-trace ledger with rationales. That means the Python side stays trivial and the same report logic applies to any run size.</p><pre>from __future__ import annotations<br><br>import argparse<br>import json<br>import os<br>from dataclasses import dataclass<br>from pathlib import Path<br><br>from dotenv import load_dotenv<br><br>TEMPLATE = r&quot;&quot;&quot;&lt;meta charset=&quot;utf-8&quot;&gt;<br>&lt;title&gt;__TITLE__&lt;/title&gt;<br>&lt;link rel=&quot;stylesheet&quot; href=&quot;https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&amp;family=IBM+Plex+Mono:wght@400;500&amp;display=swap&quot;&gt;<br>&lt;style&gt;<br>/* Layout: summary tiles → judge-probability strips → agreement matrices → per-trace ledger. Clinical slate + cobalt, monospace for numbers. */<br>:root{<br>  --bg:#f3f5f8; --surface:#ffffff; --fg:#17202b; --muted:#5b6878; --line:#d7dde6;<br>  --accent:#1f5fd6; --accent-soft:#dde8fb;<br>  --good:#1f8a5b; --good-soft:#dcf3e8; --bad:#c43d3d; --bad-soft:#f9dede; --warn:#b7791f; --warn-soft:#fbeccf; --none:#8a94a3; --none-soft:#e8ebf0;<br>  --sans:&quot;IBM Plex Sans&quot;,system-ui,-apple-system,&quot;Segoe UI&quot;,sans-serif;<br>  --mono:&quot;IBM Plex Mono&quot;,ui-monospace,SFMono-Regular,Menlo,monospace;<br>}<br>@media (prefers-color-scheme: dark){:root:not([data-theme=&quot;light&quot;]){<br>  --bg:#0f141b; --surface:#171e27; --fg:#e6ebf2; --muted:#98a4b5; --line:#2a3441;<br>  --accent:#6f9cf5; --accent-soft:#1c2a45;<br>  --good:#4cc08a; --good-soft:#163526; --bad:#ef7676; --bad-soft:#3f1d1d; --warn:#e0a94a; --warn-soft:#3b2d12; --none:#7d8795; --none-soft:#232b36; color-scheme:dark}}<br>:root[data-theme=&quot;dark&quot;]{<br>  --bg:#0f141b; --surface:#171e27; --fg:#e6ebf2; --muted:#98a4b5; --line:#2a3441;<br>  --accent:#6f9cf5; --accent-soft:#1c2a45;<br>  --good:#4cc08a; --good-soft:#163526; --bad:#ef7676; --bad-soft:#3f1d1d; --warn:#e0a94a; --warn-soft:#3b2d12; --none:#7d8795; --none-soft:#232b36; color-scheme:dark}<br>*{box-sizing:border-box}<br>body{background:var(--bg);color:var(--fg);font-family:var(--sans);font-size:15px;line-height:1.5;padding-inline:clamp(16px,4vw,40px);padding-block:28px 56px}<br>h1,h2,h3{margin:0;text-wrap:balance;font-weight:600}<br>h1{font-size:clamp(1.5rem,3vw,2rem);letter-spacing:-.01em}<br>h2{font-size:1.05rem}<br>.eyebrow{font-family:var(--mono);font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}<br>.wrap{max-width:1180px;margin:0 auto;display:grid;gap:28px}<br>header{display:grid;gap:6px}<br>header p{margin:0;color:var(--muted);max-width:70ch}<br>.num{font-family:var(--mono);font-variant-numeric:tabular-nums}<br>.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px}<br>.tile{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:14px 16px;display:grid;gap:4px;min-width:0}<br>.tile .v{font-family:var(--mono);font-size:1.7rem;line-height:1.1;font-variant-numeric:tabular-nums}<br>.tile .s{font-size:.8rem;color:var(--muted)}<br>.tile.good .v{color:var(--good)} .tile.bad .v{color:var(--bad)} .tile.warn .v{color:var(--warn)} .tile.accent .v{color:var(--accent)}<br>.note{border-left:3px solid var(--warn);background:var(--warn-soft);padding:12px 16px;border-radius:0 8px 8px 0;display:grid;gap:4px}<br>.note p{margin:0}<br>section{display:grid;gap:12px}<br>.card{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:16px;min-width:0}<br>.two{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}<br>.strip{display:grid;gap:10px}<br>.strip svg{width:100%;height:auto;display:block}<br>.legend{display:flex;flex-wrap:wrap;gap:14px;font-size:.8rem;color:var(--muted)}<br>.legend span::before{content:&quot;&quot;;display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px;vertical-align:-1px;background:var(--c)}<br>.cm{display:grid;grid-template-columns:auto 1fr 1fr;gap:6px;align-items:stretch;font-size:.85rem}<br>.cm .h{color:var(--muted);font-size:.75rem;text-align:center;align-self:end;padding-bottom:4px}<br>.cm .r{color:var(--muted);font-size:.75rem;display:flex;align-items:center;justify-content:flex-end;padding-right:6px}<br>.cm .c{border-radius:8px;padding:14px 10px;text-align:center;font-family:var(--mono);font-size:1.4rem;background:var(--none-soft)}<br>.cm .c small{display:block;font-family:var(--sans);font-size:.72rem;color:var(--muted)}<br>.cm .c.agree{background:var(--good-soft);color:var(--good)} .cm .c.dis{background:var(--bad-soft);color:var(--bad)}<br>.bars{display:grid;gap:8px;font-size:.85rem}<br>.bar{display:grid;grid-template-columns:110px 1fr 44px;align-items:center;gap:10px}<br>.bar .t{height:14px;border-radius:4px;background:var(--accent);min-width:2px}<br>.bar .t.miss{background:var(--bad)} .bar .t.none{background:var(--none)}<br>.bar .lab{color:var(--muted)}<br>.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center}<br>.toolbar button{font:inherit;font-size:.82rem;padding:6px 12px;border-radius:999px;border:1px solid var(--line);background:var(--surface);color:var(--fg);cursor:pointer}<br>.toolbar button[aria-pressed=&quot;true&quot;]{background:var(--accent);border-color:var(--accent);color:#fff}<br>.toolbar button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}<br>.tablewrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px;background:var(--surface)}<br>table{border-collapse:collapse;width:100%;font-size:.85rem;min-width:720px}<br>th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}<br>th{font-size:.72rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--surface)}<br>tr:last-child td{border-bottom:0}<br>.pill{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border-radius:999px;font-size:.75rem;font-weight:500;white-space:nowrap}<br>.pill.t{background:var(--good-soft);color:var(--good)} .pill.f{background:var(--bad-soft);color:var(--bad)} .pill.n{background:var(--none-soft);color:var(--none)}<br>.pill .p{font-family:var(--mono);font-weight:400;opacity:.85}<br>.pill.band{box-shadow:inset 0 0 0 1.5px var(--warn)}<br>td.tid{font-family:var(--mono);font-size:.78rem;color:var(--muted)}<br>tr.dis td{background:color-mix(in srgb,var(--bad-soft) 35%,transparent)}<br>details{margin-top:6px}<br>details summary{cursor:pointer;color:var(--accent);font-size:.78rem}<br>details div{margin-top:6px;display:grid;gap:6px;font-size:.8rem;color:var(--muted);max-width:70ch}<br>details b{color:var(--fg);font-weight:500}<br>.foot{color:var(--muted);font-size:.8rem}<br>@media (prefers-reduced-motion:no-preference){.tile,.card{transition:border-color .15s}}<br>&lt;/style&gt;<br><br>&lt;div class=&quot;wrap&quot;&gt;<br>&lt;header&gt;<br>  &lt;div class=&quot;eyebrow&quot;&gt;__EYEBROW__&lt;/div&gt;<br>  &lt;h1&gt;__TITLE__&lt;/h1&gt;<br>  &lt;p&gt;__INTRO__&lt;/p&gt;<br>&lt;/header&gt;<br><br>&lt;div class=&quot;tiles&quot; id=&quot;tiles&quot;&gt;&lt;/div&gt;<br><br>&lt;div class=&quot;note&quot; id=&quot;dq&quot;&gt;&lt;/div&gt;<br><br>&lt;section&gt;<br>  &lt;h2&gt;Jev decision probabilities&lt;/h2&gt;<br>  &lt;div class=&quot;card strip&quot;&gt;<br>    &lt;div class=&quot;legend&quot;&gt;&lt;span style=&quot;--c:var(--good)&quot;&gt;Jev and text judge agree&lt;/span&gt;&lt;span style=&quot;--c:var(--bad)&quot;&gt;disagree&lt;/span&gt;&lt;span style=&quot;--c:var(--none)&quot;&gt;text judge missing&lt;/span&gt;&lt;span style=&quot;--c:var(--warn)&quot;&gt;__BAND_LOW__-__BAND_HIGH__ routing band&lt;/span&gt;&lt;/div&gt;<br>    &lt;div id=&quot;strips&quot;&gt;&lt;/div&gt;<br>  &lt;/div&gt;<br>&lt;/section&gt;<br><br>&lt;section&gt;<br>  &lt;h2&gt;Agreement with the text judge&lt;/h2&gt;<br>  &lt;div class=&quot;two&quot; id=&quot;cms&quot;&gt;&lt;/div&gt;<br>&lt;/section&gt;<br><br>&lt;section&gt;<br>  &lt;h2&gt;Retrieval&lt;/h2&gt;<br>  &lt;div class=&quot;two&quot;&gt;<br>    &lt;div class=&quot;card&quot;&gt;&lt;div class=&quot;eyebrow&quot; style=&quot;margin-bottom:10px&quot;&gt;Rank of the gold context among top-5&lt;/div&gt;&lt;div class=&quot;bars&quot; id=&quot;ranks&quot;&gt;&lt;/div&gt;&lt;/div&gt;<br>    &lt;div class=&quot;card&quot;&gt;&lt;div class=&quot;eyebrow&quot; style=&quot;margin-bottom:10px&quot;&gt;Judge pass rates (scored traces only)&lt;/div&gt;&lt;div class=&quot;bars&quot; id=&quot;passrates&quot;&gt;&lt;/div&gt;&lt;/div&gt;<br>  &lt;/div&gt;<br>&lt;/section&gt;<br><br>&lt;section&gt;<br>  &lt;h2&gt;Per-trace ledger&lt;/h2&gt;<br>  &lt;div class=&quot;toolbar&quot; id=&quot;filters&quot;&gt;&lt;/div&gt;<br>  &lt;div class=&quot;tablewrap&quot;&gt;&lt;table&gt;<br>    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Trace&lt;/th&gt;&lt;th&gt;Gold rank&lt;/th&gt;&lt;th&gt;No contradiction · Jev&lt;/th&gt;&lt;th&gt;No contradiction · text&lt;/th&gt;&lt;th&gt;Coverage · Jev&lt;/th&gt;&lt;th&gt;Coverage · text&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;<br>    &lt;tbody id=&quot;rows&quot;&gt;&lt;/tbody&gt;<br>  &lt;/table&gt;&lt;/div&gt;<br>  &lt;p class=&quot;foot&quot;&gt;Rationales come from the text judge; Jev emits only the probability. Highlighted rows are Jev–text disagreements.&lt;/p&gt;<br>&lt;/section&gt;<br>&lt;/div&gt;<br><br>&lt;script id=&quot;data&quot; type=&quot;application/json&quot;&gt;__DATA__&lt;/script&gt;<br>&lt;script&gt;<br>const rows = JSON.parse(document.getElementById(&#39;data&#39;).textContent);<br>const BAND=[__BAND_LOW__,__BAND_HIGH__];<br>const scored = rows.filter(r=&gt;r.jev_no_contradiction!==null);<br>const cnt=(arr,f)=&gt;arr.filter(f).length;<br>const pct=(a,b)=&gt;b?Math.round(100*a/b)+&#39;%&#39;:&#39;–&#39;;<br>const pairs=[[&quot;jev_no_contradiction&quot;,&quot;llm_no_contradiction&quot;,&quot;No contradiction&quot;],[&quot;jev_coverage&quot;,&quot;llm_coverage&quot;,&quot;Coverage&quot;]];<br><br>function agreement(j,l){<br>  const both=rows.filter(r=&gt;r[j]!==null&amp;&amp;r[l]!==null);<br>  const agree=cnt(both,r=&gt;r[j]===r[l]);<br>  const band=both.filter(r=&gt;r[j+&#39;_prob&#39;]&gt;=BAND[0]&amp;&amp;r[j+&#39;_prob&#39;]&lt;=BAND[1]);<br>  const routed=cnt(both,r=&gt;(band.includes(r)?r[l]:r[j])===r[l]);<br>  return {both,agree,band,routed,tt:cnt(both,r=&gt;r[j]&amp;&amp;r[l]),tf:cnt(both,r=&gt;r[j]&amp;&amp;!r[l]),ft:cnt(both,r=&gt;!r[j]&amp;&amp;r[l]),ff:cnt(both,r=&gt;!r[j]&amp;&amp;!r[l])};<br>}<br>const A={}; pairs.forEach(([j,l])=&gt;A[j]=agreement(j,l));<br><br>const tiles=[<br>  [&#39;Scored traces&#39;,`${scored.length}&lt;span style=&quot;font-size:1rem;color:var(--muted)&quot;&gt;/${rows.length}&lt;/span&gt;`,&#39;with any judge output&#39;,&#39;warn&#39;],<br>  [&#39;Jev · no contradiction&#39;,pct(cnt(scored,r=&gt;r.jev_no_contradiction),scored.length),`${cnt(scored,r=&gt;r.jev_no_contradiction)} of ${scored.length} pass`,&#39;good&#39;],<br>  [&#39;Jev · coverage&#39;,pct(cnt(scored,r=&gt;r.jev_coverage),scored.length),`${cnt(scored,r=&gt;r.jev_coverage)} of ${scored.length} pass`,&#39;accent&#39;],<br>  [&#39;Gold context retrieved&#39;,pct(cnt(scored,r=&gt;r.retrieval_hit),scored.length),`${cnt(scored,r=&gt;r.retrieval_rank===1)} at rank 1`,&#39;good&#39;],<br>  [&#39;Agreement · contradiction&#39;,pct(A.jev_no_contradiction.agree,A.jev_no_contradiction.both.length),`${A.jev_no_contradiction.agree}/${A.jev_no_contradiction.both.length} · ${A.jev_no_contradiction.routed}/${A.jev_no_contradiction.both.length} after routing`,&#39;accent&#39;],<br>  [&#39;Agreement · coverage&#39;,pct(A.jev_coverage.agree,A.jev_coverage.both.length),`${A.jev_coverage.agree}/${A.jev_coverage.both.length} · ${A.jev_coverage.routed}/${A.jev_coverage.both.length} after routing`,&#39;accent&#39;],<br>];<br>document.getElementById(&#39;tiles&#39;).innerHTML=tiles.map(([k,v,s,c])=&gt;`&lt;div class=&quot;tile ${c}&quot;&gt;&lt;div class=&quot;eyebrow&quot;&gt;${k}&lt;/div&gt;&lt;div class=&quot;v&quot;&gt;${v}&lt;/div&gt;&lt;div class=&quot;s&quot;&gt;${s}&lt;/div&gt;&lt;/div&gt;`).join(&#39;&#39;);<br><br>const unscored=rows.length-scored.length, noLLM=cnt(scored,r=&gt;r.llm_no_contradiction===null||r.llm_coverage===null);<br>document.getElementById(&#39;dq&#39;).innerHTML=`&lt;div class=&quot;eyebrow&quot; style=&quot;color:var(--warn)&quot;&gt;Data quality&lt;/div&gt;<br>&lt;p&gt;&lt;b&gt;${unscored} of ${rows.length} traces carry no scores at all&lt;/b&gt; (no judge or retrieval value), so every rate on this page is computed over the ${scored.length} scored traces, not 50. On ${noLLM} scored traces the gemma3 text judge returned nothing for at least one criterion, which shrinks the agreement sample to ${A.jev_no_contradiction.both.length} and ${A.jev_coverage.both.length}. Every record also has a null &lt;span class=&quot;num&quot;&gt;id&lt;/span&gt;: the question-to-id mapping in the analyzer did not resolve, so rows are keyed by trace id.&lt;/p&gt;`;<br><br>function strip(j,l,label){<br>  const W=900,H=96,L=70,R=30,y=52;<br>  const x=p=&gt;L+(W-L-R)*p;<br>  const pts=rows.filter(r=&gt;r[j+&#39;_prob&#39;]!==undefined&amp;&amp;r[j+&#39;_prob&#39;]!==null).map(r=&gt;({p:r[j+&#39;_prob&#39;],c:r[l]===null?&#39;var(--none)&#39;:(r[j]===r[l]?&#39;var(--good)&#39;:&#39;var(--bad)&#39;),r}));<br>  const dots=pts.map(d=&gt;`&lt;circle cx=&quot;${x(d.p).toFixed(1)}&quot; cy=&quot;${y}&quot; r=&quot;7&quot; fill=&quot;${d.c}&quot; fill-opacity=&quot;.75&quot; stroke=&quot;var(--surface)&quot; stroke-width=&quot;1.5&quot;&gt;&lt;title&gt;${d.r.trace_id.slice(3,11)} · p=${d.p}&lt;/title&gt;&lt;/circle&gt;`).join(&#39;&#39;);<br>  const ticks=[0,.25,.5,.75,1].map(t=&gt;`&lt;line x1=&quot;${x(t)}&quot; x2=&quot;${x(t)}&quot; y1=&quot;${y-16}&quot; y2=&quot;${y+16}&quot; stroke=&quot;var(--line)&quot;/&gt;&lt;text x=&quot;${x(t)}&quot; y=&quot;${y+34}&quot; text-anchor=&quot;middle&quot; font-size=&quot;11&quot; fill=&quot;var(--muted)&quot; font-family=&quot;var(--mono)&quot;&gt;${t}&lt;/text&gt;`).join(&#39;&#39;);<br>  return `&lt;svg viewBox=&quot;0 0 ${W} ${H}&quot; role=&quot;img&quot; aria-label=&quot;${label} probabilities&quot;&gt;<br>   &lt;rect x=&quot;${x(BAND[0])}&quot; y=&quot;${y-22}&quot; width=&quot;${x(BAND[1])-x(BAND[0])}&quot; height=&quot;44&quot; fill=&quot;var(--warn)&quot; fill-opacity=&quot;.14&quot; rx=&quot;4&quot;/&gt;<br>   &lt;line x1=&quot;${x(0)}&quot; x2=&quot;${x(1)}&quot; y1=&quot;${y}&quot; y2=&quot;${y}&quot; stroke=&quot;var(--line)&quot; stroke-width=&quot;2&quot;/&gt;<br>   ${ticks}<br>   &lt;line x1=&quot;${x(.5)}&quot; x2=&quot;${x(.5)}&quot; y1=&quot;${y-24}&quot; y2=&quot;${y+24}&quot; stroke=&quot;var(--fg)&quot; stroke-dasharray=&quot;3 3&quot;/&gt;<br>   &lt;text x=&quot;8&quot; y=&quot;${y+4}&quot; font-size=&quot;12&quot; fill=&quot;var(--fg)&quot; font-weight=&quot;600&quot;&gt;${label}&lt;/text&gt;<br>   &lt;text x=&quot;8&quot; y=&quot;${y+18}&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;n=${pts.length}&lt;/text&gt;<br>   ${dots}&lt;/svg&gt;`;<br>}<br>document.getElementById(&#39;strips&#39;).innerHTML=pairs.map(([j,l,n])=&gt;strip(j,l,n)).join(&#39;&#39;);<br><br>document.getElementById(&#39;cms&#39;).innerHTML=pairs.map(([j,l,n])=&gt;{const a=A[j];return `&lt;div class=&quot;card&quot;&gt;&lt;div class=&quot;eyebrow&quot; style=&quot;margin-bottom:10px&quot;&gt;${n} · ${a.both.length} traces with both verdicts · ${a.band.length} in routing band&lt;/div&gt;<br>&lt;div class=&quot;cm&quot;&gt;&lt;div&gt;&lt;/div&gt;&lt;div class=&quot;h&quot;&gt;text judge: pass&lt;/div&gt;&lt;div class=&quot;h&quot;&gt;text judge: fail&lt;/div&gt;<br>&lt;div class=&quot;r&quot;&gt;Jev: pass&lt;/div&gt;&lt;div class=&quot;c agree&quot;&gt;${a.tt}&lt;small&gt;both pass&lt;/small&gt;&lt;/div&gt;&lt;div class=&quot;c dis&quot;&gt;${a.tf}&lt;small&gt;Jev accepts, text rejects&lt;/small&gt;&lt;/div&gt;<br>&lt;div class=&quot;r&quot;&gt;Jev: fail&lt;/div&gt;&lt;div class=&quot;c dis&quot;&gt;${a.ft}&lt;small&gt;Jev rejects, text accepts&lt;/small&gt;&lt;/div&gt;&lt;div class=&quot;c agree&quot;&gt;${a.ff}&lt;small&gt;both fail&lt;/small&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;`}).join(&#39;&#39;);<br><br>const rk=[[&#39;Rank 1&#39;,cnt(rows,r=&gt;r.retrieval_rank===1),&#39;&#39;],[&#39;Rank 2&#39;,cnt(rows,r=&gt;r.retrieval_rank===2),&#39;&#39;],[&#39;Rank 3&#39;,cnt(rows,r=&gt;r.retrieval_rank===3),&#39;&#39;],[&#39;Missed&#39;,cnt(rows,r=&gt;r.retrieval_rank===0),&#39;miss&#39;],[&#39;Unscored&#39;,cnt(rows,r=&gt;r.retrieval_rank===null),&#39;none&#39;]];<br>const mx=Math.max(...rk.map(x=&gt;x[1]));<br>document.getElementById(&#39;ranks&#39;).innerHTML=rk.map(([k,v,c])=&gt;`&lt;div class=&quot;bar&quot;&gt;&lt;span class=&quot;lab&quot;&gt;${k}&lt;/span&gt;&lt;div class=&quot;t ${c}&quot; style=&quot;width:${100*v/mx}%&quot;&gt;&lt;/div&gt;&lt;span class=&quot;num&quot;&gt;${v}&lt;/span&gt;&lt;/div&gt;`).join(&#39;&#39;);<br>const pr=[[&#39;Jev · no contr.&#39;,cnt(scored,r=&gt;r.jev_no_contradiction),scored.length],[&#39;Text · no contr.&#39;,cnt(rows,r=&gt;r.llm_no_contradiction===true),cnt(rows,r=&gt;r.llm_no_contradiction!==null)],[&#39;Jev · coverage&#39;,cnt(scored,r=&gt;r.jev_coverage),scored.length],[&#39;Text · coverage&#39;,cnt(rows,r=&gt;r.llm_coverage===true),cnt(rows,r=&gt;r.llm_coverage!==null)]];<br>document.getElementById(&#39;passrates&#39;).innerHTML=pr.map(([k,v,n])=&gt;`&lt;div class=&quot;bar&quot;&gt;&lt;span class=&quot;lab&quot;&gt;${k}&lt;/span&gt;&lt;div class=&quot;t&quot; style=&quot;width:${n?100*v/n:0}%&quot;&gt;&lt;/div&gt;&lt;span class=&quot;num&quot;&gt;${v}/${n}&lt;/span&gt;&lt;/div&gt;`).join(&#39;&#39;);<br><br>const pill=(v,p)=&gt;{if(v===null)return `&lt;span class=&quot;pill n&quot;&gt;none&lt;/span&gt;`;const band=p!==undefined&amp;&amp;p!==null&amp;&amp;p&gt;=BAND[0]&amp;&amp;p&lt;=BAND[1];return `&lt;span class=&quot;pill ${v?&#39;t&#39;:&#39;f&#39;}${band?&#39; band&#39;:&#39;&#39;}&quot;&gt;${v?&#39;pass&#39;:&#39;fail&#39;}${p!==undefined&amp;&amp;p!==null?`&lt;span class=&quot;p&quot;&gt;${p.toFixed(2)}&lt;/span&gt;`:&#39;&#39;}&lt;/span&gt;`};<br>const isDis=r=&gt;pairs.some(([j,l])=&gt;r[j]!==null&amp;&amp;r[l]!==null&amp;&amp;r[j]!==r[l]);<br>const views={all:r=&gt;true,scored:r=&gt;r.jev_no_contradiction!==null,disagree:isDis,band:r=&gt;pairs.some(([j])=&gt;r[j+&#39;_prob&#39;]&gt;=BAND[0]&amp;&amp;r[j+&#39;_prob&#39;]&lt;=BAND[1]),unscored:r=&gt;r.jev_no_contradiction===null};<br>const labels={all:&#39;All&#39;,scored:&#39;Scored&#39;,disagree:&#39;Disagreements&#39;,band:&#39;In routing band&#39;,unscored:&#39;Unscored&#39;};<br>let view=&#39;scored&#39;;<br>function render(){<br>  document.getElementById(&#39;filters&#39;).innerHTML=Object.keys(views).map(k=&gt;`&lt;button id=&quot;f-${k}&quot; aria-pressed=&quot;${k===view}&quot; data-v=&quot;${k}&quot;&gt;${labels[k]} &lt;span class=&quot;num&quot;&gt;${cnt(rows,views[k])}&lt;/span&gt;&lt;/button&gt;`).join(&#39;&#39;);<br>  document.querySelectorAll(&#39;#filters button&#39;).forEach(b=&gt;b.onclick=()=&gt;{view=b.dataset.v;render();});<br>  const list=rows.filter(views[view]).sort((a,b)=&gt;(a.jev_no_contradiction===null)-(b.jev_no_contradiction===null)||(a.jev_no_contradiction_prob??2)-(b.jev_no_contradiction_prob??2));<br>  document.getElementById(&#39;rows&#39;).innerHTML=list.map(r=&gt;{<br>    const rk=r.retrieval_rank===null?&#39;&lt;span class=&quot;pill n&quot;&gt;none&lt;/span&gt;&#39;:r.retrieval_rank===0?&#39;&lt;span class=&quot;pill f&quot;&gt;miss&lt;/span&gt;&#39;:`&lt;span class=&quot;num&quot;&gt;${r.retrieval_rank}&lt;/span&gt;`;<br>    const rat=(k,t)=&gt;r[k]?`&lt;details&gt;&lt;summary&gt;rationale&lt;/summary&gt;&lt;div&gt;&lt;b&gt;${t}&lt;/b&gt;${r[k]}&lt;/div&gt;&lt;/details&gt;`:&#39;&#39;;<br>    return `&lt;tr class=&quot;${isDis(r)?&#39;dis&#39;:&#39;&#39;}&quot;&gt;&lt;td class=&quot;tid&quot;&gt;${r.trace_id.slice(3,13)}&lt;/td&gt;&lt;td&gt;${rk}&lt;/td&gt;<br>      &lt;td&gt;${pill(r.jev_no_contradiction,r.jev_no_contradiction_prob)}&lt;/td&gt;&lt;td&gt;${pill(r.llm_no_contradiction)}${rat(&#39;llm_no_contradiction_rationale&#39;,&#39;Text judge · no contradiction&#39;)}&lt;/td&gt;<br>      &lt;td&gt;${pill(r.jev_coverage,r.jev_coverage_prob)}&lt;/td&gt;&lt;td&gt;${pill(r.llm_coverage)}${rat(&#39;llm_coverage_rationale&#39;,&#39;Text judge · coverage&#39;)}&lt;/td&gt;&lt;/tr&gt;`}).join(&#39;&#39;)||`&lt;tr&gt;&lt;td colspan=&quot;6&quot; style=&quot;color:var(--muted)&quot;&gt;No traces match this view.&lt;/td&gt;&lt;/tr&gt;`;<br>}<br>render();<br>&lt;/script&gt;<br>&quot;&quot;&quot;<br><br>FIELDS = (<br>    &quot;id&quot;, &quot;trace_id&quot;, &quot;retrieval_hit&quot;, &quot;retrieval_rank&quot;,<br>    &quot;jev_no_contradiction&quot;, &quot;jev_no_contradiction_prob&quot;, &quot;llm_no_contradiction&quot;, &quot;llm_no_contradiction_rationale&quot;,<br>    &quot;jev_coverage&quot;, &quot;jev_coverage_prob&quot;, &quot;llm_coverage&quot;, &quot;llm_coverage_rationale&quot;,<br>)<br><br><br>@dataclass(frozen=True)<br>class ReportConfig:<br>    input_path: Path<br>    output_path: Path<br>    title: str<br>    eyebrow: str<br>    band_low: float<br>    band_high: float<br><br>    @classmethod<br>    def from_args(cls) -&gt; &quot;ReportConfig&quot;:<br>        load_dotenv()<br>        p = argparse.ArgumentParser(description=&quot;Build an HTML evaluation report from an eval_results JSONL file.&quot;)<br>        p.add_argument(&quot;input&quot;, nargs=&quot;?&quot;, default=os.getenv(&quot;RESULTS_PATH&quot;, &quot;data/eval_results.jsonl&quot;))<br>        p.add_argument(&quot;-o&quot;, &quot;--output&quot;)<br>        p.add_argument(&quot;--title&quot;, default=os.getenv(&quot;REPORT_TITLE&quot;, &quot;Jev Judge Evaluation&quot;))<br>        p.add_argument(&quot;--eyebrow&quot;, default=os.getenv(&quot;REPORT_EYEBROW&quot;, f&quot;MLflow · {os.getenv(&#39;MLFLOW_EXPERIMENT&#39;, &#39;experiment&#39;)} · {os.getenv(&#39;OLLAMA_LLM_MODEL&#39;, &#39;LLM&#39;)} over Qdrant hybrid retrieval&quot;))<br>        p.add_argument(&quot;--band-low&quot;, type=float, default=float(os.getenv(&quot;JEV_BAND_LOW&quot;, &quot;0.2&quot;)))<br>        p.add_argument(&quot;--band-high&quot;, type=float, default=float(os.getenv(&quot;JEV_BAND_HIGH&quot;, &quot;0.8&quot;)))<br>        a = p.parse_args()<br>        inp = Path(a.input)<br>        return cls(<br>            input_path=inp,<br>            output_path=Path(a.output) if a.output else inp.with_suffix(&quot;.html&quot;),<br>            title=a.title,<br>            eyebrow=a.eyebrow,<br>            band_low=a.band_low,<br>            band_high=a.band_high,<br>        )<br><br><br>class EvalReport:<br>    def __init__(self, config: ReportConfig) -&gt; None:<br>        self.config = config<br><br>    def load(self) -&gt; list[dict]:<br>        rows = [json.loads(line) for line in self.config.input_path.open(encoding=&quot;utf-8&quot;) if line.strip()]<br>        return [{k: r.get(k) for k in FIELDS} for r in rows]<br><br>    def intro(self, rows: list[dict]) -&gt; str:<br>        scored = sum(1 for r in rows if r[&quot;jev_no_contradiction&quot;] is not None)<br>        return (<br>            f&quot;{len(rows)} traces in {self.config.input_path.name}, {scored} with judge output. &quot;<br>            f&quot;Each answer is scored by two Jev decision judges (no-contradiction, coverage) and two text judges on the same criteria. &quot;<br>            f&quot;Jev returns a probability; the pass threshold is 0.5 and the {self.config.band_low}-{self.config.band_high} band marks verdicts that would be routed for a second opinion.&quot;<br>        )<br><br>    def render(self, rows: list[dict]) -&gt; str:<br>        return (<br>            TEMPLATE.replace(&quot;__DATA__&quot;, json.dumps(rows, ensure_ascii=False, separators=(&quot;,&quot;, &quot;:&quot;)).replace(&quot;&lt;/&quot;, &quot;&lt;\\/&quot;))<br>            .replace(&quot;__TITLE__&quot;, self.config.title)<br>            .replace(&quot;__EYEBROW__&quot;, self.config.eyebrow)<br>            .replace(&quot;__INTRO__&quot;, self.intro(rows))<br>            .replace(&quot;__BAND_LOW__&quot;, str(self.config.band_low))<br>            .replace(&quot;__BAND_HIGH__&quot;, str(self.config.band_high))<br>        )<br><br>    def run(self) -&gt; Path:<br>        rows = self.load()<br>        self.config.output_path.parent.mkdir(parents=True, exist_ok=True)<br>        self.config.output_path.write_text(self.render(rows), encoding=&quot;utf-8&quot;)<br>        print(f&quot;{len(rows)} rows -&gt; {self.config.output_path}&quot;)<br>        return self.config.output_path<br><br><br>if __name__ == &quot;__main__&quot;:<br>    EvalReport(ReportConfig.from_args()).run()</pre><h4>The Execution and Results:</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UvYDWNlyhGAul9A-RBi_nA.png" /></figure><p><strong>Detect Issues: let the traces review themselves</strong></p><p>Large number of traces is already too many to read one by one. few hundreds is impossible. <em>Detect Issues</em> flips the job around: instead of you hunting for failures, an LLM reads every trace in the experiment, spots the ones that went wrong, and clusters them into named issues with a severity, a description, a count of affected traces, and tags for the failure category (latency, correctness, adherence, relevance).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V4LcjmZunJDyfp_RU7jbRw.png" /><figcaption>Detect issues over the traces generated by applicaitom</figcaption></figure><p>You pick the reviewing model from any provider or AI Gateway endpoint (here, gemma via the gateway), select the traces, see the estimated cost up front, and run. The result lands as an evaluation run with an <strong>Issues</strong> tab you triage like a bug tracker: accept, reject, or resolve each one.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/509/1*0Z3x95u2b5VU8lCT4czCUg.png" /></figure><p>On this RAG experiment it surfaced two issues without any judge or label being involved: <em>Execution timeout errors</em> across 13 traces, and <em>Incorrect medical information and timeouts</em> on 4. The first is exactly the 22 unscored rows the eval report flagged; the second overlaps with the answers both Jev and the text judge rejected. Two different routes to the same conclusion, one of them hands-off.</p><p>The point is not that it replaces judges. Judges score what you <em>asked</em> about; Detect Issues tells you what you <em>forgot to ask</em>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WgZlcmk4YkECHPZ5j1s-4A.png" /></figure><p>MLflow’s experiment Overview has three tabs. <em>Usage</em> answers “did it run?”, <em>Quality</em> answers “was it any good?”, and <em>Tool calls</em> (empty here, since this RAG makes no tool calls) covers agents.</p><p><strong>Usage tab.</strong> Three panels, all from the autologged traces. <em>Traces</em> shows volume over time: one bar of 50 on 11/10, the single evaluation run. <em>Latency</em> plots p50/p90/p99 per day; the average of 239 s per question is the cost of running a 12B generator plus two Ollama judges on a laptop, and the three percentiles lying on top of each other means every question was uniformly slow rather than a few outliers. <em>Errors</em> is the number to act on: 22 traces failed, a 44 % error rate. That is the same 22 rows that arrived with no scores in the eval report, now visible as a platform-level signal rather than something you infer from nulls.</p><p><strong>Quality tab, summary table.</strong> One row per scorer attached to the run: the two Jev judges, the two text judges, and the two retrieval code scorers, each with 50 recorded assessments. Average value is blank because the verdicts are booleans and ranks, not continuous scores.</p><p><strong>Quality tab, per-scorer charts.</strong> Each scorer gets a histogram of its values, and the null bar is the honest part of every chart: it is the 22 errored traces showing up again. Read past it and the story is consistent:</p><ul><li><em>jev_no_contradiction</em> — 25 true, 3 false: Jev rarely finds a claim that conflicts with the reference.</li><li><em>jev_coverage</em> — 21 true, 7 false: the stricter criterion, as expected; concise answers miss key points more often than they contradict them.</li><li><em>llm_no_contradiction</em> and <em>llm_coverage</em> — the gemma3 text judge has a taller null bar (31–32) than Jev (22) because it also timed out on some traces that generated fine. Where it did answer, its pass/fail split mirrors Jev’s.</li><li><em>retrieval_hit</em> — 27 true, 1 false: the gold context was in the top-5 almost every time.</li><li><em>retrieval_rank</em> — 22 at rank 1, 4 at rank 2, 1 at rank 3, 1 miss: hybrid retrieval put the right document first in about four out of five scored questions.</li></ul><p>The takeaway for the blog: before any judge comparison, the Overview already tells you that retrieval is not the problem, generation latency is, and nearly half the run never reached the judges at all.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-rvCHlNY5bF_4bSQCv2t9A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FUaodj-d0epWExcI94skHA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rLupRoNp-UXrIRV3OKGWaw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sTbKTTVZ50-TIgyISFasJw.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*scwWR8-tdqO2-rABY-8F8Q.png" /></figure><p><strong>Jev decision probabilities.</strong> Each dot is one answer, placed at the probability Jev assigned; the dashed line at 0.5 is the pass threshold and the shaded band (0.2–0.8) marks verdicts we’d escalate. Green means Jev and the text judge agreed, red means they disagreed, grey means the text judge never returned. <em>No contradiction</em> is clustered hard against 1.0: Jev is confident most answers don’t conflict with the reference, and only 5 of 28 fall in the band. <em>Coverage</em> is spread across the whole axis with 12 of 28 in the band: a much less certain question, which makes sense since “did it cover the key points” is a judgment of degree, not a yes/no fact. The two red dots at 0.76 and 0.85 are the interesting ones: answers Jev accepted that the text judge rejected, one of them <em>outside</em> the band.</p><p><strong>Agreement with the text judge.</strong> Confusion matrices on the traces where both judges answered. No contradiction: 17 of 19 agree, and both disagreements are Jev-accepts / text-rejects. Jev never rejected something the text judge passed. Coverage: 16 of 18 agree, one miss each way. Routing the banded cases to the text judge lifts both to 18/19 and 18/18.</p><p><strong>Retrieval.</strong> Of 28 scored questions the gold context was at rank 1 in 22, rank 2 in 4, rank 3 in 1, missed in 1. Retrieval is not where quality is lost. The grey “unscored 22” bar is the errored traces.</p><p><strong>Judge pass rates.</strong> Jev passes 25/28 on contradiction and 21/28 on coverage; the text judge passes 15/19 and 14/18, almost the same rates on its smaller sample. Jev is not systematically more lenient, just faster and present on every trace.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Y1QEgBpPq3gjWnpI6WZlUA.png" /></figure><h4>The Conclusion</h4><p>This article asked one question: can a System-One decision model like Jev stand in for a text-generating LLM as a RAG judge? We built a hybrid LlamaIndex + Qdrant + Ollama pipeline over MedQuAD, wired Jev in through the MLflow AI Gateway with make_judge, and ran it side by side with a text judge on the same criteria.</p><p>The answer, on this run, is a qualified yes. On the 28 traces that completed, Jev agreed with the text judge on 17/19 contradiction verdicts and 16/18 coverage verdicts, and routing its uncertain band to the text judge closed almost all of the gap. Jev answered every trace; the text judge timed out on a third of them. Retrieval was not the weak link: the gold context ranked first in 22 of 28 questions.</p><p>Two caveats matter. Jev’s misses were confident acceptances of answers that were nearly right, and one sat above the routing band, so a fixed threshold is not a safety net. And 22 of 50 traces never reached the judges at all, a generation-latency problem that MLflow’s error rate and Detect Issues surfaced before any judge did.</p><p>The practical pattern: Jev for cheap, fast, always-on scoring; a text judge for rationale on the cases Jev is unsure about; MLflow to keep the whole thing observable.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=091a80fb1540" width="1" height="1" alt=""><hr><p><a href="https://blog.devops.dev/evaluating-medical-rag-with-qdrant-jev-and-mlflow-a-production-ready-setup-091a80fb1540">Evaluating Medical RAG with Qdrant, Jev and MLflow — A Production Ready Setup</a> was originally published in <a href="https://blog.devops.dev/">DevOps.dev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Catching Hallucinations Before They Ship: A Claim-Level Verdict Gate for Clinical RAG]]></title>
            <link>https://blog.stackademic.com/catching-hallucinations-before-they-ship-a-claim-level-verdict-gate-for-clinical-rag-91f01f23def4?source=rss-934e21252654------2</link>
            <guid isPermaLink="false">https://medium.com/p/91f01f23def4</guid>
            <category><![CDATA[verdictgating]]></category>
            <category><![CDATA[mlflow]]></category>
            <category><![CDATA[claimrag]]></category>
            <category><![CDATA[laya-decision-model]]></category>
            <category><![CDATA[qdrant]]></category>
            <dc:creator><![CDATA[M K Pavan Kumar]]></dc:creator>
            <pubDate>Mon, 05 Oct 2026 16:50:40 GMT</pubDate>
            <atom:updated>2026-10-05T16:50:40.906Z</atom:updated>
            <content:encoded><![CDATA[<p>RAG answers over clinical guidelines can sound confident while citing the wrong passage or drifting from what the source says, and in a medical setting a statement that doesn’t match its evidence is a real risk. The usual remedy, asking a second large model to judge the answer, is slow and expensive to run on every response. In this article we build a RAG pipeline over a 431-page state treatment guidelines manual and add a lightweight check after generation: every statement in the answer is tested against the passage it cites, and a verdict gate decides whether to release the answer, drop the unsupported statements, or abstain. You will learn how to serve the embedding model, reranker, claim-checking model and LLM from a single MLflow setup. You will also learn how to combine dense and BM25 search in Qdrant, how to turn an answer into checkable claims, and what this kind of check catches and what it misses.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YHqENLfuZUSmf2l8q1F-7g.png" /><figcaption>created by author M K Pavan Kumar.</figcaption></figure><h4>The Architecture:</h4><p>The pipeline has one shared foundation and three phases built on top of it.</p><p><strong>The foundation: one MLflow server.</strong> Every model the pipeline uses is served from a single MLflow server. Three models are registered in the model registry: Qwen3-Embedding-4B for embeddings, Qwen3-Reranker-0.6B for reranking, and Laya for claim verification. The fourth component, the LLM that writes the answer, sits behind the MLflow AI Gateway, which exposes a locally hosted Ollama model through an OpenAI-compatible endpoint. The pipeline code never loads a model from a path. It asks for a model by name, so swapping a model is a registry change, not a code change.</p><p><strong>Phase 1: Ingest (offline).</strong> The source is a 431-page state treatment guidelines manual. We parse the PDF to markdown, split it into chunks of 512 tokens with 100 tokens of overlap, and embed each chunk twice. The dense vector comes from Qwen3-Embedding-4B (2,560 dimensions), and the sparse vector comes from BM25. Both are written to Qdrant in batches of 16, as two named vectors on the same point, with stable IDs so a re-run never creates duplicates. Keeping both vectors on one point is what makes hybrid search possible later.</p><p><strong>Phase 2: Retrieve and generate (online).</strong> A question is embedded with the same Qwen model, this time in query mode with a task instruction. Qdrant runs the dense and sparse searches and fuses the two rankings with reciprocal rank fusion, returning 30 candidates. The reranker then reads the question and each candidate together and scores how well they match, and the top five are kept. Those five are numbered and handed to the LLM with one instruction: write one short statement per line, and end each line with the number of the passage it came from.</p><p><strong>Phase 3: Verdict gating.</strong> This is where the pipeline differs from a standard RAG setup. The answer is split into claims, one per line, and each claim is paired with the chunk it cites. Laya reads the pair and labels the claim supported, unsupported or contradicted. The gate then acts on those labels. If every claim is supported, the answer is released. If some are not, they are dropped and the rest are released. If nothing is supported, the pipeline abstains instead of answering.</p><h4>The Core Algorithm:</h4><p>The algorithm has four parts: turn the answer into claims, attach evidence to each claim, get a verdict, and decide what to release.</p><p><strong>1. Claim extraction.</strong> The generator is told to write one short statement per line and to end each line with a single citation such as [2]. We treat each line as one claim. We strip any bullet or list marker, read the citation numbers, remove duplicates and delete the markers from the text, so the verifier sees only the statement. A line saying the guidelines do not cover the question is not a claim, so it is skipped. If no claims are left, there is nothing to verify and the gate abstains.</p><p><strong>2. Evidence selection.</strong> The evidence for a claim is the full text of the chunk it cites. If a claim cites two chunks, both are joined. If a citation points to a chunk that does not exist, it is ignored. If a claim has no valid citation, it is checked against all five retrieved chunks and marked as uncited.</p><p><strong>3. The verdict.</strong> Laya receives the evidence and the claim together and one question: is the claim supported by the evidence? It must choose one of three options, each with a short definition: supported (the evidence states the claim), unsupported (the evidence does not mention it), or contradicted (the evidence says the opposite). This is one forward pass per claim, and Laya returns one label.</p><p><strong>4. The gate.</strong> The gate is a small policy over the labels:</p><ul><li><strong>Release:</strong> every claim is supported, so the answer goes out as written.</li><li><strong>Revise:</strong> some claims are not supported. Those claims are dropped, and the supported ones are released with their citations.</li><li><strong>Abstain:</strong> no claim is supported, or there was nothing to check. The system says it could not verify an answer from the guidelines.</li></ul><p><strong>A worked example.</strong> For the question “How is benign paroxysmal positional vertigo treated?”, the model wrote eight claims. Seven were checked against the chunks they cited and came back supported. One, a sentence about repositioning maneuvers, cited a different chunk from the one that contains it, and Laya marked it unsupported. The gate dropped that line and released the other seven, each with its citation.</p><p><strong>What this check tests.</strong> The verdict says whether a claim is backed by the passage it cites. It does not say whether the passage is relevant to the question or appropriate for a given patient. Those are separate checks, and a clinician stays responsible for the final decision. The released answer comes with its per-claim verdicts and sources so that the decision can be audited.</p><h4>The Implementation</h4><p>The scafolding looks as below</p><pre>.<br>├── README.md<br>├── data<br>│   ├── standard-treatment-guidelines.md<br>│   └── standard-treatment-guidelines.pdf<br>├── data_ingestion<br>│   ├── __init__.py<br>│   └── ingest.py<br>├── data_retriever<br>│   ├── __init__.py<br>│   ├── claim_check.py<br>│   ├── retriever_and_generate.py<br>│   └── verdict_gate.py<br>├── model_registry<br>│   ├── __init__.py<br>│   ├── laya_model.py<br>│   ├── qwen_embedding_model.py<br>│   ├── qwen_reranking_model.py<br>│   ├── register_embedding_model.py<br>│   ├── register_laya.py<br>│   ├── register_reranking_model.py<br>│   └── smoke_test_all_models.py<br>├── pyproject.toml<br>└── uv.lock</pre><p>The dependency toml for the project looks as below</p><pre>[project]<br>name = &quot;rag-with-decisive-model&quot;<br>version = &quot;0.1.0&quot;<br>requires-python = &quot;&gt;=3.13&quot;<br>dependencies = [<br>    &quot;mlflow&gt;=3.16.1&quot;,<br>    &quot;mlflow-skinny&gt;=3.16.1&quot;,<br>    &quot;sentence-transformers&gt;=6.1.0&quot;,<br>    &quot;laya&gt;=0.3.23&quot;,<br>    &quot;psycopg&gt;=3.3.6&quot;,<br>    &quot;psycopg-binary&gt;=3.3.6&quot;,<br>    &quot;liteparse&gt;=2.15.1&quot;,<br>    &quot;llama-index&gt;=0.14.25&quot;,<br>    &quot;llama-index-vector-stores-qdrant&gt;=0.10.3&quot;,<br>    &quot;llama-index-embeddings-ollama&gt;=0.10.0&quot;,<br>    &quot;qdrant-client&gt;=1.19.1&quot;,<br>    &quot;fastembed&gt;=0.8.1&quot;,<br>]</pre><p>Let us start with our core foundation which is Mlflow where all the models like embedding, rerankers, decision models and the LLM (AI Gateway) are first configured. The following scripts are written for respective model to get registered with Mlflow and finally all are tested using one single smoke test script.</p><pre>import os<br><br>os.environ[&quot;USE_TF&quot;] = &quot;0&quot;<br><br>import laya<br>import mlflow.pyfunc<br><br><br>class LayaModel(mlflow.pyfunc.PythonModel):<br>    def load_context(self, context):<br>        self.agent = laya.load(context.artifacts[&quot;checkpoint&quot;])<br><br>    def predict(self, context, model_input, params=None):<br>        state = model_input[&quot;state&quot;]<br>        questions = model_input[&quot;questions&quot;]<br>        max_len = model_input.get(&quot;max_len&quot;, 8192)<br>        return self.agent.predict(state, questions, max_len=max_len)</pre><pre>import mlflow.pyfunc<br>import torch<br>from sentence_transformers import SentenceTransformer<br><br>DEFAULT_INSTRUCTION = &quot;Given a clinical question, retrieve relevant passages from standard treatment guidelines&quot;<br><br><br>def pick_device():<br>    if torch.cuda.is_available():<br>        return &quot;cuda&quot;<br>    if torch.backends.mps.is_available():<br>        return &quot;mps&quot;<br>    return &quot;cpu&quot;<br><br><br>class QwenEmbeddingModel(mlflow.pyfunc.PythonModel):<br>    def load_context(self, context):<br>        self.model = SentenceTransformer(<br>            context.artifacts[&quot;checkpoint&quot;],<br>            device=pick_device(),<br>            model_kwargs={&quot;torch_dtype&quot;: torch.float16},<br>        )<br><br>    def predict(self, context, model_input, params=None):<br>        texts = model_input[&quot;texts&quot;]<br>        mode = model_input.get(&quot;mode&quot;, &quot;document&quot;)<br>        instruction = model_input.get(&quot;instruction&quot;, DEFAULT_INSTRUCTION)<br>        prompt = f&quot;Instruct: {instruction}\nQuery: &quot; if mode == &quot;query&quot; else None<br>        embeddings = self.model.encode(<br>            texts,<br>            prompt=prompt,<br>            normalize_embeddings=True,<br>            batch_size=model_input.get(&quot;batch_size&quot;, 8),<br>            truncate_dim=model_input.get(&quot;dim&quot;),<br>        )<br>        return embeddings.tolist()</pre><pre>import mlflow.pyfunc<br>import torch<br>from transformers import AutoModelForCausalLM, AutoTokenizer<br><br>DEFAULT_INSTRUCTION = &quot;Given a clinical question, retrieve relevant passages from standard treatment guidelines&quot;<br><br>PREFIX = (<br>    &quot;&lt;|im_start|&gt;system\nJudge whether the Document meets the requirements based on the Query and the &quot;<br>    &#39;Instruct provided. Note that the answer can only be &quot;yes&quot; or &quot;no&quot;.&lt;|im_end|&gt;\n&lt;|im_start|&gt;user\n&#39;<br>)<br>SUFFIX = &quot;&lt;|im_end|&gt;\n&lt;|im_start|&gt;assistant\n&lt;think&gt;\n\n&lt;/think&gt;\n\n&quot;<br><br><br>def pick_device():<br>    if torch.cuda.is_available():<br>        return &quot;cuda&quot;<br>    if torch.backends.mps.is_available():<br>        return &quot;mps&quot;<br>    return &quot;cpu&quot;<br><br><br>class QwenRerankerModel(mlflow.pyfunc.PythonModel):<br>    def load_context(self, context):<br>        path = context.artifacts[&quot;checkpoint&quot;]<br>        self.device = pick_device()<br>        self.tokenizer = AutoTokenizer.from_pretrained(path, padding_side=&quot;left&quot;)<br>        self.model = AutoModelForCausalLM.from_pretrained(path, torch_dtype=torch.float16).to(self.device).eval()<br>        self.yes_id = self.tokenizer.convert_tokens_to_ids(&quot;yes&quot;)<br>        self.no_id = self.tokenizer.convert_tokens_to_ids(&quot;no&quot;)<br><br>    def predict(self, context, model_input, params=None):<br>        query = model_input[&quot;query&quot;]<br>        documents = model_input[&quot;documents&quot;]<br>        instruction = model_input.get(&quot;instruction&quot;, DEFAULT_INSTRUCTION)<br>        batch_size = model_input.get(&quot;batch_size&quot;, 4)<br><br>        scores = []<br>        for i in range(0, len(documents), batch_size):<br>            texts = [<br>                f&quot;{PREFIX}&lt;Instruct&gt;: {instruction}\n&lt;Query&gt;: {query}\n&lt;Document&gt;: {doc}{SUFFIX}&quot;<br>                for doc in documents[i : i + batch_size]<br>            ]<br>            inputs = self.tokenizer(texts, padding=True, return_tensors=&quot;pt&quot;).to(self.device)<br>            with torch.no_grad():<br>                logits = self.model(**inputs).logits[:, -1, :]<br>            pair = torch.stack([logits[:, self.no_id], logits[:, self.yes_id]], dim=1).float()<br>            scores += torch.softmax(pair, dim=1)[:, 1].tolist()<br>        return scores</pre><pre># register decision model: convaiinnovations/laya<br>import mlflow<br>from huggingface_hub import snapshot_download<br><br>from laya_model import LayaModel<br><br>mlflow.set_tracking_uri(&quot;http://localhost:5001&quot;)<br><br>local_dir = snapshot_download(&quot;convaiinnovations/laya&quot;, allow_patterns=&quot;multilingual/*&quot;, local_dir=&quot;laya_checkpoint&quot;)<br><br>with mlflow.start_run(run_name=&quot;laya-multilingual&quot;):<br>    mlflow.pyfunc.log_model(<br>        name=&quot;laya-multilingual&quot;,<br>        python_model=LayaModel(),<br>        artifacts={&quot;checkpoint&quot;: f&quot;{local_dir}/multilingual&quot;},<br>        code_paths=[&quot;laya_model.py&quot;],<br>        pip_requirements=[&quot;laya&quot;, &quot;mlflow&quot;],<br>        registered_model_name=&quot;laya-multilingual&quot;,<br>    )</pre><pre># register reranking model: Qwen/Qwen3-Reranker-0.6B<br>import mlflow<br>from huggingface_hub import snapshot_download<br><br>from qwen_reranking_model import QwenRerankerModel<br><br>mlflow.set_tracking_uri(&quot;http://localhost:5001&quot;)<br><br>local_dir = snapshot_download(&quot;Qwen/Qwen3-Reranker-0.6B&quot;, local_dir=&quot;qwen3_reranker_0.6b&quot;)<br><br>with mlflow.start_run(run_name=&quot;qwen3-reranker-0.6b&quot;):<br>    mlflow.pyfunc.log_model(<br>        name=&quot;qwen3-reranker-0-6b&quot;,<br>        python_model=QwenRerankerModel(),<br>        artifacts={&quot;checkpoint&quot;: local_dir},<br>        code_paths=[&quot;qwen_reranking_model.py&quot;],<br>        pip_requirements=[&quot;mlflow&quot;, &quot;torch&quot;, &quot;transformers&gt;=4.51.0&quot;],<br>        registered_model_name=&quot;qwen3-reranker-0-6b&quot;,<br>    )</pre><pre># register embedding model: Qwen/Qwen3-Embedding-4B<br>import mlflow<br>from huggingface_hub import snapshot_download<br><br>from qwen_embedding_model import QwenEmbeddingModel<br><br>mlflow.set_tracking_uri(&quot;http://localhost:5001&quot;)<br><br>local_dir = snapshot_download(&quot;Qwen/Qwen3-Embedding-4B&quot;, local_dir=&quot;qwen3_embedding_4b&quot;)<br><br>with mlflow.start_run(run_name=&quot;qwen3-embedding-4b&quot;):<br>    mlflow.pyfunc.log_model(<br>        name=&quot;qwen3-embedding-4b&quot;,<br>        python_model=QwenEmbeddingModel(),<br>        artifacts={&quot;checkpoint&quot;: local_dir},<br>        code_paths=[&quot;qwen_embedding_model.py&quot;],<br>        pip_requirements=[&quot;mlflow&quot;, &quot;torch&quot;, &quot;transformers&gt;=4.51.0&quot;, &quot;sentence-transformers&gt;=3.0.0&quot;],<br>        registered_model_name=&quot;qwen3-embedding-4b&quot;,<br>    )</pre><h4>Phase1: Extract &amp; Ingest</h4><p>Phase 1 runs once, before any question is asked. It turns the PDF into a searchable Qdrant collection in two steps.</p><p><strong>Extract.</strong> The guidelines manual is a 431-page PDF. A parser called LiteParse from LlamaIndex reads it and writes out a markdown file, keeping headings, tables and links. This step is run only the first time or when the source document changes, so the script keeps it as a separate function that you switch on when you need it.</p><p><strong>Ingest.</strong> The ingest step reads the markdown file and works in four stages:</p><ol><li><strong>Chunk.</strong> The text is split into pieces of about 512 tokens, with 100 tokens of overlap between neighbouring pieces. The overlap keeps a sentence that falls on a boundary from losing its context.</li><li><strong>Create the collection.</strong> A Qdrant collection is created with two named vectors per point: a dense vector of 2,560 dimensions compared by cosine similarity, and a sparse vector for BM25 keyword matching. The sparse vector uses Qdrant’s IDF modifier, so rare words count for more. If the collection already exists, it is deleted and recreated, so every run starts clean.</li><li><strong>Embed.</strong> Chunks are processed 16 at a time. The dense vector comes from the Qwen3-Embedding-4B model in the MLflow registry, called in document mode. The sparse vector comes from BM25, running locally.</li><li><strong>Store.</strong> Each chunk becomes one Qdrant point holding both vectors and the chunk text. The point ID is derived from the chunk’s own ID, so the same chunk always gets the same ID and a re-run cannot create duplicates.</li></ol><p>Storing the text on the point means the later phases never have to go back to the PDF. Whatever search returns can go straight to the reranker, the LLM and the claim checker. Keeping the dense and sparse vectors on the same point is what lets Phase 2 run both searches in one query and fuse the results.</p><pre># ingest.py<br>import uuid<br>import logging<br>import mlflow<br>from fastembed import SparseTextEmbedding<br>from liteparse import LiteParse<br>from llama_index.core import SimpleDirectoryReader<br>from llama_index.core.node_parser import SentenceSplitter<br>from llama_index.core.vector_stores.utils import node_to_metadata_dict<br>from qdrant_client import QdrantClient, models<br><br>logger = logging.getLogger(__name__)<br><br>COLLECTION = &quot;standard-treatment-guidelines&quot;<br>DENSE_SIZE = 2560<br>BATCH_SIZE = 16<br><br>client = QdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;)<br><br><br>def extract(src_path: str, dst_path: str) -&gt; None:<br>    logger.info(f&quot;extracting: {src_path} to {dst_path}&quot;)<br>    parser = LiteParse(<br>        output_format=&quot;markdown&quot;,<br>        extract_images=True,<br>        image_mode=&quot;embed&quot;,<br>        extract_links=True,<br>        extract_text_metadata=True,<br>    )<br>    result = parser.parse(src_path)<br>    with open(dst_path, &quot;w&quot;, encoding=&quot;utf-8&quot;) as f:<br>        f.write(result.text)<br>    logger.info(f&quot;extracted: {dst_path}&quot;)<br><br><br>def create_collection() -&gt; None:<br>    logger.info(f&quot;creating collection: {COLLECTION}&quot;)<br>    if client.collection_exists(COLLECTION):<br>        client.delete_collection(COLLECTION)<br>    client.create_collection(<br>        COLLECTION,<br>        vectors_config={&quot;text-dense&quot;: models.VectorParams(size=DENSE_SIZE, distance=models.Distance.COSINE)},<br>        sparse_vectors_config={&quot;text-sparse&quot;: models.SparseVectorParams(modifier=models.Modifier.IDF)},<br>    )<br><br><br>def ingest() -&gt; None:<br>    logger.info(f&quot;ingesting: {COLLECTION}&quot;)<br>    mlflow.set_tracking_uri(&quot;http://localhost:5001&quot;)<br>    embedder = mlflow.pyfunc.load_model(&quot;models:/qwen3-embedding-4b/latest&quot;)<br>    sparse_model = SparseTextEmbedding(&quot;Qdrant/bm25&quot;)<br><br>    documents = SimpleDirectoryReader(input_dir=&quot;../data&quot;, required_exts=[&quot;.md&quot;]).load_data()<br>    nodes = SentenceSplitter(chunk_size=512, chunk_overlap=100).get_nodes_from_documents(documents)<br>    print(f&quot;{len(nodes)} chunks&quot;)<br><br>    logger.info(f&quot;nodes: {len(nodes)}&quot;)<br>    create_collection()<br><br>    for start in range(0, len(nodes), BATCH_SIZE):<br>        batch = nodes[start:start + BATCH_SIZE]<br>        texts = [n.get_content() for n in batch]<br>        logger.debug(f&quot;texts: {texts}&quot;)<br>        dense = embedder.predict({&quot;texts&quot;: texts, &quot;mode&quot;: &quot;document&quot;})<br>        sparse = list(sparse_model.embed(texts))<br><br>        points = [<br>            models.PointStruct(<br>                id=str(uuid.uuid5(uuid.NAMESPACE_URL, n.node_id)),<br>                vector={<br>                    &quot;text-dense&quot;: d,<br>                    &quot;text-sparse&quot;: models.SparseVector(indices=s.indices.tolist(), values=s.values.tolist()),<br>                },<br>                payload=node_to_metadata_dict(n, remove_text=False, flat_metadata=True),<br>            )<br>            for n, d, s in zip(batch, dense, sparse)<br>        ]<br>        logger.debug(f&quot;points in collection: {len(points)}&quot;)<br>        client.upsert(COLLECTION, points=points)<br>        print(f&quot;{min(start + BATCH_SIZE, len(nodes))}/{len(nodes)}&quot;, flush=True)<br><br><br>if __name__ == &quot;__main__&quot;:<br>    # extract(&quot;../data/standard-treatment-guidelines.pdf&quot;, &quot;../data/standard-treatment-guidelines.md&quot;)<br>    ingest()</pre><h4>Phase2: Retrieve and generate</h4><p>Phase 2 runs every time someone asks a question. It has three stages: find candidates, narrow them down, and write a cited answer.</p><p><strong>1. Hybrid search.</strong> The question is searched two ways at once. For the dense search, it is embedded with the same Qwen3 model used at ingest, this time in query mode, which prepends a short instruction (“given a clinical question, retrieve relevant passages from standard treatment guidelines”). This matches the meaning of the question to the meaning of the chunks. For the sparse search, BM25 matches the exact words, which helps with drug names, doses and clinical terms that embeddings can blur. Each search returns its top 30 chunks from Qdrant. Qdrant then merges the two lists with reciprocal rank fusion, which combines positions in each list rather than raw scores, so the two scoring scales never have to be compared. The result is one list of 30 candidates.</p><p><strong>2. Rerank.</strong> Search ranks chunks by their similarity to the question. The reranker reads the question and each candidate side by side and scores how well the chunk answers it, as a probability between 0 and 1. We use Qwen3-Reranker-0.6B from the registry and keep the top five. In our BPPV test the five scores were 0.988, 0.676, 0.662, 0.514 and 0.034, so the last one was clearly a poor match, even though it had made the top 30.</p><p><strong>3. Generate.</strong> The five chunks are numbered and given to the LLM through the MLflow AI Gateway, with a system prompt that sets the rules:</p><ul><li>Use only the numbered passages.</li><li>Write one short factual statement per line.</li><li>End every line with one citation, such as [2].</li><li>Do not quote the passages, and add no introduction or summary.</li><li>If the passages do not answer the question, write only “Not covered in the provided guidelines.”</li></ul><p>Temperature is set to 0, so the same question gives the same answer. The format rules are not cosmetic. They are what makes Phase 3 possible, because they turn the answer into lines that can be checked one by one.</p><p>The output of Phase 2 is the answer plus the five chunks, with their IDs and scores. Phase 3 needs all of it.</p><pre># retrieve_and_generate.py<br>import json<br>import logging<br>import sys<br>import time<br><br>import mlflow<br>from fastembed import SparseTextEmbedding<br>from openai import OpenAI<br>from qdrant_client import QdrantClient, models<br><br>COLLECTION = &quot;standard-treatment-guidelines&quot;<br>TRACKING_URI = &quot;http://localhost:5001&quot;<br>GATEWAY_URL = &quot;http://localhost:5001/gateway/mlflow/v1&quot;<br>LLM_ENDPOINT = &quot;ollama&quot;<br>CANDIDATES = 30<br>TOP_K = 5<br><br>SYSTEM_PROMPT = (<br>    &quot;You are a clinical assistant answering from standard treatment guidelines. &quot;<br>    &quot;Use only the numbered context passages. &quot;<br>    &quot;Write one short factual statement per line. &quot;<br>    &quot;End every line with exactly one citation such as [2]. &quot;<br>    &quot;Do not quote the passages, and do not add an introduction, a summary or any comment about the context. &quot;<br>    &quot;If the context does not answer the question, write only: Not covered in the provided guidelines.&quot;<br>)<br><br>logging.basicConfig(level=logging.INFO, format=&quot;%(asctime)s %(levelname)-7s %(name)s | %(message)s&quot;, datefmt=&quot;%H:%M:%S&quot;)<br>log = logging.getLogger(&quot;retriever&quot;)<br><br><br>def timed_load(label, loader):<br>    start = time.perf_counter()<br>    log.info(&quot;loading %s&quot;, label)<br>    obj = loader()<br>    log.info(&quot;loaded %s in %.1fs&quot;, label, time.perf_counter() - start)<br>    return obj<br><br><br>mlflow.set_tracking_uri(TRACKING_URI)<br>qdrant = timed_load(&quot;qdrant client&quot;, lambda: QdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;))<br>embedder = timed_load(&quot;embedder qwen3-embedding-4b&quot;, lambda: mlflow.pyfunc.load_model(&quot;models:/qwen3-embedding-4b/latest&quot;))<br>reranker = timed_load(&quot;reranker qwen3-reranker-0-6b&quot;, lambda: mlflow.pyfunc.load_model(&quot;models:/qwen3-reranker-0-6b/latest&quot;))<br>sparse_model = timed_load(&quot;sparse model Qdrant/bm25&quot;, lambda: SparseTextEmbedding(&quot;Qdrant/bm25&quot;))<br>llm = OpenAI(base_url=GATEWAY_URL, api_key=&quot;no-api-key&quot;)<br>log.info(&quot;llm gateway=%s endpoint=%s&quot;, GATEWAY_URL, LLM_ENDPOINT)<br><br><br>def hybrid_search(query: str, limit: int = CANDIDATES):<br>    start = time.perf_counter()<br>    log.info(&quot;hybrid_search: query=%r limit=%d&quot;, query, limit)<br>    dense = embedder.predict({&quot;texts&quot;: [query], &quot;mode&quot;: &quot;query&quot;})[0]<br>    log.info(&quot;hybrid_search: dense embedding dim=%d (%.2fs)&quot;, len(dense), time.perf_counter() - start)<br>    sparse = next(iter(sparse_model.query_embed(query)))<br>    log.info(&quot;hybrid_search: sparse terms=%d&quot;, len(sparse.indices))<br>    response = qdrant.query_points(<br>        COLLECTION,<br>        prefetch=[<br>            models.Prefetch(query=dense, using=&quot;text-dense&quot;, limit=limit),<br>            models.Prefetch(<br>                query=models.SparseVector(indices=sparse.indices.tolist(), values=sparse.values.tolist()),<br>                using=&quot;text-sparse&quot;,<br>                limit=limit,<br>            ),<br>        ],<br>        query=models.FusionQuery(fusion=models.Fusion.RRF),<br>        limit=limit,<br>        with_payload=True,<br>    )<br>    log.info(&quot;hybrid_search: %d points returned (%.2fs total)&quot;, len(response.points), time.perf_counter() - start)<br>    return response.points<br><br><br>def to_chunk(point) -&gt; dict:<br>    node = json.loads(point.payload[&quot;_node_content&quot;])<br>    return {&quot;id&quot;: str(point.id), &quot;text&quot;: node[&quot;text&quot;], &quot;metadata&quot;: node.get(&quot;metadata&quot;, {}), &quot;fusion_score&quot;: point.score}<br><br><br>def retrieve(query: str, top_k: int = TOP_K) -&gt; list[dict]:<br>    chunks = [to_chunk(p) for p in hybrid_search(query)]<br>    if not chunks:<br>        log.warning(&quot;retrieve: no candidates found&quot;)<br>        return []<br>    start = time.perf_counter()<br>    log.info(&quot;retrieve: reranking %d candidates&quot;, len(chunks))<br>    scores = reranker.predict({&quot;query&quot;: query, &quot;documents&quot;: [c[&quot;text&quot;] for c in chunks]})<br>    for chunk, score in zip(chunks, scores):<br>        chunk[&quot;rerank_score&quot;] = float(score)<br>    top = sorted(chunks, key=lambda c: c[&quot;rerank_score&quot;], reverse=True)[:top_k]<br>    log.info(&quot;retrieve: reranked in %.2fs, top scores=%s&quot;, time.perf_counter() - start, [round(c[&quot;rerank_score&quot;], 3) for c in top])<br>    return top<br><br><br>def generate(query: str, chunks: list[dict]) -&gt; str:<br>    context = &quot;\n\n&quot;.join(f&quot;[{i}] {c[&#39;text&#39;]}&quot; for i, c in enumerate(chunks, 1))<br>    prompt = f&quot;Context:\n{context}\n\nQuestion: {query}&quot;<br>    start = time.perf_counter()<br>    log.info(&quot;generate: endpoint=%s chunks=%d prompt_chars=%d&quot;, LLM_ENDPOINT, len(chunks), len(prompt))<br>    response = llm.chat.completions.create(<br>        model=LLM_ENDPOINT,<br>        messages=[{&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: SYSTEM_PROMPT}, {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: prompt}],<br>        temperature=0,<br>        extra_body={&quot;think&quot;: False},<br>    )<br>    choice = response.choices[0]<br>    content = (choice.message.content or &quot;&quot;).strip()<br>    log.info(&quot;generate: finish_reason=%s usage=%s answer_chars=%d (%.2fs)&quot;, choice.finish_reason, response.usage, len(content), time.perf_counter() - start)<br>    if not content:<br>        log.error(&quot;generate: empty content, raw message=%s&quot;, choice.message)<br>        raise RuntimeError(f&quot;LLM returned empty content, finish_reason={choice.finish_reason}&quot;)<br>    return content<br><br><br>def answer(query: str) -&gt; dict:<br>    log.info(&quot;answer: start&quot;)<br>    chunks = retrieve(query)<br>    result = {&quot;query&quot;: query, &quot;answer&quot;: generate(query, chunks), &quot;chunks&quot;: chunks}<br>    log.info(&quot;answer: done&quot;)<br>    return result<br><br># uncomment to test it standalone<br># if __name__ == &quot;__main__&quot;:<br>#     question = &quot; &quot;.join(sys.argv[1:]) or &quot;How is benign paroxysmal positional vertigo treated?&quot;<br>#     result = answer(question)<br>#     for i, c in enumerate(result[&quot;chunks&quot;], 1):<br>#         print(f&quot;[{i}] rerank={c[&#39;rerank_score&#39;]:.3f} fusion={c[&#39;fusion_score&#39;]:.3f} id={c[&#39;id&#39;]}&quot;)<br>#         print(&quot;    &quot; + c[&quot;text&quot;][:160].replace(&quot;\n&quot;, &quot; &quot;))<br>#     print(&quot;\n&quot; + result[&quot;answer&quot;])</pre><h4>Phase 3: claim check and verdict gate</h4><p>Phase 3 takes the answer and the five chunks from Phase 2 and decides what is safe to release. It lives in two small files: one checks claims, the other makes the release decision.</p><p><strong>The claim check.</strong> Laya is loaded from the MLflow registry the first time it is needed and then kept in memory, so only the first claim pays the loading cost of a few seconds. The check follows four steps:</p><ol><li><strong>Split.</strong> Each line of the answer becomes a claim. The bullet marker and the [n] citation are removed from the text, and the citation numbers are kept separately. A line saying the guidelines don&#39;t cover the question is skipped.</li><li><strong>Collect the evidence.</strong> The evidence is the text of the chunk or chunks the claim cites. A citation to a chunk that doesn’t exist is ignored with a warning. If a claim has no valid citation, it is checked against all five chunks and marked as uncited.</li><li><strong>Ask Laya.</strong> Laya receives “Evidence: … Claim: …” with one question, “is the claim supported by the evidence?”, and three defined options: supported (the evidence states the claim), unsupported (it does not mention it), or contradicted (it says the opposite). It returns one choice, in a single forward pass.</li><li><strong>Record.</strong> Each claim keeps its text, its citations, the IDs of the evidence chunks, and its verdict.</li></ol><p><strong>The verdict gate.</strong> The gate runs the whole pipeline for one question and applies a simple rule to the verdicts:</p><ul><li><strong>Release:</strong> every claim is supported, so the answer goes out as written.</li><li><strong>Revised:</strong> some claims are not supported. They are removed, and the supported claims are rebuilt into the final answer with their citations.</li><li><strong>Abstain:</strong> no claim is supported, or there is nothing to check. The response is a fixed message saying the answer could not be verified from the guidelines.</li></ul><p>The gate returns more than the final text. It also returns the original LLM draft, every claim with its verdict, and the five source chunks with their scores, so a reader can see what was removed and why. In our BPPV test, eight claims were checked. One, a sentence about repositioning maneuvers, was marked unsupported against the chunk it cited, and the gate removed it. The other seven were released with their citations.</p><p><strong>What the check tests.</strong> A “supported” verdict means the claim matches the passage it cites. It does not mean the passage is relevant to the question or right for a particular patient.</p><pre># claim_check.py<br>import logging<br>import re<br>import sys<br>import time<br><br>import mlflow<br><br>TRACKING_URI = &quot;http://localhost:5001&quot;<br><br>QUESTIONS = {<br>    &quot;verdict&quot;: {<br>        &quot;type&quot;: &quot;choice&quot;,<br>        &quot;instructions&quot;: &quot;Is the claim supported by the evidence?&quot;,<br>        &quot;criteria&quot;: {<br>            &quot;supported&quot;: &quot;the evidence states the claim&quot;,<br>            &quot;unsupported&quot;: &quot;the evidence does not mention the claim&quot;,<br>            &quot;contradicted&quot;: &quot;the evidence says the opposite&quot;,<br>        },<br>    }<br>}<br><br>CITATION = re.compile(r&quot;\[(\d+)\]&quot;)<br>LIST_MARKER = re.compile(r&quot;^\s*(?:[-*•]|\d+[.)])\s+&quot;)<br>NOT_COVERED = &quot;not covered&quot;<br><br>log = logging.getLogger(&quot;claim_check&quot;)<br>_laya = None<br><br><br>def load_laya():<br>    global _laya<br>    if _laya is None:<br>        start = time.perf_counter()<br>        log.info(&quot;loading laya-multilingual&quot;)<br>        mlflow.set_tracking_uri(TRACKING_URI)<br>        _laya = mlflow.pyfunc.load_model(&quot;models:/laya-multilingual/latest&quot;)<br>        log.info(&quot;loaded laya-multilingual in %.1fs&quot;, time.perf_counter() - start)<br>    return _laya<br><br><br>def split_claims(answer: str) -&gt; list[dict]:<br>    claims = []<br>    for line in answer.splitlines():<br>        line = LIST_MARKER.sub(&quot;&quot;, line).strip()<br>        if not line or NOT_COVERED in line.lower():<br>            continue<br>        cited = list(dict.fromkeys(int(n) for n in CITATION.findall(line)))<br>        text = re.sub(r&quot;\s+([.!?,;])&quot;, r&quot;\1&quot;, re.sub(r&quot;\s+&quot;, &quot; &quot;, CITATION.sub(&quot;&quot;, line))).strip()<br>        if len(text) &gt; 3:<br>            claims.append({&quot;claim&quot;: text, &quot;citations&quot;: cited})<br>    log.info(&quot;split_claims: %d claims from %d lines&quot;, len(claims), len(answer.splitlines()))<br>    return claims<br><br><br>def check_claim(claim: str, evidence: str) -&gt; str:<br>    start = time.perf_counter()<br>    result = load_laya().predict({&quot;state&quot;: f&quot;Evidence: {evidence}\nClaim: {claim}&quot;, &quot;questions&quot;: QUESTIONS})<br>    verdict = result[&quot;answers&quot;][&quot;verdict&quot;][&quot;choice&quot;]<br>    log.info(&quot;check_claim: verdict=%s evidence_chars=%d (%.2fs)&quot;, verdict, len(evidence), time.perf_counter() - start)<br>    log.debug(&quot;check_claim: raw result=%s&quot;, result)<br>    return verdict<br><br><br>def check_answer(answer: str, chunks: list[dict]) -&gt; list[dict]:<br>    log.info(&quot;check_answer: start, %d chunks available&quot;, len(chunks))<br>    results = []<br>    for i, item in enumerate(split_claims(answer), 1):<br>        valid = list(dict.fromkeys(n for n in item[&quot;citations&quot;] if 1 &lt;= n &lt;= len(chunks)))<br>        if item[&quot;citations&quot;] and len(valid) != len(item[&quot;citations&quot;]):<br>            log.warning(&quot;claim %d cites out-of-range chunks %s&quot;, i, item[&quot;citations&quot;])<br>        if not valid:<br>            log.warning(&quot;claim %d has no valid citation, checking against all chunks&quot;, i)<br>        sources = valid or list(range(1, len(chunks) + 1))<br>        log.info(&quot;claim %d: %r citations=%s&quot;, i, item[&quot;claim&quot;][:80], sources)<br>        evidence = &quot;\n\n&quot;.join(chunks[n - 1][&quot;text&quot;] for n in sources)<br>        results.append({<br>            **item,<br>            &quot;evidence_chunks&quot;: [chunks[n - 1][&quot;id&quot;] for n in sources],<br>            &quot;cited&quot;: bool(valid),<br>            &quot;verdict&quot;: check_claim(item[&quot;claim&quot;], evidence),<br>        })<br>    verdicts = [r[&quot;verdict&quot;] for r in results]<br>    log.info(&quot;check_answer: done, verdicts=%s&quot;, {v: verdicts.count(v) for v in set(verdicts)})<br>    return results<br><br><br># if __name__ == &quot;__main__&quot;:<br>#     logging.basicConfig(level=logging.INFO, format=&quot;%(asctime)s %(levelname)-7s %(name)s | %(message)s&quot;, datefmt=&quot;%H:%M:%S&quot;)<br>#     from retriever_and_generate import answer<br>#<br>#     &quot;&quot;&quot;<br>#     1. How is benign paroxysmal positional vertigo treated?<br>#     2. Compare the treatment of BPPV and vestibular migraine.<br>#     &quot;&quot;&quot;<br>#     question = &quot; &quot;.join(sys.argv[1:]) or &quot;How is benign paroxysmal positional vertigo treated?&quot;<br>#     response = answer(question)<br>#     print(response[&quot;answer&quot;], &quot;\n&quot;)<br>#     for r in check_answer(response[&quot;answer&quot;], response[&quot;chunks&quot;]):<br>#         source = &quot;cited&quot; if r[&quot;cited&quot;] else &quot;uncited&quot;<br>#         print(f&quot;{r[&#39;verdict&#39;]:&lt;13} | {source:&lt;7} | {r[&#39;claim&#39;]}&quot;)</pre><pre># verdict_gate.py<br>import logging<br>import sys<br><br>from claim_check import check_answer<br>from retriever_and_generate import answer<br><br>log = logging.getLogger(&quot;gate&quot;)<br><br>ABSTAIN = &quot;I could not verify an answer to this question from the guidelines.&quot;<br><br><br>def render(claims: list[dict]) -&gt; str:<br>    return &quot;\n&quot;.join(f&quot;{c[&#39;claim&#39;]} &quot; + &quot;&quot;.join(f&quot;[{n}]&quot; for n in c[&quot;citations&quot;]) for c in claims)<br><br><br>def gate(question: str) -&gt; dict:<br>    result = answer(question)<br>    draft = result[&quot;answer&quot;]<br>    checks = check_answer(draft, result[&quot;chunks&quot;])<br>    supported = [c for c in checks if c[&quot;verdict&quot;] == &quot;supported&quot;]<br>    dropped = len(checks) - len(supported)<br><br>    if not supported:<br>        log.info(&quot;gate: no supported claims, abstaining&quot;)<br>        return {&quot;status&quot;: &quot;abstain&quot;, &quot;answer&quot;: ABSTAIN, &quot;draft&quot;: draft, &quot;claims&quot;: checks, &quot;chunks&quot;: result[&quot;chunks&quot;]}<br><br>    if dropped == 0:<br>        log.info(&quot;gate: all %d claims supported, releasing&quot;, len(checks))<br>        status = &quot;release&quot;<br>    else:<br>        log.info(&quot;gate: dropped %d of %d unsupported claims, releasing the rest&quot;, dropped, len(checks))<br>        status = &quot;revised&quot;<br>    return {&quot;status&quot;: status, &quot;answer&quot;: render(supported), &quot;draft&quot;: draft, &quot;claims&quot;: checks, &quot;chunks&quot;: result[&quot;chunks&quot;]}<br><br><br>if __name__ == &quot;__main__&quot;:<br>    logging.basicConfig(level=logging.INFO, format=&quot;%(asctime)s %(levelname)-7s %(name)s | %(message)s&quot;, datefmt=&quot;%H:%M:%S&quot;)<br>    question = &quot; &quot;.join(sys.argv[1:]) or &quot;How is benign paroxysmal positional vertigo treated?&quot;<br>    outcome = gate(question)<br><br>    print(&quot;\n=== LLM draft ===&quot;)<br>    print(outcome[&quot;draft&quot;])<br>    print(&quot;\n=== Claim check ===&quot;)<br>    for c in outcome[&quot;claims&quot;]:<br>        cites = &quot;&quot;.join(f&quot;[{n}]&quot; for n in c[&quot;citations&quot;]) or &quot;[uncited]&quot;<br>        print(f&quot;{c[&#39;verdict&#39;]:&lt;13} | {cites:&lt;8} | {c[&#39;claim&#39;]}&quot;)<br>    print(&quot;\n=== Sources ===&quot;)<br>    for i, ch in enumerate(outcome[&quot;chunks&quot;], 1):<br>        print(f&quot;[{i}] rerank={ch[&#39;rerank_score&#39;]:.3f} id={ch[&#39;id&#39;]}&quot;)<br>    print(f&quot;\n=== Final response: {outcome[&#39;status&#39;]} ===&quot;)<br>    print(outcome[&quot;answer&quot;])</pre><h4>The Run</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JSraPQiiyg7DAObaykkmZg.png" /><figcaption>Mlflow AI Gateway with gemma3 configured</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*220N3yc-YzOkfE2bGmmQTg.png" /><figcaption>registered models in Mlflow model registry</figcaption></figure><pre>06:21:36 INFO    retrieve and generate | loaded sparse model Qdrant/bm25 in 1.2s<br>06:21:36 INFO    retrieve and generate | llm gateway=http://localhost:5001/gateway/mlflow/v1 endpoint=ollama<br>06:21:36 INFO    retrieve and generate | answer: start<br>06:21:36 INFO    retrieve and generate | hybrid_search: query=&#39;How is benign paroxysmal positional vertigo treated?&#39; limit=30<br>Batches: 100%|██████████| 1/1 [00:08&lt;00:00,  8.82s/it]<br>06:21:45 INFO    retrieve and generate | hybrid_search: dense embedding dim=2560 (8.83s)<br>06:21:45 INFO    retrieve and generate | hybrid_search: sparse terms=5<br>06:21:45 INFO    httpx | HTTP Request: POST http://localhost:6333/collections/standard-treatment-guidelines/points/query &quot;HTTP/1.1 200 OK&quot;<br>06:21:45 INFO    retrieve and generate | hybrid_search: 30 points returned (8.96s total)<br>06:21:45 INFO    retrieve and generate | retrieve and generate: reranking 30 candidates<br>06:21:55 INFO    retrieve and generate | retrieve and generate: reranked in 9.47s, top scores=[0.988, 0.676, 0.662, 0.514, 0.034]<br>06:21:55 INFO    retrieve and generate | generate: endpoint=ollama chunks=5 prompt_chars=7554<br>06:22:18 INFO    httpx | HTTP Request: POST http://localhost:5001/gateway/mlflow/v1/chat/completions &quot;HTTP/1.1 200 OK&quot;<br>06:22:18 INFO    retrieve and generate | generate: finish_reason=stop usage=CompletionUsage(completion_tokens=248, prompt_tokens=1936, total_tokens=2184, completion_tokens_details=None, prompt_tokens_details=PromptTokensDetails(audio_tokens=None, cache_write_tokens=None, cached_tokens=1930)) answer_chars=781 (23.65s)<br>06:22:18 INFO    retrieve and generate | answer: done<br><br><br>06:22:18 INFO    claim_check | loading laya-multilingual<br>Downloading artifacts: 100%|██████████| 11/11 [00:01&lt;00:00,  8.52it/s]<br>/Users/pavanmantha/Pavans/PracticeExamples/DataScience_Practice/LLMs/rag_with_decisive_model/.venv/lib/python3.13/site-packages/mlflow/pyfunc/utils/data_validation.py:187: UserWarning: Add type hints to the `predict` method to enable data validation and automatic signature inference during model logging. Check https://mlflow.org/docs/latest/model/python_model.html#type-hint-usage-in-pythonmodel for more details.<br>  color_warning(<br>06:22:32 INFO    claim_check | loaded laya-multilingual in 13.1s<br>06:22:32 INFO    claim_check | check_claim: verdict=unsupported evidence_chars=1445 (13.43s)<br>06:22:32 INFO    claim_check | claim 2: &#39;The nystagmus beats upward and torsionally with posterior canal BPPV.&#39; citations=[1]<br>06:22:32 INFO    claim_check | check_claim: verdict=supported evidence_chars=1687 (0.08s)<br>06:22:32 INFO    claim_check | claim 3: &#39;Horizontal canal BPPV resulting in a horizontal nystagmus.&#39; citations=[1]<br>06:22:32 INFO    claim_check | check_claim: verdict=supported evidence_chars=1687 (0.08s)<br>06:22:32 INFO    claim_check | claim 4: &#39;Antihistamines like Tab. meclizine (25-50 mg 3 times daily), Tab. dimenhydrinate&#39; citations=[1]<br>06:22:32 INFO    claim_check | check_claim: verdict=supported evidence_chars=1687 (0.08s)<br>06:22:32 INFO    claim_check | claim 5: &#39;Benzodiazepines like Tab. diazepam (2.5 mg 1-3 times daily) and Tab. clonazepam &#39; citations=[1]<br>06:22:32 INFO    claim_check | check_claim: verdict=supported evidence_chars=1687 (0.08s)<br>06:22:32 INFO    claim_check | claim 6: &#39;Tab. baclofen 15-240 mg/d in divided doses is effective.&#39; citations=[3]<br>06:22:32 INFO    claim_check | check_claim: verdict=supported evidence_chars=808 (0.05s)<br>06:22:32 INFO    claim_check | claim 7: &#39;Tab. diazepam 2-4 mg at bed time acts by a similar mechanism and is useful for l&#39; citations=[3]<br>06:22:32 INFO    claim_check | check_claim: verdict=supported evidence_chars=808 (0.04s)<br>06:22:32 INFO    claim_check | claim 8: &#39;Tab. Tizanidine 2-8 mg TDS is another option.&#39; citations=[3]<br>06:22:32 INFO    claim_check | check_claim: verdict=supported evidence_chars=808 (0.04s)<br>06:22:32 INFO    claim_check | check_answer: done, verdicts={&#39;unsupported&#39;: 1, &#39;supported&#39;: 7}<br><br><br>06:22:32 INFO    gate | gate: dropped 1 of 8 unsupported claims, releasing the rest<br><br>ACTUAL REPONSE  === LLM draft ===<br>With repositioning maneuvers that utilize gravity to remove the otoconia from the semicircular canal [4].<br>The nystagmus beats upward and torsionally with posterior canal BPPV [1].<br>Horizontal canal BPPV resulting in a horizontal nystagmus [1].<br>Antihistamines like Tab. meclizine (25-50 mg 3 times daily), Tab. dimenhydrinate (50 mg 1-2 times daily) and Tab. promethazine (25 mg 2-3 times daily, also can be given rectally and IM) are used [1].<br>Benzodiazepines like Tab. diazepam (2.5 mg 1-3 times daily) and Tab. clonazepam (0.25 mg 1 - 3 times daily [1].<br>Tab. baclofen 15-240 mg/d in divided doses is effective [3].<br>Tab. diazepam 2-4 mg at bed time acts by a similar mechanism and is useful for leg spasms that interrupt sleep [3].<br>Tab. Tizanidine 2-8 mg TDS is another option [3].<br><br>=== Claim check ===<br>unsupported   | [4]      | With repositioning maneuvers that utilize gravity to remove the otoconia from the semicircular canal.<br>supported     | [1]      | The nystagmus beats upward and torsionally with posterior canal BPPV.<br>supported     | [1]      | Horizontal canal BPPV resulting in a horizontal nystagmus.<br>supported     | [1]      | Antihistamines like Tab. meclizine (25-50 mg 3 times daily), Tab. dimenhydrinate (50 mg 1-2 times daily) and Tab. promethazine (25 mg 2-3 times daily, also can be given rectally and IM) are used.<br>supported     | [1]      | Benzodiazepines like Tab. diazepam (2.5 mg 1-3 times daily) and Tab. clonazepam (0.25 mg 1 - 3 times daily.<br>supported     | [3]      | Tab. baclofen 15-240 mg/d in divided doses is effective.<br>supported     | [3]      | Tab. diazepam 2-4 mg at bed time acts by a similar mechanism and is useful for leg spasms that interrupt sleep.<br>supported     | [3]      | Tab. Tizanidine 2-8 mg TDS is another option.<br><br>=== Sources ===<br>[1] rerank=0.988 id=372cd729-4e4a-5d1c-b462-04d6880bf691<br>[2] rerank=0.676 id=2fa8b876-7caf-5263-94a5-dd4b21c5a170<br>[3] rerank=0.662 id=73570d2b-02ae-51d3-8b96-f4850a5e62fc<br>[4] rerank=0.514 id=f2c0bb7c-bace-5ddd-9925-309ff2b72d94<br>[5] rerank=0.034 id=117ff53f-ff65-5deb-b3d2-029ce56a89c0<br><br>=== Final response: revised ===<br>The nystagmus beats upward and torsionally with posterior canal BPPV. [1]<br>Horizontal canal BPPV resulting in a horizontal nystagmus. [1]<br>Antihistamines like Tab. meclizine (25-50 mg 3 times daily), Tab. dimenhydrinate (50 mg 1-2 times daily) and Tab. promethazine (25 mg 2-3 times daily, also can be given rectally and IM) are used. [1]<br>Benzodiazepines like Tab. diazepam (2.5 mg 1-3 times daily) and Tab. clonazepam (0.25 mg 1 - 3 times daily. [1]<br>Tab. baclofen 15-240 mg/d in divided doses is effective. [3]<br>Tab. diazepam 2-4 mg at bed time acts by a similar mechanism and is useful for leg spasms that interrupt sleep. [3]<br>Tab. Tizanidine 2-8 mg TDS is another option. [3]<br><br>Process finished with exit code 0</pre><h4>The Conclusion</h4><p>We started with a simple question: after a RAG system writes an answer, how do you know each statement in it is backed by the passage it cites? The answer here was to keep the pipeline small and make every step inspectable. One MLflow server serves the models, Qdrant combines meaning-based and keyword search, and the answer is written in a format that can be split into claims and checked one at a time by a small, fast model called Laya. The gate then releases, trims or withholds the answer. What you take away is a pattern more than a product. A verifier like this tells you whether a claim matches its cited passage, and it does not tell you whether that passage is the right one for the question or for the patient. The base Laya checkpoint we used is not fine-tuned, so before relying on it for anything real you should test it with claims you know are wrong and calibrate it on your own documents. A natural next step is to weight the checks by risk, with stricter handling for doses and contraindications, and to add a relevance check beside faithfulness. A clinician stays in charge of the final decision.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=91f01f23def4" width="1" height="1" alt=""><hr><p><a href="https://blog.stackademic.com/catching-hallucinations-before-they-ship-a-claim-level-verdict-gate-for-clinical-rag-91f01f23def4">Catching Hallucinations Before They Ship: A Claim-Level Verdict Gate for Clinical RAG</a> was originally published in <a href="https://blog.stackademic.com/">Stackademic</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Slicing and Prefixing a Medical Knowledge Base: Two Small Qdrant Features That Solve Big…]]></title>
            <link>https://towardsdev.com/slicing-and-prefixing-a-medical-knowledge-base-two-small-qdrant-features-that-solve-big-0fd256ed7c7e?source=rss-934e21252654------2</link>
            <guid isPermaLink="false">https://medium.com/p/0fd256ed7c7e</guid>
            <category><![CDATA[qdrant-filters]]></category>
            <category><![CDATA[qdrant]]></category>
            <category><![CDATA[vector-search]]></category>
            <category><![CDATA[qdrant-prefix-match]]></category>
            <category><![CDATA[qdrant-search-filter]]></category>
            <dc:creator><![CDATA[M K Pavan Kumar]]></dc:creator>
            <pubDate>Sat, 26 Sep 2026 11:03:56 GMT</pubDate>
            <atom:updated>2026-10-21T13:25:30.820Z</atom:updated>
            <content:encoded><![CDATA[<h3>Slicing and Prefixing a Medical Knowledge Base: Two Small Qdrant Features That Solve Big Operational Headaches</h3><p>A few months ago I loaded the MedQuAD corpus into Qdrant to build a patient-education assistant. Nothing fancy: 7,936 medical explainer pages, one point each, with a topic, a summary and some keywords sitting in the payload next to the vector. The search part worked on day one. What kept eating my evenings was everything around the search.</p><p>Two requests in particular kept coming back. A content reviewer wanted “every page about childhood cancers” so she could check them against updated guidelines. I had a keyword index on the topic field, but keyword indexes match whole values, so “Childhood” alone found nothing and “Childhood Leukemia” found exactly one page. I ended up writing a small script that scrolled all 7,936 payloads and did a startswith in Python. It worked. It was also embarrassing.</p><p>The second request came from the evaluation side. We were about to switch embedding models and I wanted two things: to re-embed the whole collection in parallel without two workers ever touching the same page, and to keep a fixed ten percent of the pages as an evaluation set so that recall numbers from this week could honestly be compared with next week’s. Qdrant’s random sampling gave me a different ten percent on every call, and splitting IDs across workers meant hand-maintaining ranges that broke the moment someone added a page.</p><p>Qdrant 1.19 shipped two filter conditions that made both scripts disappear. One is prefix matching on keyword indexes. The other is a slice condition that carves any collection into fixed, non-overlapping pieces using nothing but the point ID. Neither is glamorous. Both are the kind of feature you only appreciate after you’ve built the workaround yourself.</p><p>This post walks through how each of them works under the hood, with the medical corpus as the running example. By the end you’ll know when a prefix index beats a text index, why a hash of the point ID is a better sampling tool than a random number, and how to combine the two to get a reproducible sample of exactly the slice of data you care about. All the code is in the linked notebook; the numbers you’ll see in the post are real runs against the collection.</p><h4>Introduction</h4><p>Vector search is very good at one question: “<strong>which points are closest to this query?</strong>” It is silent on every other question you might have about your data. Which of these results belong to the childhood oncology section? Which were published after the guideline change? Which pages am I even allowed to show this user? None of that lives in the vector. It lives in the payload, and the only way to bring it into the search is a filter.</p><p>Payload metadata is what turns a pile of embeddings into a system you can actually operate. In the MedQuAD collection every point carries a topic, a short summary, ten keywords and the original text. The vector answers “<strong>what is this page about in a fuzzy sense</strong>”. The topic field answers “<strong>what is this page about, exactly</strong>”. Both are needed. A user asking about treatment options for a child gets a semantically close page about adult chemotherapy dosing unless something narrows the candidate set first, and that something is a filter on the payload.</p><p>Qdrant treats filters as first-class rather than a post-processing step. A filter condition is evaluated during the HNSW traversal, so the graph walk only ever visits points that satisfy it, and the same condition works for scroll, count and recommend. The catch is that a filter is only as fast as the index behind it. A keyword index gives you constant-time exact matches. A text index gives you tokenised full-text matches. Until 1.19, “<strong>give me everything whose topic starts with Childhood</strong>” fell between those two: too loose for keyword, too strict for text, so it degraded to a scan.</p><p>There is also a class of questions that no payload field can answer, because they are about the point itself rather than its content. “<strong>Give me a fixed quarter of this collection</strong>” is one. “<strong>Give me the same ten percent I evaluated last week</strong>” is another. These need a filter that derives its answer from the point ID and nothing else, deterministically, with no index to maintain. That is exactly the gap the slice condition fills, and it’s the one I’ll start with.</p><h4>Slice Filter:</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6YCFS6OoKmlChayv_LrYKA.png" /><figcaption>created by author M K Pavan Kumar</figcaption></figure><p>The slice condition takes two integers, index and total, and answers a single yes-or-no question for each point: does hash(point_id) mod total equal index? That is the entire mechanism. No payload is read, no index is built, and nothing is written to disk. The hash is fixed, so a given ID lands in the same bucket on every run and on every Qdrant version, and because every integer has exactly one remainder mod total, the buckets 0 through total − 1 are disjoint and together cover the whole collection. When I asked four workers to each scroll slice(i, 4) over the medical corpus, they came back with 2,011, 1,957, 1,998 and 1,970 points; the sum is 7,936, the union is 7,936, and no ID appeared twice. Each worker only knows its own index. There is no queue, no ID range to hand out and nothing to reconcile if one of them dies and restarts.</p><p>The same property turns slicing into a sampling tool that random sampling can’t match. Asking for slice(0, 10) twice returned the identical 794 IDs, which is what you want when this week&#39;s retrieval recall has to be compared with last week&#39;s. Shrinking the sample is safe too: slice(0, 4) is a strict subset of slice(0, 2), because a hash divisible by four is also divisible by two, so nothing you have already evaluated drops out when you halve the set. And since slice is an ordinary filter condition, it composes with everything else. Combining slice(0, 5) with a topic prefix of &quot;Childhood&quot; gave me the same fifth of the childhood cancer pages every time, a reproducible stratified sample ready for a canary run. One honest caveat: the condition is checked per candidate in every shard, so it divides the work across your clients rather than reducing the server&#39;s total work. For a collection this size that is exactly the trade you want.</p><p>Replace my four worker counts and the 794 with the numbers from your actual run so they match the notebook.</p><pre>import asyncio<br>import logging<br>from qdrant_client import AsyncQdrantClient, models<br><br>logging.basicConfig(level=logging.INFO, format=&quot;%(asctime)s %(levelname)s %(message)s&quot;)<br>log = logging.getLogger(&quot;slice&quot;)<br><br><br>class SliceScroller:<br>    def __init__(self, client: AsyncQdrantClient, collection: str, show: int = 3):<br>        self.client = client<br>        self.collection = collection<br>        self.show = show<br><br>    def print_points(self, label: str, points: dict[int, dict]):<br>        print(f&quot;\n{label}: {len(points)} points&quot;)<br>        for pid, p in list(points.items())[: self.show]:<br>            print(f&quot;  [{pid}] {p[&#39;topic&#39;]}\n      {p[&#39;text&#39;][:150]}...&quot;)<br><br>    async def slice_points(self, index: int, total: int,<br>                           extra: list[models.Condition] | None = None) -&gt; dict[int, dict]:<br>        conditions = [models.SliceCondition(slice=models.Slice(index=index, total=total))]<br>        points, offset = {}, None<br>        while True:<br>            records, offset = await self.client.scroll(<br>                collection_name=self.collection,<br>                scroll_filter=models.Filter(must=conditions + (extra or [])),<br>                limit=1000,<br>                with_payload=[&quot;topic&quot;, &quot;text&quot;],<br>                with_vectors=False,<br>                offset=offset,<br>            )<br>            points |= {r.id: r.payload for r in records}<br>            if offset is None:<br>                break<br>        log.info(&quot;slice %d/%d -&gt; %d points&quot;, index, total, len(points))<br>        return points<br><br>    async def parallel_scroll(self, workers: int):<br>        log.info(&quot;scrolling with %d workers&quot;, workers)<br>        slices = await asyncio.gather(*(self.slice_points(i, workers) for i in range(workers)))<br>        for i, s in enumerate(slices):<br>            self.print_points(f&quot;worker {i} (slice {i}/{workers})&quot;, s)<br>        union = set().union(*(s.keys() for s in slices))<br>        total = sum(map(len, slices))<br>        log.info(&quot;sum=%d  union=%d  overlap=%s&quot;, total, len(union), total != len(union))<br><br>    async def reproducible_sample(self, index: int, total: int):<br>        first = await self.slice_points(index, total)<br>        second = await self.slice_points(index, total)<br>        self.print_points(f&quot;sample slice {index}/{total}&quot;, first)<br>        log.info(&quot;same sample on repeat: %s&quot;, first.keys() == second.keys())<br><br>    async def nested_samples(self):<br>        half = await self.slice_points(0, 2)<br>        quarter = await self.slice_points(0, 4)<br>        log.info(&quot;slice 0/4 is a subset of slice 0/2: %s&quot;, quarter.keys() &lt;= half.keys())<br><br>    async def stratified_sample(self, index: int, total: int, prefix: str):<br>        points = await self.slice_points(index, total, extra=[<br>            models.FieldCondition(key=&quot;topic&quot;, match=models.MatchPrefix(prefix=prefix))<br>        ])<br>        self.print_points(f&quot;stratified slice {index}/{total} within {prefix!r}&quot;, points)<br>        log.info(&quot;all topics start with %r: %s&quot;, prefix,<br>                 all(p[&quot;topic&quot;].startswith(prefix) for p in points.values()))<br><br><br>async def main():<br>    s = SliceScroller(AsyncQdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;), &quot;medquad&quot;)<br>    await s.parallel_scroll(workers=4)<br>    await s.reproducible_sample(index=0, total=10)<br>    await s.nested_samples()<br>    await s.stratified_sample(index=0, total=5, prefix=&quot;Childhood&quot;)<br><br><br>if __name__ == &quot;__main__&quot;:<br>    asyncio.run(main())</pre><h4>Prefix Match:</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*b71Dzv4D8kyS56Ayo1DLFQ.png" /><figcaption>created by author M K Pavan Kumar</figcaption></figure><p>A keyword index in Qdrant is a dictionary: each distinct value maps to the list of point IDs that carry it. That design is why exact matches are fast and why identifiers, URLs and category names belong in a keyword field rather than a text field. It is also why “starts with” used to be awkward. A text index would let me find pages containing the token “Childhood”, but it tokenises values and stops guaranteeing that “Bile Duct Cancer” is one thing, and it would happily match “Childhood” inside “Adult Survivors of Childhood Cancer”. A keyword index gave exactness but only for whole values. Creating the index with prefix: true in 1.19 closes that gap by keeping the dictionary keys in sorted order in a dedicated structure, so every value sharing a prefix sits in one contiguous run. A prefix query is a binary search to the first key at or after the prefix, followed by a walk forward until the keys stop matching, then a union of the ID lists it passed. The cost depends on the number of matching values and the size of the dictionary, not on the number of points in the collection.</p><p>In practice this changed two things for me. The reviewer’s request became a single scroll with MatchPrefix(prefix=&quot;Childhood&quot;) on the topic field, which returned all 219 pages across 41 distinct childhood cancer topics with no Python loop and no full payload read. More usefully for the assistant itself, the same condition sits inside query_points as a filter, so a question like &quot;what are the treatment options&quot; is scored only against the childhood subset and never surfaces an adult dosing page, however close the vectors happen to be. Two things to keep in mind: matching is exact and case-sensitive because the dictionary stores values verbatim, so consistent casing in your metadata matters, and prefix support must be enabled when the index is created, it&#39;s not something you toggle on an existing keyword index.</p><p>Swap the 219 and 41 for the counts from your run. Say proceed for the conclusion.</p><pre>import logging<br>from openai import OpenAI<br>from qdrant_client import QdrantClient, models<br>from dotenv import load_dotenv, find_dotenv<br><br>load_dotenv(find_dotenv())<br><br>logging.basicConfig(level=logging.INFO, format=&quot;%(asctime)s %(levelname)s %(message)s&quot;)<br>log = logging.getLogger(&quot;prefix&quot;)<br><br><br>class PrefixSearch:<br>    def __init__(self, client: QdrantClient, openai: OpenAI, collection: str,<br>                 embedder: str = &quot;text-embedding-3-small&quot;):<br>        self.client = client<br>        self.openai = openai<br>        self.collection = collection<br>        self.embedder = embedder<br><br>    def prefix_filter(self, prefix: str) -&gt; models.Filter:<br>        return models.Filter(must=[<br>            models.FieldCondition(key=&quot;topic&quot;, match=models.MatchPrefix(prefix=prefix))<br>        ])<br><br>    def topics_starting_with(self, prefix: str) -&gt; list[str]:<br>        log.info(&quot;scrolling topics with prefix %r&quot;, prefix)<br>        topics, offset = [], None<br>        while True:<br>            records, offset = self.client.scroll(<br>                collection_name=self.collection,<br>                scroll_filter=self.prefix_filter(prefix),<br>                limit=256,<br>                with_payload=[&quot;topic&quot;],<br>                with_vectors=False,<br>                offset=offset,<br>            )<br>            topics += [r.payload[&quot;topic&quot;] for r in records]<br>            if offset is None:<br>                break<br>        log.info(&quot;%d topics start with %r&quot;, len(topics), prefix)<br>        return sorted(set(topics))<br><br>    def search(self, query: str, prefix: str, limit: int = 5) -&gt; list[models.ScoredPoint]:<br>        log.info(&quot;semantic search %r within prefix %r&quot;, query, prefix)<br>        vector = self.openai.embeddings.create(model=self.embedder, input=query).data[0].embedding<br>        hits = self.client.query_points(<br>            collection_name=self.collection,<br>            query=vector,<br>            query_filter=self.prefix_filter(prefix),<br>            limit=limit,<br>            with_payload=[&quot;topic&quot;, &quot;summary&quot;],<br>        ).points<br>        for h in hits:<br>            log.info(&quot;  %.3f  %s&quot;, h.score, h.payload[&quot;topic&quot;])<br>        return hits<br><br><br>if __name__ == &quot;__main__&quot;:<br>    ps = PrefixSearch(QdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;), OpenAI(), &quot;medquad&quot;)<br><br>    for t in ps.topics_starting_with(&quot;Childhood&quot;):<br>        print(t)<br><br>    ps.search(&quot;what are the treatment options&quot;, prefix=&quot;Childhood&quot;, limit=5)</pre><p>The ingestion of the data looks as below. The dataset is my own regrouped version of MedQuAD, published on Hugging Face as pavanmantha/MedQuAD_Context_GroupedQA. MedQuAD itself is a collection of medical question-answer pairs scraped from NIH websites such as the National Cancer Institute and the Genetic and Rare Diseases centre, so the language is patient-facing and the topics run from common cancers to rare genetic conditions. The original release is question-centric, one row per question. For retrieval work I found that inconvenient: many questions share the same source passage, and what a RAG system actually indexes is the passage, not the question. So I regrouped it by context. Each row is one source passage plus the list of every question and answer that was written against it. There are 7,936 rows; passages range from a single sentence of 77 characters to long treatment overviews of over 30,000 characters.</p><p>For this experiment I only used the context column. The question-answer pairs never enter the collection, which keeps the payload clean and avoids leaking evaluation questions into the index. Each context became one point in Qdrant with four payload fields: topic, the single condition or subject the passage is about; key_words, the ten most important terms; summary, one to three sentences; and text, the passage itself. The first three were generated with an LLM through the OpenAI Batch API, which processed all 7,936 passages overnight at half the price of synchronous calls. The embeddings came from the same batch pipeline, one request per passage, joined back to the metadata by custom_id. The topic field is the one that matters for this post: it&#39;s what the keyword index with prefix support is built on, and it&#39;s what makes &quot;everything starting with Childhood&quot; a meaningful question to ask.</p><pre>import json<br>import logging<br>import time<br>from pathlib import Path<br><br>from datasets import load_dataset<br>from openai import OpenAI<br>from pydantic import BaseModel, ConfigDict<br>from qdrant_client import QdrantClient, models<br>from dotenv import load_dotenv, find_dotenv<br><br>load_dotenv(find_dotenv())<br><br>logging.basicConfig(level=logging.INFO, format=&quot;%(asctime)s %(levelname)s %(message)s&quot;)<br>log = logging.getLogger(&quot;medquad&quot;)<br><br><br>class Metadata(BaseModel):<br>    model_config = ConfigDict(extra=&quot;forbid&quot;)<br>    topic: str<br>    key_words: list[str]<br>    summary: str<br><br><br>class MedQuADBatchIndexer:<br>    PROMPT = (<br>        &quot;Read the medical text and return: &quot;<br>        &quot;topic (the single condition or subject it is about), &quot;<br>        &quot;key_words (the 10 most important terms), &quot;<br>        &quot;summary (1 to 3 sentences).\n\n{text}&quot;<br>    )<br><br>    def __init__(self, client: QdrantClient, openai: OpenAI, collection: str,<br>                 llm: str = &quot;gpt-4o-mini&quot;,<br>                 embedder: str = &quot;text-embedding-3-small&quot;, dim: int = 1536,<br>                 workdir: str = &quot;batch&quot;):<br>        self.client = client<br>        self.openai = openai<br>        self.collection = collection<br>        self.llm = llm<br>        self.embedder = embedder<br>        self.dim = dim<br>        self.workdir = Path(workdir)<br>        self.workdir.mkdir(exist_ok=True)<br>        log.info(&quot;indexer ready: collection=%s llm=%s embedder=%s&quot;, collection, llm, embedder)<br><br>    def create_collection(self):<br>        log.info(&quot;creating collection %s (dim=%d)&quot;, self.collection, self.dim)<br>        self.client.create_collection(<br>            collection_name=self.collection,<br>            vectors_config=models.VectorParams(size=self.dim, distance=models.Distance.COSINE),<br>        )<br>        self.client.create_payload_index(<br>            collection_name=self.collection,<br>            field_name=&quot;topic&quot;,<br>            field_schema=models.KeywordIndexParams(type=&quot;keyword&quot;, prefix=True),<br>        )<br>        log.info(&quot;keyword index with prefix=True created on &#39;topic&#39;&quot;)<br><br>    def chat_request(self, idx: int, text: str) -&gt; dict:<br>        return {<br>            &quot;custom_id&quot;: str(idx),<br>            &quot;method&quot;: &quot;POST&quot;,<br>            &quot;url&quot;: &quot;/v1/chat/completions&quot;,<br>            &quot;body&quot;: {<br>                &quot;model&quot;: self.llm,<br>                &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: self.PROMPT.format(text=text)}],<br>                &quot;response_format&quot;: {<br>                    &quot;type&quot;: &quot;json_schema&quot;,<br>                    &quot;json_schema&quot;: {&quot;name&quot;: &quot;metadata&quot;, &quot;strict&quot;: True,<br>                                    &quot;schema&quot;: Metadata.model_json_schema()},<br>                },<br>            },<br>        }<br><br>    def embed_request(self, idx: int, text: str) -&gt; dict:<br>        return {<br>            &quot;custom_id&quot;: str(idx),<br>            &quot;method&quot;: &quot;POST&quot;,<br>            &quot;url&quot;: &quot;/v1/embeddings&quot;,<br>            &quot;body&quot;: {&quot;model&quot;: self.embedder, &quot;input&quot;: text},<br>        }<br><br>    def submit(self, name: str, endpoint: str, requests: list[dict]) -&gt; str:<br>        path = self.workdir / f&quot;{name}.jsonl&quot;<br>        path.write_text(&quot;\n&quot;.join(json.dumps(r) for r in requests))<br>        log.info(&quot;[%s] wrote %d requests to %s&quot;, name, len(requests), path)<br><br>        file = self.openai.files.create(file=path.open(&quot;rb&quot;), purpose=&quot;batch&quot;)<br>        batch = self.openai.batches.create(<br>            input_file_id=file.id, endpoint=endpoint, completion_window=&quot;24h&quot;,<br>            metadata={&quot;job&quot;: name},<br>        )<br>        log.info(&quot;[%s] batch %s submitted (status=%s)&quot;, name, batch.id, batch.status)<br>        return batch.id<br><br>    def wait(self, batch_id: str, every: int = 60) -&gt; dict:<br>        while True:<br>            b = self.openai.batches.retrieve(batch_id)<br>            c = b.request_counts<br>            log.info(&quot;[%s] %s  %d/%d done, %d failed&quot;, batch_id, b.status, c.completed, c.total, c.failed)<br>            if b.status in (&quot;completed&quot;, &quot;failed&quot;, &quot;expired&quot;, &quot;cancelled&quot;):<br>                if b.status != &quot;completed&quot;:<br>                    raise RuntimeError(f&quot;batch {batch_id} ended with status {b.status}&quot;)<br>                if b.error_file_id:<br>                    log.warning(&quot;[%s] error file: %s&quot;, batch_id, b.error_file_id)<br>                return self.results(b.output_file_id)<br>            time.sleep(every)<br><br>    def results(self, output_file_id: str) -&gt; dict:<br>        lines = self.openai.files.content(output_file_id).text.splitlines()<br>        out = {}<br>        for line in lines:<br>            r = json.loads(line)<br>            if r[&quot;response&quot;][&quot;status_code&quot;] == 200:<br>                out[r[&quot;custom_id&quot;]] = r[&quot;response&quot;][&quot;body&quot;]<br>            else:<br>                log.warning(&quot;request %s failed: %s&quot;, r[&quot;custom_id&quot;], r.get(&quot;error&quot;))<br>        log.info(&quot;parsed %d successful results&quot;, len(out))<br>        return out<br><br>    def index(self, batch_size: int = 64) -&gt; int:<br>        log.info(&quot;loading dataset&quot;)<br>        ds = load_dataset(&quot;pavanmantha/MedQuAD_Context_GroupedQA&quot;, split=&quot;train&quot;)<br>        texts = {i: row[&quot;context&quot;] for i, row in enumerate(ds)}<br>        log.info(&quot;%d contexts loaded&quot;, len(texts))<br><br>        meta_id = self.submit(&quot;metadata&quot;, &quot;/v1/chat/completions&quot;,<br>                              [self.chat_request(i, t) for i, t in texts.items()])<br>        emb_id = self.submit(&quot;embeddings&quot;, &quot;/v1/embeddings&quot;,<br>                             [self.embed_request(i, t) for i, t in texts.items()])<br><br>        meta, emb = self.wait(meta_id), self.wait(emb_id)<br><br>        points = []<br>        for i, text in texts.items():<br>            k = str(i)<br>            if k not in meta or k not in emb:<br>                log.warning(&quot;point %d skipped: missing metadata or embedding&quot;, i)<br>                continue<br>            m = Metadata.model_validate_json(meta[k][&quot;choices&quot;][0][&quot;message&quot;][&quot;content&quot;])<br>            points.append(models.PointStruct(<br>                id=i,<br>                vector=emb[k][&quot;data&quot;][0][&quot;embedding&quot;],<br>                payload={**m.model_dump(), &quot;text&quot;: text},<br>            ))<br><br>        log.info(&quot;uploading %d points in batches of %d&quot;, len(points), batch_size)<br>        self.client.upload_points(self.collection, points, batch_size=batch_size, wait=True)<br>        count = self.client.count(self.collection).count<br>        log.info(&quot;done: %d points in collection&quot;, count)<br>        return count<br><br><br>if __name__ == &quot;__main__&quot;:<br>    indexer = MedQuADBatchIndexer(<br>        client=QdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;),<br>        openai=OpenAI(),<br>        collection=&quot;medquad&quot;,<br>    )<br>    indexer.create_collection()<br>    indexer.index()</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cWhB3i7Dr8SEtIvaiGHgzQ.png" /><figcaption>The collection with full data ingested.</figcaption></figure><h4>The Conclusion</h4><p>These two features in this post look small on a release-notes page and both of them replaced code I had been maintaining for months. The lesson I took away is that filters come in two kinds. Prefix match is a filter that knows the payload: it reads a value you wrote, and its power comes from the index structure behind it, a sorted dictionary that turns “starts with” into a range lookup. Slice is a filter that knows the point: it never reads the payload at all, and its power comes from a hash of the ID that is stable, disjoint and complete by construction. Once that distinction is clear, it becomes obvious which one to reach for. Questions about content go to prefix. Questions about partitioning, sampling and reproducibility go to slice. Questions that need both, like “a fixed fifth of the childhood cancer pages”, go to a filter that combines them.</p><p>Where I’d apply these next. Prefix match belongs anywhere a keyword field has natural hierarchy in its string: file paths, URL prefixes, SKU families, ICD-style codes, tenant IDs that share a customer prefix. Anywhere you’re tempted to switch to a text index just to get “starts with”, don’t; enable prefix on the keyword index instead and keep exact matching. Slice belongs in every batch job that touches a whole collection: re-embedding after a model change, backfilling a new payload field, exporting to a data lake, running periodic quality checks. Give each worker an index and let the hash do the coordination. And for evaluation, stop using random sampling for anything you intend to compare over time. A slice is a frozen sample you can shrink without losing history, and combining it with a payload filter gives you stratified samples for free.</p><p>Both features need Qdrant 1.19 or later, and prefix support must be set when the keyword index is created. The notebook linked at the top reproduces every number in this post against the MedQuAD collection, including the worker counts and the repeat-sample check. If you try either feature on your own data and hit something I didn’t cover, I’d like to hear about it.</p><p>full source code: <a href="https://github.com/pavanjava/qdrant_filters_tutorial">https://github.com/pavanjava/qdrant_filters_tutorial</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0fd256ed7c7e" width="1" height="1" alt=""><hr><p><a href="https://towardsdev.com/slicing-and-prefixing-a-medical-knowledge-base-two-small-qdrant-features-that-solve-big-0fd256ed7c7e">Slicing and Prefixing a Medical Knowledge Base: Two Small Qdrant Features That Solve Big…</a> was originally published in <a href="https://towardsdev.com/">Towards Dev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Creating coding agents with Agno]]></title>
            <link>https://medium.com/aimonks/creating-coding-agents-with-agno-8807e4a73f15?source=rss-934e21252654------2</link>
            <guid isPermaLink="false">https://medium.com/p/8807e4a73f15</guid>
            <category><![CDATA[qdrant]]></category>
            <category><![CDATA[qdrant-skills]]></category>
            <category><![CDATA[qdrant-coding-agent]]></category>
            <category><![CDATA[coding-agents]]></category>
            <dc:creator><![CDATA[M K Pavan Kumar]]></dc:creator>
            <pubDate>Thu, 24 Sep 2026 10:06:43 GMT</pubDate>
            <atom:updated>2026-10-19T06:30:01.421Z</atom:updated>
            <content:encoded><![CDATA[<p>Most coding assistants stop after generating code. But real software engineering does not work that way. Code needs to be <strong>written, executed, reviewed, corrected, and validated</strong> before it becomes useful.</p><p>That is the idea behind this architecture. Using <strong>Agno</strong>, we create a small team of AI agents where a <strong>Coding Agent</strong> builds the solution and a <strong>Critique Agent</strong> independently reviews it. With access to <strong>Web Search, coding tools, FileSystem, and Shell</strong>, the agent can go beyond code generation and actually work with the development environment.</p><p>The result is a simple but powerful idea: <strong>AI that does not just write code, but iteratively works toward a validated final artifact.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*72YUkHcshje3frYsZo5fTQ.png" /><figcaption>created by author M K Pavan Kumar</figcaption></figure><h4>The Architecture:</h4><p>The architecture starts with a <strong>user query</strong>. This could be a request to create a new application, implement a feature, fix a bug, modify an existing repository, or generate a complete coding artifact.</p><p>The request enters the agent environment built using <strong>Agno</strong>. Instead of relying on a single general-purpose agent, the architecture separates responsibilities between two specialized agents: the <strong>Coding Agent</strong> and the <strong>Critique Agent</strong>.</p><p>The <strong>Coding Agent</strong> is the primary implementation agent. It receives dedicated <strong>coding instructions</strong> that define how it should understand the requirement, plan the implementation, generate code, and work with the surrounding development environment. Its responsibility is not limited to producing code as text. It can actively work on the solution using the tools available to it.</p><p>One of those capabilities is <strong>Web Search</strong>. When the Coding Agent needs additional technical information, documentation, API references, library usage details, or implementation guidance, it can retrieve that information rather than depending entirely on the model’s existing knowledge.</p><p>The Coding Agent also has access to <strong>coding tools</strong>, which allow it to create, update, and reason over source code. This becomes especially useful when the task contains multiple files or requires changes across an existing codebase.</p><p>Another important component is <strong>FileSystem &amp; Shell access</strong>. This moves the agent from being simply a code-generation assistant toward behaving more like a development agent. It can inspect directories, read existing source files, create new files, execute commands, run applications, install or inspect dependencies, execute tests, and observe runtime errors.</p><p>The architecture also supplies <strong>environment variables</strong> to the agent runtime. These can contain configuration such as <strong>API keys, model settings, service endpoints, repository configuration, database connections, or runtime parameters</strong>. Keeping these outside the core agent instructions makes the architecture easier to configure across different environments.</p><p>Once the Coding Agent produces an implementation, the work is passed to the <strong>Critique Agent</strong>.</p><p>The Critique Agent operates using a separate set of <strong>critique and verification instructions</strong>. Its purpose is to independently inspect what the Coding Agent has produced. It can look for incorrect implementations, missing requirements, logical problems, incomplete code, or areas that require improvement.</p><p>The key part of the architecture is the <strong>feedback loop between the Coding Agent and Critique Agent</strong>.</p><p>The Coding Agent produces an implementation. The Critique Agent reviews it and provides feedback. If changes are required, the feedback is returned to the Coding Agent. The Coding Agent then updates the implementation and sends it back for another review.</p><p>Conceptually, the workflow becomes:</p><p><strong>User Query → Coding Agent → Implementation → Critique Agent → Feedback → Coding Agent → Validation → Final Artifact</strong></p><p>This iterative process can continue until the implementation satisfies the expected requirements.</p><p>Finally, once the solution has been implemented and verified, the system produces the <strong>Final Artifact</strong>. Depending on the original request, this could be source code, a complete application, configuration files, scripts, repository changes, or another executable software deliverable.</p><p>At the center of the entire architecture is <strong>Agno</strong>, which provides the framework for defining the agents, assigning their instructions, integrating tools, and coordinating the interaction between them. So rather than building one large coding agent responsible for everything, this architecture follows a much cleaner separation of responsibilities:</p><p><strong>Coding Agent builds. Critique Agent verifies. Tools provide real-world execution capabilities. Agno coordinates the workflow. </strong>That combination turns a simple coding assistant into a more practical <strong>agentic coding system</strong>.</p><h4>The Implementation</h4><p>Source code of this project: <a href="https://github.com/pavanjava/coding_agents_with_agno">https://github.com/pavanjava/coding_agents_with_agno</a></p><h4>pyproject.toml</h4><p>The <strong>pyproject.toml</strong> file defines the project metadata, required Python dependencies, build configuration, and the qdrant-agent CLI entry point. It makes the project installable as a Python package and connects the terminal command directly to the application&#39;s main CLI function.</p><h4>cli.py</h4><p>The <strong>cli.py</strong> file is the main entry point of the application. It loads environment variables, lets the user choose models for the coder and critic agents, validates required API keys, collects the Qdrant connection details, builds the agent team, and finally starts the interactive CLI experience.</p><h4>agent.py</h4><p>The <strong>agent.py</strong> file contains the core agent setup and orchestration logic. It creates the <strong>Coder Agent</strong> and <strong>Critic Agent</strong>, assigns models, tools, shared Qdrant context, local skills, persistent session memory, and combines everything into an <strong>Agno Team</strong> that manages the code → review → fix workflow.</p><h4>instructions.py</h4><p>The <strong>instructions.py</strong> file defines the behavioral rules for the entire system. It contains the shared instructions, coder-specific instructions, critic-specific verification rules, and the overall team workflow that ensures one agent writes the code while the other independently reviews it.</p><h4>skills/</h4><p>The <strong>skills/</strong> directory contains a local offline copy of the Qdrant knowledge base used by both agents. It provides structured guidance across topics such as <strong>Qdrant deployment, scaling, monitoring, multitenancy, SDKs, and operations</strong>, allowing the agents to rely on documented Qdrant knowledge before using external web search.</p><h4>CodingTools</h4><p>The <strong>CodingTools</strong> component gives the agents direct access to the development environment. The coder receives full capabilities such as <strong>reading, writing, editing, searching, listing files, and executing shell commands</strong>, while the critic gets a restricted version that allows inspection and execution but prevents modification of the final deliverable.</p><h4>TavilyTools</h4><p>The <strong>TavilyTools</strong> integration provides web search capabilities when local Qdrant skills are not enough. It allows the agents to look up recent documentation, APIs, implementation details, or external technical information needed to complete a task correctly.</p><h4>JsonDb</h4><p>The <strong>JsonDb</strong> component provides lightweight persistent storage for conversation history and agent sessions. Each project directory gets its own stable session identity, allowing the coding workflow and context to continue across application restarts without mixing information between unrelated projects.</p><h4>Agno Team</h4><p>The <strong>Agno Team</strong> is the coordination layer that brings the coder and critic agents together. It manages delegation, passes work between the agents, maintains shared context and history, and continues the <strong>write → critique → fix → approve</strong> cycle until a validated final result is ready.</p><h4>The Conclusion</h4><p>Today we have seen how <strong>Agno</strong> can be used to build a practical coding agent where software development is treated as an iterative process rather than a single prompt-and-response interaction.</p><p>By combining a <strong>Coder Agent</strong>, an independent <strong>Critic Agent</strong>, <strong>Qdrant skills</strong>, <strong>CodingTools</strong>, <strong>Tavily web search</strong>, persistent <strong>JsonDb sessions</strong>, and controlled access to the local development environment, the system can move through a clear <strong>write → review → fix → validate</strong> workflow.</p><p>The important idea here is simple: a useful coding agent should not only generate code. It should understand the project, use the right tools, verify its own work through an independent reviewer, and finally produce a cleaner and more reliable software artifact.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8807e4a73f15" width="1" height="1" alt=""><hr><p><a href="https://medium.com/aimonks/creating-coding-agents-with-agno-8807e4a73f15">Creating coding agents with Agno</a> was originally published in <a href="https://medium.com/aimonks">𝐀𝐈 𝐦𝐨𝐧𝐤𝐬.𝐢𝐨</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[RAG Beyond Retrieval: Model Registry, Vector Search and Intelligent LLM Routing]]></title>
            <link>https://blog.gopenai.com/rag-beyond-retrieval-model-registry-vector-search-and-intelligent-llm-routing-efd01ba33fc7?source=rss-934e21252654------2</link>
            <guid isPermaLink="false">https://medium.com/p/efd01ba33fc7</guid>
            <category><![CDATA[qdrant]]></category>
            <category><![CDATA[smartrag]]></category>
            <category><![CDATA[intent-based-routing]]></category>
            <category><![CDATA[mlflow]]></category>
            <category><![CDATA[model-registry]]></category>
            <dc:creator><![CDATA[M K Pavan Kumar]]></dc:creator>
            <pubDate>Sun, 06 Sep 2026 07:37:26 GMT</pubDate>
            <atom:updated>2026-10-08T10:16:51.546Z</atom:updated>
            <content:encoded><![CDATA[<p>These days, most RAG applications are built around a <strong>single embedding model, a single knowledge base, and a single LLM</strong>. But not every model is equally good at everything. A model that works well for mortgage-related questions may not be the best choice for medical queries, and sending every request to the same large model is not always the most efficient approach.</p><p>This thought process can be changed by combining <strong>domain-specific embedding models, intelligent query classification, vector search, and dynamic LLM routing</strong>. Instead of forcing every query through the same path, the application can first understand what the user is asking, select the right knowledge base and embedding model, retrieve the relevant context, and finally route the request to the most appropriate LLM.</p><p>In this blog article, we are going to build this entire flow hands-on using <strong>MLflow Model Registry, MLflow AI Gateway, Qdrant, Chonkie, Sentence Transformers, Ollama, OpenAI, and Gemma</strong>. We will register domain-specific embedding models with a champion alias, ingest and index knowledge into Qdrant, classify incoming queries locally with Ollama, dynamically select the right embedding and collection, retrieve the relevant context, and route the final RAG request to the right LLM.</p><p>The result is a RAG application where <strong>both retrieval and generation adapt to the user’s intent</strong>, rather than depending on one fixed model for everything.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZBqa1KrybpC6V3oFC-rmdA.png" /><figcaption>Created by author M K Pavan Kumar</figcaption></figure><h4>Setting up Mlflow in Raspberry pi 5:</h4><p>For this experiment, I wanted to decouple the MLflow tracking server from my main development machine and run it on a Raspberry Pi instead, while keeping the actual metadata store on Postgres running on my Mac. The idea was simple on paper — point the Pi’s MLflow server at a Postgres instance over the local network as the backend store — but getting there meant untangling a few networking assumptions that Postgres makes by default. Out of the box, Postgres only listens on localhost and only trusts connections coming from the same machine, which meant the first few attempts to connect from the Pi were met with a flat connection refused, even though both machines could ping each other just fine. The fix involved opening up listen_addresses to accept connections on all interfaces and adding an explicit entry in pg_hba.conf to trust the Pi&#39;s IP with password authentication. One detail that cost me more time than it should have: on macOS, Homebrew installs versioned Postgres instances under paths like postgresql@15, and it&#39;s easy to end up editing a stale, unused config directory instead of the one the running service actually reads from. A quick SHOW config_file; inside psql settled that once and for all.</p><p><strong>1. Locate the correct Postgres config directory on the Mac</strong></p><p>If Postgres was installed via Homebrew, it’s likely versioned (e.g. postgresql@15). Confirm which config file the <em>running</em> instance actually uses before editing anything:</p><pre>psql -U &lt;your_user&gt; -d postgres -c &quot;SHOW config_file;&quot;</pre><p>This returns the real path, e.g. /opt/homebrew/var/postgresql@15/postgresql.conf. Always edit files in that directory — not a similarly named but unused one like /opt/homebrew/var/postgres/.</p><p><strong>2. Allow Postgres to listen on the network, not just localhost</strong></p><p>In postgresql.conf:</p><pre>cd /opt/homebrew/var/postgresql@15<br>sed -i &#39;&#39; &quot;s/#listen_addresses = &#39;localhost&#39;/listen_addresses = &#39;*&#39;/&quot; postgresql.conf</pre><p>Verify:</p><pre>grep -v &quot;^#&quot; postgresql.conf | grep listen_addresses<br># listen_addresses = &#39;*&#39;</pre><p>3. Whitelist the Pi’s IP in pg_hba.conf</p><pre>echo &quot;host  all  all  192.168.1.9/32  md5&quot; &gt;&gt; pg_hba.conf</pre><p>Verify</p><pre>grep &quot;192.168.1.9&quot; pg_hba.conf</pre><p>4. Restart Postgres and confirm it’s listening on all interfaces</p><pre>brew services restart postgresql@15<br>lsof -i -P | grep LISTEN | grep postgres</pre><p>You should see *:5432 in the output rather than localhost:5432. If it still shows localhost:5432, you&#39;re likely editing the wrong config directory (see Step 1).</p><p><strong>5. Create/verify the database user and password</strong></p><p>Confirm the role exists and has login privileges:</p><pre>psql -U &lt;admin_user&gt; -d postgres -c &quot;\du&quot;</pre><p>Set (or reset) the password for the user MLflow will connect as:</p><pre>psql -U &lt;admin_user&gt; -d postgres -c &quot;ALTER USER root WITH PASSWORD &#39;root&#39;;&quot;</pre><p>Confirm the target database exists, or create it:</p><pre>psql -U &lt;admin_user&gt; -d postgres -c &quot;\l&quot; | grep mlflow<br>psql -U &lt;admin_user&gt; -d postgres -c &quot;CREATE DATABASE mlflow OWNER root;&quot;</pre><p>6. Test raw connectivity from the Pi before touching MLflow</p><pre>nc -zv 192.168.1.8 5432</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5Sy_O1bQjoLeHq0zLOd9bA.png" /><figcaption>Mlflow server on raspberry pi calling back the mac for saving state to postgres.</figcaption></figure><p>Once the network layer was sorted and Postgres accepted authenticated connections from 192.168.1.9, the MLflow server on the Pi came up cleanly against the remote backend store.</p><pre>mlflow server \<br>  --backend-store-uri postgresql://root:root@192.168.1.8:5432/mlflow \<br>  --host 0.0.0.0 \<br>  --port 5000 \<br>  --allowed-hosts &quot;192.168.1.9:5000&quot; \<br>  --cors-allowed-origins &quot;http://192.168.1.9:5000&quot;</pre><p>The next wrinkle showed up not on the database side but in the browser — accessing the MLflow UI from the Pi&#39;s address triggered a wall of 403s in the server logs, all flagged as blocked cross-origin requests. This turned out to be a byproduct of MLflow&#39;s newer FastAPI-based server, which ships with security middleware from version 3.5.0 onward that explicitly validates allowed hosts and CORS origins rather than accepting requests from anywhere by default. Since the browser was pointed at the Pi&#39;s IP directly, that had to be declared as both an allowed host and an allowed CORS origin when starting the server. Once that was in place, validation was straightforward: the UI loaded without console errors, experiment runs and their metrics showed up in near real time, and a quick check against the mlflow database on the Mac confirmed that run metadata, parameters, and the registered medquad-embedder model with its champion alias were all persisting correctly to Postgres rather than to the default local SQLite file. That combination — a lightweight ARM device serving the tracking UI, with the durability of a proper relational backend sitting on a separate machine — turned out to be a genuinely low-overhead way to run MLflow for a home lab setup.</p><h4>The RAG architecture</h4><h3>Architecture Overview</h3><p>This architecture brings together <strong>MLflow, Qdrant, Ollama, embeddings, and multiple LLMs</strong> to build a RAG system that does more than just retrieve documents and send them to a language model. The idea is to manage the complete lifecycle, starting from embedding generation and model registration, all the way to vector search, query understanding, model selection, and response generation.</p><p>The architecture is divided into two logical stages. <strong>Stage 1 focuses on embeddings, knowledge indexing, and retrieval</strong>, while <strong>Stage 2 focuses on intelligent LLM routing and response generation</strong>.</p><h4>Stage 1: Building and Managing the Knowledge Layer</h4><p>The first important component is the <strong>embedding model</strong>. Instead of embedding documents using an unmanaged model hidden somewhere inside the application code, the embedding model is treated as a first-class component of the system.</p><p>The selected embedding model is registered in the <strong>MLflow Model Registry</strong>. This gives us a central place to manage which embedding model is being used by the application. This becomes particularly useful when we experiment with different embedding models or replace one model with another later.</p><p>The same registered embedding model is used for both sides of the retrieval process.</p><p>On the knowledge side, documents are converted into vector representations. These vectors capture the semantic meaning of the content rather than just the literal words appearing in the document. The generated <strong>dense embeddings, along with the corresponding document metadata</strong>, are stored inside <strong>Qdrant</strong>.</p><p>Qdrant becomes the retrieval layer of the architecture.</p><p>So instead of storing only something like:</p><p>&quot;MLflow provides model management capabilities&quot;</p><p>we store its semantic representation as a vector similar to:</p><p>[0.34, 0.198, -1.57, ...]</p><p>along with the original content and any useful metadata.</p><p>This indexing process happens ahead of the user’s query and creates the searchable knowledge base for the RAG application.</p><h4>What Happens When the User Asks a Question?</h4><p>When a user submits a question, that query goes through the <strong>same embedding model registered in MLflow</strong>.</p><p>For example, if the user asks:</p><blockquote><em>How can I manage different versions of my ML models?</em></blockquote><p>the query is converted into its own dense vector representation.</p><p>That query vector is then sent to <strong>Qdrant</strong>, where similarity search is performed against the vectors already stored in the collection.</p><p>The important point here is that retrieval is based on <strong>semantic similarity</strong>. Qdrant is not simply searching for documents containing the exact same words. It tries to identify pieces of knowledge whose meaning is close to the user’s question.</p><p>The most relevant pieces of information are returned as the <strong>search context</strong>.</p><p>At this point, the traditional RAG part of the architecture is complete.</p><p>We now have two important pieces of information:</p><p><strong>the original user query</strong> and <strong>the retrieved knowledge context</strong>.</p><p>But instead of immediately sending both of them to a fixed LLM, the architecture introduces another layer.</p><p>This is where the system becomes more interesting.</p><h4>Stage 2: Intelligent LLM Routing</h4><p>Many RAG applications are designed around a single LLM. Every request goes to the same model regardless of whether the question is simple, complex, technical, or computationally expensive.</p><p>That approach works, but it is not always efficient.</p><p>A simple question may not require a large cloud-hosted model. At the same time, a complex reasoning request might benefit from a much stronger model.</p><p>The <strong>AI Gateway</strong> in this architecture acts as the decision layer between the RAG pipeline and the available language models.</p><p>Before routing the request, the user query is classified using a <strong>small local model running through Ollama</strong>. The classifier tries to understand what kind of query has been submitted.</p><p>Based on that classification, the gateway decides which configured LLM should handle the request.</p><p>For example, the architecture may have models such as <strong>GPT-4.1</strong>, a smaller <strong>Gemma model</strong>, and another locally hosted <strong>Gemma model</strong> available behind the gateway.</p><p>A relatively straightforward request could therefore be handled by a smaller local model, while a question requiring stronger reasoning could be routed to GPT-4.1.</p><p>The routing decision happens dynamically rather than being hardcoded into the application.</p><h4>Bringing Retrieval and Routing Together</h4><p>The retrieved context from Qdrant and the original user query are then passed to the <strong>AI Gateway</strong>.</p><p>Conceptually, the request reaching the selected LLM looks something like:</p><p><strong>User Query + Retrieved Knowledge Context → Selected LLM</strong></p><p>The selected model uses the retrieved knowledge as grounding while generating the answer.</p><p>This is an important distinction.</p><p>The LLM is not expected to answer purely from what it learned during training. The relevant information has already been retrieved from Qdrant and supplied as context, allowing the model to generate a response grounded in the application’s knowledge base.</p><p>Once the selected model generates the answer, the response travels back through the gateway and is returned to the user.</p><h4>Why This Architecture Is Interesting</h4><p>What makes this architecture different from a basic RAG implementation is that <strong>retrieval and generation are treated as separate, manageable layers</strong>.</p><p>MLflow manages the embedding model and provides the model management layer. Qdrant handles high-performance semantic retrieval. Ollama provides lightweight local intelligence for understanding the incoming query. The AI Gateway decides which LLM is most appropriate, while the final language model focuses on producing the answer.</p><p>The resulting flow is essentially:</p><p><strong>User Query → Embedding → Qdrant Search → Relevant Context → Query Classification → LLM Routing → Grounded Response</strong></p><p>This gives us a RAG architecture where we are not only asking, <strong>“What information should I retrieve?”</strong></p><p>We are also asking:</p><p><strong>“Which model is the right model to answer this particular question?”</strong></p><p>That second question becomes increasingly important as applications start working with a mixture of <strong>local models, open-source models, and commercial LLM APIs</strong>.</p><p>Instead of tying the entire application to one model, this architecture creates a flexible RAG layer where <strong>knowledge retrieval, embedding management, and LLM selection can evolve independently</strong>.</p><h4>The Implementation</h4><p><strong>model_registry.py</strong> — the setup step. It loads two sentence-transformer models (Arctic-embed for mortgage, MiniLM for leukemia), logs each one to MLflow on the Raspberry Pi, and pins a champion alias to the latest version. So downstream code never hardcodes a version, it just asks for the champion.</p><pre>import mlflow<br>import mlflow.sentence_transformers<br>from mlflow import MlflowClient<br>from mlflow.models import infer_signature<br>from sentence_transformers import SentenceTransformer<br><br>mlflow.set_tracking_uri(&quot;http://192.168.1.9:5000&quot;)<br>mlflow.set_experiment(&quot;embedding-models&quot;)<br><br>EMBEDDERS = {<br>    &quot;mortgage-embedder&quot;: {&quot;source&quot;: &quot;Snowflake/snowflake-arctic-embed-m-v1.5&quot;},<br>    &quot;leukemia-embedder&quot;: {&quot;source&quot;: &quot;all-MiniLM-L6-v2&quot;},<br>}<br><br>sample_texts = [&quot;MLflow tracks embedding models&quot;, &quot;Qdrant stores vectors&quot;]<br>client = MlflowClient()<br><br>for name, cfg in EMBEDDERS.items():<br>    model = SentenceTransformer(<br>        cfg[&quot;source&quot;],<br>        trust_remote_code=cfg.get(&quot;trust_remote_code&quot;, False),<br>        config_kwargs=cfg.get(&quot;config_kwargs&quot;),<br>        device=cfg.get(&quot;device&quot;),<br>    )<br>    with mlflow.start_run(run_name=name):<br>        info = mlflow.sentence_transformers.log_model(<br>            model=model,<br>            name=&quot;embedder&quot;,<br>            signature=infer_signature(sample_texts, model.encode(sample_texts)),<br>            input_example=sample_texts,<br>            registered_model_name=name,<br>        )<br>    client.set_registered_model_alias(name, &quot;champion&quot;, info.registered_model_version)<br>    print(f&quot;{name}: v{info.registered_model_version}, dim={model.get_embedding_dimension()}&quot;)</pre><p><strong>ingest_data.py</strong> — the data loader. It wipes and rebuilds the leukemia_details collection in Qdrant, then uses a Chonkie pipeline to read PDFs from the parent folder, chunk them (512 tokens, 100 overlap), embed with MiniLM, and store the vectors. One chained call, no ceremony.</p><pre>import os<br>from chonkie import Pipeline<br>from qdrant_client import QdrantClient<br>from dotenv import load_dotenv, find_dotenv<br><br>load_dotenv(find_dotenv())<br><br>client = QdrantClient(url=&quot;http://localhost:6333&quot;, api_key=os.environ[&quot;QDRANT_API_KEY&quot;])<br>client.delete_collection(&quot;leukemia_details&quot;) # this will delete and recreate the collection.<br><br>(Pipeline()<br> .fetch_from(&quot;file&quot;, dir=&quot;../&quot;, ext=[&quot;.pdf&quot;])<br> .process_with(&quot;liteparse&quot;)<br> .chunk_with(&quot;recursive&quot;, chunk_size=512)<br> .refine_with(&quot;overlap&quot;, context_size=100)<br> .store_in(&quot;qdrant&quot;, collection_name=&quot;leukemia_details&quot;, client=client,<br>           embedding_model=&quot;sentence-transformers/all-MiniLM-L6-v2&quot;)<br> .run())<br><br>count = client.count(&quot;leukemia_details&quot;, exact=True).count<br>print(f&quot;Ingested {count} chunks into leukemia_details&quot;)</pre><p><strong>ai_gateway.py</strong> — the traffic cop. Every query first goes to a local Ollama model with a strict prompt: “is this LEUKEMIA, MORTGAGE, or OTHER?” Leukemia questions get sent to OpenAI, mortgage questions to Gemma, and anything else gets a polite refusal. All of it flows through the MLflow gateway using the plain OpenAI client, so swapping backends is a config change, not a code change.</p><pre>from openai import OpenAI<br>import mlflow<br><br>mlflow.set_tracking_uri(&quot;http://192.168.1.9:5000&quot;)<br><br>client = OpenAI(<br>    base_url=&quot;http://192.168.1.9:5000/gateway/mlflow/v1&quot;,<br>    api_key=&quot;no-api-key&quot;,<br>)<br><br>CLASSIFIER_PROMPT = (<br>    &quot;Classify the user query into exactly one label: LEUKEMIA, MORTGAGE, or OTHER.\n&quot;<br>    &quot;LEUKEMIA = questions about leukemia (AML, CML, ALL, CLL), its symptoms, diagnosis, &quot;<br>    &quot;treatment, prognosis, or related blood cancers.\n&quot;<br>    &quot;MORTGAGE = questions about home loans, mortgage rates, refinancing, EMI, &quot;<br>    &quot;eligibility, lenders, or mortgage documents.\n&quot;<br>    &quot;OTHER = everything else, including general medical questions not about leukemia.\n&quot;<br>    &quot;Reply with only the label.&quot;<br>)<br><br>ROUTES = {<br>    &quot;LEUKEMIA&quot;: &quot;openai&quot;,<br>    &quot;MORTGAGE&quot;: &quot;gemma&quot;,<br>}<br><br>REJECT_MESSAGE = (<br>    &quot;Sorry, this setup can only answer Leukemia-specific medical questions &quot;<br>    &quot;and mortgage-related questions.&quot;<br>)<br><br><br>def classify(query: str) -&gt; str:<br>    resp = client.chat.completions.create(<br>        model=&quot;ollama&quot;,  # local Ollama endpoint does the classification<br>        messages=[<br>            {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: CLASSIFIER_PROMPT},<br>            {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: query},<br>        ],<br>        temperature=0,<br>    )<br>    label = resp.choices[0].message.content.strip().upper()<br>    for intent in ROUTES:<br>        if intent in label:<br>            return intent<br>    return &quot;OTHER&quot;<br><br><br>def route(query: str) -&gt; str:<br>    intent = classify(query)<br>    target = ROUTES.get(intent)<br>    if target is None:<br>        print(f&quot;[intent={intent} -&gt; rejected]&quot;)<br>        return REJECT_MESSAGE<br>    resp = client.chat.completions.create(<br>        model=target,<br>        messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: query}],<br>    )<br>    print(f&quot;[intent={intent} -&gt; model={target}]&quot;)<br>    return resp.choices[0].message.content<br><br>#<br># if __name__ == &quot;__main__&quot;:<br>#     print(route(&quot;What are the first-line treatments for acute myeloid leukemia?&quot;))<br>#     print(route(&quot;How does refinancing affect my monthly mortgage EMI?&quot;))<br>#     print(route(&quot;What are the side effects of metformin?&quot;))   # medical but not leukemia -&gt; rejected<br>#     print(route(&quot;Write a haiku about monsoon in Hyderabad.&quot;))  # rejected</pre><p><strong>smart_rag.py</strong> — the actual chatbot. It reuses the classifier, then picks the matching Qdrant collection and embedder for that intent, pulls the top 5 chunks, stuffs them into a system prompt (“answer using only this context”), and sends it to the model the gateway routed to. Wrapped in a simple you&gt; / assistant&gt; loop.</p><pre>from qdrant_client import QdrantClient<br>import os<br><br>from ai_gateway import client, classify, ROUTES, REJECT_MESSAGE<br>from embedding_model_inference import EMBEDDERS<br>from dotenv import load_dotenv, find_dotenv<br><br>load_dotenv(find_dotenv())<br><br>COLLECTIONS = {<br>    &quot;LEUKEMIA&quot;: {&quot;collection&quot;: &quot;leukemia_details&quot;, &quot;embedder&quot;: &quot;leukemia-embedder&quot;},<br>    &quot;MORTGAGE&quot;: {&quot;collection&quot;: &quot;mortgage&quot;, &quot;embedder&quot;: &quot;mortgage-embedder&quot;},<br>}<br>TEXT_FIELD = &quot;text&quot;<br>EXIT_WORDS = {&quot;bye&quot;, &quot;exit&quot;, &quot;quit&quot;}<br><br><br>class SmartRAG:<br>    def __init__(self, qdrant_url=&quot;http://localhost:6333&quot;, top_k=5):<br>        self.qdrant = QdrantClient(url=qdrant_url, api_key=os.environ.get(&quot;QDRANT_API_KEY&quot;))<br>        self.top_k = top_k<br><br>    def retrieve(self, query, intent):<br>        cfg = COLLECTIONS[intent]<br>        embedder = EMBEDDERS[cfg[&quot;embedder&quot;]]<br>        prompt = &quot;query&quot; if &quot;query&quot; in embedder.prompts else None  # arctic needs its query prefix<br>        vec = embedder.encode(query, prompt_name=prompt, normalize_embeddings=True).tolist()<br>        hits = self.qdrant.query_points(cfg[&quot;collection&quot;], query=vec, limit=self.top_k, with_payload=True).points<br>        return &quot;\n\n&quot;.join(str(h.payload.get(TEXT_FIELD, h.payload)) for h in hits)<br><br>    def answer(self, query):<br>        intent = classify(query)<br>        if intent not in ROUTES:<br>            return REJECT_MESSAGE<br>        context = self.retrieve(query, intent)<br>        resp = client.chat.completions.create(<br>            model=ROUTES[intent],<br>            messages=[<br>                {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: f&quot;Answer using only this context:\n{context}&quot;},<br>                {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: query},<br>            ],<br>        )<br>        print(f&quot;[intent={intent} -&gt; {COLLECTIONS[intent][&#39;collection&#39;]} -&gt; {ROUTES[intent]}]&quot;)<br>        return resp.choices[0].message.content<br><br>    def run(self):<br>        while True:<br>            query = input(&quot;\nyou&gt; &quot;).strip()<br>            if query.lower() in EXIT_WORDS:<br>                break<br>            if query:<br>                print(&quot;\nassistant&gt;&quot;, self.answer(query))<br><br><br>if __name__ == &quot;__main__&quot;:<br>    SmartRAG().run()</pre><p><strong>embedding_model_inference.py</strong> pulls both embedders back out of MLflow by alias (models:/leukemia-embedder@champion, same for mortgage) and hands them over as live SentenceTransformer objects in the EMBEDDERS dict. That&#39;s exactly what smart_rag.py imports, so the loop is now closed: registry writes the champion, this file reads it.</p><pre>import mlflow<br>import mlflow.sentence_transformers<br><br>mlflow.set_tracking_uri(&quot;http://192.168.1.9:5000&quot;)<br><br>EMBEDDERS = {<br>    name: mlflow.sentence_transformers.load_model(f&quot;models:/{name}@champion&quot;)<br>    for name in (&quot;leukemia-embedder&quot;, &quot;mortgage-embedder&quot;)<br>}<br><br>QUERIES = {<br>    &quot;leukemia-embedder&quot;: &quot;What are the first-line treatments for acute myeloid leukemia?&quot;,<br>    &quot;mortgage-embedder&quot;: &quot;How does refinancing affect my monthly mortgage EMI?&quot;,<br>}<br><br>for name, embedder in EMBEDDERS.items():<br>    vecs = embedder.encode([QUERIES[name]], normalize_embeddings=True)<br>    print(f&quot;{name}: shape={vecs.shape}, prompts={list(embedder.prompts) or None}&quot;)</pre><h4>The result</h4><pre>/Users/pavanmantha/.local/bin/uv run /Users/pavanmantha/Pavans/PracticeExamples/DataScience_Practice/mlflow-tutorials/mlflow_on_edge/.venv/bin/python /Users/pavanmantha/Pavans/PracticeExamples/DataScience_Practice/mlflow-tutorials/mlflow_on_edge/src/smart_rag.py <br>Downloading artifacts: 100%|██████████| 16/16 [00:19&lt;00:00,  1.19s/it]<br>2026/09/06 12:48:35 INFO mlflow.sentence_transformers: &#39;models:/leukemia-embedder@champion&#39; resolved as &#39;mlflow-artifacts:/3/models/m-f568ee233dd748f997ba66763e41653b/artifacts&#39;<br>Downloading artifacts: 100%|██████████| 1/1 [00:00&lt;00:00, 76.69it/s]<br>Loading weights: 100%|██████████| 103/103 [00:00&lt;00:00, 15587.15it/s]<br>Downloading artifacts: 100%|██████████| 16/16 [01:29&lt;00:00,  5.60s/it]<br>2026/09/06 12:50:06 INFO mlflow.sentence_transformers: &#39;models:/mortgage-embedder@champion&#39; resolved as &#39;mlflow-artifacts:/3/models/m-c4221c844dfb45bfa15c6a4ecde56920/artifacts&#39;<br>Downloading artifacts: 100%|██████████| 1/1 [00:00&lt;00:00, 27.17it/s]<br>Loading weights: 100%|██████████| 197/197 [00:00&lt;00:00, 8855.57it/s]<br>[transformers] BertModel LOAD REPORT from: /var/folders/1x/36nz44_569501n4xs0px2_8m0000gn/T/tmpef4y6tj5/model.sentence_transformer<br>Key                 | Status  | <br>--------------------+---------+-<br>pooler.dense.weight | MISSING | <br>pooler.dense.bias   | MISSING | <br><br>Notes:<br>- MISSING: those params were newly initialized because missing from the checkpoint. Consider training on your downstream task.<br>leukemia-embedder: shape=(1, 384), prompts=[&#39;query&#39;, &#39;document&#39;]<br>mortgage-embedder: shape=(1, 768), prompts=[&#39;query&#39;, &#39;document&#39;]<br>/Users/pavanmantha/Pavans/PracticeExamples/DataScience_Practice/mlflow-tutorials/mlflow_on_edge/src/smart_rag.py:20: UserWarning: Api key is used with an insecure connection.<br>  self.qdrant = QdrantClient(url=qdrant_url, api_key=os.environ.get(&quot;QDRANT_API_KEY&quot;))<br><br>you&gt; What are the phases of treatment for acute lymphoblastic leukemia?<br>[intent=LEUKEMIA -&gt; leukemia_details -&gt; openai]<br><br>assistant&gt; The phases of treatment for acute lymphoblastic leukemia (ALL) are:<br><br>1. Induction - achieving remission.<br>2. Consolidation - eradicating residual disease with different agents to circumvent resistance.<br>3. Maintenance (in ALL) - prolonged low-dose therapy to suppress re-emergence of the clone.<br><br>you&gt; What was 30-day Average SOFR referred to in Series issued before July 2023?<br>[intent=MORTGAGE -&gt; mortgage -&gt; gemma]<br><br>assistant&gt; *   Question: &quot;What was 30-day Average SOFR referred to in Series issued before July 2023?&quot;<br>    *   Constraint: Use *only* the provided context.<br><br>    *   Chunk 1: Discusses &quot;Excess inclusion income&quot;, Residual Holders, taxable income, Trust REMIC taxable income, etc. No mention of SOFR or July 2023.<br>    *   Chunk 2: Discusses &quot;Backup withholding&quot;, Non-U.S. Persons, Ginnie Mae Guaranty, State/Local/Foreign tax. No mention of SOFR or July 2023.<br>    *   Chunk 3: Discusses &quot;Residual Holder&#39;s share of expenses&quot;, REITs, RICs, phantom income. No mention of SOFR or July 2023.<br>    *   Chunk 4: Discusses &quot;Principal Distribution Amount&quot;, Ginnie Mae Project Loan Certificates, Underlying Certificates. No mention of SOFR or July 2023.<br>    *   Chunk 5: Discusses &quot;OID on a Teaser Security&quot;, &quot;Initial Distribution Period&quot;, &quot;Excess Interest Payment&quot;, &quot;Accrual of Original Issue Discount&quot;. No mention of SOFR or July 2023.<br><br>    *   I have scanned all five provided text nodes.<br><br>you&gt; </pre><h4>The Conclusion</h4><p>Today we have seen how to build a <strong>multi-domain, intelligently routed RAG application</strong> where the embedding model, vector collection, and final LLM are selected based on the user’s query.</p><p>We used <strong>MLflow Model Registry</strong> to manage domain-specific embedding models through a champion alias, <strong>Chonkie and Qdrant</strong> to build the knowledge layer, <strong>Ollama</strong> to classify queries locally, and <strong>MLflow AI Gateway</strong> to route requests across different LLMs such as OpenAI and Gemma. Finally, we connected everything inside the RAG application so that each query automatically follows the most appropriate retrieval and generation path.</p><p>The important takeaway is that a RAG system does not have to be tied to <strong>one embedder, one collection, or one LLM</strong>. By separating these responsibilities and making the routing decision dynamically, we can build RAG applications that are much more flexible and easier to evolve as new models and domains are introduced.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=efd01ba33fc7" width="1" height="1" alt=""><hr><p><a href="https://blog.gopenai.com/rag-beyond-retrieval-model-registry-vector-search-and-intelligent-llm-routing-efd01ba33fc7">RAG Beyond Retrieval: Model Registry, Vector Search and Intelligent LLM Routing</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[MLflow MCP Registry in Action: An End-to-End Hands-On Guide]]></title>
            <link>https://blog.devops.dev/mlflow-mcp-registry-in-action-an-end-to-end-hands-on-guide-226fe3830471?source=rss-934e21252654------2</link>
            <guid isPermaLink="false">https://medium.com/p/226fe3830471</guid>
            <category><![CDATA[qdrant-mcp-server]]></category>
            <category><![CDATA[mlflow-platform]]></category>
            <category><![CDATA[fastmcp]]></category>
            <category><![CDATA[mlflow]]></category>
            <category><![CDATA[mcp-registry]]></category>
            <dc:creator><![CDATA[M K Pavan Kumar]]></dc:creator>
            <pubDate>Thu, 20 Aug 2026 10:35:17 GMT</pubDate>
            <atom:updated>2026-08-25T12:08:15.741Z</atom:updated>
            <content:encoded><![CDATA[<p>In this article, we will see how to take the <strong>MLflow MCP Registry</strong> from concept to a complete working setup. We will build MCP servers, test them with a client, register both custom and standard servers (Qdrant MCP Server), and then explore how MLflow helps us discover, manage, version, and connect them.</p><p>This is a completely <strong>hands-on journey</strong>, and the article is paired with a YouTube demo so you can follow the entire flow in action. By the end, the MCP Registry will feel less like a new feature and more like something you can immediately start using in your own agentic AI projects.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/884/1*Oobehq_bygFGhfLudrEXug.png" /><figcaption>Created by author M K Pavan Kumar</figcaption></figure><h4>The MLflow Platform</h4><p>MLflow is an <strong>open-source platform for managing the lifecycle of machine learning and Generative AI applications</strong>. It helps teams track experiments, manage models, evaluate outputs, monitor traces, and compare different versions of an application.</p><p>For GenAI development, MLflow is useful when working with <strong>LLMs, prompts, RAG pipelines, agents, tools, and MCP servers</strong>. It gives developers a central place to understand application behavior, measure quality, troubleshoot failures, and manage changes as the system evolves.</p><p>MLflow also offers a growing set of features such as <strong>Experiment Tracking, Model Registry, Prompt Management, GenAI Evaluation, Tracing and Observability, AI Gateway capabilities, and MCP Registry support</strong>.</p><p>In simple terms, MLflow provides the <strong>tracking, evaluation, observability, and lifecycle management layer</strong> needed to move GenAI applications from experimentation to production</p><h4>MCP Registry and its Features</h4><p>As the number of MCP servers in an organization starts growing, managing them through individual configuration files quickly becomes difficult. <strong>Which servers are available? Which version should an application use? What tools does a server expose? Where is the endpoint? How should a client connect to it?</strong> MLflow MCP Registry is designed to solve exactly this problem.</p><p>Introduced in <strong>MLflow 3.15.0</strong>, the MCP Registry provides a <strong>centralized catalog for registering, versioning, discovering, and managing MCP servers</strong>. Instead of every developer or AI agent maintaining its own collection of MCP configurations, the registry becomes a common place where MCP servers can be discovered and accessed.</p><p>The diagram above represents this idea very simply. We may have <strong>Server 1, Server 2, Server 3, and many more MCP servers</strong>, but they can all be registered with MLflow. Once registered, MLflow can maintain their metadata, semantic versions and aliases such as production or staging, discover the tools exposed by those servers, and maintain the endpoints required to connect to them.</p><p>An important thing to understand is that <strong>MLflow MCP Registry is not where your MCP server actually runs</strong>. The MCP server can continue running locally, in the cloud, or on another remote infrastructure. MLflow maintains the information required to <strong>discover, manage, version, and connect to that server</strong>. An access endpoint connects a registered server version or alias with the actual running MCP endpoint.</p><p>This is where the registry becomes useful for clients and AI agents. Instead of manually exchanging MCP configuration snippets, developers can register servers through <strong>server.json, the MLflow UI, or programmatically</strong>, discover their available tools, manage updates, and obtain ready-to-use connection configurations. In simple terms, <strong>MLflow MCP Registry becomes the single place between your growing collection of MCP servers and the clients or agents that want to use them.</strong></p><p><strong>Note:</strong> One thing to keep in mind while experimenting with it is that the <strong>MCP Registry is currently marked as an experimental MLflow feature</strong>, so its APIs and behavior may evolve in future releases.</p><h4>The Experiment</h4><p>As a first step start the mlflow server and back it with some persistant storage in my case its postgresql.</p><pre>mlflow server --backend-store-uri postgresql://root:root@localhost:5432/mlflow --host 127.0.0.1 --port 5000 </pre><p>We shall deal with 3 MCP servers</p><ol><li>Simple MCP Server (custom built)</li><li>Complex Postgres MCP Server (custom built)</li><li>A standard Qdrant server provided by Qdrant.tech</li></ol><p><strong>Simple Server:</strong></p><p>FastMCP(&quot;My MCP Server&quot;) creates the MCP server instance. The @mcp.tool(...) decorators then expose normal Python functions as <strong>MCP tools</strong>. In this example, the server exposes two tools: <strong>Greeting</strong> and <strong>Sendoff</strong>.</p><p>The greet() function accepts a name and returns &quot;Hello, &lt;name&gt;!&quot;, while the bye() function returns &quot;Bye, &lt;name&gt;!&quot;. Because these functions are decorated with @mcp.tool, an MCP client can discover and invoke them remotely.</p><p>Finally, mcp.run(transport=&quot;http&quot;, port=8000) starts the MCP server using <strong>HTTP transport on port 8000</strong>. Once it is running, an MCP client or later the <strong>MLflow MCP Registry</strong> can connect to this server and discover the tools it exposes.</p><pre>from fastmcp import FastMCP<br><br>mcp = FastMCP(&quot;My MCP Server&quot;)<br><br>@mcp.tool(<br>    name=&quot;Greeting&quot;,<br>    description=&quot;Greets the incoming message&quot;,<br>    title=&quot;Greeting&quot;,<br>)<br>def greet(name: str) -&gt; str:<br>    return f&quot;Hello, {name}!&quot;<br><br>@mcp.tool(<br>    name=&quot;Sendoff&quot;,<br>    description=&quot;Sendoff the incoming message&quot;,<br>    title=&quot;Sendoff&quot;,<br>)<br>def bye(name: str) -&gt; str:<br>    return f&quot;Bye, {name}!&quot;<br><br><br>if __name__ == &quot;__main__&quot;:<br>    mcp.run(transport=&quot;http&quot;, port=8000)</pre><p><strong>PostgreSQL Explorer MCP Server </strong>connects to a PostgreSQL instance and exposes database exploration capabilities as MCP tools. The PostgreSQL connection details such as host, port, username, password, SSL mode, and admin database are loaded from environment variables.</p><p>The server uses two helper functions, _connect() and _query(), to connect to PostgreSQL, execute read-only SQL queries, return the results as dictionaries, and close the connection after execution. This keeps the actual MCP tools simple and focused.</p><p>The important part is the set of <strong>MCP tools exposed by the server</strong>. ListDatabases discovers available PostgreSQL databases, ListSchemas finds schemas inside a database, ListTables discovers tables and views, and ListTableColumns provides column-level metadata such as datatype, nullability, and default values.</p><p>It goes one step further with relationship discovery. <strong>ListTableRelations</strong> identifies incoming and outgoing foreign-key relationships for an individual table, while <strong>ListSchemaRelations</strong> discovers relationships across the entire schema. This can even be used to construct a database relationship graph or ER diagram.</p><p>Finally, the server runs using <strong>HTTP transport on port 8000</strong>, exactly like our simple MCP example.</p><pre>&quot;&quot;&quot;<br>PostgreSQL Explorer MCP Server<br>================================<br><br>Exposes tools to browse a PostgreSQL server&#39;s structure:<br>  - list databases<br>  - list schemas within a database<br>  - list tables within a schema<br>  - list foreign-key relations for a single table<br>  - list all foreign-key relations (a relation graph) within a schema<br><br>Run (same pattern as the sample server):<br>    fastmcp run postgres_server.py --transport http --port 8000<br>    # or simply: python postgres_server.py<br>&quot;&quot;&quot;<br><br>import os<br>from typing import Any<br>from dotenv import load_dotenv, find_dotenv<br>import psycopg2<br>import psycopg2.extras<br>from fastmcp import FastMCP<br><br>load_dotenv(find_dotenv())<br><br>mcp = FastMCP(&quot;PostgreSQL Explorer&quot;)<br><br>PGHOST = os.environ.get(&quot;PGHOST&quot;, &quot;localhost&quot;)<br>PGPORT = int(os.environ.get(&quot;PGPORT&quot;, &quot;5432&quot;))<br>PGUSER = os.environ.get(&quot;PGUSER&quot;, &quot;root&quot;)<br>PGPASSWORD = os.environ.get(&quot;PGPASSWORD&quot;, &quot;root&quot;)<br>PGSSLMODE = os.environ.get(&quot;PGSSLMODE&quot;, &quot;prefer&quot;)<br>PGADMINDB = os.environ.get(&quot;PGADMINDB&quot;, &quot;postgres&quot;)<br><br>SYSTEM_SCHEMAS = (&quot;pg_catalog&quot;, &quot;information_schema&quot;, &quot;pg_toast&quot;)<br><br><br>def _connect(database: str):<br>    &quot;&quot;&quot;Open a connection to a specific database on the configured server.&quot;&quot;&quot;<br>    return psycopg2.connect(<br>        host=PGHOST,<br>        port=PGPORT,<br>        user=PGUSER,<br>        password=PGPASSWORD,<br>        dbname=database,<br>        sslmode=PGSSLMODE,<br>    )<br><br><br>def _query(database: str, sql: str, params: tuple = ()) -&gt; list[dict[str, Any]]:<br>    &quot;&quot;&quot;Run a read-only query against `database` and return rows as dicts.&quot;&quot;&quot;<br>    conn = _connect(database)<br>    try:<br>        with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:<br>            cur.execute(sql, params)<br>            return [dict(row) for row in cur.fetchall()]<br>    finally:<br>        conn.close()<br><br><br>@mcp.tool(<br>    name=&quot;ListDatabases&quot;,<br>    description=&quot;Lists all non-template databases on the configured PostgreSQL server, with owner and size.&quot;,<br>    title=&quot;List Databases&quot;,<br>)<br>def list_databases() -&gt; list[dict[str, Any]]:<br>    sql = &quot;&quot;&quot;<br>          SELECT<br>              d.datname AS database,<br>            pg_get_userbyid(d.datdba) AS owner,<br>            pg_size_pretty(pg_database_size(d.datname)) AS size<br>          FROM pg_database d<br>          WHERE d.datistemplate = false<br>          ORDER BY d.datname; \<br>          &quot;&quot;&quot;<br>    return _query(PGADMINDB, sql)<br><br><br>@mcp.tool(<br>    name=&quot;ListSchemas&quot;,<br>    description=&quot;Lists all user-defined schemas within a given database (excludes system schemas).&quot;,<br>    title=&quot;List Schemas&quot;,<br>)<br>def list_schemas(database: str) -&gt; list[dict[str, Any]]:<br>    sql = &quot;&quot;&quot;<br>          SELECT<br>              schema_name AS schema,<br>            schema_owner AS owner<br>          FROM information_schema.schemata<br>          WHERE schema_name NOT IN %s<br>            AND schema_name NOT LIKE &#39;pg_temp%%&#39;<br>            AND schema_name NOT LIKE &#39;pg_toast_temp%%&#39;<br>          ORDER BY schema_name; \<br>          &quot;&quot;&quot;<br>    return _query(database, sql, (SYSTEM_SCHEMAS,))<br><br><br>@mcp.tool(<br>    name=&quot;ListTables&quot;,<br>    description=&quot;Lists all tables (and views) within a given schema of a given database.&quot;,<br>    title=&quot;List Tables&quot;,<br>)<br>def list_tables(database: str, schema: str) -&gt; list[dict[str, Any]]:<br>    sql = &quot;&quot;&quot;<br>          SELECT<br>              table_name AS table,<br>            table_type AS type<br>          FROM information_schema.tables<br>          WHERE table_schema = %s<br>          ORDER BY table_name; \<br>          &quot;&quot;&quot;<br>    return _query(database, sql, (schema,))<br><br><br>@mcp.tool(<br>    name=&quot;ListTableColumns&quot;,<br>    description=&quot;Lists columns, data types, nullability, and defaults for a given table.&quot;,<br>    title=&quot;List Table Columns&quot;,<br>)<br>def list_table_columns(database: str, schema: str, table: str) -&gt; list[dict[str, Any]]:<br>    sql = &quot;&quot;&quot;<br>          SELECT<br>              column_name AS column,<br>            data_type AS type,<br>            is_nullable AS nullable,<br>            column_default AS default<br>          FROM information_schema.columns<br>          WHERE table_schema = %s AND table_name = %s<br>          ORDER BY ordinal_position; \<br>          &quot;&quot;&quot;<br>    return _query(database, sql, (schema, table))<br><br><br>@mcp.tool(<br>    name=&quot;ListTableRelations&quot;,<br>    description=(<br>            &quot;Lists foreign-key relations for a single table: relations where this table &quot;<br>            &quot;references another table (outgoing), and relations where other tables &quot;<br>            &quot;reference this table (incoming).&quot;<br>    ),<br>    title=&quot;List Table Relations&quot;,<br>)<br>def list_table_relations(database: str, schema: str, table: str) -&gt; dict[str, Any]:<br>    outgoing_sql = &quot;&quot;&quot;<br>                   SELECT<br>                       tc.constraint_name,<br>                       kcu.column_name AS local_column,<br>                       ccu.table_schema AS foreign_schema,<br>                       ccu.table_name AS foreign_table,<br>                       ccu.column_name AS foreign_column<br>                   FROM information_schema.table_constraints tc<br>                            JOIN information_schema.key_column_usage kcu<br>                                 ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema<br>                            JOIN information_schema.constraint_column_usage ccu<br>                                 ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema<br>                   WHERE tc.constraint_type = &#39;FOREIGN KEY&#39;<br>                     AND tc.table_schema = %s<br>                     AND tc.table_name = %s<br>                   ORDER BY tc.constraint_name; \<br>                   &quot;&quot;&quot;<br>    incoming_sql = &quot;&quot;&quot;<br>                   SELECT<br>                       tc.constraint_name,<br>                       tc.table_schema AS referencing_schema,<br>                       tc.table_name AS referencing_table,<br>                       kcu.column_name AS referencing_column,<br>                       ccu.column_name AS local_column<br>                   FROM information_schema.table_constraints tc<br>                            JOIN information_schema.key_column_usage kcu<br>                                 ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema<br>                            JOIN information_schema.constraint_column_usage ccu<br>                                 ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema<br>                   WHERE tc.constraint_type = &#39;FOREIGN KEY&#39;<br>                     AND ccu.table_schema = %s<br>                     AND ccu.table_name = %s<br>                   ORDER BY tc.constraint_name; \<br>                   &quot;&quot;&quot;<br>    return {<br>        &quot;table&quot;: table,<br>        &quot;schema&quot;: schema,<br>        &quot;outgoing&quot;: _query(database, outgoing_sql, (schema, table)),<br>        &quot;incoming&quot;: _query(database, incoming_sql, (schema, table)),<br>    }<br><br><br>@mcp.tool(<br>    name=&quot;ListSchemaRelations&quot;,<br>    description=(<br>            &quot;Lists all foreign-key relations across every table in a schema, as a flat &quot;<br>            &quot;edge list (from_table.from_column -&gt; to_table.to_column). Useful for &quot;<br>            &quot;building a full relationship graph / ERD of the schema.&quot;<br>    ),<br>    title=&quot;List Schema Relations&quot;,<br>)<br>def list_schema_relations(database: str, schema: str) -&gt; list[dict[str, Any]]:<br>    sql = &quot;&quot;&quot;<br>          SELECT<br>              tc.constraint_name,<br>              tc.table_name AS from_table,<br>              kcu.column_name AS from_column,<br>              ccu.table_name AS to_table,<br>              ccu.column_name AS to_column<br>          FROM information_schema.table_constraints tc<br>                   JOIN information_schema.key_column_usage kcu<br>                        ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema<br>                   JOIN information_schema.constraint_column_usage ccu<br>                        ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema<br>          WHERE tc.constraint_type = &#39;FOREIGN KEY&#39;<br>            AND tc.table_schema = %s<br>          ORDER BY tc.table_name, tc.constraint_name; \<br>          &quot;&quot;&quot;<br>    return _query(database, sql, (schema,))<br><br><br>if __name__ == &quot;__main__&quot;:<br>    mcp.run(transport=&quot;http&quot;, port=8000)</pre><p>Testing these MCP servers in a standalone mode using FastMCP client is as below. before the client is run we should run the MCP server and it should be up and running.</p><pre>import asyncio<br>from typing import Any<br>import json<br><br>from fastmcp import Client<br><br>client = Client(&quot;http://localhost:8000/mcp&quot;)<br><br>async def call_tool(tool_name: str):<br>    async with client:<br>        tool_result: Any = await client.call_tool(name=tool_name)<br>        for tool in tool_result.content:<br>            for obj in json.loads(tool.text):<br>                print(obj[&#39;database&#39;])<br><br><br>asyncio.run(call_tool(&quot;ListDatabases&quot;))</pre><h4>Registering MCP Servers:</h4><p>There are <strong>two simple ways to register an MCP server in MLflow MCP Registry: through the UI or programmatically</strong>.</p><p>Using the <strong>MLflow UI</strong>, you open the MCP Registry, choose to create a new MCP server, provide the server details such as name, version, description, and remote MCP endpoint, and then register it. This is the easiest option when you want to quickly add and inspect a server manually.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oL-obJr2-OYlN_gFTyPhNA.png" /></figure><p>Using the <strong>programmatic approach</strong>, you can register the MCP server directly from Python using MLflow APIs. This is more suitable for automation, CI/CD pipelines, or environments where MCP servers are created and updated frequently.</p><pre>import asyncio<br>from typing import Any<br><br>import mlflow<br><br># Point at the same MLflow server your browser UI is connected to.<br># Check the URL in your browser&#39;s address bar — commonly http://127.0.0.1:5000<br>mlflow.set_tracking_uri(&quot;http://127.0.0.1:5000&quot;)<br><br>async def register_postgresql_mcp_server():<br>    version = mlflow.genai.register_mcp_server(<br>        server_json={<br>            &quot;name&quot;: &quot;io.github.pavanjava/postgresql-server&quot;,<br>            &quot;version&quot;: &quot;0.1.0&quot;,<br>            &quot;description&quot;: &quot;PostgreSQL FastMCP server exposing DB tools&quot;,<br>            &quot;remotes&quot;: [<br>                {&quot;url&quot;: &quot;http://localhost:8000/mcp&quot;, &quot;type&quot;: &quot;streamable-http&quot;},<br>            ],<br>        },<br>        status=&quot;active&quot;,<br>        source=&quot;local dev server via fastmcp&quot;,<br>        create_access_endpoints_from_remotes=True,<br>    )<br>    print(f&quot;Registered &#39;{version.name}&#39; version {version.version}&quot;)<br><br>async def register_qdrant_mcp_server():<br>    version = mlflow.genai.register_mcp_server(<br>        server_json={<br>            &quot;name&quot;: &quot;io.github.qdrant/mcp-server-qdrant&quot;,<br>            &quot;version&quot;: &quot;0.1.0&quot;,<br>            &quot;description&quot;: &quot;Qdrant MCP server exposing vector search tools&quot;,<br>            &quot;packages&quot;: [<br>                {<br>                    &quot;registryType&quot;: &quot;pypi&quot;,<br>                    &quot;identifier&quot;: &quot;mcp-server-qdrant&quot;,<br>                    &quot;version&quot;: &quot;0.8.1&quot;,<br>                    &quot;runtimeHint&quot;: &quot;uvx&quot;,<br>                    &quot;transport&quot;: {&quot;type&quot;: &quot;stdio&quot;},<br>                    &quot;environmentVariables&quot;: [<br>                        {&quot;name&quot;: &quot;QDRANT_URL&quot;, &quot;description&quot;: &quot;Qdrant endpoint URL&quot;, &quot;isRequired&quot;: True, &quot;isSecret&quot;: False},<br>                        {&quot;name&quot;: &quot;QDRANT_API_KEY&quot;, &quot;description&quot;: &quot;Qdrant API key&quot;, &quot;isRequired&quot;: True, &quot;isSecret&quot;: True},<br>                        {&quot;name&quot;: &quot;COLLECTION_NAME&quot;, &quot;description&quot;: &quot;Collection name&quot;, &quot;default&quot;: &quot;code&quot;},<br>                        {&quot;name&quot;: &quot;EMBEDDING_MODEL&quot;, &quot;description&quot;: &quot;Embedding model&quot;, &quot;default&quot;: &quot;BAAI/bge-small-en-v1.5&quot;},<br>                    ],<br>                }<br>            ],<br>        },<br>        status=&quot;active&quot;,<br>        source=&quot;local dev server via uvx mcp-server-qdrant&quot;,<br>    )<br><br>    print(f&quot;Registered &#39;{version.name}&#39; version {version.version}&quot;)<br><br><br>if __name__ == &quot;__main__&quot;:<br>    # asyncio.run(register_mcp())<br>    asyncio.run(register_qdrant_mcp_server())</pre><p>Once registered, MLflow can maintain the server metadata, versions and aliases, discover the tools exposed by the MCP server, and provide the connection information required by clients and agents.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tVzckV7WPV1B9APSjodKKA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jxkaNg78kD7WFeHziTNj-A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ku4FV74ccKTOXWYjI7RqWg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gFAJEqijPlNyvPt3mIIIHg.png" /></figure><p>Now lets access these registered MCP servers using the client to see what they have programatically.</p><pre>import asyncio<br>from typing import Any<br><br>import mlflow<br><br># Point at the same MLflow server your browser UI is connected to.<br># Check the URL in your browser&#39;s address bar — commonly http://127.0.0.1:5000<br>mlflow.set_tracking_uri(&quot;http://127.0.0.1:5000&quot;)<br><br><br>async def discover_tools():<br>    server_version: Any = mlflow.genai.refresh_mcp_server_version_tools(<br>        name=&quot;io.github.pavanjava/postgresql-server&quot;,<br>        version=&quot;0.1.0&quot;,<br>    )<br>    for tool in server_version.tools:<br>        print(tool.name)<br><br>async def list_endpoints():<br>    # List all endpoints for a server<br>    endpoints = mlflow.genai.search_mcp_access_endpoints(<br>        server_name=&quot;io.github.pavanjava/postgresql-server&quot;,<br>    )<br><br>    for ep in endpoints:<br>        resolved = ep.resolved_version.version if ep.resolved_version else None<br>        print(f&quot;  {ep.url} ({ep.transport_type}) → version {resolved}&quot;)<br><br>if __name__ == &quot;__main__&quot;:<br>    asyncio.run(discover_tools())<br>    # asyncio.run(list_endpoints())</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*A2zQ41yDT-7CO1f36WyM4A.png" /></figure><h4>The Conclusion:</h4><p>In this hands-on session, we went beyond just understanding the MLflow MCP Registry. We built <strong>custom MCP servers</strong>, registered both a <strong>standard Qdrant MCP server </strong>and our own custom MCP servers, and explored how to manage them through the registry.</p><p>We also saw how to register servers using both the <strong>MLflow UI and programmatically</strong>, access them from MCP clients, and finally integrate the registered MCP server with <strong>Claude Code</strong> to validate the complete flow end to end.</p><p>Overall, the MLflow MCP Registry gives us a practical way to <strong>organize, discover, manage, and consume MCP servers</strong> as our agentic AI ecosystem grows.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=226fe3830471" width="1" height="1" alt=""><hr><p><a href="https://blog.devops.dev/mlflow-mcp-registry-in-action-an-end-to-end-hands-on-guide-226fe3830471">MLflow MCP Registry in Action: An End-to-End Hands-On Guide</a> was originally published in <a href="https://blog.devops.dev/">DevOps.dev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Observable Voice RAG Agents: Tracing RAG Agents End-to-End with MLflow and Qdrant]]></title>
            <link>https://blog.stackademic.com/observable-voice-rag-agents-tracing-rag-agents-end-to-end-with-mlflow-and-qdrant-61404a6b0791?source=rss-934e21252654------2</link>
            <guid isPermaLink="false">https://medium.com/p/61404a6b0791</guid>
            <category><![CDATA[voice-rag]]></category>
            <category><![CDATA[cascaded-ai]]></category>
            <category><![CDATA[livekit]]></category>
            <category><![CDATA[mlflow-tracking]]></category>
            <category><![CDATA[qdrant]]></category>
            <dc:creator><![CDATA[M K Pavan Kumar]]></dc:creator>
            <pubDate>Sun, 16 Aug 2026 03:10:41 GMT</pubDate>
            <atom:updated>2026-08-16T03:10:41.820Z</atom:updated>
            <content:encoded><![CDATA[<p>Today in this article, we shall look at how to build a <strong>voice-enabled RAG agent</strong> that can interact with users in real time, retrieve relevant knowledge using <strong>Qdrant vector search</strong>, generate grounded responses through an LLM, and provide <strong>end-to-end traceability with MLflow</strong>.</p><p>We will walk through the complete architecture, starting from speech input and speech-to-text processing, moving through semantic retrieval and RAG-based context generation, and finally converting the response back into speech. We will also see how <strong>LiveKit</strong> enables real-time voice interaction, <strong>LlamaIndex</strong> manages the knowledge ingestion and retrieval workflow, <strong>Qdrant</strong> powers semantic search, and <strong>MLflow</strong> helps trace and observe the agent’s execution.</p><p>The goal is to move beyond a simple voice chatbot and build a <strong>production-oriented, observable Voice RAG system</strong> where every interaction, retrieval step, LLM call, and response can be traced and understood.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W6b7gijsHT1CWdswFq4gwg.png" /><figcaption>created by author M K Pavan Kumar</figcaption></figure><h4>The Architecture</h4><p>The idea behind this project is simple. A user should be able to speak naturally, ask a mortgage-related question, and get a useful answer back in voice. At the same time, the answer should come from trusted mortgage documents rather than only from the LLM’s own knowledge.</p><p>The flow starts with the mortgage guideline documents. These documents are processed using <strong>LlamaIndex</strong> and stored in <strong>Qdrant Vector Search Engine</strong> in a form that makes semantic search possible. This means the system can find information based on the meaning of the user’s question, not just on exact keyword matches.</p><p>When the user starts speaking, <strong>LiveKit</strong> manages the voice session. <strong>Silero VAD</strong> detects when the user is speaking, and <strong>Cartesia Speech-to-Text</strong> converts the speech into text. That text is then passed to the voice agent.</p><p>The voice agent uses <strong>GPT-4o-mini</strong> as its main language model. The agent is instructed to answer only mortgage-related questions and to use the retrieval tool whenever it needs information. This is important because the agent should not simply generate an answer from memory. It should first look for supporting information from the mortgage knowledge base.</p><p>For retrieval, the agent calls a tool named <strong>query_info</strong>. This tool uses <strong>LlamaIndex</strong> to search the mortgage collection in <strong>Qdrant Vector Search Engine</strong>. The most relevant pieces of information are returned to the agent as context.</p><p>The LLM then uses this retrieved context to prepare a short and conversational answer. Since this is a voice application, the response should sound natural when spoken and should not be unnecessarily long.</p><p>The generated text is then passed to <strong>Cartesia Text-to-Speech</strong>, which converts the answer back into audio. LiveKit sends this audio back to the user, completing the full speech-to-speech interaction.</p><p>Alongside this entire flow, <strong>MLflow tracing</strong> is connected through <strong>OpenTelemetry</strong>. This gives us visibility into what is happening inside the agent while a conversation is running. Instead of treating the voice agent as a black box, we can trace the agent execution and understand how a request moves through the system.</p><p>So, at a high level, the flow is <strong>User Speech → Speech-to-Text → Voice Agent → Retrieval → Qdrant Search → Retrieved Context → LLM Response → Text-to-Speech → User</strong>. <strong>MLflow</strong> sits alongside this flow and provides the tracing needed to observe the agent.</p><h4>The Implementation</h4><p>The project structure is something as below</p><pre>.<br>├── __init__.py<br>├── .env<br>├── agent_with_langfuse_observability.py<br>└── agent_with_mlflow_observability.py</pre><p>The sample .env as below, The implementation starts by loading the required environment variables using load_dotenv(). These environment variables hold the configuration needed by services such as LiveKit, OpenAI, Cartesia, Qdrant, and OpenTelemetry.</p><pre># Cartesia (STT + TTS)<br>CARTESIA_API_KEY=<br><br># OpenAI<br>OPENAI_API_KEY=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<br><br># LiveKit local dev server (default dev credentials)<br>LIVEKIT_URL=ws://localhost:7880<br>LIVEKIT_API_KEY=devkey<br>LIVEKIT_API_SECRET=secret<br><br># langfuse<br>LANGFUSE_SECRET_KEY=<br>LANGFUSE_PUBLIC_KEY=<br>LANGFUSE_HOST=http://172.20.10.8:3000<br><br># mlflow<br>OTEL_EXPORTER_OTLP_ENDPOINT=http://0.0.0.0:5000<br>OTEL_EXPORTER_OTLP_HEADERS=x-mlflow-experiment-id=0<br>OTEL_SERVICE_NAME=livekit-voice-agent</pre><pre>import os<br>import logging<br><br>from livekit.agents import JobContext, JobProcess, WorkerOptions, cli, function_tool<br>from livekit.agents.voice import Agent, AgentSession<br>from livekit.agents.telemetry import set_tracer_provider<br>from livekit.plugins import openai, silero, cartesia<br><br>from opentelemetry import trace<br>from opentelemetry.sdk.trace import TracerProvider<br>from opentelemetry.sdk.trace.export import BatchSpanProcessor<br>from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter<br>from opentelemetry.sdk.resources import Resource, SERVICE_NAME<br><br>from dotenv import load_dotenv, find_dotenv<br><br>from llama_index.core import VectorStoreIndex, Settings, SimpleDirectoryReader, StorageContext<br>from llama_index.vector_stores.qdrant import QdrantVectorStore<br>from llama_index.embeddings.ollama import OllamaEmbedding<br>from llama_index.llms.ollama import Ollama<br>from qdrant_client import QdrantClient, AsyncQdrantClient<br><br>load_dotenv(find_dotenv())</pre><p>Next, <strong>LlamaIndex</strong> is configured with embeddinggemma:latest as the embedding model. This model is used when a user query needs to be represented as an embedding for semantic retrieval. The code also configures gemma3:latest as the default LlamaIndex LLM, while the actual voice conversation is handled separately by <strong>GPT-4o-mini</strong> inside the LiveKit agent.</p><p>The code then creates both a <strong>QdrantClient</strong> and an <strong>AsyncQdrantClient</strong>. These clients connect the application to the locally running <strong>Qdrant Vector Search Engine</strong>. The mortgage collection is then connected to LlamaIndex through <strong>QdrantVectorStore</strong>.</p><p>After this connection is created, <strong>VectorStoreIndex.from_vector_store()</strong> gives LlamaIndex access to the already existing mortgage collection. At this stage, the code is not loading or ingesting the documents again. It is simply connecting to the knowledge that has already been stored in Qdrant.</p><pre># configs<br>Settings.embed_model = OllamaEmbedding(model_name=&quot;embeddinggemma:latest&quot;)<br>Settings.llm = Ollama(model=&quot;gemma3:latest&quot;)<br><br># creates a persistant index to disk<br>client = QdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;)<br>aclient = AsyncQdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;)<br><br># create our vector store with hybrid indexing enabled<br>vector_store = QdrantVectorStore(<br>    &quot;mortgage&quot;,<br>    client=client,<br>    aclient=aclient<br>)<br><br># create the index reference to vector store<br>index = VectorStoreIndex.from_vector_store(vector_store=vector_store)<br><br>logging.basicConfig(level=logging.INFO)<br>logger = logging.getLogger(&quot;voice-agent&quot;)</pre><p>The <strong>query_info()</strong> function is the main retrieval function in the project. It is decorated with <strong>@function_tool()</strong>, which means the voice agent can call it as a tool. When the agent receives a mortgage question, this function creates a retriever, searches for the top relevant results, collects the text from those results, and sends that information back to the agent.</p><pre>@function_tool()<br>async def query_info(query: str) -&gt; str:<br>    &quot;&quot;&quot;Get more information about a specific topic&quot;&quot;&quot;<br>    print(f&quot;query: {query}&quot;)<br>    retriever = index.as_retriever(similarity_top_k=20)<br>    nodes_with_scores = retriever.retrieve(query)<br>    context = &quot;&quot;<br>    for node in nodes_with_scores:<br>        print(f&quot;Query result: {node.text}&quot;)<br>        context = context + node.text + &quot;. \n&quot;<br>    return context</pre><p>The <strong>configure_mlflow_tracing()</strong> function is responsible for tracing. It checks whether an OpenTelemetry endpoint has been configured. If it is available, the function creates a tracer provider and connects it to an OTLP exporter. The same tracer is also registered with LiveKit. This is what allows agent activity to be sent to <strong>MLflow</strong> for tracing and inspection.</p><pre>def configure_mlflow_tracing():<br>    &quot;&quot;&quot;Configure OpenTelemetry to send traces to MLflow.&quot;&quot;&quot;<br>    if not os.getenv(&quot;OTEL_EXPORTER_OTLP_ENDPOINT&quot;):<br>        logger.warning(&quot;OTEL_EXPORTER_OTLP_ENDPOINT not set, tracing disabled&quot;)<br>        return None<br><br>    service_name = os.getenv(&quot;OTEL_SERVICE_NAME&quot;, &quot;livekit-voice-agent&quot;)<br>    resource = Resource.create({SERVICE_NAME: service_name})<br><br>    provider = TracerProvider(resource=resource)<br>    provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))<br><br>    # Set both global and LiveKit tracer provider<br>    trace.set_tracer_provider(provider)<br>    set_tracer_provider(provider)<br><br>    logger.info(&quot;MLflow tracing configured successfully!&quot;)<br>    return provider</pre><p>The <strong>entrypoint()</strong> function is where the actual voice agent is created. It first connects to the LiveKit room. It then creates an <strong>Agent</strong> with a set of instructions that define how the agent should behave. These instructions tell the agent to stay within the mortgage domain, keep answers short, use the retrieval tool, and clearly say when the required information is not available.</p><pre>async def entrypoint(ctx: JobContext) -&gt; None:<br>    &quot;&quot;&quot;Main entrypoint for the agent.&quot;&quot;&quot;<br>    logger.info(f&quot;Agent starting for room: {ctx.room.name}&quot;)<br><br>    # Connect to the room<br>    await ctx.connect()<br><br>    # Create the voice agent with all components<br>    agent = Agent(<br>        instructions=(<br>            &quot;You are a helpful mortgage voice assistant. Your interface &quot;<br>            &quot;with users will be voice. You should use short and concise &quot;<br>            &quot;responses, and avoiding usage of unpronouncable punctuation.&quot;<br>            &quot;Always use your tools to get the realtime information.&quot;<br>            &quot;If you dont find any information politely respond the same to user.&quot;<br>            &quot;Never answer anything other than mortgage subject and reject politely.&quot;<br>            &quot;Keep your responses concise and conversational since you&#39;re speaking out loud.&quot;<br>            &quot;Be friendly and helpful.&quot;<br>        ),<br>        stt=cartesia.STT(), # Speech-to-Text<br>        llm=openai.LLM(model=&quot;gpt-4o-mini&quot;), # Language Model<br>        # voice-id: 9626c31c-bec5-4cca-baa8-f8ba9e84c8bc, f786b574-daa5-4673-aa0c-cbe3e8534c02, 630ed21c-2c5c-41cf-9d82-10a7fd668370<br>        tts=cartesia.TTS(model=&quot;sonic-3&quot;, voice=&quot;630ed21c-2c5c-41cf-9d82-10a7fd668370&quot;), # Text-to-Speech<br>        vad=silero.VAD.load(),<br>        tools=[query_info]<br>    )<br><br>    # Create and start the agent session<br>    session = AgentSession()<br>    await session.start(agent, room=ctx.room)<br><br>    logger.info(&quot;Agent session started! Ready for conversation.&quot;)</pre><p>Inside the same agent configuration, <strong>Cartesia STT</strong> is used for speech-to-text, <strong>GPT-4o-mini</strong> is used for reasoning and response generation, <strong>Cartesia Sonic-3</strong> is used for text-to-speech, and <strong>Silero VAD</strong> is used to detect speech activity. The query_info function is also attached as a tool so that the LLM can access the mortgage knowledge base when needed.</p><p>The <strong>AgentSession()</strong> then starts the agent inside the LiveKit room. Once this session starts, the agent is ready to listen to the user, understand the question, retrieve information, generate an answer, and speak the response back.</p><p>The <strong>prewarm()</strong> function runs before the actual conversation starts. It configures the tracing and loads the Silero VAD model ahead of time. Preloading the VAD model helps avoid unnecessary startup delay when the first user joins the session.</p><pre>def prewarm(proc: JobProcess) -&gt; None:<br>    &quot;&quot;&quot;Prewarm function to load models before handling requests.&quot;&quot;&quot;<br>    # Configure tracing before anything else<br>    configure_mlflow_tracing()<br><br>    # Preload Silero VAD model for faster startup<br>    proc.userdata[&quot;vad&quot;] = silero.VAD.load()<br>    logger.info(&quot;Prewarmed VAD model&quot;)</pre><p>Finally, <strong>cli.run_app()</strong> starts the LiveKit worker. It tells LiveKit which function should start the agent and which function should run during prewarming.</p><pre>if __name__ == &quot;__main__&quot;:<br>    cli.run_app(<br>        WorkerOptions(<br>            entrypoint_fnc=entrypoint,<br>            prewarm_fnc=prewarm,<br>        )<br>    )</pre><p>Overall, the implementation keeps each part of the system focused on one job. <strong>LiveKit manages the real-time conversation, Cartesia handles speech, GPT-4o-mini handles reasoning, LlamaIndex handles retrieval logic, Qdrant Vector Search Engine performs semantic search, and MLflow provides traceability.</strong></p><h4>How to run?</h4><p>To run the <strong>Voice RAG agent locally</strong>, first install the <strong>LiveKit Server</strong> and <strong>LiveKit CLI</strong> using Homebrew. Start the local LiveKit server with</p><pre>livekit-server --dev</pre><p>and keep that terminal running. In your project .env file, configure</p><pre>LIVEKIT_URL=ws://localhost:7880<br>LIVEKIT_API_KEY=devkey<br>LIVEKIT_API_SECRET=secret</pre><p>Make sure the other required services, such as <strong>Qdrant</strong>, <strong>Ollama</strong>, and the credentials needed for <strong>OpenAI, Cartesia, and MLflow tracing</strong>, are also available. Then open another terminal in the project directory and start the agent in console mode using</p><pre>python voice_agent.py console</pre><p>or the corresponding agent filename in your project. The agent will then use your system’s microphone and speakers directly, allowing you to speak to the <strong>Voice RAG agent</strong> and receive spoken responses.</p><h4>The Experiment Results</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tIvi4BxcJllwe2PlZ9cOow.png" /><figcaption>mlflow server running on port 5000</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iBj9kr3Wli4sHHrW9xZkig.png" /><figcaption>livekit dev server running on 7882</figcaption></figure><p>The below run log shows how the Agent was able to connect to Qdrant Search engine to fetch the related context and metedata like page numbers.</p><pre>╭─    ~/Pavans/Workshops/s2s_agents/livekit_observability  on   main !3 ?84 ···························································· ✔  s2s_agents   base   at 08:15:41 AM  <br>╰─ python agent_with_mlflow_observability.py console<br>/Users/pavanmantha/Pavans/Workshops/s2s_agents/livekit_observability/agent_with_mlflow_observability.py:30: UserWarning: Api key is used with an insecure connection.<br>  client = QdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;)<br>/Users/pavanmantha/Pavans/Workshops/s2s_agents/livekit_observability/agent_with_mlflow_observability.py:31: UserWarning: Api key is used with an insecure connection.<br>  aclient = AsyncQdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;th3s3cr3tk3y&quot;)<br>    Agents   Starting console mode 🚀<br>              <br>DEBUG:asyncio:Using selector: KqueueSelector<br>    08:15:55.965 DEBUG    asyncio            Using selector: KqueueSelector  <br>INFO:livekit.agents:starting worker<br>    08:15:55.967 INFO     livekit.agents     starting worker {&quot;version&quot;: &quot;1.6.7&quot;, &quot;rtc-version&quot;: &quot;1.1.13&quot;}<br>INFO:livekit.agents:plugin registered<br>                 INFO     livekit.agents     plugin registered {&quot;plugin&quot;: &quot;livekit.plugins.openai&quot;, &quot;version&quot;: &quot;1.6.7&quot;}<br>INFO:livekit.agents:plugin registered<br>    08:15:55.968 INFO     livekit.agents     plugin registered {&quot;plugin&quot;: &quot;livekit.plugins.silero&quot;, &quot;version&quot;: &quot;1.6.7&quot;}<br>INFO:livekit.agents:plugin registered<br>                 INFO     livekit.agents     plugin registered {&quot;plugin&quot;: &quot;livekit.plugins.cartesia&quot;, &quot;version&quot;: &quot;1.6.7&quot;}<br>INFO:livekit.agents:HTTP server listening on :63907<br>    08:15:55.969 INFO     livekit.agents     HTTP server listening on :63907  <br>/Users/pavanmantha/Pavans/Workshops/s2s_agents/.venv/lib/python3.12/site-packages/jwt/api_jwt.py:147: InsecureKeyLengthWarning: The HMAC key is 6 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.<br>  return self._jws.encode(<br>WARNING:livekit.agents:no warmed process available for job, waiting for one to be created<br>    08:15:55.985 WARNING  livekit.agents     no warmed process available for job, waiting for one to be created {&quot;job_id&quot;: &quot;mock-job-dfd2d7a8afa9&quot;}<br>INFO:livekit.agents:initializing job runner<br>    08:15:55.986 INFO     livekit.agents     initializing job runner {&quot;tid&quot;: 27303873}<br>INFO:voice-agent:MLflow tracing configured successfully!<br>    08:15:55.988 INFO     voice-agent        MLflow tracing configured successfully!  <br>INFO:voice-agent:Prewarmed VAD model<br>    08:15:56.059 INFO     voice-agent        Prewarmed VAD model  <br>DEBUG:asyncio:Using selector: KqueueSelector<br>INFO:livekit.agents:job runner initialized<br>    08:15:56.060 DEBUG    asyncio            Using selector: KqueueSelector  <br>                 INFO     livekit.agents     job runner initialized {&quot;tid&quot;: 27303873, &quot;elapsed_time&quot;: 0.07}<br>INFO:voice-agent:Agent starting for room: console<br>    08:15:56.078 INFO     voice-agent        Agent starting for room: console  <br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:15:56.469 DEBUG    livekit.agents     http_session(): creating a new httpclient ctx  <br>/Users/pavanmantha/Pavans/Workshops/s2s_agents/.venv/lib/python3.12/site-packages/jwt/api_jwt.py:147: InsecureKeyLengthWarning: The HMAC key is 6 bytes long, which is below the minimum <br>recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.<br>  return self._jws.encode(<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:15:56.513 DEBUG    livekit.agents     using audio io: `Console` -&gt; `AgentSession` -&gt; `TranscriptSynchronizer` -&gt; `AudioSinkProxy` -&gt; `Console`  <br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:15:56.514 DEBUG    livekit.agents     using transcript io: `AgentSession` -&gt; `TranscriptSynchronizer`  <br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:15:56.515 INFO     voice-agent        Agent session started! Ready for conversation.  <br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:15:56.673 DEBUG    livekit.….cartesia Established new Cartesia STT WebSocket connection {&quot;cartesia_request_id&quot;: &quot;55dd9277-c7ff-4aaf-994f-c5e234200b90&quot;}<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▂ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:15:56.687 DEBUG    livekit.….cartesia Established new Cartesia TTS WebSocket connection {&quot;cartesia_request_id&quot;: &quot;b0e833e0-1838-4282-a812-5e1d0c3eaa45&quot;}<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▆ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:15:56.744 WARNING  livekit.agents     cloud turn detector failed (message=&#39;Invalid response status (401 Unauthorized)&#39;, status_code=401, retryable=False); falling back to local mini   <br>                                             model                                                                                                                                             <br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:16:04.410 DEBUG    livekit.agents     received user preflight transcript {&quot;user_transcript&quot;: &quot;What does FHA section 223 F of housing act provide for?&quot;, &quot;language&quot;: &quot;en&quot;}<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:16:04.633 DEBUG    livekit.agents     received user transcript  <br>                                         {&quot;user_transcript&quot;: &quot;What does FHA section 223 F of housing act provide for?&quot;, &quot;language&quot;: &quot;en&quot;, &quot;transcript_delay&quot;: 1.0138118267059326}<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:16:04.636 DEBUG    livekit.agents     eot prediction  <br>                                         {&quot;probability&quot;: 0.7253056168556213, &quot;unlikely_threshold&quot;: 0.36, &quot;endpointing_delay&quot;: 0.3, &quot;language&quot;: &quot;en&quot;, &quot;trigger&quot;: &quot;stt&quot;, &quot;from_cache&quot;: true}<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:16:04.639 DEBUG    livekit.agents     user turn committed  <br>                                         {&quot;last_speaking_time&quot;: 1786848363.619498, &quot;last_final_transcript_time&quot;: 1786848364.63628, &quot;speech_start_time&quot;: 1786848358.330898, &quot;delay_completed&quot;: <br>false, &quot;source&quot;: &quot;stt&quot;, &quot;end_of_turn_probability&quot;: 0.7253056168556213, &quot;unlikely_threshold&quot;: 0.36}<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:16:04.643 DEBUG    livekit.agents     using preemptive generation {&quot;preemptive_lead_time&quot;: 0.22996997833251953}<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:16:04.646 DEBUG    livekit.agents     conversation_item_added {&quot;role&quot;: &quot;user&quot;, &quot;text&quot;: &quot;What does FHA section 223 F of housing act provide for?&quot;}<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:16:05.943 DEBUG    livekit.agents     executing tool {&quot;function&quot;: &quot;query_info&quot;, &quot;arguments&quot;: &quot;{\&quot;query\&quot;: \&quot;FHA section 223 F of housing act\&quot;}&quot;, &quot;speech_id&quot;: &quot;speech_c521790e881a&quot;}<br>query: FHA section 223 F of housing act<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>   ? for shortcuts                                                                                                                                                                             DEBUG:llama_index.core.indices.utils:&gt; Top 20 nodes:<br>&gt; [Node d8308013-6aa4-4c16-8389-6f50d2669512] [Similarity score:                 0.634752] Section 223(a)(7) (Refinancing of FHA-Insured Mortgages). Section 223(a)(7) of the Housing Act pe...<br>&gt; [Node 32cfb8da-d14d-427e-9bfa-308fb4f2a9cc] [Similarity score:                 0.586968] Sections 213 and 213(i) of the Housing Act provides for FHA insurance of mortgage loans on cooper...<br>&gt; [Node 9381a6ec-23c0-4278-8e1b-7401d60e1001] [Similarity score:                 0.551707] Such projects may have been financed originally with conventional or FHA-insured mortgage loans.<br>...<br>&gt; [Node 786cec8d-0e08-4cff-b00a-94e1b61e98dc] [Similarity score:                 0.499063] Section 236 of the Housing Act combines governmental mortgage insurance on multifamily housing pr...<br>&gt; [Node 1f472f42-614b-45a8-86a1-138e21a89922] [Similarity score:                 0.483326] Mortgage Loans insured under the programs described in the related Offering Circular Supplement h...<br>&gt; [Node 03d4c561-aa8c-4033-b8c7-fa3c28a89cf9] [Similarity score:                 0.388449] Each Ginnie Mae Project Loan Certificate will provide for the payment to the registered holder of...<br>&gt; [Node f7927814-6be4-472b-9c96-51389a9ba8b5] [Similarity score:                 0.370135] ---<br><br>### 📄 Page 78<br><br>**Weighted Average Remaining Period from Issuance** — With respect to designa...<br>&gt; [Node 3b62d0f1-a471-455b-ba2a-ed31950b2b5b] [Similarity score:                 0.361758] A corporation will not be treated as an instrumentality of the United States or any state or poli...<br>&gt; [Node fcc56c3d-1f91-4004-beb4-19985b765d5e] [Similarity score:                 0.354325] **Prepayment Penalty End Date** — With respect to any multifamily Mortgage Loan, the date as of w...<br>&gt; [Node 86415b5d-c93a-42d2-b185-0ecaea1150f3] [Similarity score:                 0.348899] If the prepayment rate on the Mortgage Loans decreases during a period of rising interest rates, ...<br>&gt; [Node 59725d69-37a2-4ee7-a016-9cc9d0ac9dba] [Similarity score:                 0.347137] ---<br><br>### 📄 Page 20<br><br>### Section 538 Guarantee Program<br><br>The Section 538 Guaranteed Rural Rental Ho...<br>&gt; [Node 6acc230f-5014-4423-8258-22269b50b418] [Similarity score:                 0.344156] If any of these representations and warranties are untrue with respect to any Trust MBS, the Spon...<br>&gt; [Node 3a9de742-ca46-4570-8dcd-45e33c6b3792] [Similarity score:                 0.342905] 00 exceeds (b) the product of (i) the original principal amount of the Ginnie Mae Project Loan Ce...<br>&gt; [Node fe0dd607-31e3-4533-b902-1ce1a38002db] [Similarity score:                 0.337367] Certain Trust MBS may be based on and backed by Mortgage Loans secured by a mark-to-market projec...<br>&gt; [Node 02720481-a4c1-4377-8745-9d432d75621a] [Similarity score:                 0.334485] The original maturity of a Ginnie Mae Construction Loan Certificate is at least 200% of the const...<br>&gt; [Node 8143abb0-2e8a-4a4c-b401-6a64d3fc47cc] [Similarity score:                 0.331362] The Trust Asset Depository or its nominees, as registered holder (on behalf of the Trustee) of th...<br>&gt; [Node 2893b0c6-b1d0-40b8-9102-4f77eca8822c] [Similarity score:                 0.329946] Any transfer of a Mortgaged Property is subject to HUD review and approval under the terms of HUD...<br>&gt; [Node 1f830d75-a35d-422a-a0f8-84fa1a7f283f] [Similarity score:                 0.327878] An election will be made to treat each Trust or certain assets of each Trust as one or more real ...<br>    08:16:06.202 DEBUG    llama_in…ces.utils &gt; Top 20 nodes:                                                                                                                                   <br>                                             &gt; [Node d8308013-6aa4-4c16-8389-6f50d2669512] [Similarity score:                 0.634752] Section 223(a)(7) (Refinancing of FHA-Insured          <br>                                             Mortgages). Section 223(a)(7) of the Housing Act pe...                                                                                            <br>                                             &gt; [Node 32cfb8da-d14d-427e-9bfa-308fb4f2a9cc] [Similarity score:                 0.586968] Sections 213 and 213(i) of the Housing Act provides    <br>                                             for FHA insurance of mortgage loans on cooper...                                                                                                  <br>                                             &gt; [Node 9381a6ec-23c0-4278-8e1b-7401d60e1001] [Similarity score:                 0.551707] Such projects may have been financed originally with   <br>                                             conventional or FHA-insured mortgage loans.                                                                                                       <br>                                             ...                                                                                                                                               <br>                                             &gt; [Node 786cec8d-0e08-4cff-b00a-94e1b61e98dc] [Similarity score:                 0.499063] Section 236 of the Housing Act combines governmental   <br>                                             mortgage insurance on multifamily housing pr...                                                                                                   <br>                                             &gt; [Node 1f472f42-614b-45a8-86a1-138e21a89922] [Similarity score:                 0.483326] Mortgage Loans insured under the programs described    <br>                                             in the related Offering Circular Supplement h...                                                                                                  <br>                                             &gt; [Node 03d4c561-aa8c-4033-b8c7-fa3c28a89cf9] [Similarity score:                 0.388449] Each Ginnie Mae Project Loan Certificate will provide  <br>                                             for the payment to the registered holder of...                                                                                                    <br>                                             &gt; [Node f7927814-6be4-472b-9c96-51389a9ba8b5] [Similarity score:                 0.370135] ---                                                    <br>                                                                                                                                                                                               <br>                                             ### 📄 Page 78                                                                                                                                    <br>                                                                                                                                                                                               <br>                                             **Weighted Average Remaining Period from Issuance** — With respect to designa...                                                                  <br>                                             &gt; [Node 3b62d0f1-a471-455b-ba2a-ed31950b2b5b] [Similarity score:                 0.361758] A corporation will not be treated as an                <br>                                             instrumentality of the United States or any state or poli...                                                                                      <br>                                             &gt; [Node fcc56c3d-1f91-4004-beb4-19985b765d5e] [Similarity score:                 0.354325] **Prepayment Penalty End Date** — With respect to any  <br>                                             multifamily Mortgage Loan, the date as of w...                                                                                                    <br>                                             &gt; [Node 86415b5d-c93a-42d2-b185-0ecaea1150f3] [Similarity score:                 0.348899] If the prepayment rate on the Mortgage Loans           <br>                                             decreases during a period of rising interest rates, ...                                                                                           <br>                                             &gt; [Node 59725d69-37a2-4ee7-a016-9cc9d0ac9dba] [Similarity score:                 0.347137] ---                                                    <br>                                                                                                                                                                                               <br>                                             ### 📄 Page 20                                                                                                                                    <br>                                                                                                                                                                                               <br>                                             ### Section 538 Guarantee Program                                                                                                                 <br>                                                                                                                                                                                               <br>                                             The Section 538 Guaranteed Rural Rental Ho...                                                                                                     <br>                                             &gt; [Node 6acc230f-5014-4423-8258-22269b50b418] [Similarity score:                 0.344156] If any of these representations and warranties are     <br>                                             untrue with respect to any Trust MBS, the Spon...                                                                                                 <br>                                             &gt; [Node 3a9de742-ca46-4570-8dcd-45e33c6b3792] [Similarity score:                 0.342905] 00 exceeds (b) the product of (i) the original         <br>                                             principal amount of the Ginnie Mae Project Loan Ce...                                                                                             <br>                                             &gt; [Node fe0dd607-31e3-4533-b902-1ce1a38002db] [Similarity score:                 0.337367] Certain Trust MBS may be based on and backed by        <br>                                             Mortgage Loans secured by a mark-to-market projec...                                                                                              <br>                                             &gt; [Node 02720481-a4c1-4377-8745-9d432d75621a] [Similarity score:                 0.334485] The original maturity of a Ginnie Mae Construction     <br>                                             Loan Certificate is at least 200% of the const...                                                                                                 <br>                                             &gt; [Node 8143abb0-2e8a-4a4c-b401-6a64d3fc47cc] [Similarity score:                 0.331362] The Trust Asset Depository or its nominees, as         <br>                                             registered holder (on behalf of the Trustee) of th...                                                                                             <br>                                             &gt; [Node 2893b0c6-b1d0-40b8-9102-4f77eca8822c] [Similarity score:                 0.329946] Any transfer of a Mortgaged Property is subject to     <br>                                             HUD review and approval under the terms of HUD...                                                                                                 <br>                                             &gt; [Node 1f830d75-a35d-422a-a0f8-84fa1a7f283f] [Similarity score:                 0.327878] An election will be made to treat each Trust or        <br>                                             certain assets of each Trust as one or more real ...                                                                                              <br>                                             &gt; [Node 16478aeb-8d79-4294-9011-918e57601e17] [Similarity score:                 0.326987] The Information Agent identified in the Offering       <br>                                             Circular Supplement will post the Class Factors,...                                                                                               <br>                                             &gt; [Node b933dd07-7423-4f11-bcd2-6c6ec1062ccc] [Similarity score:                 0.323226] At any time following the final endorsement of a       <br>                                             Mortgage Loan and upon satisfaction of the prere...                                                                                               <br>Query result: Section 223(a)(7) (Refinancing of FHA-Insured Mortgages). Section 223(a)(7) of the Housing Act permits FHA to refinance existing insured mortgage loans under any section or <br>title of the Housing Act. Such refinancing results in prepayment of the existing insured mortgage. The new, refinanced mortgage loan is limited to the original principal amount of the <br>existing mortgage loan and the unexpired term of the existing mortgage loan plus 12 years.<br><br>Section 223(d)(Operating Loss Loans). Section 223(d) of the Housing Act provides for FHA insurance of separate loans that cover (1) operating losses during the first 2 years after completion <br>or (2) up to 80% of the unreimbursed cash contributions by the project owner during any period of up to two years within the first 10 years after date of completion of the project. The <br>project must be secured by an existing HUD-insured first mortgage loan.<br><br>Section 223(f) (Purchase or Refinancing of Existing Projects). Section 223(f) of the Housing Act provides for federal insurance of mortgage loans originated by FHA-approved lenders in <br>connection with the purchase or refinancing of existing multifamily housing complexes, hospitals and nursing homes that do not require substantial rehabilitation. The principal objective of <br>the Section 223(f) program is to permit the refinancing of mortgage loans to provide for a lower debt service or the purchase of existing properties in order to preserve an adequate supply of<br>affordable rental housing. Such projects may have been financed originally with conventional or FHA-insured mortgage loans.<br><br>Section 231 (Mortgage Insurance for Rental Housing for the Elderly). Section 231 of the Housing Act provides for insurance of mortgage loans to facilitate the construction and substantial <br>rehabilitation of multifamily rental housing for elderly (62 or older) or disabled persons. The mortgage insurance may be used to finance the construction and substantial rehabilitation of <br>detached, semi-detached, walk-up or elevator type rental housing designed specifically for elderly or disabled individuals consisting of 8 or more dwelling units. Section 231 was designed to <br>increase the supply of rental housing specifically for the use and occupancy of elderly and/or disabled persons.<br><br>Section 232 (Mortgage Insurance for Nursing Homes, Immediate Care Facilities and Board and Care Homes).<br>Query result: Sections 213 and 213(i) of the Housing Act provides for FHA insurance of mortgage loans on cooperative housing projects. Sections 213 and 213(i) mortgage insurance enables <br>nonprofit cooperative ownership housing corporations or trusts to develop or sponsor housing projects that will be operated as cooperatives. By using Sections 213 and 213(i) insurance, <br>investors can construct or rehabilitate multifamily housing that will be sold to such nonprofit corporations or trusts.<br><br>Section 220 (Urban Renewal Mortgage Insurance). Section 220 of the Housing Act provides for federal insurance of mortgage loans on multifamily rental projects located in federally aided urban<br>renewal areas or in areas having a local redevelopment or urban renewal plan certified by FHA. The mortgage loans may finance the rehabilitation of existing salvable housing (including the <br>refinancing of existing loans) or new construction in targeted areas. The purpose of Section 220 is to encourage quality rental housing in urban areas targeted for overall revitalization.<br><br>---<br><br>### 📄 Page 19<br><br>Section 221(d) (Housing for Moderate Income and Displaced Families). Sections 221(d)(3) and 221(d)(4) of the Housing Act provide for mortgage insurance to assist private industry in the <br>construction or substantial rehabilitation of rental and cooperative housing for low- and moderate-income families and families that have been displaced as a result of urban renewal, <br>governmental actions or disaster.<br><br>Section 223(a)(7) (Refinancing of FHA-Insured Mortgages). Section 223(a)(7) of the Housing Act permits FHA to refinance existing insured mortgage loans under any section or title of the <br>Housing Act. Such refinancing results in prepayment of the existing insured mortgage. The new, refinanced mortgage loan is limited to the original principal amount of the existing mortgage <br>loan and the unexpired term of the existing mortgage loan plus 12 years.<br><br>Section 223(d)(Operating Loss Loans). Section 223(d) of the Housing Act provides for FHA insurance of separate loans that cover (1) operating losses during the first 2 years after completion <br>or (2) up to 80% of the unreimbursed cash contributions by the project owner during any period of up to two years within the first 10 years after date of completion of the project. The <br>project must be secured by an existing HUD-insured first mortgage loan.<br>Query result: Such projects may have been financed originally with conventional or FHA-insured mortgage loans.<br><br>Section 231 (Mortgage Insurance for Rental Housing for the Elderly). Section 231 of the Housing Act provides for insurance of mortgage loans to facilitate the construction and substantial <br>rehabilitation of multifamily rental housing for elderly (62 or older) or disabled persons. The mortgage insurance may be used to finance the construction and substantial rehabilitation of <br>detached, semi-detached, walk-up or elevator type rental housing designed specifically for elderly or disabled individuals consisting of 8 or more dwelling units. Section 231 was designed to <br>increase the supply of rental housing specifically for the use and occupancy of elderly and/or disabled persons.<br><br>Section 232 (Mortgage Insurance for Nursing Homes, Immediate Care Facilities and Board and Care Homes). Section 232 of the Housing Act provides for FHA insurance of private construction <br>mortgage loans to finance new or rehabilitated nursing homes, intermediate care facilities, board and care homes, assisted living for the frail or elderly or allowable combinations thereof, <br>including equipment to be used in their operation. Section 232 also provides for supplemental loans to finance the purchase and installation of fire safety equipment in these facilities.<br><br>Section 236 (Mortgage Insurance for Subsidized Rental Housing Projects). Section 236 of the Housing Act combines governmental mortgage insurance on multifamily housing projects with <br>supplemental payments to reduce the project owners&#39; monthly debt service payments. The supplemental payments are paid directly to the mortgagee of the project for the purpose of reducing the <br>interest payment due from the project owner. The objective of these supplemental payments is to reduce rental payments required of low-income and elderly residents. To qualify for rental <br>assistance under Section 236, tenants&#39; annual income must be less than 80 percent of the median income of the area. Originations under Section 236 are no longer active, although refinancings <br>under Section 223(a)(7) are authorized.<br><br>Section 241 (Supplemental Loans for Multifamily Projects). Sections 241, and 241(a) and 241(f) of the Housing Act provide for FHA insurance to finance property improvements, energy-conserving<br>improvements, supplemental increases or additions to any FHA-insured multifamily loan.<br>Query result: Section 236 of the Housing Act combines governmental mortgage insurance on multifamily housing projects with supplemental payments to reduce the project owners&#39; monthly debt <br>service payments. The supplemental payments are paid directly to the mortgagee of the project for the purpose of reducing the interest payment due from the project owner. The objective of <br>these supplemental payments is to reduce rental payments required of low-income and elderly residents. To qualify for rental assistance under Section 236, tenants&#39; annual income must be less <br>than 80 percent of the median income of the area. Originations under Section 236 are no longer active, although refinancings under Section 223(a)(7) are authorized.<br><br>Section 241 (Supplemental Loans for Multifamily Projects). Sections 241, and 241(a) and 241(f) of the Housing Act provide for FHA insurance to finance property improvements, energy-conserving<br>improvements, supplemental increases or additions to any FHA-insured multifamily loan. The overall purpose of the Section 241 loan program is to provide a project with a means to remain <br>competitive, to extend its economic life and to finance the replacement of obsolete equipment without the refinancing of the existing mortgage.<br><br>---<br><br>### 📄 Page 20<br><br>### Section 538 Guarantee Program<br><br>The Section 538 Guaranteed Rural Rental Housing Program is under the United States Department of Agriculture Rural Development. The authorizing statute is Title V of the Housing Act. Rural <br>Development operates a broad range of programs that were formerly administered by the Farmers Home Administration to support affordable housing and community development in rural areas. <br>Section 538 was established pursuant to Title V of the Housing Act. Section 538 is designed to increase the supply of affordable rural rental housing, through the use of loan guarantees that <br>encourage partnerships between Rural Development, private lenders and public agencies. Mortgage loans are provided by Rural Development-approved multifamily lenders, including state and local<br>housing agencies. The Mortgage Loans guaranteed under the program will have the maturity and amortization features as Rural Development may approve.<br>Query result: Mortgage Loans insured under the programs described in the related Offering Circular Supplement have such maturities and amortization features as the FHA may approve. Generally <br>the minimum mortgage loan term is at least ten years, and the maximum mortgage loan term does not exceed the lesser of 40 years and 75% of the estimated remaining economic life of the <br>improvements on the Mortgaged Property.<br><br>Tenant eligibility for FHA-insured projects generally is not restricted by income, except for projects as to which rental subsidies are made available with respect to some of or all the units<br>therein or to specified tenants.<br><br>The principal characteristics of the Mortgage Loans underlying the Ginnie Mae Multifamily Certificates in a particular Trust, and the FHA Programs under which each Mortgage Loan is insured, <br>are described below. The FHA Programs under which each Mortgage Loan in a Series is insured will be listed under &quot;Terms Sheets – Certain Characteristics of the Ginnie Mae Multifamily <br>Certificates and the Related Mortgage Loans Underlying the Trust Assets&quot; in the related Offering Circular Supplement.<br><br>Section 207 (Mortgage Insurance for Multifamily Housing). Section 207 of the Housing Act provides for federal insurance of mortgage loans originated by FHA-approved lenders in connection with<br>the construction or substantial rehabilitation of multifamily housing projects, which includes manufactured home parks.<br><br>Section 213(i) (Cooperative Housing Projects). Sections 213 and 213(i) of the Housing Act provides for FHA insurance of mortgage loans on cooperative housing projects. Sections 213 and 213(i)<br>mortgage insurance enables nonprofit cooperative ownership housing corporations or trusts to develop or sponsor housing projects that will be operated as cooperatives. By using Sections 213 <br>and 213(i) insurance, investors can construct or rehabilitate multifamily housing that will be sold to such nonprofit corporations or trusts.<br><br>Section 220 (Urban Renewal Mortgage Insurance). Section 220 of the Housing Act provides for federal insurance of mortgage loans on multifamily rental projects located in federally aided urban<br>renewal areas or in areas having a local redevelopment or urban renewal plan certified by FHA. The mortgage loans may finance the rehabilitation of existing salvable housing (including the <br>refinancing of existing loans) or new construction in targeted areas. The purpose of Section 220 is to encourage quality rental housing in urban areas targeted for overall revitalization.<br>Query result: Each Ginnie Mae Project Loan Certificate will provide for the payment to the registered holder of that Ginnie Mae Project Loan Certificate of monthly payments of principal and <br>interest equal to the aggregate amount of<br><br>---<br><br>### 📄 Page 18<br><br>the scheduled monthly principal and interest payments on the Mortgage Loans underlying that Ginnie Mae Project Loan Certificate, less applicable servicing and guaranty fees. In addition, such<br>payments will include any prepayments and other unscheduled recoveries of principal of, and any Prepayment Penalties on, an underlying Mortgage Loan to the extent received by the Ginnie Mae <br>Issuer during the month preceding the month of the payment.<br><br>The related Offering Circular Supplement will describe certain characteristics of the Ginnie Mae Project Loan Certificates and the underlying Mortgage Loans.<br><br>### FHA Insurance Programs<br><br>The FHA is an organizational unit within the U.S. Department of Housing and Urban Development. FHA was established to encourage improvement in housing standards and conditions, to provide an <br>adequate home financing system by insuring housing mortgages and credit and to exert a stabilizing influence on the mortgage market.<br><br>FHA multifamily insurance programs generally are designed to assist private and public mortgagors in obtaining insured financing for the construction, purchase or rehabilitation of <br>multifamily housing pursuant to the Housing Act or, with respect to a risk sharing pilot program, the Housing and Community Development Act of 1992, as amended. Mortgage Loans are provided by<br>FHA-approved institutions, which include mortgage banks, commercial banks, savings and loan associations, trust companies, insurance companies, pension funds, state and local housing finance <br>agencies and certain other approved entities.<br><br>Mortgage Loans insured under the programs described in the related Offering Circular Supplement have such maturities and amortization features as the FHA may approve. Generally the minimum <br>mortgage loan term is at least ten years, and the maximum mortgage loan term does not exceed the lesser of 40 years and 75% of the estimated remaining economic life of the improvements on the<br>Mortgaged Property.<br><br>Tenant eligibility for FHA-insured projects generally is not restricted by income, except for projects as to which rental subsidies are made available with respect to some of or all the units<br>therein or to specified tenants.<br><br>The principal characteristics of the Mortgage Loans underlying the Ginnie Mae Multifamily Certificates in a particular Trust, and the FHA Programs under which each Mortgage Loan is insured, <br>are described below.<br>Query result: ---<br><br>### 📄 Page 78<br><br>**Weighted Average Remaining Period from Issuance** — With respect to designated Ginnie Mae Multifamily Certificates and a Cut-off Date, the average, expressed in months, of the remaining <br>period from issuance, as of the Cut-off Date, of all the Mortgage Loans underlying the Ginnie Mae Multifamily Certificates, weighted on the basis of the respective current principal balances <br>of those Mortgage Loans after giving effect to all payments of principal due on or before the Cut-off Date. For purposes of this definition &quot;issuance&quot; means the later of (a) one month prior <br>to the first interest rate payment date after the most recent FHA endorsement and (b) the Cut-off Date of the related Ginnie Mae Multifamily Certificate, unless otherwise indicated in the <br>Offering Circular Supplement.<br><br>**Weighted Average Remaining Prepayment Penalty Period** — With respect to designated Ginnie Mae Multifamily Certificates and a Cut-off Date, the average, expressed in months, of the <br>remaining periods during which Prepayment Penalties will be payable, as of the Cut-off Date, of all the Mortgage Loans underlying the Ginnie Mae Multifamily Certificates, weighted on the <br>basis of the respective current principal balances of those Mortgage Loans after giving effect to all payments of principal due on or before the related Cut-off Date.<br><br>---<br><br>### 📄 Page 79<br><br>## THIS PAGE INTENTIONALLY LEFT BLANK<br>Query result: A corporation will not be treated as an instrumentality of the United States or any state or political subdivision thereof if all of its activities are subject to tax and, with <br>the exception of Freddie Mac, a majority of its board of directors is not selected by that governmental unit.<br><br>**Distribution Amount** — With respect to each Series (or, if the Series is segregated into Security Groups, each Security Group) and each Distribution Date, the sum of the Principal <br>Distribution Amount (less principal, if any, payable to the Trustee as a Trustee Fee), the Interest Distribution Amount and the Accrual Amount(s), if any, for the Series (or Security Group).<br><br>---<br><br>### 📄 Page 59<br><br>**Distribution Date** — The date specified in the Trust Agreement or MX Trust Agreement, as applicable, relating to each Series (or Security Group) upon which distributions are required to be<br>made to Holders of Securities of such Series (or Security Group).<br><br>**Double REMIC Series** — A Series that provides for an Issuing REMIC and one or more Pooling REMICs.<br><br>**Effective Range** — With respect to any PAC, Scheduled or TAC Class or Component, the range of constant prepayment rates for which such Class or Component adheres to its schedule of <br>Scheduled Principal Balances.<br><br>**Eligible Certificates** — Any Ginnie Mae Securities, as well as any Fannie Mae Securities or Freddie Mac Securities that are held in book-entry form.<br><br>ERISA ................................................. The Employee Retirement Income Security Act of 1974, as amended.<br><br>**Excess Class** — A Class that receives any principal and interest paid on the underlying Trust Assets in excess of the amount of the prescribed principal and interest required to be paid on<br>all Classes in the Series.<br><br>Fannie Mae.......................................... The Federal National Mortgage Association.<br><br>**Fannie Mae Securities** — Any securities previously issued and guaranteed by Fannie Mae that evidence beneficial ownership interests in Ginnie Mae Multifamily Certificates.<br><br>**Fedwire Book-Entry System** — The book-entry system for securities operated and maintained by the U.S. Federal Reserve Banks.<br><br>FHA .................................................... The Federal Housing Administration.<br><br>FHLB of San Francisco....................... The Federal Home Loan Bank of San Francisco.<br><br>**Final Data Statement** — With respect to each Series, the final list of Trust Assets to be included in the related Trust.<br>Query result: **Prepayment Penalty End Date** — With respect to any multifamily Mortgage Loan, the date as of which any such Mortgage Loan would no longer be subject to the payment of any <br>Prepayment Penalties.<br><br>**Principal Distribution Amount** — With respect to each Series (or if the Series is segregated into Security Groups, each Security Group) and each Distribution Date, the sum of (I) with <br>respect to each Ginnie Mae Multifamily Certificate (other than any Ginnie Mae Project Loan Certificate issued as a result of a conversion of the Ginnie Mae Construction Loan Certificate since<br>the preceding Distribution Date), the amount by which (a) the product of (i) the original principal amount of that Ginnie Mae Multifamily Certificate and (ii) the Certificate Factor, <br>Corrected Certificate Factor or Calculated Certificate Factor, as applicable, for the preceding Distribution Date exceeds (b) the product of (i) the original principal amount of the Ginnie <br>Mae Multifamily Certificate and (ii) the<br><br>---<br><br>### 📄 Page 68<br><br>Certificate Factor, Corrected Certificate Factor or Calculated Certificate Factor, as applicable, for the current Distribution Date; (II) with respect to each Ginnie Mae Project Loan <br>Certificate issued as a result of a conversion of a Ginnie Mae Construction Loan Certificate since the preceding Distribution Date, the sum of (1) the amount by which (a) the product of (i) <br>the original principal amount of the Ginnie Mae Project Loan Certificate and (ii) 1.00 exceeds (b) the product of (i) the original principal amount of the Ginnie Mae Project Loan Certificate <br>and (ii) the Certificate Factor, Corrected Certificate Factor or Calculated Certificate Factor, as applicable, for the current Distribution Date and (2) any amounts received by the Trust from<br>the Ginnie Mae Issuer from the related P&amp;I Custodial Account on the date of conversion of a Ginnie Mae Construction Loan Certificate to a Ginnie Mae Project Loan Certificate; (III) with <br>respect to each Ginnie Mae Construction Loan Certificate that has been liquidated since the preceding Distribution Date, any proceeds received by the Trust from the Ginnie Mae Issuer with <br>respect to any liquidation, exclusive of any amounts distributed pursuant to (I) above; (IV) with respect to each Underlying Certificate, Underlying Callable Security or Underlying SMBS <br>Security,<br>Query result: If the prepayment rate on the Mortgage Loans decreases during a period of rising interest rates, investors may receive declining principal distributions when those investors <br>otherwise may have been able to reinvest at higher interest rates than the Interest Rates of the applicable Classes of Securities.<br><br>### Payment Delay<br><br>Distributions of interest on the Securities on any Distribution Date will include interest accrued thereon through the Accounting Date, which for Fixed Rate Classes and Delay Classes is the <br>last day of the month preceding the month in which such Distribution Date occurs. The effective yield to the Holders of such Securities will be lower than the yield otherwise produced by the <br>applicable Interest Rate and purchase price because interest will not be distributed on such Securities that are Book-Entry Securities until the Distribution Date in the month following the <br>month in which such interest accrues on the Trust Assets, and interest will not be distributed on Certificated Securities until the Business Day after such Distribution Date.<br><br>---<br><br>### 📄 Page 22<br><br>### Assumability of Mortgage Loans<br><br>Mortgage Loans do not contain &quot;due-on-sale&quot; clauses restricting sale or other transfer of the related Mortgaged Properties. Any transfer of a Mortgaged Property is subject to HUD review and <br>approval under the terms of HUD&#39;s Regulatory Agreement with the owner, which is incorporated by reference into the mortgage.<br><br>### Weighted Average Life<br><br>The Weighted Average Life of a security refers to the average amount of time that will elapse from the date of its issuance until each dollar of principal of that security will be repaid to <br>the investor. As a result, any projection of the Weighted Average Life of and yield on any Class of the Securities must include an assumption about the anticipated timing and amount of <br>payments on those Securities, which will depend upon the rate of prepayments of the Mortgage Loans, including optional borrower prepayments and prepayments resulting from liquidation of <br>defaulted Mortgage Loans. In general, prepayments of principal and defaults on the Mortgage Loans will shorten the Weighted Average Life and term to maturity of each related Class of <br>Securities.<br>Query result: ---<br><br>### 📄 Page 20<br><br>### Section 538 Guarantee Program<br><br>The Section 538 Guaranteed Rural Rental Housing Program is under the United States Department of Agriculture Rural Development. The authorizing statute is Title V of the Housing Act. Rural <br>Development operates a broad range of programs that were formerly administered by the Farmers Home Administration to support affordable housing and community development in rural areas. <br>Section 538 was established pursuant to Title V of the Housing Act. Section 538 is designed to increase the supply of affordable rural rental housing, through the use of loan guarantees that <br>encourage partnerships between Rural Development, private lenders and public agencies. Mortgage loans are provided by Rural Development-approved multifamily lenders, including state and local<br>housing agencies. The Mortgage Loans guaranteed under the program will have the maturity and amortization features as Rural Development may approve.<br><br>Tenant eligibility for Section 538 guaranteed projects is restricted to persons with income not in excess of 115% of the area median income<br><br>Under Section 515 of Title V of the Housing Act, Rural Development is authorized to make direct loans secured by multifamily properties with respect to which the tenants may include very <br>low-, low- and moderate-income families, elderly persons and persons with handicaps and disabilities. Under Section 538, Rural Development subsequently may guarantee new loans, secured by the<br>same properties, made to revitalize the properties.<br><br>## UNDERLYING CERTIFICATES<br><br>If so specified in the related Offering Circular Supplement, the Trust Assets for a Series of Securities may include one or more Underlying Certificates. Any such Underlying Certificate will <br>evidence a direct or indirect beneficial ownership interest in a separate pool of Ginnie Mae Multifamily Certificates and will have been issued and guaranteed as described in the Underlying <br>Certificate Disclosure Document. Each Offering Circular Supplement will include a general description of the characteristics of each Underlying Certificate. In the event that any issue arises<br>under the trust agreement that governs the Underlying Trust, which requires a vote of the holders of Underlying Certificates, the Trustee will vote the Underlying Certificates in accordance <br>with the Trust Agreement.<br><br>Investors in any Security representing an interest in one or more Underlying Certificates are urged to review, in particular, the related Underlying Certificate Disclosure Documents, which <br>may be obtained from the Information Agent as described in the related Offering Circular Supplement.<br>Query result: If any of these representations and warranties are untrue with respect to any Trust MBS, the Sponsor, at its option, may (a) cure the breach, (b) substitute another Ginnie Mae <br>Multifamily Certificate for the affected Trust MBS, or (c) with Ginnie Mae&#39;s consent, repurchase the affected Trust MBS from the Trust, in each case only to the extent permitted under the <br>Trust Agreement and REMIC Provisions.<br><br>The Mortgage Loans underlying each Trust MBS will consist of Mortgage Loans secured by mortgages on real properties that are either residences housing five families or more or nursing <br>facilities (collectively, &quot;Mortgaged Properties&quot;).<br><br>---<br><br>### 📄 Page 16<br><br>Each multifamily Mortgage Loan underlying any Trust MBS (other than a Trust MBS issued under the Small Loan Program (as defined below under &quot;The Ginnie Mae Project Loan Certificates&quot;)) bears <br>interest at a Mortgage Rate that is 0.25% to 0.50% per annum greater than the related Certificate Rate, unless Ginnie Mae has given its prior consent to a spread in excess of 0.50% per annum.<br>Under the Small Loan Program, the Mortgage Rate on the Mortgage Loans backing the Trust MBS is required to be at least 0.50% greater than the Certificate Rate, but there is no maximum limit <br>on this percentage. Certain Trust MBS may be based on and backed by Mortgage Loans secured by a mark-to-market project developed under HUD&#39;s Office of Multifamily Housing Assistance <br>Restructuring Processing Procedures (the &quot;RX Program&quot;). The Mortgage Rate of Mortgage Loans underlying any such Trust MBS is required to be at least 0.50% greater than the Certificate Rate of<br>the related Trust MBS.<br><br>Each Ginnie Mae Issuer will perform the routine functions required for servicing of Trust MBS and related Mortgage Loans for which it is responsible, including mortgagor billings, receipt and<br>posting of payments, payment of property taxes and hazard insurance premiums, remittance, collections and customer service. Each Ginnie Mae Issuer will be obligated under its Certificate <br>Guaranty Agreements with Ginnie Mae to service the Mortgage Loans in accordance with FHA requirements and with generally accepted practices in the mortgage lending industry.<br>Query result: 00 exceeds (b) the product of (i) the original principal amount of the Ginnie Mae Project Loan Certificate and (ii) the Certificate Factor, Corrected Certificate Factor or <br>Calculated Certificate Factor, as applicable, for the current Distribution Date and (2) any amounts received by the Trust from the Ginnie Mae Issuer from the related P&amp;I Custodial Account on <br>the date of conversion of a Ginnie Mae Construction Loan Certificate to a Ginnie Mae Project Loan Certificate; (III) with respect to each Ginnie Mae Construction Loan Certificate that has <br>been liquidated since the preceding Distribution Date, any proceeds received by the Trust from the Ginnie Mae Issuer with respect to any liquidation, exclusive of any amounts distributed <br>pursuant to (I) above; (IV) with respect to each Underlying Certificate, Underlying Callable Security or Underlying SMBS Security, the amount by which (a) the product of (i) the original <br>principal amount of that Underlying Certificate, Underlying Callable Security or Underlying SMBS Security, and (ii) the Underlying Certificate Factor (or Certificate Factor) for the preceding<br>Distribution Date exceeds (b) the product of (i) the original principal amount of the Underlying Certificate, Underlying Callable Security or Underlying SMBS Security, and (ii) the Underlying<br>Certificate Factor (or Certificate Factor) for the current Distribution Date; provided however, that the amount calculated pursuant to clause (IV) is subject to adjustment pursuant to the <br>applicable Trust Agreement in the event that Underlying Certificate Factors (or Certificate Factors) are unavailable in respect of any Distribution Date. (For the first Distribution Date, the<br>product in clause (I)(a) and (IV)(a) above shall be the principal amount of the Ginnie Mae Multifamily Certificate as of the Closing Date.) The sum of the amounts so calculated for each <br>Ginnie Mae Multifamily Certificate conveyed to a Trust for a Series (or, if the Series is segregated into Security Groups, for each Ginnie Mae Multifamily Certificate included in the related <br>Trust Asset Group) is the Principal Distribution Amount for that Series (or Security Group(s)).<br><br>When preceded by a group or subgroup designation (e.g., the &quot;Group 2 Principal Distribution Amount&quot; or the &quot;Subgroup 2A Principal Distribution Amount&quot;), such amount for the specified Trust <br>Asset Group or Subgroup.<br>Query result: Certain Trust MBS may be based on and backed by Mortgage Loans secured by a mark-to-market project developed under HUD&#39;s Office of Multifamily Housing Assistance Restructuring <br>Processing Procedures (the &quot;RX Program&quot;). The Mortgage Rate of Mortgage Loans underlying any such Trust MBS is required to be at least 0.50% greater than the Certificate Rate of the related <br>Trust MBS.<br><br>Each Ginnie Mae Issuer will perform the routine functions required for servicing of Trust MBS and related Mortgage Loans for which it is responsible, including mortgagor billings, receipt and<br>posting of payments, payment of property taxes and hazard insurance premiums, remittance, collections and customer service. Each Ginnie Mae Issuer will be obligated under its Certificate <br>Guaranty Agreements with Ginnie Mae to service the Mortgage Loans in accordance with FHA requirements and with generally accepted practices in the mortgage lending industry. Each Ginnie Mae <br>Issuer&#39;s responsibilities with respect to its Mortgage Loans will include:<br><br>(a) collection of all principal and interest payments, Prepayment Penalties, if any, and payments made by the borrower or borrowers toward escrows established for taxes and insurance <br>premiums;<br><br>(b) maintenance of necessary hazard insurance policies;<br><br>(c) institution of all actions necessary to foreclose on, or take other appropriate action with respect to, loans in default; and<br><br>(d) collection of insurance and guaranty benefits.<br><br>The Trust Asset Depository or its nominees, as registered holder (on behalf of the Trustee) of the related Trust MBS, will have the right to proceed directly against Ginnie Mae under the <br>terms of the related Trust MBS for any amounts that are not paid when due.<br><br>Ginnie Mae Multifamily Certificates are issued under the Ginnie Mae I Program.<br><br>### The Ginnie Mae Construction Loan Certificates<br><br>Each Ginnie Mae Construction Loan Certificate is based on and backed by a single Mortgage Loan secured by a multifamily project under construction and insured by FHA pursuant to an FHA <br>Insurance Program described in the related Offering Circular Supplement. Ginnie Mae Construction Loan Certificates are generally issued monthly by the related Ginnie Mae Issuer as <br>construction progresses on the related multifamily project and as advances are insured by FHA.<br>Query result: The original maturity of a Ginnie Mae Construction Loan Certificate is at least 200% of the construction period for the multifamily project anticipated by FHA. The stated <br>maturity of a Ginnie Mae Construction Loan Certificate may be extended after issuance at the request of the related Ginnie Mae Issuer with the prior written approval of Ginnie Mae. Prior to <br>approving any extension request, Ginnie Mae requires that the Contracted Security Purchaser, the entity bound under contract with the related Ginnie Mae issuer to purchase all of the Ginnie <br>Mae Construction Loan Certificates relating to a particular project, consent to the extension of the maturity date. Upon the maturity of any Ginnie Mae Construction Loan Certificate, the <br>related Ginnie Mae Issuer must retire the Ginnie Mae Construction Loan Certificate by the payment of cash to the holders provided that (i) no extension has been granted and (ii) the Ginnie <br>Mae Construction Loan Certificate has failed to convert prior to that time. In addition if<br><br>---<br><br>### 📄 Page 17<br><br>the underlying Mortgage Loan is liquidated prior to maturity, the related Ginnie Mae Issuer must retire the Ginnie Mae Construction Loan Certificates by the payment of cash to the holders.<br><br>Each Ginnie Mae Construction Loan Certificate will provide for the payment to the holder of that Ginnie Mae Construction Loan Certificate of monthly payments of interest equal to a pro rata <br>share of the interest payments on the underlying Mortgage Loan, less applicable servicing and guaranty fees. Holders of Ginnie Mae Construction Loan Certificates are not entitled to receive <br>any payments of principal collected on the Mortgage Loan as long as the Ginnie Mae Construction Loan Certificates are outstanding. During such period, any prepayments and other recoveries of <br>principal (other than proceeds from the liquidation of the related Mortgage Loan) or any Prepayment Penalties on the underlying Mortgage Loan received by the Ginnie Mae Issuer will be <br>deposited into a non-interest bearing escrow account (the &quot;P&amp;I Custodial Account&quot;).<br>Query result: The Trust Asset Depository or its nominees, as registered holder (on behalf of the Trustee) of the related Trust MBS, will have the right to proceed directly against Ginnie Mae <br>under the terms of the related Trust MBS for any amounts that are not paid when due.<br><br>Ginnie Mae Multifamily Certificates are issued under the Ginnie Mae I Program.<br><br>### The Ginnie Mae Construction Loan Certificates<br><br>Each Ginnie Mae Construction Loan Certificate is based on and backed by a single Mortgage Loan secured by a multifamily project under construction and insured by FHA pursuant to an FHA <br>Insurance Program described in the related Offering Circular Supplement. Ginnie Mae Construction Loan Certificates are generally issued monthly by the related Ginnie Mae Issuer as <br>construction progresses on the related multifamily project and as advances are insured by FHA. Prior to the issuance of Ginnie Mae Construction Loan Certificates, the Ginnie Mae Issuer must <br>provide Ginnie Mae with supporting documentation regarding advances and disbursements on the Mortgage Loan and must satisfy the prerequisites for issuance as described in Chapter 32 of the <br>Ginnie Mae Mortgage-Backed Securities Guide (the &quot;MBS Guide&quot;). Each Ginnie Mae Construction Loan Certificate upon conversion will be redeemed for a pro rata share of a Ginnie Mae Project Loan<br>Certificate.<br><br>The original maturity of a Ginnie Mae Construction Loan Certificate is at least 200% of the construction period for the multifamily project anticipated by FHA. The stated maturity of a Ginnie<br>Mae Construction Loan Certificate may be extended after issuance at the request of the related Ginnie Mae Issuer with the prior written approval of Ginnie Mae. Prior to approving any <br>extension request, Ginnie Mae requires that the Contracted Security Purchaser, the entity bound under contract with the related Ginnie Mae issuer to purchase all of the Ginnie Mae <br>Construction Loan Certificates relating to a particular project, consent to the extension of the maturity date. Upon the maturity of any Ginnie Mae Construction Loan Certificate, the related <br>Ginnie Mae Issuer must retire the Ginnie Mae Construction Loan Certificate by the payment of cash to the holders provided that (i) no extension has been granted and (ii) the Ginnie Mae <br>Construction Loan Certificate has failed to convert prior to that time.<br>Query result: Any transfer of a Mortgaged Property is subject to HUD review and approval under the terms of HUD&#39;s Regulatory Agreement with the owner, which is incorporated by reference into <br>the mortgage.<br><br>### Weighted Average Life<br><br>The Weighted Average Life of a security refers to the average amount of time that will elapse from the date of its issuance until each dollar of principal of that security will be repaid to <br>the investor. As a result, any projection of the Weighted Average Life of and yield on any Class of the Securities must include an assumption about the anticipated timing and amount of <br>payments on those Securities, which will depend upon the rate of prepayments of the Mortgage Loans, including optional borrower prepayments and prepayments resulting from liquidation of <br>defaulted Mortgage Loans. In general, prepayments of principal and defaults on the Mortgage Loans will shorten the Weighted Average Life and term to maturity of each related Class of <br>Securities.<br><br>The Weighted Average Life of a Class is determined by (a) multiplying the amount of the net reduction, if any, of the Class Principal Balance (or Class Notional Balance) of such Class from <br>one Distribution Date to the next Distribution Date by the number of years from the Closing Date to such next Distribution Date, (b) summing the results and (c) dividing the sum by the <br>aggregate amount of the net reductions in Class Principal Balance (or Class Notional Balance) of such Class referred to in clause (a).<br><br>The Weighted Average Lives of the Securities will be influenced by, among other things, the rate at which principal is paid on the Mortgage Loans (or, in the case of a Security Group, on the <br>Mortgage Loans underlying the related Trust Asset Group). In general, the Weighted Average Lives of the Securities will be shortened if the rate of prepayments of principal of the Mortgage <br>Loans increases. However, the Weighted Average Lives will depend upon a variety of other factors, including the timing of changes in such rate of principal prepayments. Accordingly, no <br>assurance can be given as to the Weighted Average Life of any Class. Further, to the extent the prices of the Securities represent discounts or premiums to their respective original principal<br>balances, variability in the Weighted Average Lives of such Classes could result in variability in the related yields to maturity.<br>Query result: An election will be made to treat each Trust or certain assets of each Trust as one or more real estate mortgage investment conduits for United States federal income tax <br>purposes. See &quot;Certain United States Federal Income Tax Consequences&quot; in this Multifamily Base Offering Circular.<br><br>---<br><br>&gt; **THE GOVERNMENT NATIONAL MORTGAGE ASSOCIATION GUARANTEES THE TIMELY PAYMENT OF PRINCIPAL AND INTEREST ON THE SECURITIES. THE GINNIE MAE GUARANTY IS BACKED BY THE FULL FAITH AND CREDIT OF <br>THE UNITED STATES OF AMERICA. GINNIE MAE DOES NOT GUARANTEE THE PAYMENT OF PREPAYMENT PENALTIES. THE SECURITIES ARE EXEMPT FROM THE REGISTRATION REQUIREMENTS OF THE SECURITIES ACT OF 1933 AND<br>CONSTITUTE EXEMPTED SECURITIES UNDER THE SECURITIES EXCHANGE ACT OF 1934.**<br><br>---<br><br>*Offers of the Securities may be made through one or more different methods, including offerings through the Sponsor, as more fully described in the related Offering Circular Supplement. This<br>Multifamily Base Offering Circular may not be used to consummate sales of Securities unless you have received the related Offering Circular Supplement.*<br><br>**The date of this Multifamily Base Offering Circular is July 1, 2023.<br>Query result: The Information Agent identified in the Offering Circular Supplement will post the Class Factors, along with the Interest Rate for each Class, on ginniemae.gov.<br><br>For any Distribution Date, investors can calculate the amount of principal to be distributed on any Class (other than an Accrual Class or Partial Accrual Class) by multiplying the Original <br>Class Principal Balance of that Class by the difference between its Class Factors for the preceding and current months. The amount of interest to be distributed on any Class (other than an <br>Accrual Class or Partial Accrual Class) on each Distribution Date will equal 30 days&#39; interest at the Interest Rate for that Class on its Class Principal Balance (or Class Notional Balance) <br>as determined by its Class Factor for the preceding month. Based on the Class Factors and Interest Rates published each month, investors in an Accrual Class or Partial Accrual Class can <br>calculate the total amount of principal and interest to be distributed to (or interest to be added to the Class Principal Balance of) that Class.<br><br>Prepayment Penalties received by the Trustee with respect to a Ginnie Mae Multifamily Certificate will be distributed, along with the related prepayment, on the Distribution Date, immediately<br>following the Trustee&#39;s actual receipt of the Prepayment Penalties, to Holders of one or more Classes of Securities in accordance with provisions set forth in the related Offering Circular <br>Supplement. A distribution of Prepayment Penalties will not decrease the Class Principal Balance of any Class receiving such a distribution.<br><br>### Method of Distributions<br><br>Distributions of principal and interest (or, where applicable, of principal only or interest only) and related Prepayment Penalties, if any, on a Series (or, if the Series is segregated into <br>Security Groups, on a Security Group) will be made on each Distribution Date for that Series (or Security Group) (or, with respect to Certificated Securities, the Business Day following the <br>Distribution Date) to the Persons in whose names the Securities are registered on the related Record Date.<br>Query result: At any time following the final endorsement of a Mortgage Loan and upon satisfaction of the prerequisites for conversion outlined in Chapter 32 of the MBS Guide, Ginnie Mae <br>Construction Loan Certificates shall be redeemed for Ginnie Mae Project Loan Certificates. The principal amount of the Ginnie Mae Project Loan Certificates may be less than or equal to the <br>amount of advances that have been disbursed and insured on the Mortgage Loan underlying the related Ginnie Mae Construction Loan Certificates. Any difference between the principal balance of <br>the Ginnie Mae Construction Loan Certificates and the principal balance of the Ginnie Mae Project Loan Certificates issued at conversion will be disbursed to the holders of the Ginnie Mae <br>Construction Loan Certificates as principal upon conversion. To the extent the underlying Mortgage Loan begins to amortize prior to the conversion of the Ginnie Mae Construction Loan <br>Certificates to Ginnie Mae Project Loan Certificate, the related Ginnie Mae Issuer will be required to pay, at its option, any such prepayments (i) on the date of the conversion of the Ginnie<br>Mae Construction Loan Certificates or (ii) on the Ginnie Mae Certificate Payment Date of the Ginnie Mae Project Loan Certificate following conversion.<br><br>The related Offering Circular Supplement will describe certain characteristics of the Ginnie Mae Construction Loan Certificates and the underlying Mortgage Loans.<br><br>Although the Trust may receive principal payments, unscheduled recoveries of principal and Prepayment Penalties, if any, in connection with the liquidation of a Mortgage Loan or following <br>conversion of a Ginnie Mae Construction Loan Certificate prior to the Ginnie Mae Certificate Payment Date, the Trust will not distribute any such amounts until the Distribution Date <br>immediately following receipt of such amounts. The Holders of the Securities will not be entitled to receive any interest on such amounts, and the related WACR will be reduced accordingly.<br><br>### The Ginnie Mae Project Loan Certificates<br><br>All Ginnie Mae Project Loan Certificates issued prior to August 1, 1997 will have an original term to maturity of not more than 40 years and will be based on and backed by one multifamily <br>Mortgage Loan. Ginnie Mae Project Loan Certificates issued after August 1, 1997, may be based on or backed by more than one multi-family Mortgage Loan.<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:16:06.249 DEBUG    livekit.agents     tools execution completed {&quot;speech_id&quot;: &quot;speech_c521790e881a&quot;}<br>   MacBook Pro Microphone   ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                                                                                                                                        <br>                                                                                                                                                                                               <br>    08:16:07.884 DEBUG    livekit.agents     aec warmup active, disabling interruptions for 3.00s  <br>   MacBook Pro Microphone          ▁ ▁ ▁ ▁ ▃ ▅ ▄ ▃ ▁ ▂ ▁ ▁ ▁ ▁                             end_of_turn: 1020ms · turn_completed_cb: 0.00ms · llm_ttft: 1249ms · tts_ttfb: 133ms  ─  e2e: 4264ms<br>                                                                                                                                                                                               <br>    08:16:10.890 DEBUG    livekit.agents     aec warmup expired, re-enabling interruptions  <br>   MacBook Pro Microphone          ▁ ▁ ▁ ▆ ▂ ▆ ▁ ▃ ▁ ▁ ▁ ▁ ▁ ▁                             end_of_turn: 1020ms · turn_completed_cb: 0.00ms · llm_ttft: 1249ms · tts_ttfb: 133ms  ─  e2e: 4264ms<br>                                                                                                                                                                                               <br>    08:16:23.004 DEBUG    livekit.agents     conversation_item_added  <br>                                         {&quot;role&quot;: &quot;assistant&quot;, &quot;text&quot;: &quot;FHA Section 223(f) allows for federal insurance of mortgage loans to purchase or refinance existing multifamily housing<br>that doesn&#39;t require substantial rehabilitation. It aims to provide lower debt service or support affordable rental housing.&quot;}<br>   MacBook Pro Microphone          ▁ ▁ ▁ ▃ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁                             end_of_turn: 1020ms · turn_completed_cb: 0.00ms · llm_ttft: 1249ms · tts_ttfb: 133ms  ─  e2e: 4264ms<br>                                                                                                                                                                                               <br>   ? for shortcuts                                                                                                                                                                             </pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RDAWImnI5m-iHs_Lkmj7tw.png" /><figcaption>Mlflow captures all the tracesfor agent interactions.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qvHQLNWF5F1m7CaI71enzg.png" /><figcaption>Mlflow records the agent interactions</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*evneatcVbqQ6L1glq4ZVbw.png" /><figcaption>Mlflow gives the interaction graph view. click on the graph node to navigate the exact interaction.</figcaption></figure><h4>The Conclusion</h4><h3>Conclusion</h3><p>This was a very intense, deep hands-on session where the article explained how to build a complete <strong>Voice-Enabled RAG Agent</strong> from the ground up. We looked at how a user’s speech can be captured in real time, converted into text, passed through an LLM, enriched with relevant context retrieved from the <strong>Qdrant Vector Search Engine</strong>, and finally converted back into a natural voice response.</p><p>We also explored how <strong>LiveKit</strong> brings the real-time voice experience together, how <strong>LlamaIndex</strong> connects the agent with the knowledge layer, and how <strong>MLflow with OpenTelemetry</strong> gives us the traceability needed to understand what is happening inside the agent during every interaction.</p><p>The key takeaway is that a production-ready voice agent is not just about connecting speech-to-text and text-to-speech. The real value comes from combining <strong>voice interaction, semantic retrieval, grounded generation, and observability</strong> into one complete flow. This architecture gives us a strong foundation for building reliable voice assistants for domains such as mortgage, finance, healthcare, customer support, and other knowledge-intensive applications.</p><p>With this implementation in place, we now have a Voice RAG system that can <strong>listen, retrieve, reason, respond, and trace every interaction end to end</strong>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=61404a6b0791" width="1" height="1" alt=""><hr><p><a href="https://blog.stackademic.com/observable-voice-rag-agents-tracing-rag-agents-end-to-end-with-mlflow-and-qdrant-61404a6b0791">Observable Voice RAG Agents: Tracing RAG Agents End-to-End with MLflow and Qdrant</a> was originally published in <a href="https://blog.stackademic.com/">Stackademic</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Dense + Sparse + Filter: Building Precision Retrieval for Annual Reports and 10-Ks]]></title>
            <link>https://towardsdev.com/dense-sparse-filter-building-precision-retrieval-for-annual-reports-and-10-ks-edf9586e8a95?source=rss-934e21252654------2</link>
            <guid isPermaLink="false">https://medium.com/p/edf9586e8a95</guid>
            <category><![CDATA[metadata-enrichment]]></category>
            <category><![CDATA[hybrid-search]]></category>
            <category><![CDATA[sec-10k]]></category>
            <category><![CDATA[financial-rag]]></category>
            <category><![CDATA[qdrant]]></category>
            <dc:creator><![CDATA[M K Pavan Kumar]]></dc:creator>
            <pubDate>Thu, 06 Aug 2026 05:52:38 GMT</pubDate>
            <atom:updated>2026-08-25T11:46:09.342Z</atom:updated>
            <content:encoded><![CDATA[<p>Today we are going to see how a simple idea, teaching your retrieval system to respect boundaries instead of just chasing meaning, can fix one of the most frustrating problems in enterprise RAG. If you’ve ever built search over financial documents, SEC 10-Ks, mortgage packages, annual reports, you already know the pain: every company’s risk section sounds like every other company’s risk section, every loan’s terms sound like every other loan’s terms, and your vector search keeps confidently handing you the right answer for the wrong entity. We’re going to walk through exactly why that happens, and then build the fix ourselves, dense search for meaning, sparse BM25 for the exact terms that meaning misses, and metadata filtering as a hard rule that no similarity score gets to override. By the end, you’ll see the difference in the numbers themselves, unfiltered search getting it right by luck, filtered search getting it right every single time. Let’s get into it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fpjkUhcuJVp18-_CfquMhA.png" /><figcaption>created by author M K Pavan Kumar</figcaption></figure><h4>The Architecture:</h4><p>This architecture is basically a straight line from raw document to answer, with two branches for how the content gets embedded.</p><p><strong>Source Document</strong> is where it starts — PDF filings, SEC 10-Ks, mortgage docs, whatever dense text-heavy file you’re working with.</p><p><strong>Parse + Chunk</strong> breaks that document into smaller pieces. Nothing fancy here, just page and paragraph-level splitting. The idea is not to over-engineer this step, because the next step does most of the real work.</p><p><strong>Metadata Enrichment</strong> is the important one. Each chunk gets tagged with things like entity, section, and reporting period. This is what lets you filter later instead of relying purely on vector similarity, which tends to blur together content that’s semantically similar but actually belongs to different brands, companies, or sections.</p><p>From there, each chunk goes through two embedding paths at the same time:</p><ul><li><strong>Dense Embedding</strong> captures meaning — good for queries like “risk factors” even if the document says “principal risks” instead.</li><li><strong>Sparse Embedding</strong> captures exact keyword matches — good for things like specific terms, ticker symbols, or ingredient/product names that dense embeddings tend to under-weight.</li></ul><p>Both vectors, along with the metadata, get stored together in <strong>Qdrant</strong>. That’s the key part: it’s not vectors in one place and metadata in another. They live on the same point, and Qdrant indexes the metadata right alongside the vector index, so filtering happens during the search itself, not after.</p><p>The <strong>Query Layer</strong> is where everything comes together — hybrid search (dense + sparse) combined with a metadata filter, fused using RRF, all in a single query.</p><p>And finally, that result goes to the <strong>Application Layer / User</strong> — whatever’s consuming it, a chatbot, a search UI, or an internal tool.</p><p>The short version: chunk it, tag it, embed it two ways, store it with its metadata, then query with both semantic search and hard filters at once instead of picking one or the other.</p><h4>The Implementation:</h4><pre>import os<br><br>DATA_DIR = &quot;data/10k_filings&quot;<br>os.makedirs(DATA_DIR, exist_ok=True)<br><br>FILINGS = {<br>    &quot;AAPL&quot;: os.path.join(DATA_DIR, &quot;AAPL_10K_FY24.pdf&quot;),<br>    &quot;MSFT&quot;: os.path.join(DATA_DIR, &quot;MSFT_10K_FY24.pdf&quot;),<br>    &quot;HON&quot;:  os.path.join(DATA_DIR, &quot;HON_10K_FY24.pdf&quot;),<br>    &quot;GE&quot;:   os.path.join(DATA_DIR, &quot;GE_10K_FY24.pdf&quot;),<br>}<br><br>for ticker, path in FILINGS.items():<br>    exists = os.path.exists(path)<br>    print(f&quot;{ticker}: {&#39;found&#39; if exists else &#39;MISSING&#39;} at {path}&quot;)</pre><p>The above code sets up a folder for filings and defines a dictionary mapping each company ticker to its 10-K PDF path. It then checks each file actually exists on disk before the rest of the pipeline touches it. You would place your downloaded 10-K PDFs, from SEC EDGAR or wherever you source them, into that folder ahead of running this.</p><pre>import fitz<br>from typing import List, Dict<br><br>MAX_CHUNK_CHARS = 800<br><br><br>def parse_document(pdf_path: str, ticker: str) -&gt; List[Dict]:<br>    doc = fitz.open(pdf_path)<br>    chunks = []<br>    for page_num, page in enumerate(doc):<br>        text = page.get_text()<br>        for para in text.split(&quot;\n\n&quot;):<br>            para = para.strip()<br>            if len(para) &lt;= 100:<br>                continue<br>            for i in range(0, len(para), MAX_CHUNK_CHARS):<br>                piece = para[i:i + MAX_CHUNK_CHARS].strip()<br>                if len(piece) &gt; 100:<br>                    chunks.append({<br>                        &quot;text&quot;: piece,<br>                        &quot;page&quot;: page_num + 1,<br>                        &quot;ticker&quot;: ticker,<br>                    })<br>    return chunks<br><br><br>raw_chunks = []<br>for ticker, path in FILINGS.items():<br>    if os.path.exists(path):<br>        raw_chunks.extend(parse_document(path, ticker))<br><br>print(f&quot;Parsed {len(raw_chunks)} chunks across {len(FILINGS)} filings&quot;)</pre><p>The above code opens each PDF page by page, splits the page text on blank lines to get paragraphs, and drops anything under 100 characters since that’s usually noise like headers or page numbers. Any paragraph longer than 800 characters gets sliced into smaller pieces so no single chunk is too large. Every chunk gets stamped with its page number and the ticker of the filing it came from, and the loop at the bottom runs this once per company so all four filings end up in one combined list.</p><pre>import re<br>from collections import Counter<br><br>ITEM_PATTERNS = {<br>    &quot;Item 1 - Business&quot;: r&quot;item\s+1\.?\s+business&quot;,<br>    &quot;Item 1A - Risk Factors&quot;: r&quot;item\s+1a\.?\s+risk\s+factors&quot;,<br>    &quot;Item 3 - Legal Proceedings&quot;: r&quot;item\s+3\.?\s+legal\s+proceedings&quot;,<br>    &quot;Item 7 - MD&amp;A&quot;: r&quot;item\s+7\.?\s+management.s\s+discussion&quot;,<br>    &quot;Item 7A - Market Risk&quot;: r&quot;item\s+7a\.?\s+quantitative\s+and\s+qualitative&quot;,<br>    &quot;Item 8 - Financial Statements&quot;: r&quot;item\s+8\.?\s+financial\s+statements&quot;,<br>    &quot;Item 9A - Controls and Procedures&quot;: r&quot;item\s+9a\.?\s+controls\s+and\s+procedures&quot;,<br>}<br><br><br>def classify_item(text: str) -&gt; str:<br>    low = text.lower()<br>    for item, pattern in ITEM_PATTERNS.items():<br>        if re.search(pattern, low):<br>            return item<br>    return &quot;General&quot;<br><br><br>def enrich_chunk(chunk: Dict, fiscal_year: str) -&gt; Dict:<br>    text = chunk[&quot;text&quot;]<br>    item = classify_item(text)<br><br>    chunk[&quot;payload&quot;] = {<br>        &quot;ticker&quot;: chunk[&quot;ticker&quot;],<br>        &quot;document_section&quot;: item,<br>        &quot;page&quot;: chunk[&quot;page&quot;],<br>        &quot;fiscal_year&quot;: fiscal_year,<br>    }<br>    return chunk<br><br><br>enriched_chunks = [enrich_chunk(c, &quot;FY24&quot;) for c in raw_chunks]<br><br>print(f&quot;Total chunks: {len(enriched_chunks)}&quot;)<br>print(&quot;\nItem distribution:&quot;)<br>print(Counter(c[&quot;payload&quot;][&quot;document_section&quot;] for c in enriched_chunks))<br>print(&quot;\nChunks per ticker:&quot;)<br>print(Counter(c[&quot;payload&quot;][&quot;ticker&quot;] for c in enriched_chunks))</pre><p>The above code attaches structured metadata to every chunk. classify_item checks the chunk text against a set of regex patterns for the standard 10-K Item headers, Item 1A for risk factors, Item 7 for MD&amp;A, and so on, and returns whichever one matches. Since 10-K numbering is mandated by SEC Regulation S-K, this is a reliable check regardless of which company filed it. The ticker doesn&#39;t need any detection logic here since it&#39;s already known from which file the chunk came from, it just gets carried into the payload alongside the section label, page number, and fiscal year. The print statements at the end just give you a sanity check on how chunks are distributed across items and across companies.</p><pre>from sentence_transformers import SentenceTransformer<br><br>embedding_model = SentenceTransformer(&quot;all-MiniLM-L6-v2&quot;)<br><br>texts = [c[&quot;text&quot;] for c in enriched_chunks]<br>dense_vectors = embedding_model.encode(texts, show_progress_bar=True, batch_size=64)<br><br>VECTOR_SIZE = dense_vectors.shape[1]<br>print(f&quot;Embedded {len(texts)} chunks into {VECTOR_SIZE}-dim dense vectors&quot;)</pre><p>The above code loads a small local embedding model and runs every chunk’s text through it to produce dense vectors, 384 numbers per chunk that capture meaning rather than exact wording. This is what lets a query like “risk factors” also match a chunk that says “principal risks” instead. Since there are four filings’ worth of text here, this step will take noticeably longer to run than it would on a single document.</p><pre>from fastembed import SparseTextEmbedding<br><br>sparse_model = SparseTextEmbedding(model_name=&quot;Qdrant/bm25&quot;)<br>sparse_vectors = list(sparse_model.embed(texts))<br><br>print(f&quot;Generated {len(sparse_vectors)} sparse (BM25) vectors&quot;)</pre><p>The above code generates a second type of vector for every chunk, this time using BM25, a statistical method that rewards exact keyword matches instead of semantic closeness. This matters a lot for 10-Ks specifically, since filings are full of exact tokens like accounting standard codes, litigation names, and regulatory citations that a dense embedding model tends to smooth over rather than treat as distinct.</p><pre>from qdrant_client import QdrantClient<br>from qdrant_client.models import (<br>    VectorParams, Distance, SparseVectorParams, PointStruct, SparseVector<br>)<br><br>COLLECTION_NAME = &quot;sec_10k_filings&quot;<br><br>client = QdrantClient(url=&quot;http://localhost:6333&quot;, api_key=&quot;****&quot;)<br><br>if client.collection_exists(COLLECTION_NAME):<br>    client.delete_collection(COLLECTION_NAME)<br><br>client.create_collection(<br>    collection_name=COLLECTION_NAME,<br>    vectors_config={&quot;dense&quot;: VectorParams(size=VECTOR_SIZE, distance=Distance.COSINE)},<br>    sparse_vectors_config={&quot;sparse&quot;: SparseVectorParams()},<br>)<br><br>points = []<br>for idx, (chunk, dense_vec, sparse_vec) in enumerate(zip(enriched_chunks, dense_vectors, sparse_vectors)):<br>    points.append(<br>        PointStruct(<br>            id=idx,<br>            vector={<br>                &quot;dense&quot;: dense_vec.tolist(),<br>                &quot;sparse&quot;: SparseVector(indices=sparse_vec.indices.tolist(), values=sparse_vec.values.tolist()),<br>            },<br>            payload={&quot;text&quot;: chunk[&quot;text&quot;], **chunk[&quot;payload&quot;]},<br>        )<br>    )<br><br>client.upsert(collection_name=COLLECTION_NAME, points=points)<br>print(f&quot;Upserted {len(points)} points into &#39;{COLLECTION_NAME}&#39;&quot;)</pre><p>The above code starts an in-memory Qdrant instance and creates a collection configured to hold two named vectors per point, one dense and one sparse. It then loops through every chunk and builds a PointStruct combining the chunk&#39;s dense vector, its sparse vector, and its full metadata payload, all attached to the same point ID. The final upsert call writes everything into Qdrant in one batch. Keeping the vectors and the metadata on the same point is what lets Qdrant build its payload index right alongside the similarity index, so filtering by ticker or section can happen during the search itself rather than as a separate step afterward.</p><pre>def show_results(results, title):<br>    print(f&quot;--- {title} ---&quot;)<br>    for p in results.points:<br>        print(f&quot;score={p.score:.3f}  ticker={p.payload[&#39;ticker&#39;]:&lt;6}  &quot;<br>              f&quot;section={p.payload[&#39;document_section&#39;]:&lt;30} page={p.payload[&#39;page&#39;]}&quot;)<br>        print(&quot;   &quot;, p.payload[&quot;text&quot;][:140].replace(&quot;\n&quot;, &quot; &quot;), &quot;...&quot;)<br>    print()<br><br><br>query_text = &quot;supply chain disruption and litigation risk&quot;<br>query_vector = embedding_model.encode(query_text).tolist()<br><br>unfiltered_results = client.query_points(<br>    collection_name=COLLECTION_NAME, query=query_vector, using=&quot;dense&quot;, limit=5,<br>)<br><br>show_results(unfiltered_results, &quot;UNFILTERED (pure semantic search)&quot;)</pre><p>The above code defines a small helper to print search results cleanly, then runs a single query with no metadata filter at all, just plain dense vector search across the whole collection. Run it and the top five results will typically mix tickers freely, a chunk from Apple’s filing sitting right next to one from GE, because cosine similarity has no built-in concept of “only Microsoft” or “only this section.” That’s the gap the next cell closes.</p><pre>from qdrant_client.models import Filter, FieldCondition, MatchValue<br><br>filtered_results = client.query_points(<br>    collection_name=COLLECTION_NAME,<br>    query=query_vector,<br>    using=&quot;dense&quot;,<br>    query_filter=Filter(<br>        must=[<br>            FieldCondition(key=&quot;ticker&quot;, match=MatchValue(value=&quot;MSFT&quot;)),<br>            FieldCondition(key=&quot;document_section&quot;, match=MatchValue(value=&quot;Item 1A - Risk Factors&quot;)),<br>        ]<br>    ),<br>    limit=5,<br>)<br><br>show_results(filtered_results, &quot;FILTERED (ticker = MSFT, section = Item 1A)&quot;)</pre><p>The above code runs the exact same query as the previous cell, but this time layers a hard filter on top, restricting results to Microsoft’s filing and specifically its Item 1A risk factors section. Any chunk that fails either condition is excluded before it even gets ranked, regardless of how high its similarity score would have been. This is the core of the whole pattern, a semantic query with structural constraints enforced as a database-style rule rather than a soft nudge.</p><pre>from qdrant_client.models import Prefetch, FusionQuery, Fusion<br><br>hybrid_query_text = &quot;ASC 606 revenue recognition&quot;<br>hybrid_query_vector = embedding_model.encode(hybrid_query_text).tolist()<br>hybrid_query_sparse = list(sparse_model.embed([hybrid_query_text]))[0]<br><br>hybrid_results = client.query_points(<br>    collection_name=COLLECTION_NAME,<br>    prefetch=[<br>        Prefetch(query=hybrid_query_vector, using=&quot;dense&quot;, limit=20),<br>        Prefetch(<br>            query=SparseVector(indices=hybrid_query_sparse.indices.tolist(), values=hybrid_query_sparse.values.tolist()),<br>            using=&quot;sparse&quot;,<br>            limit=20,<br>        ),<br>    ],<br>    query=FusionQuery(fusion=Fusion.RRF),<br>    limit=5,<br>)<br><br>show_results(hybrid_results, &quot;HYBRID (dense + sparse RRF fusion, query = &#39;ASC 606 revenue recognition&#39;)&quot;)</pre><p>The above code runs a query for an accounting standard citation, exactly the kind of literal token a dense model tends to under-weight since it cares more about general topic than exact phrasing. It fetches candidates from both the dense and sparse legs separately, then fuses the two ranked lists together using Reciprocal Rank Fusion so a chunk that scores well on either signal has a chance to surface in the final top five.</p><pre>FILTER_TEST_QUERIES = [<br>    {&quot;query&quot;: &quot;product liability and litigation exposure&quot;, &quot;ticker&quot;: &quot;GE&quot;, &quot;label&quot;: &quot;GE / Item 3&quot;},<br>    {&quot;query&quot;: &quot;cybersecurity and data protection risk&quot;, &quot;ticker&quot;: &quot;AAPL&quot;, &quot;label&quot;: &quot;AAPL / Item 1A&quot;},<br>    {&quot;query&quot;: &quot;interest rate and currency exposure&quot;, &quot;ticker&quot;: &quot;HON&quot;, &quot;label&quot;: &quot;HON / Item 7A&quot;},<br>]<br><br>filter_test_results = []<br>for tc in FILTER_TEST_QUERIES:<br>    q_vec = embedding_model.encode(tc[&quot;query&quot;]).tolist()<br>    q_filter = Filter(must=[FieldCondition(key=&quot;ticker&quot;, match=MatchValue(value=tc[&quot;ticker&quot;]))])<br><br>    unfiltered = client.query_points(collection_name=COLLECTION_NAME, query=q_vec, using=&quot;dense&quot;, limit=5)<br>    filtered = client.query_points(collection_name=COLLECTION_NAME, query=q_vec, using=&quot;dense&quot;, query_filter=q_filter, limit=5)<br><br>    hits_unfiltered = sum(p.payload[&quot;ticker&quot;] == tc[&quot;ticker&quot;] for p in unfiltered.points)<br>    hits_filtered = sum(p.payload[&quot;ticker&quot;] == tc[&quot;ticker&quot;] for p in filtered.points)<br><br>    filter_test_results.append({<br>        &quot;label&quot;: tc[&quot;label&quot;],<br>        &quot;unfiltered_rate&quot;: hits_unfiltered / 5,<br>        &quot;filtered_rate&quot;: hits_filtered / 5,<br>    })<br><br>for r in filter_test_results:<br>    print(f&quot;{r[&#39;label&#39;]:&lt;16} unfiltered={r[&#39;unfiltered_rate&#39;]:.2f}  filtered={r[&#39;filtered_rate&#39;]:.2f}&quot;)</pre><p>The above code runs three different company-scoped queries and, for each one, compares how often the unfiltered search happens to land on the right company versus how often the filtered search does, out of its top five results. The unfiltered rate will vary from query to query since it’s essentially getting lucky or unlucky depending on how much the filings overlap in language, while the filtered rate should sit at 1.0 every time, since a hard filter simply doesn’t allow a chunk from the wrong ticker through no matter how relevant it looks otherwise.</p><p>If you want, I can also do the charting cell that turns this comparison into the two bar charts, or extend the filter tests to combine ticker and Item section together in the same query.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H61VW4Hy4I2zhoKw_9QlBA.png" /><figcaption>evals report with metedata enrichment + hybrid search</figcaption></figure><h4>The Conclusion</h4><p>We have seen how combining dense embeddings, sparse BM25, and metadata as a hard filter closes a gap that plain vector search cannot close on its own, unfiltered semantic search kept mixing tickers and sections together since cosine similarity has no concept of “only this company” or “only this Item,” while layering a metadata filter on top pushed precision to a flat 100% every time, and the sparse leg separately rescued literal terms like accounting standard citations that dense embeddings tend to smooth over. This implementation can improve retrieval quality anywhere a corpus reuses similar language across distinct entities, filings across companies, loan documents across borrowers, annual reports across brands, and because Qdrant indexes the payload right alongside the vector index, this precision doesn’t come at the cost of a slower search, filter and retrieval happen together in a single call, making it a practical pattern to carry into a production RAG pipeline rather than just a notebook experiment.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=edf9586e8a95" width="1" height="1" alt=""><hr><p><a href="https://towardsdev.com/dense-sparse-filter-building-precision-retrieval-for-annual-reports-and-10-ks-edf9586e8a95">Dense + Sparse + Filter: Building Precision Retrieval for Annual Reports and 10-Ks</a> was originally published in <a href="https://towardsdev.com/">Towards Dev</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Hybrid Retrieval Under the Microscope: BM25 vs MiniCOIL on MedQuAD]]></title>
            <link>https://blog.stackademic.com/hybrid-retrieval-under-the-microscope-bm25-vs-minicoil-on-medquad-129b464b0166?source=rss-934e21252654------2</link>
            <guid isPermaLink="false">https://medium.com/p/129b464b0166</guid>
            <category><![CDATA[qdrant]]></category>
            <category><![CDATA[hybrid-search]]></category>
            <category><![CDATA[minicoil]]></category>
            <category><![CDATA[hybrid-retrieval]]></category>
            <category><![CDATA[bm25]]></category>
            <dc:creator><![CDATA[M K Pavan Kumar]]></dc:creator>
            <pubDate>Wed, 22 Jul 2026 11:01:50 GMT</pubDate>
            <atom:updated>2026-07-23T11:33:12.550Z</atom:updated>
            <content:encoded><![CDATA[<p>Today, we are going to learn how BM25 and miniCOIL perform in a hybrid search pipeline. Using the MedQuAD dataset, EmbeddingGemma, and Qdrant, we will build two retrieval setups, compare their benchmark results, and understand where miniCOIL improves over traditional BM25.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Xa-bco1cbjnegQIOHqh1sQ.png" /><figcaption>created by author M K Pavan Kumar</figcaption></figure><h4>What is BM25 ?</h4><p>BM25, or <strong>Best Matching 25</strong>, is a keyword-based ranking algorithm used in search engines. It ranks documents by considering:</p><ul><li>How frequently the query terms appear in a document</li><li>How rare or important those terms are across the document collection</li><li>The length of the document</li></ul><p>For example, when searching for <strong>“vector database”</strong>, BM25 gives higher scores to documents containing both terms, especially when those words are uncommon and appear prominently. However, BM25 matches words statistically and does not understand their contextual meaning.</p><h4>What is miniCOIL?</h4><p>miniCOIL is Qdrant’s open-source <strong>sparse neural retrieval model</strong>. It extends BM25-style keyword matching by considering the contextual meaning of matching words.</p><p>For example, for the query <strong>“vectors in medicine”</strong>, BM25 may treat every occurrence of the word <em>vector</em> similarly. miniCOIL can understand that <em>vector</em> in <strong>“vector-borne diseases”</strong> is more relevant than <em>vector</em> in graphics or mathematics.</p><p>miniCOIL is inspired by the COIL approach and can be understood as:</p><blockquote><strong><em>BM25-style keyword retrieval with contextual understanding.</em></strong></blockquote><p>Because miniCOIL still relies on overlapping words, it is commonly combined with dense embeddings in hybrid search to retrieve documents that use different terminology.</p><h4>The Architecture</h4><p>For this experiment, I designed a controlled benchmarking architecture to compare two hybrid retrieval approaches in Qdrant. The first approach combines dense embeddings with BM25, while the second combines the same dense embeddings with miniCOIL. My intention was to keep every other part of the pipeline unchanged so that the final comparison would clearly show the impact of replacing BM25 with miniCOIL.</p><p>The experiment begins with the MedQuAD dataset, which contains medical questions, answers, and their corresponding contexts. During ingestion, I use only the contextual content as the searchable knowledge base. The questions and expected answers are maintained separately for validation. This separation is important because the benchmark should evaluate whether the retrieval system can find the correct medical context for a given question, rather than simply matching against the question-answer pairs already stored in the database.</p><p>The ingester processes each MedQuAD context and prepares it for storage in two separate Qdrant collections. For both collections, I generate dense vectors using the embeddinggemma-300m model. Using the same dense embedding model ensures that the semantic retrieval component remains identical across the two experiments. The only component that changes is the sparse retrieval method.</p><p>The first Qdrant collection uses a combination of embeddinggemma-300m dense vectors and BM25 sparse vectors. BM25 represents the traditional lexical side of hybrid retrieval. It rewards documents that contain query terms based on factors such as term frequency, document length, and inverse document frequency. This collection therefore combines semantic similarity from the dense vectors with conventional keyword matching from BM25.</p><p>The second collection uses the same embeddinggemma-300m dense vectors, but replaces BM25 with miniCOIL. Unlike traditional BM25, miniCOIL does not treat every occurrence of a matching word in exactly the same way. It generates contextualized sparse representations, allowing the retrieval system to consider the meaning of matching terms within the query and document. This makes the second collection an interesting alternative to the conventional dense-plus-BM25 setup.</p><p>Once both collections are populated with the same MedQuAD contexts, the benchmark component reads questions from the MedQuAD validation dataset. Each validation question is submitted independently to both collections. The first retrieval run searches the dense-plus-BM25 collection, while the second searches the dense-plus-miniCOIL collection. Since both systems receive exactly the same questions and search over exactly the same content, the comparison remains fair and reproducible.</p><p>For every query, the benchmark component records the ranked contexts returned by both retrieval configurations. These results are then compared with the expected context or answer associated with the validation question. The evaluation measures how often the correct context appears among the top retrieved results, how highly it is ranked, and how much time each retrieval approach requires.</p><p>In simple terms, the architecture creates two parallel retrieval pipelines. Both pipelines use the same dataset, the same dense embedding model, the same Qdrant environment, and the same validation questions. The only deliberate difference is the sparse retrieval mechanism: BM25 in the first pipeline and miniCOIL in the second. This controlled setup allows me to determine whether miniCOIL provides a measurable improvement over BM25 when combined with dense retrieval for medical question answering.</p><h4>The Implementation</h4><p>This script prepares the MedQuAD medical dataset for hybrid search in Qdrant using both dense embeddings and BM25 sparse vectors. It first downloads the dataset from Hugging Face and extracts only the medical context from each record. Every context is stored with a simple payload containing a document ID and the original text.</p><p>Next, the script calculates the average document length, which BM25 uses during scoring. It then generates dense semantic embeddings for all contexts using google/embeddinggemma-300m. These dense vectors capture the meaning of the medical text, while BM25 captures exact keyword relevance.</p><p>The script creates a Qdrant collection named medquad_dense_bm25 with two vector types: a dense vector using cosine similarity and a sparse BM25 vector using the IDF modifier. Each MedQuAD context is then stored as a Qdrant point containing the original text, its dense embedding, and its BM25 representation.</p><p>The documents are uploaded to Qdrant in batches of 16. To make ingestion faster, indexing is temporarily delayed while the data is inserted. Once all documents are uploaded, the script triggers indexing and waits until the Qdrant collection reaches the green state.</p><p>In simple terms, this code converts the MedQuAD contexts into a searchable Qdrant knowledge base that supports hybrid retrieval by combining semantic search from EmbeddingGemma with keyword search from BM25.</p><pre>from datasets import load_dataset<br>from sentence_transformers import SentenceTransformer<br>from qdrant_client import QdrantClient, models<br><br># --- config ---<br>HF_REPO_ID = &quot;pavanmantha/MedQuAD_Context_GroupedQA&quot;<br>SPLIT = &quot;train&quot;<br>MODEL_NAME = &quot;google/embeddinggemma-300m&quot;<br>BM25_MODEL = &quot;Qdrant/bm25&quot;<br>COLLECTION_NAME = &quot;medquad_dense_bm25&quot;<br>QDRANT_URL = &quot;http://localhost:6333&quot;<br>API_KEY = &quot;API KEY&quot;<br>BATCH_SIZE = 16<br>RECREATE = False<br><br><br>def load_contexts():<br>    &quot;&quot;&quot;Pull the grouped dataset and return (contexts, payloads).&quot;&quot;&quot;<br>    ds = load_dataset(HF_REPO_ID, split=SPLIT)<br>    contexts, payloads = [], []<br>    for i, row in enumerate(ds):<br>        ctx = row[&quot;context&quot;]<br>        contexts.append(ctx)<br>        payloads.append({<br>            &quot;doc_id&quot;: f&quot;doc_{i}&quot;,<br>            &quot;text&quot;: ctx,<br>        })<br>    return contexts, payloads<br><br><br>def compute_avg_length(contexts):<br>    &quot;&quot;&quot;Average word count across the corpus, part of BM25&#39;s scoring formula.&quot;&quot;&quot;<br>    return sum(len(c.split()) for c in contexts) / len(contexts)<br><br><br>def embed_dense(contexts):<br>    &quot;&quot;&quot;Precompute dense EmbeddingGemma vectors, same as the original dense-only pipeline.&quot;&quot;&quot;<br>    print(f&quot;Embedding {len(contexts)} contexts with {MODEL_NAME}...&quot;)<br>    model = SentenceTransformer(MODEL_NAME, device=&quot;mps&quot;)<br>    embeddings = model.encode_document(<br>        contexts, batch_size=BATCH_SIZE, show_progress_bar=True<br>    )<br>    vectors = [vec.tolist() for vec in embeddings]<br>    dim = len(vectors[0])<br>    print(f&quot;Got {len(vectors)} dense vectors of dim {dim}&quot;)<br>    return vectors, dim<br><br><br>def create_collection(client, collection_name, dense_dim):<br>    if RECREATE and client.collection_exists(collection_name):<br>        client.delete_collection(collection_name)<br><br>    if not client.collection_exists(collection_name):<br>        client.create_collection(<br>            collection_name=collection_name,<br>            vectors_config={<br>                &quot;dense&quot;: models.VectorParams(<br>                    size=dense_dim,<br>                    distance=models.Distance.COSINE,<br>                    hnsw_config=models.HnswConfigDiff(<br>                        m=64,<br>                        ef_construct=200,<br>                    ),<br>                ),<br>            },<br>            sparse_vectors_config={<br>                &quot;bm25&quot;: models.SparseVectorParams(<br>                    modifier=models.Modifier.IDF,<br>                ),<br>            },<br>            optimizers_config=models.OptimizersConfigDiff(<br>                indexing_threshold=100000,<br>            ),<br>        )<br><br><br>def build_points(contexts, payloads, dense_vectors, avg_len):<br>    points = []<br>    for i, (ctx, payload, dvec) in enumerate(zip(contexts, payloads, dense_vectors)):<br>        points.append(<br>            models.PointStruct(<br>                id=i,<br>                payload=payload,<br>                vector={<br>                    &quot;dense&quot;: dvec,<br>                    &quot;bm25&quot;: models.Document(<br>                        text=ctx,<br>                        model=BM25_MODEL,<br>                        options={&quot;avg_len&quot;: avg_len},<br>                    ),<br>                },<br>            )<br>        )<br>    return points<br><br><br>def upsert_points(client, collection_name, points):<br>    print(&quot;1. Upserting points (dense + bm25)...&quot;)<br>    for start in range(0, len(points), BATCH_SIZE):<br>        batch = points[start:start + BATCH_SIZE]<br>        client.upsert(collection_name=collection_name, points=batch)<br>        print(f&quot;  upserted {start + len(batch)}/{len(points)}&quot;)<br><br><br>def trigger_indexing(client, collection_name):<br>    print(&quot;2. All points upserted. Triggering indexing...&quot;)<br>    client.update_collection(<br>        collection_name=collection_name,<br>        optimizers_config=models.OptimizersConfigDiff(<br>            indexing_threshold=1000,<br>        ),<br>    )<br><br>    import time<br>    while True:<br>        info = client.get_collection(collection_name)<br>        if info.status.value == &quot;green&quot;:<br>            print(&quot;Index ready — collection is green&quot;)<br>            break<br>        print(f&quot;  status: {info.status.value} — waiting...&quot;)<br>        time.sleep(5)<br>    print(&quot;3. indexing done&quot;)<br><br><br>def main():<br>    contexts, payloads = load_contexts()<br>    avg_len = compute_avg_length(contexts)<br>    dense_vectors, dense_dim = embed_dense(contexts)<br><br>    client = QdrantClient(url=QDRANT_URL, api_key=API_KEY)<br>    create_collection(client, COLLECTION_NAME, dense_dim)<br><br>    points = build_points(contexts, payloads, dense_vectors, avg_len)<br>    upsert_points(client, COLLECTION_NAME, points)<br>    trigger_indexing(client, COLLECTION_NAME)<br><br><br>if __name__ == &quot;__main__&quot;:<br>    main()</pre><p>This script builds the second retrieval pipeline in the experiment, where dense semantic search is combined with miniCOIL instead of BM25. It downloads the MedQuAD dataset from Hugging Face, extracts the medical context from each record, and prepares the original text and document ID as payload data for Qdrant.</p><p>The dense representation is generated using google/embeddinggemma-300m. To avoid recomputing the same embeddings every time the script runs, the vectors are saved in a local .cache folder using a pickle file. On later runs, the script checks whether the cached vectors match the current dataset size and loads them directly. This makes repeated ingestion much faster.</p><p>Since both EmbeddingGemma and miniCOIL may consume significant memory, the script releases the dense embedding model after the vectors are generated. It clears Python memory and the Apple MPS cache before miniCOIL is used. This prevents memory conflicts when running both models in the same process on a Mac.</p><p>The script calculates the average document length because miniCOIL uses a BM25-based scoring structure. It then creates a Qdrant collection called medquad_dense_minicoil with two named vector spaces. The dense vector stores the EmbeddingGemma representation and uses cosine similarity, while the minicoil vector stores the contextual sparse representation generated using Qdrant/minicoil-v1. The miniCOIL vector is also configured with the required IDF modifier.</p><p>Each MedQuAD context is stored as a Qdrant point containing its payload, dense embedding, and miniCOIL representation. The documents are uploaded in batches of 16. Index construction is initially delayed to make ingestion faster, and after all the records are inserted, the indexing threshold is lowered so Qdrant can build the final search index.</p><p>The script then waits until the collection reaches the green state, confirming that indexing has finished successfully. In simple terms, this code creates a searchable MedQuAD collection that combines semantic understanding from EmbeddingGemma with context-aware lexical matching from miniCOIL. This collection forms the miniCOIL side of the comparison against the dense-plus-BM25 pipeline.</p><pre>import os<br><br>os.environ.setdefault(&quot;TOKENIZERS_PARALLELISM&quot;, &quot;false&quot;)  # avoid HF tokenizer fork after MPS init<br><br>import gc<br>import pickle<br>from pathlib import Path<br><br>from datasets import load_dataset<br>from sentence_transformers import SentenceTransformer<br>from qdrant_client import QdrantClient, models<br><br># --- config ---<br>HF_REPO_ID = &quot;pavanmantha/MedQuAD_Context_GroupedQA&quot;<br>SPLIT = &quot;train&quot;<br>MODEL_NAME = &quot;google/embeddinggemma-300m&quot;<br>MINICOIL_MODEL = &quot;Qdrant/minicoil-v1&quot;<br>COLLECTION_NAME = &quot;medquad_dense_minicoil&quot;<br>QDRANT_URL = &quot;http://localhost:6333&quot;<br>API_KEY = &quot;API key&quot;<br>BATCH_SIZE = 16<br>RECREATE = False<br><br>CACHE_DIR = Path(__file__).resolve().parent / &quot;.cache&quot;<br>CACHE_DIR.mkdir(exist_ok=True)<br>print(f&quot;Cache dir: {CACHE_DIR}&quot;)<br><br><br>def load_contexts():<br>    &quot;&quot;&quot;Pull the grouped dataset and return (contexts, payloads).&quot;&quot;&quot;<br>    ds = load_dataset(HF_REPO_ID, split=SPLIT)<br>    contexts, payloads = [], []<br>    for i, row in enumerate(ds):<br>        ctx = row[&quot;context&quot;]<br>        contexts.append(ctx)<br>        payloads.append({<br>            &quot;doc_id&quot;: f&quot;doc_{i}&quot;,<br>            &quot;text&quot;: ctx,<br>        })<br>    return contexts, payloads<br><br><br>def compute_avg_length(contexts):<br>    &quot;&quot;&quot;Average word count across the corpus, used by miniCOIL&#39;s BM25-based scoring.&quot;&quot;&quot;<br>    return sum(len(c.split()) for c in contexts) / len(contexts)<br><br><br>def embed_dense(contexts):<br>    &quot;&quot;&quot;Precompute dense EmbeddingGemma vectors, cached to disk, model released<br>    from MPS before returning so it isn&#39;t still alive when minicoil&#39;s ONNX<br>    model loads during upsert.&quot;&quot;&quot;<br>    cache_path = CACHE_DIR / &quot;dense.pkl&quot;<br>    if cache_path.exists():<br>        with open(cache_path, &quot;rb&quot;) as f:<br>            cached_len, vectors = pickle.load(f)<br>        if cached_len == len(contexts):<br>            print(f&quot;Loaded {cached_len} cached dense vectors from {cache_path}&quot;)<br>            return vectors, len(vectors[0])<br>        print(f&quot;Cache is for {cached_len} contexts, need {len(contexts)} — recomputing&quot;)<br><br>    print(f&quot;Embedding {len(contexts)} contexts with {MODEL_NAME}...&quot;)<br>    model = SentenceTransformer(MODEL_NAME, device=&quot;mps&quot;)<br>    embeddings = model.encode_document(<br>        contexts, batch_size=BATCH_SIZE, show_progress_bar=True<br>    )<br>    vectors = [vec.tolist() for vec in embeddings]<br>    dim = len(vectors[0])<br>    print(f&quot;Got {len(vectors)} dense vectors of dim {dim}&quot;)<br><br>    # Release before minicoil&#39;s ONNX model ever loads in this same process<br>    del model<br>    gc.collect()<br>    try:<br>        import torch<br>        torch.mps.empty_cache()<br>    except Exception:<br>        pass<br><br>    with open(cache_path, &quot;wb&quot;) as f:<br>        pickle.dump((len(vectors), vectors), f)<br>    print(f&quot;Cached dense vectors to {cache_path}&quot;)<br><br>    return vectors, dim<br><br><br>def create_collection(client, collection_name, dense_dim):<br>    if RECREATE and client.collection_exists(collection_name):<br>        client.delete_collection(collection_name)<br><br>    if not client.collection_exists(collection_name):<br>        client.create_collection(<br>            collection_name=collection_name,<br>            vectors_config={<br>                &quot;dense&quot;: models.VectorParams(<br>                    size=dense_dim,<br>                    distance=models.Distance.COSINE,<br>                    hnsw_config=models.HnswConfigDiff(<br>                        m=64,<br>                        ef_construct=200,<br>                    ),<br>                ),<br>            },<br>            sparse_vectors_config={<br>                &quot;minicoil&quot;: models.SparseVectorParams(<br>                    modifier=models.Modifier.IDF,<br>                ),<br>            },<br>            optimizers_config=models.OptimizersConfigDiff(<br>                indexing_threshold=100000,<br>            ),<br>        )<br><br><br>def build_points(contexts, payloads, dense_vectors, avg_len):<br>    points = []<br>    for i, (ctx, payload, dvec) in enumerate(zip(contexts, payloads, dense_vectors)):<br>        points.append(<br>            models.PointStruct(<br>                id=i,<br>                payload=payload,<br>                vector={<br>                    &quot;dense&quot;: dvec,<br>                    &quot;minicoil&quot;: models.Document(<br>                        text=ctx,<br>                        model=MINICOIL_MODEL,<br>                        options={&quot;avg_len&quot;: avg_len},<br>                    ),<br>                },<br>            )<br>        )<br>    return points<br><br><br>def upsert_points(client, collection_name, points):<br>    print(&quot;1. Upserting points (dense + minicoil)...&quot;)<br>    for start in range(0, len(points), BATCH_SIZE):<br>        batch = points[start:start + BATCH_SIZE]<br>        client.upsert(collection_name=collection_name, points=batch)<br>        print(f&quot;  upserted {start + len(batch)}/{len(points)}&quot;)<br><br><br>def trigger_indexing(client, collection_name):<br>    print(&quot;2. All points upserted. Triggering indexing...&quot;)<br>    client.update_collection(<br>        collection_name=collection_name,<br>        optimizers_config=models.OptimizersConfigDiff(<br>            indexing_threshold=1000,<br>        ),<br>    )<br><br>    import time<br>    while True:<br>        info = client.get_collection(collection_name)<br>        if info.status.value == &quot;green&quot;:<br>            print(&quot;Index ready — collection is green&quot;)<br>            break<br>        print(f&quot;  status: {info.status.value} — waiting...&quot;)<br>        time.sleep(5)<br>    print(&quot;3. indexing done&quot;)<br><br><br>def main():<br>    contexts, payloads = load_contexts()<br>    avg_len = compute_avg_length(contexts)<br>    dense_vectors, dense_dim = embed_dense(contexts)<br><br>    client = QdrantClient(url=QDRANT_URL, api_key=API_KEY)<br>    create_collection(client, COLLECTION_NAME, dense_dim)<br><br>    points = build_points(contexts, payloads, dense_vectors, avg_len)<br>    upsert_points(client, COLLECTION_NAME, points)<br>    trigger_indexing(client, COLLECTION_NAME)<br><br><br>if __name__ == &quot;__main__&quot;:<br>    main()</pre><h4>The Results</h4><p>These benchmark results make the comparison much clearer. Both configurations successfully evaluated all 100 questions without errors. The dense-plus-BM25 pipeline achieved a contextual precision of <strong>0.879</strong> and contextual recall of <strong>0.840</strong>, while the dense-plus-miniCOIL pipeline improved these scores to <strong>0.898</strong> and <strong>0.875</strong>, respectively.</p><p>In practical terms, miniCOIL improved contextual precision by approximately <strong>1.93 percentage points</strong> and contextual recall by <strong>3.51 percentage points</strong>. The larger improvement in recall suggests that miniCOIL was more successful at retrieving the complete set of medical information required to answer the validation questions. Its contextualized term representations appear to provide an advantage over traditional BM25 when matching specialised medical queries with relevant contexts.</p><p>Answer relevancy remained almost identical across both pipelines. BM25 achieved <strong>0.3997</strong>, while miniCOIL achieved <strong>0.3990</strong>. This difference is negligible and indicates that changing the sparse retrieval model improved retrieval quality but did not improve how the generation model used the retrieved information. The answer-generation stage remains a separate area that requires further optimisation.</p><pre>{<br>  &quot;bm25&quot;: {<br>    &quot;n_scored&quot;: 100,<br>    &quot;n_errors&quot;: 0,<br>    &quot;avg_contextual_precision&quot;: 0.8785067433768732,<br>    &quot;avg_contextual_recall&quot;: 0.8398373220591189,<br>    &quot;avg_answer_relevancy&quot;: 0.39968247900511716<br>  },<br>  &quot;minicoil&quot;: {<br>    &quot;n_scored&quot;: 100,<br>    &quot;n_errors&quot;: 0,<br>    &quot;avg_contextual_precision&quot;: 0.8978049792139078,<br>    &quot;avg_contextual_recall&quot;: 0.8749693639693639,<br>    &quot;avg_answer_relevancy&quot;: 0.3989726123549653<br>  }<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/924/1*3DB061pjzQ6277JPn_2JGw.png" /></figure><h4>The Conclusion</h4><p>In this article, we learned what BM25 is, how miniCOIL extends lexical retrieval with contextual understanding, and how both approaches can be combined with dense embeddings to build hybrid search pipelines in Qdrant. We designed an experimental architecture using the MedQuAD dataset, EmbeddingGemma for dense representations, and two separate Qdrant collections to compare dense retrieval combined with BM25 and miniCOIL.</p><p>We also implemented the complete workflow, from loading and preparing the dataset to generating vectors, ingesting them into Qdrant, executing validation queries, and evaluating the retrieved results. By keeping the dataset, dense embedding model, validation questions, and Qdrant configuration consistent, the experiment created a fair environment for studying the effect of changing only the sparse retrieval model.</p><p>The benchmark produced strong retrieval results, with an average contextual precision of <strong>0.879</strong> and contextual recall of <strong>0.840</strong>. These scores show that the system was generally successful in retrieving and ranking the medical contexts required to answer the validation questions. However, the lower answer relevancy score also highlighted an important lesson: retrieving the correct context does not automatically guarantee a correct final answer. The generation model must still understand, follow, and accurately use the retrieved evidence.</p><p>Overall, BM25 remains a reliable and efficient choice when exact keyword matching is important, while miniCOIL introduces contextual awareness into sparse retrieval and can better distinguish the meaning of matching terms. The right choice depends on the dataset, query patterns, latency requirements, and domain. Rather than selecting a retrieval technique based only on its popularity, this experiment shows why it is important to benchmark each configuration using real domain data and measurable evaluation metrics.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=129b464b0166" width="1" height="1" alt=""><hr><p><a href="https://blog.stackademic.com/hybrid-retrieval-under-the-microscope-bm25-vs-minicoil-on-medquad-129b464b0166">Hybrid Retrieval Under the Microscope: BM25 vs MiniCOIL on MedQuAD</a> was originally published in <a href="https://blog.stackademic.com/">Stackademic</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>