The LLM Was the Easy Part: Building a Hybrid RAG API A developer built a hybrid RAG API for PDF question-answering, combining dense and sparse vector search with reciprocal rank fusion and a cross-encoder reranker. The system processes PDFs asynchronously, caches answers, and preserves source metadata to provide grounded responses. A basic Retrieval-Augmented Generation RAG demo is surprisingly small: But when I turned that flow into an API, the LLM call became the least interesting part. I needed to process PDFs without blocking requests, combine semantic and keyword search, rerank noisy results, preserve source metadata, cache answers, and secure the API. So I built a PDF question-answering backend with: This article focuses on the most interesting part: the path from a user’s question to a grounded answer. The application has two main workflows. When a client uploads a PDF, the API: When a question arrives, the API: Here is the complete query flow: ┌─────────────────┐ │ User question │ └────────┬────────┘ │ ┌────────────┴────────────┐ │ │ ▼ ▼ ┌────────────────┐ ┌────────────────┐ │ Dense embedding│ │ Sparse vector │ └───────┬────────┘ └───────┬────────┘ │ │ └───────────┬────────────┘ ▼ ┌───────────────────┐ │ Qdrant + RRF │ │ 20 candidates │ └─────────┬─────────┘ ▼ ┌───────────────────┐ │ Cross-encoder │ │ Top 5 chunks │ └─────────┬─────────┘ ▼ ┌───────────────────┐ │ Grounded prompt │ └─────────┬─────────┘ ▼ ┌───────────────────┐ │ LLM answer │ └───────────────────┘ Dense embeddings are good at retrieving text by meaning. For example, a semantic search system may recognize that these sentences are related: "How are API credentials invalidated?" "How can I revoke an access key?" The wording is different, but the intent is similar. Technical documents also contain exact lexical signals: A semantic model may not always preserve the importance of an identifier such as: ERR AUTH 0042 Sparse retrieval helps with those exact words and identifiers. Instead of choosing between semantic and lexical retrieval, I store both representations for every chunk: PointStruct id=point id, vector={ "dense": dense vector, "sparse": SparseVector indices=sparse vector "indices" , values=sparse vector "values" , , }, payload={ "text": chunk text, "source": filename, "document id": str document id , "page number": page number, "chunk index": chunk index, }, Each Qdrant point contains: Keeping provenance next to the vectors makes it possible to return useful sources with each answer. Dense and sparse searches produce different score scales. Adding their raw scores directly would require normalization and tuning. Instead, I use reciprocal rank fusion, or RRF. RRF focuses on where a result appears in each ranked list rather than directly comparing the original scores. The hybrid query looks like this: response = await qdrant client.query points collection name="embeddings", prefetch= Prefetch query=dense query vector, using="dense", limit=limit 4, , Prefetch query=SparseVector indices=sparse query "indices" , values=sparse query "values" , , using="sparse", limit=limit 4, , , query=FusionQuery fusion=Fusion.RRF , limit=limit, with payload=True, Qdrant executes the dense and sparse searches and then fuses their rankings. This allows a chunk to rank well because it: Hybrid retrieval is not automatically better for every dataset. Its value depends on the documents, query patterns, embedding models, and search configuration. It still needs evaluation against real questions. Initial retrieval needs to be fast enough to search the full collection. It does not always need to produce the final ordering. My pipeline retrieves 20 candidates and sends them to a cross-encoder: pairs = query, candidate "text" for candidate in candidates scores = reranker.predict pairs The candidates are sorted using those scores: reranked = sorted zip candidates, scores , key=lambda item: item