<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vasu Buchingari</title>
    <description>The latest articles on DEV Community by Vasu Buchingari (@vasu_buchingari).</description>
    <link>https://dev.to/vasu_buchingari</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1823661%2Fb2c2b973-6647-4fcc-ac7f-0ce1c5b1ef8e.jpg</url>
      <title>DEV Community: Vasu Buchingari</title>
      <link>https://dev.to/vasu_buchingari</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vasu_buchingari"/>
    <language>en</language>
    <item>
      <title>Building a Complete RAG Pipeline: From Document Ingestion to Retrieval, Answer Generation.</title>
      <dc:creator>Vasu Buchingari</dc:creator>
      <pubDate>Mon, 26 Oct 2026 06:32:45 +0000</pubDate>
      <link>https://dev.to/vasu_buchingari/building-a-complete-rag-pipeline-from-document-ingestion-to-retrieval-answer-generation-4p5d</link>
      <guid>https://dev.to/vasu_buchingari/building-a-complete-rag-pipeline-from-document-ingestion-to-retrieval-answer-generation-4p5d</guid>
      <description>&lt;p&gt;This article walks through a complete RAG pipeline using a practical enterprise-document search example.&lt;/p&gt;

&lt;p&gt;The goal is to explain not just &lt;em&gt;what&lt;/em&gt; each component does, but also &lt;em&gt;why&lt;/em&gt; it is needed, how the components work together, and what trade-offs to consider.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What is RAG?
&lt;/h2&gt;

&lt;p&gt;RAG stands for &lt;strong&gt;Retrieval-Augmented Generation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Retrieval-Augmented Generation (RAG) is a common approach to building AI applications that answer questions using external documents instead of relying only on an LLM's pretrained knowledge.&lt;/p&gt;

&lt;p&gt;It combines two capabilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval:&lt;/strong&gt; Find relevant information from an external knowledge source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation:&lt;/strong&gt; Give that information to an LLM so it can produce an answer grounded in the retrieved evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Consider an employee handbook containing annual leave policies, payroll rules, eligibility requirements, and other internal information.&lt;/p&gt;

&lt;p&gt;A user asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How many days of annual leave are employees entitled to?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A general-purpose LLM might provide a plausible answer based on its training data. However, that answer may not match the organization's actual policy.&lt;/p&gt;

&lt;p&gt;A RAG system first searches the uploaded handbook, retrieves the relevant passages, and supplies them to the LLM. The model then generates an answer based on those passages and cites its sources.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two major pipelines
&lt;/h3&gt;

&lt;p&gt;RAG has two main stages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Ingestion pipeline&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Transforms source documents into searchable representations and stores them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Retrieval and generation pipeline&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Searches those representations, selects relevant evidence, and uses an LLM to generate an answer.&lt;/p&gt;

&lt;p&gt;Document deletion is a separate lifecycle operation that removes indexed content and updates the document registry.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The complete architecture
&lt;/h2&gt;

&lt;p&gt;At a high level, the system works like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  DOCUMENT INGESTION
                          |
                    Upload PDF/DOCX
                          |
                    Validate File
                          |
                 Calculate SHA-256 Hash
                          |
             Deduplication and Versioning
                          |
                   Extract Text
                          |
                 Structure-Aware Chunking
                          |
                   Add Metadata
                          |
               +----------+----------+
               |                     |
         Dense Embedding        Sparse Encoding
               |                     |
               +----------+----------+
                          |
                        Qdrant
                          |
                Searchable Document Chunks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Example Technology Stack
&lt;/h3&gt;

&lt;p&gt;The following technology stack can be used to build a RAG (Retrieval-Augmented Generation) pipeline, covering document ingestion, indexing, retrieval, reranking, and answer generation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Component&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Example Technology&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Responsibility&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API and orchestration&lt;/td&gt;
&lt;td&gt;FastAPI&lt;/td&gt;
&lt;td&gt;Expose API endpoints and coordinate the ingestion and retrieval workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File validation and parsing&lt;/td&gt;
&lt;td&gt;Python, &lt;code&gt;pypdf&lt;/code&gt;, &lt;code&gt;python-docx&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Validate uploaded files and extract text from PDF and DOCX documents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text chunking&lt;/td&gt;
&lt;td&gt;LangChain text splitters and custom logic&lt;/td&gt;
&lt;td&gt;Split documents into meaningful, searchable chunks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dense embeddings&lt;/td&gt;
&lt;td&gt;Hugging Face &lt;code&gt;BAAI/bge-small-en-v1.5&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Convert text into dense vectors that capture semantic meaning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sparse encoding&lt;/td&gt;
&lt;td&gt;Custom Python encoder using BLAKE2b hashing&lt;/td&gt;
&lt;td&gt;Represent lexical terms for keyword-oriented retrieval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector database&lt;/td&gt;
&lt;td&gt;Qdrant&lt;/td&gt;
&lt;td&gt;Store vectors and chunk metadata and perform similarity searches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registry database&lt;/td&gt;
&lt;td&gt;PostgreSQL with SQLAlchemy&lt;/td&gt;
&lt;td&gt;Track document identity, versions, processing status, and ingestion claims&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reranking&lt;/td&gt;
&lt;td&gt;Hugging Face &lt;code&gt;cross-encoder/ms-marco-MiniLM-L-6-v2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reorder retrieved chunks based on their relevance to the user's query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM integration&lt;/td&gt;
&lt;td&gt;OpenRouter&lt;/td&gt;
&lt;td&gt;Access an LLM for query rewriting and answer generation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Important distinction:&lt;/strong&gt; PostgreSQL is the document lifecycle registry, while Qdrant is the searchable evidence store. They are separate systems and do not share a single database transaction.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 1: Document Ingestion
&lt;/h1&gt;

&lt;h2&gt;
  
  
  3. Step 1 — Accepting a document upload
&lt;/h2&gt;

&lt;p&gt;The ingestion pipeline starts when a client uploads a document through a FastAPI endpoint.&lt;/p&gt;

&lt;p&gt;The API accepts PDF and DOCX files using multipart form data.&lt;/p&gt;

&lt;p&gt;Multipart uploads allow a client to send file content and other form fields in the same HTTP request.&lt;/p&gt;

&lt;p&gt;For example, an upload request might contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /documents/upload

File: employee_handbook.pdf
Content-Type: multipart/form-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The uploaded file is processed in memory during the request in this implementation.&lt;/p&gt;

&lt;p&gt;For larger-scale systems, another common approach is to upload the original file to object storage, create an ingestion job, and let a background worker process it asynchronously.&lt;/p&gt;

&lt;p&gt;This avoids keeping a long-running HTTP request open while parsing and embedding a large document.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Step 2 — File validation
&lt;/h2&gt;

&lt;p&gt;A filename alone cannot prove that a file is actually a PDF or DOCX.&lt;/p&gt;

&lt;p&gt;For example, a user could rename a ZIP archive to &lt;code&gt;employee_handbook.pdf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Therefore, file validation checks multiple properties before parsing begins:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;File extension.&lt;/li&gt;
&lt;li&gt;File size.&lt;/li&gt;
&lt;li&gt;Whether the file is empty.&lt;/li&gt;
&lt;li&gt;Whether the file signature matches the expected format.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The implementation uses these signatures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Signature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PDF&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%PDF-&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOCX&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PK\x03\x04&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A DOCX file is a ZIP package containing XML documents, relationships, styles, and other resources. This explains why its initial bytes resemble those of other ZIP-based formats.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why check the file signature?
&lt;/h3&gt;

