# I Combined Dense and Sparse Vectors to Search Medical Research

> Source: <https://pub.towardsai.net/i-combined-dense-and-sparse-vectors-to-search-medical-research-4ec8076686e1?source=rss----98111c9905da---4>
> Published: 2026-08-28 18:01:01+00:00

Code: [GitHub Repository](https://github.com/Pranshu640/pubmed-hybrid-retrieval)

When I started this project, I thought the question was simple: if someone searches biomedical research with a full sentence, surely semantic search should be enough.

Then I looked at the kinds of queries doctors and researchers actually write. A query can contain a plain-language request, a disease name, an exact gene symbol, a mutation such as BRAF V600E, and a date restriction at the same time. A search system has to understand the meaning of the sentence without losing the exact notation hidden inside it.

That is the experiment I built. I compared three ways to search biomedical papers:

This is a retrieval benchmark, not a diagnosis tool and not a chatbot. The input is a search query. The output is a ranked list of PubMed papers. I deliberately stopped there so I could measure whether the system found useful evidence before a language model or a generated answer could hide a retrieval mistake.

[PubMed](https://pubmed.ncbi.nlm.nih.gov/) is the US National Library of Medicine’s public catalogue of biomedical research citations and abstracts. It is where developers building literature tools, researchers, and clinicians often start when they need to locate published evidence.

The difficulty is that biomedical language mixes several kinds of information:

If someone searches for “treatment evidence for glioma with BRAF,” a useful system should understand the sentence. If they search for KIT L576P, it should not dilute that precise mutation into a generic search for cancer treatment.

That tension is why I did not want to test one retrieval method in isolation.

A retriever is simply the part of a search system that accepts a query and returns ranked documents. This project has two different retrievers.

Dense search converts text into a list of numbers called a vector or embedding. The idea is that texts with related meaning should end up close together in this numeric space. I used sentence-transformers/all-MiniLM-L6-v2, a lightweight general-purpose text model, to produce the dense vectors.

To compare two dense vectors, the system uses cosine similarity. Despite the name, it is just a way to compare the direction of two vectors. A higher score means the model considers the texts more related in meaning.

This helps when wording changes. For example, a query that spells out a mutation in descriptive language can still connect to a paper that uses a shorter medical term.

Dense search has a weakness: a short identifier can contribute very little to the vector. KIT L576P is only a few characters, but it carries a lot of meaning for the person searching.

Sparse search keeps track of terms rather than representing the whole sentence as one dense vector. I used BM25, a long-established ranking method used in text search. It gives a document more credit when it contains important query terms, while avoiding over-rewarding common words.

BM25 is useful here because it keeps exact notation visible. A paper containing BRAF V600E or NCT01234567 is easier to surface when the query uses the same notation.

Its weakness is the opposite of dense search. It does not automatically know that “malignant melanocytic tumor” and “melanoma” refer to the same disease.

The hybrid method runs dense and sparse search separately, then combines their lists with reciprocal rank fusion, or RRF. RRF is a simple rule: a paper gets more credit when it appears near the top of either list, and even more credit when both methods rank it highly.

This matters because dense and BM25 scores are different kinds of numbers. It would be misleading to add a cosine score directly to a BM25 score. RRF avoids that problem by combining positions in the ranked lists rather than raw scores.

I wanted the experiment to stay focused on retrieval instead of building vectorisation, sparse indexing, fusion, and filtering infrastructure from scratch. That is where [Qdrant](https://qdrant.tech/) fits well.

Qdrant is a vector database. In this project, each paper is stored once as a Qdrant point with two named representations: a dense vector for semantic search and a sparse BM25 vector for exact-term search. The same Python client can query either representation or ask Qdrant to fuse the two ranked lists with RRF.

I also needed filters to run during the search. For example, a developer may want papers about a drug or gene, but only after 2020. Qdrant stores these fields as a payload, which is searchable metadata attached to the paper. That lets a query say “find papers similar to this request, but only where the year is 2020 or later and the gene is EGFR.” EGFR is a gene involved in cell growth and is often used as a treatment-relevant marker in cancer research.

In short, Qdrant let me keep one corpus, two retrieval methods, metadata filters, and fusion in one small Python pipeline. For a comparison experiment, that made the moving parts easier to reason about and reproduce.

The first implementation choice is where Qdrant runs. I kept that decision behind one function, so the same retrieval code can use local storage for reproduction or a Qdrant server when QDRANT_URL is configured.

``` php
def get_client() -> QdrantClient:    url = os.getenv("QDRANT_URL")    if url:        return QdrantClient(            url=url,            api_key=os.getenv("QDRANT_API_KEY"),            local_inference_batch_size=16,        )    QDRANT_PATH.parent.mkdir(parents=True, exist_ok=True)    return QdrantClient(        path=str(QDRANT_PATH),        local_inference_batch_size=16,    )
```

A reader can therefore run the experiment without provisioning a service. Moving the same collection to a server changes the client configuration, not the indexing or retrieval logic.

```
client.create_collection(    collection_name="trec_pm_2018_pool",    vectors_config={        "dense": models.VectorParams(size=dense_size, distance=models.Distance.COSINE)    },    sparse_vectors_config={        "sparse": models.SparseVectorParams(modifier=models.Modifier.IDF)    },)
```

The dense vector uses cosine similarity. The sparse vector uses BM25-style term weighting with inverse document frequency, which gives rarer terms more influence than common ones.

For the benchmark, I used the [TREC 2018 Precision Medicine scientific-abstract task](https://www.trec-cds.org/2018.html). TREC is a long-running information-retrieval evaluation programme. In this task, precision oncologists created 50 synthetic patient cases, and physicians trained in medical informatics assessed which research records were relevant to each case.

The source files are public:

The downloaded corpus is not committed to Git. The repository downloads the numeric PubMed IDs found in the TREC judgment file through the NCBI API and builds the corpus locally. That makes the data path clear without redistributing a large copy of PubMed.

The original judgments contain 14,946 numeric PubMed IDs. When I fetched the current title and abstract records, 12,868 usable records were available. The original TREC task also includes conference abstracts from AACR and ASCO. Those are not PubMed records, so I excluded them rather than mixing data sources.

I then created seven deterministic versions of each source query. For example, the same case can be expressed as a natural-language question, an exact medical phrase, a query with a synonym, or a query containing an exact mutation. These are controlled rewrites, not new physician-written cases. They reuse the original case’s relevance labels so I can ask one narrow question: what changes when the wording changes but the information needed stays the same?

The final evaluation used 210 held-out queries from 30 source cases. All seven rewrites from one source case stayed in the same split, so a near-duplicate version of a test case could not appear in training or validation.

The preparation code mirrors the data flow described above. It downloads the official TREC files, parses the topics and relevance judgments, creates the controlled query forms, and extracts only numeric PubMed IDs for the NCBI fetch.

```
download_trec_pm_sources()topics = parse_topics()source_qrels = parse_source_qrels()queries = make_all_controlled_queries(topics)pubmed_ids = ordered_pubmed_ids(source_qrels)records, _ = _fetch_batches(    pubmed_ids,    batch_size=400,    request_delay=0.4,)
```

The downloader caches every XML batch before parsing it, so an interrupted run can reuse completed downloads. The generated manifest records the source checksums, corpus count, and code version used for the benchmark.

Each paper becomes one Qdrant point. Its title and abstract are joined into the searchable text. The payload keeps readable fields such as the publication year, diseases, genes, drugs, and trial IDs.

I used Qdrant Client’s FastEmbed integration rather than writing embedding code by hand. Passing a models.Document object tells the client which model should encode the text for each named vector.

```
point = models.PointStruct(    id=paper_id,    vector={        "dense": models.Document(text=search_text, model=DENSE_MODEL),        "sparse": models.Document(text=search_text, model=SPARSE_MODEL),    },    payload=paper_metadata,)
```

The benchmark does not send all 12,868 papers in one request. It groups the prepared points into batches of 32 and waits for each upsert to finish.

```
points = [make_point(record) for record in records]for batch in batches(points, size=32):    client.upsert(        collection_name=collection_name,        points=batch,        wait=True,    )
```

Batching keeps each embedding and upload step bounded, while the completed-document counter makes a long indexing run easier to monitor.

That is the whole indexing idea: the same paper gets two ways to be found, plus metadata for filters.

Before fusing the rankings, the benchmark runs dense and sparse retrieval separately. Both branches use the same collection, result limit, payload handling, and optional filter. Only the model and named vector change.

```
if mode == "dense":    response = client.query_points(        collection_name=collection_name,        query=models.Document(text=query_text, model=DENSE_MODEL),        using="dense",        query_filter=query_filter,        limit=limit,        with_payload=True,    )elif mode == "sparse":    response = client.query_points(        collection_name=collection_name,        query=models.Document(text=query_text, model=SPARSE_MODEL),        using="sparse",        query_filter=query_filter,        limit=limit,        with_payload=True,    )
```

Keeping the surrounding query path identical matters for the benchmark: the comparison changes the retrieval representation rather than changing unrelated application behavior.

For a hybrid query, I ask for more candidates from each retriever than I plan to show the user. For example, if the final UI needs five papers, pulling 20 candidates from dense search and 20 from BM25 gives RRF a wider set to combine. A paper ranked sixth in both lists can still become useful after fusion. Asking each retriever for only the final five would throw that paper away too early.

```
response = client.query_points(    collection_name=collection_name,    prefetch=[        models.Prefetch(            query=models.Document(text=query_text, model=DENSE_MODEL),            using="dense",            limit=20,        ),        models.Prefetch(            query=models.Document(text=query_text, model=SPARSE_MODEL),            using="sparse",            limit=20,        ),    ],    query=models.FusionQuery(fusion=models.Fusion.RRF),    limit=5,    with_payload=True,)
```

The main metric is Recall@20. It asks: of all the papers judged relevant for a query, how many did the system place in its first 20 results? I used 20 because a retrieval layer often hands a small evidence set to the next stage, such as a reranker or an answer-writing model. If a relevant paper is missing from those 20 results, later steps cannot use it.

The implementation calculates Recall@20 per query from two sets: every document judged relevant for that query and the document IDs returned in the first 20 positions.

``` python
def query_recall(ranked, judgments, depth=20):    relevant = {        document_id        for document_id, score in judgments.items()        if score > 0    }    retrieved = {        document_id        for document_id, _ in ranked[:depth]    }    if not relevant:        return 0.0    return len(relevant & retrieved) / len(relevant)
```

Those per-query values are averaged for the reported Recall@20. The separate complete-miss count records the harsher case where the intersection is empty.

I also counted complete misses: queries where the top 20 had no judged relevant paper. This is simpler to interpret than a single average. If a system gets a high average but completely fails for many real queries, that matters.

The benchmark also records additional ranking metrics and latency measurements in the repository. A warm query latency is the time after the model and index have already been loaded into memory. It is useful for comparing repeated local queries, but it is not a production latency claim.

Here is the held-out Recall@20 result. Higher is better.

The headline is not “hybrid wins everything.” It does not. Dense search was strongest when the query was phrased naturally or used synonyms. BM25 was particularly valuable when exact medical terms carried the intent. Hybrid did best overall and was strongest on exact terminology, mutation-like entities, and mixed queries.

The practical result was the miss count. Dense had 13 held-out queries with no relevant paper in the first 20. Sparse had 14. Hybrid had 5.

One example makes the trade-off concrete. When the system received a descriptive version of a melanoma question that expanded KIT L576P into words, dense search found a relevant paper at rank one. BM25 did not find relevant evidence in its first 20 because the exact notation had disappeared from the query.

The opposite happened with the exact mutation form. BM25 placed a highly relevant KIT (L576P) paper first, while dense search returned no relevant paper in its first 20. Neither method was universally better. They failed in different ways.

For “What treatment evidence is available for glioma with BRAF?”, a dense and sparse search each found four relevant papers in the top 20, but not the same four. Hybrid found six. That is the useful case for fusion: it expands the evidence set when the two methods bring back different relevant material.

Search meaning and search constraints are different jobs. If a user wants papers about an EGFR-related treatment after 2020, “after 2020” is not something an embedding model should have to guess from the sentence. It is a hard constraint on a metadata field.

The project stores structured fields in Qdrant’s payload and applies them at query time.

```
query_filter = models.Filter(    must=[        models.FieldCondition(            key="genes", match=models.MatchValue(value="EGFR")        ),        models.FieldCondition(            key="publication_year", range=models.Range(gte=2020)        ),    ])
```

Creating the filter is only half of the operation. The same object is passed into the hybrid search, so Qdrant applies the hard constraints while it retrieves and fuses candidates.

```
points = search(    client,    "treatment evidence for EGFR resistance",    mode="hybrid",    limit=20,    query_filter=query_filter,    collection_name="trec_pm_2018_pool",)
```

The returned points therefore satisfy both payload conditions before they reach any later reranking or answer-generation stage.

must mean both conditions must match. The code checks filters one at a time, together, with an impossible value that should return nothing, and with exact-case matching. The point is not that filters are medically intelligent. The point is that once you have reliable metadata, you can keep hard constraints out of fuzzy semantic matching.

This is a fair comparison of three retrieval methods on a reconstructed, judged PubMed pool. It is not a full PubMed index, a clinical recommendation system, or a proof that MiniLM is the best model for oncology literature.

There are three important limits:

Those limits are why I would use this project as a starting point for a literature-retrieval pipeline, not as an end product for clinical decisions.

The next useful experiment is not to add an LLM immediately. First, I would keep this benchmark fixed and swap one retrieval component at a time: a biomedical embedding model, a reranker that reorders the retrieved papers, or a larger distractor corpus to test scale.

Only after measuring retrieval and reranking separately would I put an answer-generation layer on top. That distinction matters: a fluent answer is not evidence that the search system found the right papers.

My main takeaway is simple. In biomedical search, users can ask for meaning, exact notation, and hard filters in the same sentence. Dense search and BM25 each cover a different failure mode. Qdrant made it practical to test both representations against the same corpus and combine them without building the search infrastructure from scratch.

[I Combined Dense and Sparse Vectors to Search Medical Research](https://pub.towardsai.net/i-combined-dense-and-sparse-vectors-to-search-medical-research-4ec8076686e1) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.