&lt;p&gt;Consider this upload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Filename: employee_handbook.pdf
Actual signature: PK\x03\x04
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extension suggests a PDF, but the signature suggests a ZIP-based format. The file should not be processed as a PDF.&lt;/p&gt;

&lt;p&gt;Signature validation is useful, but it is not a complete security mechanism. Malformed documents and malicious files can still have valid signatures.&lt;/p&gt;

&lt;h3&gt;
  
  
  File-size limits
&lt;/h3&gt;

&lt;p&gt;The example implementation uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Maximum uploaded file size: 10 MiB.&lt;/li&gt;
&lt;li&gt;Maximum uncompressed DOCX package size: 100 MiB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second limit matters because a compressed DOCX file can expand significantly when its contents are processed.&lt;/p&gt;

&lt;p&gt;A production system should also consider parser vulnerabilities, decompression bombs, resource consumption, encrypted documents, and malicious content.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Step 3 — Generate a SHA-256 content hash
&lt;/h2&gt;

&lt;p&gt;After validation, the system calculates a SHA-256 hash of the original uploaded bytes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;

&lt;span class="n"&gt;content_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_bytes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SHA-256 produces a 256-bit digest, usually represented as a 64-character hexadecimal string.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why calculate a hash?
&lt;/h3&gt;

&lt;p&gt;Suppose the same file is uploaded three times.&lt;/p&gt;

&lt;p&gt;Without duplicate detection, the system could repeatedly parse the document, generate embeddings, and write redundant vector records.&lt;/p&gt;

&lt;p&gt;A content hash gives the system a compact identity for the exact file bytes.&lt;/p&gt;

&lt;p&gt;The implementation uses the hash to detect duplicate content within a tenant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Content identity versus logical document identity
&lt;/h3&gt;

&lt;p&gt;These are two different concepts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content identity&lt;/strong&gt; answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Are the uploaded file bytes identical?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Logical document identity&lt;/strong&gt; answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does this upload represent an existing document that is being replaced?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Upload 1
Filename: employee_handbook.docx
Content: Version A

Upload 2
Filename: employee_handbook.docx
Content: Version B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two files have different hashes if their bytes differ. However, the normalized filename can identify the second upload as a replacement for the same logical document.&lt;/p&gt;

&lt;p&gt;This distinction supports both deduplication and document versioning.&lt;/p&gt;

&lt;p&gt;One limitation is that identical text saved with different formatting or document metadata can produce a different file hash.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Step 4 — Coordinate ingestion using PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Hashing identifies the uploaded content, but it does not prevent two requests from processing the same file simultaneously.&lt;/p&gt;

&lt;p&gt;Imagine two requests arrive at almost the same time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request A                 Request B
---------                 ---------
Calculate hash            Calculate hash
Start processing           Start processing
Parse document             Parse document
Generate embeddings        Generate embeddings
Write vectors              Write vectors
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both requests could perform expensive work before a database conflict is detected.&lt;/p&gt;

&lt;p&gt;To avoid this, the architecture uses a PostgreSQL-backed ingestion-claim mechanism.&lt;/p&gt;

&lt;p&gt;The registry has three main tables:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tracks logical documents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;document_versions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tracks individual versions and their status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ingestion_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Coordinates concurrent ingestion attempts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The claim mechanism uses database locking, uniqueness constraints, and a lease.&lt;/p&gt;

&lt;p&gt;If another request already holds a conflicting claim, the new request can receive an HTTP &lt;code&gt;409 Conflict&lt;/code&gt; rather than repeating the entire ingestion process.&lt;/p&gt;

&lt;p&gt;The general principle is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Perform inexpensive identity and concurrency checks before expensive parsing and embedding operations.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The current implementation creates database tables using SQLAlchemy's &lt;code&gt;create_all&lt;/code&gt;; it does not use an Alembic migration workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Step 5 — Extract text from PDF and DOCX
&lt;/h2&gt;

&lt;p&gt;Embedding models work with text or tokens, not raw PDF or DOCX files.&lt;/p&gt;

&lt;p&gt;The document must first be converted into a representation that downstream processing can understand.&lt;/p&gt;

&lt;p&gt;The example uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pypdf&lt;/code&gt; for PDF text extraction.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;python-docx&lt;/code&gt; for DOCX extraction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7.1 Why are PDF and DOCX handled differently?
&lt;/h3&gt;

&lt;p&gt;A DOCX file contains structured elements such as paragraphs, headings, tables, and styles.&lt;/p&gt;

&lt;p&gt;A PDF primarily represents rendered pages. Recovering reading order and layout can be difficult, especially when the document contains multiple columns, tables, or complex formatting.&lt;/p&gt;

&lt;p&gt;Treating both formats identically can lose useful structural information.&lt;/p&gt;

&lt;h3&gt;
  
  
  7.2 DOCX extraction
&lt;/h3&gt;

&lt;p&gt;DOCX files are structured XML-based ZIP packages rather than fixed-page documents.&lt;/p&gt;

&lt;p&gt;The extraction process traverses document elements and creates blocks representing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paragraphs.&lt;/li&gt;
&lt;li&gt;Headings.&lt;/li&gt;
&lt;li&gt;Tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each block carries text and relevant metadata, including the heading hierarchy where available.&lt;/p&gt;

&lt;p&gt;Page numbers are stored as &lt;code&gt;null&lt;/code&gt; for DOCX chunks because reliable page positions are not available from this structural extraction alone. Word pagination can change depending on fonts, margins, page size, and rendering settings.&lt;/p&gt;

&lt;h3&gt;
  
  
  7.3 Heading detection
&lt;/h3&gt;

&lt;p&gt;Document authors do not always apply Word heading styles consistently.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2.1 Annual Leave Eligibility
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This line might be a section heading even if its Word style is configured as a normal paragraph.&lt;/p&gt;

&lt;p&gt;The extraction logic therefore uses Word styles when available and a numbered-heading heuristic as a fallback.&lt;/p&gt;

&lt;p&gt;Heuristics help recover structure, but they cannot identify every heading correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  7.4 Table extraction
&lt;/h3&gt;

&lt;p&gt;Tables often contain relationships between column headers and row values.&lt;/p&gt;

&lt;p&gt;Simply concatenating all cells into one string can make those relationships difficult to understand.&lt;/p&gt;

&lt;p&gt;A table can instead be rendered into text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Leave type | Annual entitlement
Annual leave | 25 days
Sick leave | According to policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This representation preserves the relationship between each row's values and the table headers more clearly than an unstructured sequence of cells.&lt;/p&gt;

&lt;p&gt;Other options include Markdown tables, HTML table serialization, and structured JSON.&lt;/p&gt;

&lt;h3&gt;
  
  
  7.5 PDF extraction
&lt;/h3&gt;

&lt;p&gt;For PDFs, the implementation extracts text page by page.&lt;/p&gt;

&lt;p&gt;Each page becomes a separate structural block with its actual one-based page number.&lt;/p&gt;

&lt;p&gt;This preserves useful source-location information for citations later.&lt;/p&gt;

&lt;p&gt;However, extracting text does not guarantee that the original reading order or table layout will be reconstructed perfectly.&lt;/p&gt;

&lt;h3&gt;
  
  
  7.6 What about scanned PDFs?
&lt;/h3&gt;

&lt;p&gt;A scanned PDF may contain images of text rather than an extractable text layer.&lt;/p&gt;

&lt;p&gt;The current pipeline does not implement optical character recognition (OCR). If a document produces no usable text, ingestion rejects it with HTTP &lt;code&gt;422 Unprocessable Entity&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Possible OCR and document-processing options include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tesseract.&lt;/li&gt;
&lt;li&gt;Amazon Textract.&lt;/li&gt;
&lt;li&gt;Azure AI Document Intelligence.&lt;/li&gt;
&lt;li&gt;Google Document AI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These tools can be added when scanned documents or more complex layouts need to be supported.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Step 6 — Reject documents with no usable text
&lt;/h2&gt;

&lt;p&gt;Passing file validation does not mean a document contains searchable content.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;File validation: Passed
PDF parsing: Passed
Pages detected: 15
Extractable text: Empty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can happen with image-only PDFs, blank documents, or unsupported document content.&lt;/p&gt;

&lt;p&gt;The pipeline checks whether usable text was extracted before continuing to chunking and embedding.&lt;/p&gt;

&lt;p&gt;Without this check, the system could spend resources processing a document that cannot provide meaningful retrieval results.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Step 7 — Chunk the extracted text
&lt;/h2&gt;

&lt;p&gt;A complete document may contain hundreds of pages, but sending the entire document to an embedding model or an LLM is often impractical.&lt;/p&gt;

&lt;p&gt;The document is therefore divided into smaller units called &lt;strong&gt;chunks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Each chunk becomes an independent candidate for retrieval.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not embed the entire document?
&lt;/h3&gt;

&lt;p&gt;Imagine a 100-page employee handbook covering annual leave, payroll, tax, and disciplinary policies.&lt;/p&gt;

&lt;p&gt;A single embedding represents the document as a whole. It may not represent a specific paragraph about payroll as precisely as a smaller chunk would.&lt;/p&gt;

&lt;p&gt;Chunking makes it possible to retrieve the particular passage that answers a question.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.1 Chunking strategies
&lt;/h3&gt;

&lt;p&gt;Several strategies are commonly used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed-size chunking&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Splits text into segments of a fixed character, word, or token count.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Advantage: Simple and fast.&lt;/li&gt;
&lt;li&gt;Limitation: Can cut sentences or related information in the middle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sliding-window chunking&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Creates chunks with overlapping text.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Advantage: Preserves context across chunk boundaries.&lt;/li&gt;
&lt;li&gt;Limitation: Repeats content and increases storage requirements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Token-based chunking&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Splits text according to a model's tokenizer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Advantage: Helps control token limits accurately.&lt;/li&gt;
&lt;li&gt;Limitation: Requires tokenizer-aware configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Recursive character splitting&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Attempts larger separators first, then falls back to smaller separators until chunks fit the target size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structure-aware chunking&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Uses document boundaries such as headings, paragraphs, and tables.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Advantage: Preserves logical relationships.&lt;/li&gt;
&lt;li&gt;Limitation: Depends on the quality of document extraction and structural detection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Semantic chunking&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Uses semantic similarity between nearby text segments to decide where to split.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Advantage: Can keep related ideas together.&lt;/li&gt;
&lt;li&gt;Limitation: Adds computational cost and requires threshold tuning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hierarchical chunking&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Creates smaller searchable child chunks linked to larger parent chunks that can provide additional context.&lt;/p&gt;

&lt;p&gt;These approaches solve different problems. The best choice depends on document structure, retrieval quality, cost, and latency requirements.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.2 Chunking configuration
&lt;/h3&gt;

&lt;p&gt;The example uses these defaults:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CHUNK_SIZE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,000 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CHUNK_OVERLAP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;150 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CHUNK_MIN_CHARS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;120 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CHUNK_STRATEGY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;structure&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These limits are character-based, not token-based. A 1,000-character chunk does not necessarily contain a fixed number of model tokens.&lt;/p&gt;

&lt;p&gt;The implementation supports both a recursive character splitter and a custom structure-aware chunker. The structure-aware strategy is the default.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.3 How structure-aware chunking works
&lt;/h3&gt;

&lt;p&gt;The chunker processes extracted blocks in document order.&lt;/p&gt;

&lt;p&gt;Its main rules are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Maintain a stack of section headings.&lt;/li&gt;
&lt;li&gt;Start a new chunk when a new heading introduces a section.&lt;/li&gt;
&lt;li&gt;Attach the heading to the content it introduces.&lt;/li&gt;
&lt;li&gt;Preserve the heading path as metadata.&lt;/li&gt;
&lt;li&gt;Keep tables intact when they fit within the chunk-size limit.&lt;/li&gt;
&lt;li&gt;Split oversized tables into smaller row-based pieces.&lt;/li&gt;
&lt;li&gt;Apply overlap where appropriate.&lt;/li&gt;
&lt;li&gt;Merge eligible small chunks after the initial chunking pass.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  9.4 Why tables need special handling
&lt;/h3&gt;

&lt;p&gt;Consider this table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Leave Type | Entitlement
Annual Leave | 25 days
Sick Leave | According to policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the table is split arbitrarily, a retrieved chunk might contain a value without its corresponding column header.&lt;/p&gt;

&lt;p&gt;The chunker therefore attempts to keep tables intact when possible. Oversized tables are split into smaller pieces with repeated header context.&lt;/p&gt;

&lt;p&gt;This helps each resulting chunk remain understandable on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.5 What is chunk overlap?
&lt;/h3&gt;

&lt;p&gt;Overlap repeats part of one chunk at the beginning of the next.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chunk 1: ... the employee must submit the request before ...
Chunk 2: ... submit the request before the manager approves it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repeated text helps preserve context when a relevant sentence spans a chunk boundary.&lt;/p&gt;

&lt;p&gt;However, overlap is not applied everywhere. It is suppressed at certain structural boundaries, including page boundaries, new headings, and oversized tables.&lt;/p&gt;

&lt;h3&gt;
  
  
  9.6 Handling very small chunks
&lt;/h3&gt;

&lt;p&gt;A chunk containing only a short heading may not provide enough information to answer a question.&lt;/p&gt;

&lt;p&gt;The implementation uses a minimum character threshold of 120 and merges eligible small chunks after the initial chunking pass.&lt;/p&gt;

&lt;p&gt;This reduces the number of weak, isolated retrieval units.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Step 8 — Attach metadata to each chunk
&lt;/h2&gt;

&lt;p&gt;Chunk text alone is not enough for a useful RAG system.&lt;/p&gt;

&lt;p&gt;A retrieved passage should identify its source document, version, section, and other information needed to filter or cite the result.&lt;/p&gt;

&lt;p&gt;The chunk metadata includes fields such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"document_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document-123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chunk_index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"employee_handbook.docx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"file_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"docx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"section"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Annual Leave Eligibility"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"heading_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Leave Policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Annual Leave"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"page_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"is_current"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an illustrative payload; identifiers and field values will vary.&lt;/p&gt;

&lt;p&gt;Metadata supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tenant-scoped retrieval.&lt;/li&gt;
&lt;li&gt;Filtering by document or file type.&lt;/li&gt;
&lt;li&gt;Selecting a specific version.&lt;/li&gt;
&lt;li&gt;Excluding superseded versions.&lt;/li&gt;
&lt;li&gt;Reconstructing the source of an answer.&lt;/li&gt;
&lt;li&gt;Removing or replacing document versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PDF chunks preserve page numbers. DOCX chunks use &lt;code&gt;null&lt;/code&gt; where page positions cannot be determined reliably.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata is part of the retrieval design, not just extra information attached to a vector.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Step 9 — Generate dense embeddings
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;       Structure-Aware Chunking
                 |
                 v
        Each Chunk (Text + Metadata)
                 |
                 v
      Hugging Face Embedding Model
          BAAI/bge-small-en-v1.5
                 |
                 v
      384-Dimensional Dense Vector
                 |
                 v
           L2 Normalization
                 |
                 v
      Attach Vector to Qdrant Point
                 |
                 v
       Dense Vector + Metadata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each document is extracted into structural blocks, such as paragraphs, headings, and tables. The chunking stage combines these bloc&lt;br&gt;
After chunking, the system converts each chunk into a numerical vector using an embedding model.&lt;/p&gt;

&lt;p&gt;An embedding model maps text into a vector space where semantically related passages can often be located near one another.&lt;/p&gt;

&lt;p&gt;The example uses:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BAAI/bge-small-en-v1.5&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This model produces vectors with &lt;strong&gt;384 dimensions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A simplified representation might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="mf"&gt;0.014&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.032&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mf"&gt;0.087&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# ... more values ...
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model generates the actual values. Each dimension does not correspond to a simple, manually assigned word or concept.&lt;/p&gt;

&lt;h3&gt;
  
  
  11.1 Why dense embeddings?
&lt;/h3&gt;

&lt;p&gt;Consider these sentences:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sentence A:
Employees may take annual leave after approval.

Sentence B:
Staff can use vacation days once their manager
has approved the request.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wording differs, but the meaning is similar.&lt;/p&gt;

&lt;p&gt;Dense embeddings help the system retrieve semantically related passages even when the query and document do not use exactly the same words.&lt;/p&gt;

&lt;h3&gt;
  
  
  11.2 Normalization and cosine similarity
&lt;/h3&gt;

&lt;p&gt;The implementation uses L2-normalized vectors and configures Qdrant to use cosine distance for dense retrieval.&lt;/p&gt;

&lt;p&gt;L2 normalization scales a vector to a length of 1 without changing its direction.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5uujwaeojufx91wg4yro.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5uujwaeojufx91wg4yro.png" alt=" " width="511" height="243"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Example for an 3-dimensional vector Divide every component by 5:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxucjbn52temyjqhjvbky.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxucjbn52temyjqhjvbky.png" alt=" " width="800" height="236"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Cosine similarity measures how closely two vectors point in the same direction:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs2oxqtu52565bz86ufyx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs2oxqtu52565bz86ufyx.png" alt=" " width="764" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For normalized vectors, cosine similarity is equivalent to their dot product.&lt;/p&gt;

&lt;p&gt;In simple terms, cosine similarity compares the directions of vectors rather than allowing vector magnitude alone to dominate the comparison.&lt;/p&gt;

&lt;p&gt;When a user asks a question, the query is embedded using the same embedding model. Qdrant compares the query vector with stored document vectors and returns candidates according to the configured similarity metric.&lt;/p&gt;

&lt;h3&gt;
  
  
  11.3 Why model consistency matters
&lt;/h3&gt;

&lt;p&gt;Changing the embedding model without re-embedding existing documents can make retrieval unreliable.&lt;/p&gt;

&lt;p&gt;For example, stored document vectors generated by one model may not be comparable with query vectors generated by another model.&lt;/p&gt;

&lt;p&gt;The embedding model, vector dimensions, and query encoding configuration must remain compatible.&lt;/p&gt;

&lt;p&gt;The example runs the embedding model on CPU by default, avoiding the need for a dedicated GPU, although large documents can still take time to process.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Step 10 — Generate sparse vectors
&lt;/h2&gt;

&lt;p&gt;Dense embeddings help match meaning, but exact terms also matter.&lt;/p&gt;

&lt;p&gt;Consider a question such as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What does error code &lt;code&gt;AUTH_401&lt;/code&gt; mean?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A semantically similar passage may discuss authentication failures without containing the exact identifier. Lexical retrieval helps locate passages that contain specific terms, codes, names, or identifiers.&lt;/p&gt;

&lt;h3&gt;
  
  
  12.1 What is a sparse vector?
&lt;/h3&gt;

&lt;p&gt;A sparse vector stores only the indices and weights of dimensions with non-zero values.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sparse indices: [12, 104, 983]
Sparse values:  [1.2, 0.8, 2.1]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unlike a dense vector, which contains a value for every dimension, a sparse vector represents only the dimensions that contribute to its result.&lt;/p&gt;

&lt;h3&gt;
  
  
  12.2 Sparse encoding in this example
&lt;/h3&gt;

&lt;p&gt;The custom sparse encoder follows this process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        Chunk Text + Metadata
                  |
                  v
           Tokenization
                  |
                  v
          Stopword Filtering
                  |
                  v
       BLAKE2b Hashing
    (2^20 = 1,048,576 Buckets)
                  |
                  v
       Term Frequency Weighting
          + Token-Type Boosts
                  |
                  v
          Sparse Vector
       (Indices + Values)
                  |
                  v
       Qdrant Sparse Vector
          IDF Modifier
                  |
                  v
       Qdrant Point Storage
   (Dense Vector + Sparse Vector
            + Metadata)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Qdrant is configured with an inverse document frequency (IDF) modifier for the sparse vector. This is intended to account for how common or rare terms are across the indexed collection.&lt;/p&gt;

&lt;p&gt;The configured server-side behavior should be verified against a running Qdrant instance before relying on it in production.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazeylbwg2o8don2zeafc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fazeylbwg2o8don2zeafc.png" alt=" " width="654" height="549"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  12.3 BM25 versus custom sparse encoding
&lt;/h3&gt;

&lt;p&gt;BM25 is a traditional lexical-ranking algorithm that considers term frequency, inverse document frequency, and document-length normalization.&lt;/p&gt;

&lt;p&gt;Other approaches include learned sparse models such as SPLADE, which generate lexical representations using learned term weights.&lt;/p&gt;

&lt;p&gt;A custom hashed encoder is another option. It can be lightweight and avoids requiring a learned sparse model, but it has trade-offs, including possible hash collisions and less sophisticated lexical weighting.&lt;/p&gt;

&lt;p&gt;No single method is best for every retrieval workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Step 11 — Store vectors in Qdrant
&lt;/h2&gt;

&lt;p&gt;Each chunk is stored as a point in a Qdrant collection.&lt;/p&gt;

&lt;p&gt;A point contains three main parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Point ID:&lt;/strong&gt; Identifies the chunk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vectors:&lt;/strong&gt; Contains the dense and sparse representations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payload:&lt;/strong&gt; Contains the chunk text and metadata.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The collection is named &lt;code&gt;enterprise_docs&lt;/code&gt; in this example.&lt;/p&gt;

&lt;p&gt;Its named vectors are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dense  -&amp;gt; 384-dimensional embedding
sparse -&amp;gt; lexical sparse representation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dense vector uses cosine distance. The sparse vector is configured with the IDF modifier.&lt;/p&gt;

&lt;p&gt;The payload includes fields such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tenant_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;document_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;version&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;is_current&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;filename&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;file_type&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;chunk_index&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;section&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;heading_path&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;text&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Payload indexes are configured for fields used by retrieval filters.&lt;/p&gt;

&lt;h3&gt;
  
  
  13.1 Deterministic point IDs
&lt;/h3&gt;

&lt;p&gt;Each chunk receives a deterministic UUIDv5 point ID derived from identifiers including:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenant_id
document_id
version
chunk_index
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same inputs produce the same ID.&lt;/p&gt;

&lt;p&gt;This is useful when retrying vector writes: an upsert using the same point ID replaces the matching point instead of creating another point with a different random ID.&lt;/p&gt;

&lt;p&gt;The point ID is stored separately from the payload in Qdrant.&lt;/p&gt;

&lt;h3&gt;
  
  
  13.2 Batch upserts
&lt;/h3&gt;

&lt;p&gt;The example writes points in batches of 64 and uses &lt;code&gt;wait=True&lt;/code&gt; for Qdrant upserts.&lt;/p&gt;

&lt;p&gt;Batching avoids issuing one network operation for every individual chunk.&lt;/p&gt;

&lt;p&gt;However, PostgreSQL and Qdrant do not share a distributed transaction. The ingestion service must coordinate their updates and handle partial failures.&lt;/p&gt;

&lt;h3&gt;
  
  
  Duplicate uploads
&lt;/h3&gt;

&lt;p&gt;If the same file bytes are uploaded again under the same tenant, the SHA-256 hash allows the system to detect an already indexed version and avoid creating a redundant indexed version.&lt;/p&gt;

&lt;p&gt;If the logical filename matches but the bytes differ, the upload is treated as a new version of the logical document.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 2: Retrieval and Answer Generation
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;              RETRIEVAL AND GENERATION
                          |
                    User Question
                          |
                Optional Query Rewriting
                          |
               +----------+----------+
               |                     |
           Dense Search          Sparse Search
               |                     |
               +----------+----------+
                          |
             Reciprocal Rank Fusion (RRF)
                          |
                 Cross-Encoder Reranking
                          |
                  Build Context + Sources
                          |
                    LLM Answer
                          |
                  Citation Validation
                          |
                   JSON Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  15. Step 13 — Accept a user question
&lt;/h2&gt;

&lt;p&gt;Once documents have been indexed, a user can query the knowledge base through the retrieval API.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /ask
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An illustrative request body is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"question"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Which Python versions are supported?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"top_k"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retrieval_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hybrid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rerank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optional filters can restrict retrieval by document ID, file type, or version. Conversation history can also be supplied.&lt;/p&gt;

&lt;p&gt;The API validates the request, resolves the tenant identifier, and passes the request to the RAG service.&lt;/p&gt;

&lt;p&gt;The default maximum &lt;code&gt;top_k&lt;/code&gt; is 10, although the request schema permits values up to 20. Requests above the configured maximum are rejected.&lt;/p&gt;

&lt;p&gt;The implementation also enforces a 2,000-character question limit in the service, even though the request schema permits up to 4,000 characters.&lt;/p&gt;

&lt;h2&gt;
  
  
  16. Step 14 — Rewrite follow-up questions when necessary
&lt;/h2&gt;

&lt;p&gt;Questions in a conversation often depend on earlier messages.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User: What are the supported Python versions?

Assistant: Python 3.11 and 3.12.

User: What about their compatibility requirements?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final question is ambiguous when searched on its own.&lt;/p&gt;

&lt;p&gt;A query-rewriting step can convert it into a standalone question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What are the compatibility requirements for Python 3.11 and 3.12?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rewritten query is used for retrieval, while the original question is preserved for answer generation.&lt;/p&gt;

&lt;p&gt;In this implementation, query rewriting runs only when conversation history is present and the feature is enabled.&lt;/p&gt;

&lt;p&gt;If rewriting fails, produces an empty result, or returns an invalid response, the original question is used for retrieval.&lt;/p&gt;

&lt;p&gt;Query rewriting adds another LLM call, which increases latency and may add cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  17. Step 15 — Retrieve candidates from Qdrant
&lt;/h2&gt;

&lt;p&gt;The retrieval service uses two complementary search methods by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dense semantic search.&lt;/li&gt;
&lt;li&gt;Sparse lexical search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both searches use the query and the applicable metadata filters.&lt;/p&gt;

&lt;p&gt;The retrieval filter always includes the tenant ID and &lt;code&gt;is_current = true&lt;/code&gt;. Optional document, file-type, and version filters further restrict the search.&lt;/p&gt;

&lt;p&gt;This prevents superseded document versions from being included in normal retrieval.&lt;/p&gt;

&lt;h2&gt;
  
  
  17. How Dense and Sparse Search Retrieve Relevant Chunks from Qdrant
&lt;/h2&gt;

&lt;p&gt;During ingestion, every chunk is stored in Qdrant with a dense vector, a sparse vector, and metadata. During retrieval, the user's question is encoded using the corresponding dense and sparse encoders.&lt;/p&gt;

&lt;p&gt;Let's understand how both searches work using this example.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User query:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How many annual leave days can an employee take?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Suppose the document collection contains these chunks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chunk&lt;/th&gt;
&lt;th&gt;Stored text&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Employees are entitled to 20 days of annual leave per year.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Staff may take vacation days after manager approval.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Policy AL-2026 defines the annual leave entitlement.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;Employees can claim travel expenses after approval.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These chunks already have their own vectors stored in Qdrant.&lt;/p&gt;

&lt;h3&gt;
  
  
  17.1 Dense Search — Finding Similar Meaning
&lt;/h3&gt;

&lt;p&gt;Dense search identifies chunks whose meaning is similar to the user's question, even when the exact words differ.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flow:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Query
    |
    v
BGE Embedding Model
BAAI/bge-small-en-v1.5
    |
    v
384-Dimensional Query Vector
    |
    v
Qdrant Dense Vector Search
(Cosine Similarity)
    |
    v
Rank Candidate Chunks
    |
    v
Return Top Candidates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How does it select chunks?&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The query is converted into a normalized 384-dimensional vector using &lt;code&gt;BAAI/bge-small-en-v1.5&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Qdrant compares this query vector with the stored dense vector of each eligible chunk.&lt;/li&gt;
&lt;li&gt;It calculates cosine similarity between the query vector and each stored vector.&lt;/li&gt;
&lt;li&gt;Chunks are ranked by similarity, with higher scores indicating greater similarity.&lt;/li&gt;
&lt;li&gt;Qdrant returns the requested number of candidates, subject to the metadata filters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For example, an illustrative ranking might look like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Chunk&lt;/th&gt;
&lt;th&gt;Why it might match&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Directly answers the question about annual leave entitlement.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Uses “vacation days,” which expresses a similar concept.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Discusses annual leave entitlement and its policy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;Mentions employee claims and approval but is less relevant.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These ranks are illustrative, not actual scores produced by the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Dense search does not understand the answer in the same way a person does. It ranks chunks using vector similarity. A highly similar chunk is not necessarily sufficient to answer the question correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  17.2 Sparse Search — Matching Important Terms
&lt;/h3&gt;

&lt;p&gt;Sparse search provides another retrieval path. It is particularly useful for exact words, policy identifiers, acronyms, numbers, and other lexical details.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flow:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Query
    |
    v
Tokenization
    |
    v
Stopword Filtering
    |
    v
BLAKE2b Hashing
    |
    v
Sparse Query Vector
(Indices + Weights)
    |
    v
Qdrant Sparse Vector Search
(IDF Modifier)
    |
    v
Rank Candidate Chunks
    |
    v
Return Top Candidates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How does it select chunks?&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The custom sparse encoder tokenizes the query and applies its configured stopword filtering.&lt;/li&gt;
&lt;li&gt;It hashes the surviving terms into indices in a vocabulary of (2^{20}), or 1,048,576, buckets.&lt;/li&gt;
&lt;li&gt;It calculates term weights using sublinear term-frequency weighting and configured token-type boosts.&lt;/li&gt;
&lt;li&gt;Qdrant compares the sparse query representation against stored sparse vectors. Its collection configuration applies an IDF modifier during sparse scoring.&lt;/li&gt;
&lt;li&gt;Candidates are ranked by their sparse scores and returned under the same applicable metadata filters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the query about annual leave, terms such as &lt;code&gt;annual&lt;/code&gt;, &lt;code&gt;leave&lt;/code&gt;, and &lt;code&gt;days&lt;/code&gt; can contribute to lexical matching, depending on the encoder's tokenization and filtering rules.&lt;/p&gt;

&lt;p&gt;An illustrative result might be:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Chunk&lt;/th&gt;
&lt;th&gt;Why it might match&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Contains “annual leave” and “days.”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Contains “annual leave” and a policy identifier.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Expresses a related concept using “vacation days.”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;Shares fewer relevant terms.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The actual ranking depends on the generated sparse vectors, term weights, IDF scoring, and eligible candidates. It cannot be determined from the text alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if no terms survive filtering?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the query produces no surviving sparse terms—for example, if it contains only stopwords—the sparse encoder returns an empty result without querying Qdrant.&lt;/p&gt;

&lt;p&gt;If sparse search raises an exception, the retrieval service logs the error and continues with dense results alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  17.3 How Do Both Search Results Work Together?
&lt;/h3&gt;

&lt;p&gt;Dense and sparse searches can return overlapping chunks, but their rankings may differ. The system combines their rankings using Reciprocal Rank Fusion (RRF).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  User Query
                      |
              +-------+-------+
              |               |
              v               v
         Dense Search     Sparse Search
              |               |
              v               v
         Dense Ranking    Sparse Ranking
              |               |
              +-------+-------+
                      |
                      v
           Reciprocal Rank Fusion
                  RRF_K = 60
                      |
                      v
             Fused Candidate List
                      |
                      v
          Cross-Encoder Reranking
                      |
                      v
               Final Top-k Chunks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example, Chunk A may rank first in dense search and second in sparse search, while Chunk C ranks third in dense search and first in sparse search. RRF combines their rank contributions rather than adding their original scores.&lt;/p&gt;

&lt;p&gt;The fused candidates are then reranked using &lt;code&gt;cross-encoder/ms-marco-MiniLM-L-6-v2&lt;/code&gt;. The cross-encoder scores each query–chunk pair, and the service sorts the candidates by those scores in descending order.&lt;/p&gt;

&lt;p&gt;With the default hybrid configuration, &lt;code&gt;top_k = 4&lt;/code&gt;, and reranking enabled, the system retrieves up to 20 candidates per search method, fuses them, and returns the four highest-ranked chunks after reranking.&lt;/p&gt;

&lt;h3&gt;
  
  
  17.4 Metadata Filters Applied During Search
&lt;/h3&gt;

&lt;p&gt;Qdrant also filters which chunks are eligible for retrieval. In this implementation, every search includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tenant_id&lt;/code&gt; — restricts results to the requested tenant.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;is_current = true&lt;/code&gt; — excludes chunks from superseded document versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Optional filters can further restrict results by document ID, file type, or version.&lt;/p&gt;

&lt;p&gt;These filters are applied during the vector searches, before the candidate rankings are fused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaway:&lt;/strong&gt; Dense search finds semantic similarities, sparse search captures lexical matches, RRF combines their rankings, and the cross-encoder refines the final ordering. Together, these stages help the RAG pipeline retrieve relevant evidence for answer generation.&lt;/p&gt;

&lt;h3&gt;
  
  
  17.5 Why retrieve more candidates than needed?
&lt;/h3&gt;

&lt;p&gt;Suppose the user requests four final results.&lt;/p&gt;

&lt;p&gt;The retrieval service may retrieve 20 candidates from each search method before combining the results.&lt;/p&gt;

&lt;p&gt;This provides a larger candidate pool for fusion and reranking.&lt;/p&gt;

&lt;p&gt;The configured hybrid candidate depth is 20, while the default final &lt;code&gt;top_k&lt;/code&gt; is 4.&lt;/p&gt;

&lt;p&gt;Retrieving additional candidates increases the opportunity to find relevant passages, but it also adds computation and may increase latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  18. Step 16 — Combine Dense and Sparse Rankings Using RRF
&lt;/h2&gt;

&lt;p&gt;In hybrid retrieval, Qdrant performs two searches for the same query:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dense search:&lt;/strong&gt; Encodes the query using &lt;code&gt;BAAI/bge-small-en-v1.5&lt;/code&gt; and ranks chunks by cosine similarity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sparse search:&lt;/strong&gt; Encodes the query using the custom BLAKE2b-based encoder and ranks chunks using sparse lexical scores, with IDF configured in Qdrant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each search returns its own ranked list. The raw scores are not directly comparable, so the system combines their &lt;strong&gt;ranks&lt;/strong&gt;, not their original scores.&lt;/p&gt;

&lt;h3&gt;
  
  
  How is the fusion rank calculated?
&lt;/h3&gt;

&lt;p&gt;The implementation uses Reciprocal Rank Fusion (RRF), with &lt;code&gt;RRF_K = 60&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8sn2con9mtmrgpe6dj2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8sn2con9mtmrgpe6dj2.png" alt=" " width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fio1qoj38n029uzwwg34f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fio1qoj38n029uzwwg34f.png" alt=" " width="782" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For each chunk, the system adds a contribution from every result list in which that chunk appears. A rank of 1 contributes more than a rank of 10.&lt;/p&gt;

&lt;p&gt;For example, assume the searches return these rankings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chunk&lt;/th&gt;
&lt;th&gt;Dense rank&lt;/th&gt;
&lt;th&gt;Sparse rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fusion scores are calculated as follows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chunk&lt;/th&gt;
&lt;th&gt;RRF calculation&lt;/th&gt;
&lt;th&gt;Fusion score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;(1/61 + 1/64)&lt;/td&gt;
&lt;td&gt;0.03201&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;(1/62 + 1/61)&lt;/td&gt;
&lt;td&gt;0.03252&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;(1/65 + 1/62)&lt;/td&gt;
&lt;td&gt;0.03125&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fused ranking is therefore:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Chunk B&lt;/strong&gt; — 0.03252&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunk A&lt;/strong&gt; — 0.03201&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunk C&lt;/strong&gt; — 0.03125&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These are illustrative calculations using the configured RRF constant. The important point is that a chunk can rank highly by appearing near the top of both result lists, even if its original dense and sparse scores use different scales.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens in the implementation?
&lt;/h3&gt;

&lt;p&gt;The default hybrid retrieval configuration requests up to 20 candidates from each search method when &lt;code&gt;top_k = 4&lt;/code&gt; and reranking is enabled. The two ranked lists are merged using RRF, producing a fused candidate list for reranking.&lt;/p&gt;

&lt;p&gt;If sparse search fails, the error is logged and retrieval continues using the dense results alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  19. Step 17 — Rerank Candidates Using a Cross-Encoder
&lt;/h2&gt;

&lt;p&gt;RRF combines rankings efficiently, but it uses the positions of chunks rather than directly evaluating how well each chunk answers the query.&lt;/p&gt;

&lt;p&gt;To improve the final ordering, the system uses:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cross-encoder/ms-marco-MiniLM-L-6-v2&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How is the reranking score calculated?
&lt;/h3&gt;

&lt;p&gt;Unlike the dense embedding model, which encodes a query and each chunk independently, a cross-encoder evaluates the &lt;strong&gt;query and candidate chunk together&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Which Python versions are supported?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Candidate chunk&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The supported Python versions are 3.11 and 3.12.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cross-encoder processes the query–chunk pair and produces a relevance score. The system does not calculate this score using the RRF formula; it comes directly from the cross-encoder model.&lt;/p&gt;

&lt;p&gt;The reranking process is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take the fused candidate chunks from RRF.&lt;/li&gt;
&lt;li&gt;Pair each candidate's text with the original search query.&lt;/li&gt;
&lt;li&gt;Score the query–chunk pairs using the cross-encoder.&lt;/li&gt;
&lt;li&gt;Attach the resulting &lt;code&gt;rerank_score&lt;/code&gt; to each candidate.&lt;/li&gt;
&lt;li&gt;Sort candidates by &lt;code&gt;rerank_score&lt;/code&gt; in descending order.&lt;/li&gt;
&lt;li&gt;Return the top &lt;code&gt;k&lt;/code&gt; chunks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The model is loaded lazily and processes candidates in batches of 16.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why rerank only the candidate set?
&lt;/h3&gt;

&lt;p&gt;Reranking every chunk in the database would require scoring the query against the entire document collection. That would increase processing cost and latency.&lt;/p&gt;

&lt;p&gt;Instead, the system uses a two-stage retrieval process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stage 1 — Candidate retrieval:&lt;/strong&gt; Dense and sparse search retrieve candidates, and RRF combines their rankings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 2 — Reranking:&lt;/strong&gt; The cross-encoder evaluates the query and each candidate together, then selects the best &lt;code&gt;k&lt;/code&gt; results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the default hybrid configuration and &lt;code&gt;top_k = 4&lt;/code&gt;, the system retrieves up to 20 candidates per search method, fuses them, and returns the top 4 after reranking.&lt;/p&gt;

&lt;p&gt;If reranking fails, the system logs a warning and returns the fused ranking truncated to &lt;code&gt;k&lt;/code&gt;. If reranking is disabled, the fused ranking is used directly; dense-only mode skips sparse search and RRF.&lt;/p&gt;

&lt;h3&gt;
  
  
  Final Retrieval Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;             User Query
                  |
          +-------+-------+
          |               |
          v               v
     Dense Search     Sparse Search
          |               |
          v               v
     Dense Ranking    Sparse Ranking
          |               |
          +-------+-------+
                  |
                  v
       Reciprocal Rank Fusion
             RRF_K = 60
                  |
                  v
        Fused Candidate List
                  |
                  v
       Cross-Encoder Reranking
                  |
                  v
       Sort by Rerank Score
                  |
                  v
          Return Top-k Chunks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key distinction:&lt;/strong&gt; Dense and sparse search retrieve candidates, RRF combines their rankings, and the cross-encoder independently scores query–chunk pairs to determine the final order. These are three different operations, each serving a specific purpose in the retrieval pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  20. Step 18 — Build the context and source labels
&lt;/h2&gt;

&lt;p&gt;After retrieval and reranking, the selected chunks are assembled into a context block for the LLM.&lt;/p&gt;

&lt;p&gt;Each chunk receives a source label based on its final ranking.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Source 1: employee_handbook.docx, section 4.6 Acceptable Use]
Employees must follow the organization's acceptable-use policy.

[Source 2: python_setup.pdf, page 12]
The supported Python versions are 3.11 and 3.12.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual context contains the retrieved chunk text and available source metadata.&lt;/p&gt;

&lt;p&gt;The source label uses the page number for PDFs, the section when available, or the chunk index as a fallback.&lt;/p&gt;

&lt;p&gt;A parallel &lt;code&gt;sources&lt;/code&gt; array preserves information needed to trace each result, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Filename and file type.&lt;/li&gt;
&lt;li&gt;Page number, section, and heading path.&lt;/li&gt;
&lt;li&gt;Document ID and version.&lt;/li&gt;
&lt;li&gt;Chunk index and content hash.&lt;/li&gt;
&lt;li&gt;Dense, sparse, fused, and reranking scores where available.&lt;/li&gt;
&lt;li&gt;The retrieved text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These labels allow the answer to refer to specific evidence using citations such as &lt;code&gt;[Source 1]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  21. Step 19 — Generate a grounded answer
&lt;/h2&gt;

&lt;p&gt;The LLM receives the original question and the retrieved context.&lt;/p&gt;

&lt;p&gt;The system prompt instructs the model to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Answer using the supplied context.&lt;/li&gt;
&lt;li&gt;Avoid relying on its internal knowledge when the evidence is insufficient.&lt;/li&gt;
&lt;li&gt;Cite claims using &lt;code&gt;[Source N]&lt;/code&gt; labels.&lt;/li&gt;
&lt;li&gt;Return a specified no-answer response when the context does not contain enough information.&lt;/li&gt;
&lt;li&gt;Treat document text as data rather than instructions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treating document text as data is important because retrieved documents may contain irrelevant or malicious instructions. The LLM should not automatically obey instructions found inside retrieved content.&lt;/p&gt;

&lt;p&gt;The configured model is accessed through OpenRouter, with a default model setting of &lt;code&gt;openrouter/free&lt;/code&gt; in this example.&lt;/p&gt;

&lt;p&gt;The system extracts the generated answer and validates its citations.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does citation validation do?
&lt;/h3&gt;

&lt;p&gt;Citation validation scans the answer for references such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Source 1]
[Source 2]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks which sources were used, whether the cited source numbers exist, and whether the references are valid.&lt;/p&gt;

&lt;p&gt;The response includes a citation report with fields such as &lt;code&gt;used&lt;/code&gt;, &lt;code&gt;valid&lt;/code&gt;, &lt;code&gt;invalid&lt;/code&gt;, and &lt;code&gt;is_valid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Citation validation is not the same as factual verification.&lt;/strong&gt; It checks source references, but it does not prove that every claim is supported by the cited passage.&lt;/p&gt;

&lt;p&gt;In the current implementation, an answer containing an invalid citation is returned with &lt;code&gt;citations.is_valid = false&lt;/code&gt;; the answer is not automatically corrected or rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  22. Step 20 — Return the response
&lt;/h2&gt;

&lt;p&gt;The final API response contains the generated answer, its source information, usage information, and retrieval details.&lt;/p&gt;

&lt;p&gt;An illustrative response is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"answer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The supported Python versions are 3.11 and 3.12 [Source 1]."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Source 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"filename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python_setup.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"page_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"chunk_index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The supported Python versions are 3.11 and 3.12."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"citations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"used"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"invalid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"is_valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a simplified example to illustrate the response structure; the actual response also includes additional metadata, scores, and retrieval information.&lt;/p&gt;

&lt;p&gt;If no relevant chunks are found and no filters were supplied, the service returns a predefined no-information answer without calling the LLM.&lt;/p&gt;

&lt;p&gt;If filters were supplied and no chunks match, the API returns a filter-specific error response.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when a retrieval stage fails?
&lt;/h3&gt;

&lt;p&gt;The implementation has several fallback paths:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Query rewriting fails&lt;/td&gt;
&lt;td&gt;Use the original question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sparse search fails&lt;/td&gt;
&lt;td&gt;Continue with dense results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reranking fails&lt;/td&gt;
&lt;td&gt;Keep the fused ranking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding or Qdrant fails&lt;/td&gt;
&lt;td&gt;Request fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM call fails or times out&lt;/td&gt;
&lt;td&gt;Request fails with an appropriate HTTP error&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These fallbacks help the system continue when optional stages fail, but they do not guarantee that every request can be answered.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 3: Document Deletion
&lt;/h1&gt;

&lt;h2&gt;
  
  
  23. Step 21 — Why deletion needs more than removing vectors
&lt;/h2&gt;

&lt;p&gt;Document deletion is an important part of the RAG lifecycle.&lt;/p&gt;

&lt;p&gt;Suppose an outdated employee handbook has been indexed. Even if the source file is removed from an upload directory, its vectors may still remain in Qdrant.&lt;/p&gt;

&lt;p&gt;If those vectors remain searchable, the LLM could continue using outdated information.&lt;/p&gt;

&lt;p&gt;Deleting a document therefore requires updating both the searchable evidence store and the document registry.&lt;/p&gt;

&lt;p&gt;The architecture exposes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DELETE /documents/{document_id}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;document_id&lt;/code&gt; is obtained from the upload response. The implementation does not provide a document-listing or document-detail endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  24. Step 22 — Locate and validate the document
&lt;/h2&gt;

&lt;p&gt;The deletion process first looks up the document in PostgreSQL using its document ID and tenant ID.&lt;/p&gt;

&lt;p&gt;If the document does not exist for that tenant, or it has already been deleted, the API returns HTTP &lt;code&gt;404 Not Found&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The tenant ID is also included in the Qdrant deletion filter.&lt;/p&gt;

&lt;p&gt;This is important because the same operation must not remove points belonging to another tenant.&lt;/p&gt;

&lt;p&gt;However, the example's &lt;code&gt;X-Tenant-ID&lt;/code&gt; header is validated only for its format and length. It is not backed by authentication or authorization. A production system must derive the tenant from a trusted identity and enforce access permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  25. Step 23 — Deactivate and delete vectors from Qdrant
&lt;/h2&gt;

&lt;p&gt;The deletion service removes all points matching the document ID and tenant ID.&lt;/p&gt;

&lt;p&gt;The filter does not restrict the version or current-version flag, so it includes superseded versions as well.&lt;/p&gt;

&lt;p&gt;The operation happens in two stages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 1: Deactivate the points&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The service updates matching points so that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"is_current"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stage 2: Delete the points&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The service deletes the matching points from Qdrant.&lt;/p&gt;

&lt;p&gt;Both operations use &lt;code&gt;wait=True&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why deactivate before deleting?&lt;/p&gt;

&lt;p&gt;Normal retrieval filters require &lt;code&gt;is_current = true&lt;/code&gt;. If the physical delete fails after deactivation succeeds, the remaining points are excluded from normal retrieval.&lt;/p&gt;

&lt;p&gt;The collection itself is not dropped; only points belonging to the selected document are removed.&lt;/p&gt;

&lt;p&gt;This operation is idempotent at the Qdrant level: repeating the deletion against an empty match set is effectively a no-op.&lt;/p&gt;

&lt;h2&gt;
  
  
  26. Step 24 — Soft-delete the PostgreSQL records
&lt;/h2&gt;

&lt;p&gt;After Qdrant deletion succeeds, the service updates the PostgreSQL registry.&lt;/p&gt;

&lt;p&gt;The registry transaction locks the document row and marks the document and its versions as deleted.&lt;/p&gt;

&lt;p&gt;The main changes are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Record&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Status becomes &lt;code&gt;deleted&lt;/code&gt;; chunk and page counters are set to zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;document_versions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All non-deleted versions become &lt;code&gt;deleted&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ingestion_claims&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claims associated with the document are removed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The registry rows are retained instead of being physically removed.&lt;/p&gt;

&lt;p&gt;This is called a &lt;strong&gt;soft delete&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It preserves lifecycle history, while the corresponding Qdrant points are physically deleted.&lt;/p&gt;

&lt;p&gt;The collection and unrelated documents remain untouched.&lt;/p&gt;

&lt;p&gt;The response includes the document ID, tenant ID, deletion status, deleted-version count, and deleted-chunk count.&lt;/p&gt;

&lt;p&gt;One detail matters: &lt;code&gt;deleted_chunks&lt;/code&gt; is calculated from the registry's stored chunk counts. It is not a direct count of the points actually removed from Qdrant.&lt;/p&gt;

&lt;h1&gt;
  
  
  Part 4: Putting It All Together
&lt;/h1&gt;

&lt;h2&gt;
  
  
  28. The complete lifecycle
&lt;/h2&gt;

&lt;p&gt;The entire pipeline can be summarized in three flows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ingestion
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Upload
  -&amp;gt; Validate
  -&amp;gt; Hash and deduplicate
  -&amp;gt; Claim ingestion
  -&amp;gt; Extract text
  -&amp;gt; Chunk
  -&amp;gt; Add metadata
  -&amp;gt; Generate dense and sparse vectors
  -&amp;gt; Upsert into Qdrant
  -&amp;gt; Commit the new document version
  -&amp;gt; Retire old vectors
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Retrieval and generation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Question
  -&amp;gt; Validate request and tenant
  -&amp;gt; Rewrite follow-up query when needed
  -&amp;gt; Dense and sparse retrieval
  -&amp;gt; Reciprocal Rank Fusion
  -&amp;gt; Cross-encoder reranking
  -&amp;gt; Build labelled context
  -&amp;gt; Generate answer using the LLM
  -&amp;gt; Validate citations
  -&amp;gt; Return answer and sources
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Deletion
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Document ID
  -&amp;gt; Validate document and tenant
  -&amp;gt; Deactivate matching Qdrant points
  -&amp;gt; Delete all document versions' points
  -&amp;gt; Soft-delete PostgreSQL records
  -&amp;gt; Return deletion result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  29. Important engineering lessons
&lt;/h2&gt;

&lt;p&gt;Building a RAG application involves multiple interconnected systems. Several design principles make the architecture easier to understand and maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Document parsing affects retrieval quality.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If headings, table relationships, or reading order are lost during extraction, later stages cannot reliably recover that information.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Chunking is a retrieval decision.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Chunk boundaries determine what evidence can be retrieved together. A chunk should be large enough to preserve meaning but focused enough to answer a specific question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Metadata matters as much as the vector.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Vectors help identify relevant content, while metadata provides document identity, version filtering, tenant scoping, and source traceability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Hybrid retrieval addresses different matching needs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Dense retrieval helps with semantic similarity. Sparse retrieval helps with exact terms. Combining them can improve retrieval coverage, but the benefit should be measured against a suitable evaluation dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Reranking is a second-stage decision.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A larger candidate pool improves the chance of finding relevant chunks. A cross-encoder can then evaluate query–chunk relevance more directly, at additional computational cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Citations improve traceability, not guaranteed correctness.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A citation validator can detect missing or invalid source references. It does not prove that every answer is factually correct or fully supported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Versioning is a lifecycle problem.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A content hash detects byte-level changes, while logical document identity supports replacement. The registry and vector store must be coordinated carefully when versions change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Deterministic IDs make vector writes easier to retry.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Predictable point IDs support idempotent upserts, but they do not make an entire multi-database workflow transactional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Deletion needs consistency handling.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Removing vectors and updating registry records are separate operations. Partial failures can leave the two stores inconsistent, so production systems should consider retries, reconciliation, monitoring, and recovery procedures.&lt;/p&gt;

&lt;h2&gt;
  
  
  30. What could be improved for production?
&lt;/h2&gt;

&lt;p&gt;The current architecture provides a useful foundation, but additional capabilities would make it more robust at larger scale.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Possible improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Document parsing&lt;/td&gt;
&lt;td&gt;Add OCR and more advanced layout-aware extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunking&lt;/td&gt;
&lt;td&gt;Evaluate token-based, semantic, or hierarchical chunking for suitable documents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental updates&lt;/td&gt;
&lt;td&gt;Compare stable chunk identifiers or hashes to avoid recomputing unchanged chunks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval quality&lt;/td&gt;
&lt;td&gt;Evaluate Recall@K, MRR, nDCG, answer correctness, and citation support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;Track stage latency, failures, token usage, retrieval scores, and ingestion metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model upgrades&lt;/td&gt;
&lt;td&gt;Version embedding and reranking configurations and re-index when required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;It is a document-processing and retrieval system with a lifecycle: documents are validated and transformed into searchable representations, queries retrieve and rank evidence, an LLM generates answers from that evidence, and deletion removes content from the search system while updating its lifecycle records.&lt;/p&gt;

&lt;p&gt;The most important principle is this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The quality of a RAG answer depends not only on the LLM, but also on the quality of the extracted content, chunk boundaries, metadata, retrieval strategy, and evidence supplied to the model.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>rag</category>
      <category>genai</category>
    </item>
  </channel>
</rss>
