Skip to main content

Command Palette

Search for a command to run...

Building Hybrid Search for Financial Intelligence

From Pure BM25 to Dense Vectors and Splade With Qdrant, Why a Single Retrieval Method Will Never Be Enough, Highlighting Where Each One Excels and Where Each Falls Short

Updated
44 min readView as Markdown
S
Data & AI Engineer | Freelance Technical Writer | I write about LLMs, AI agents, RAG, evals and the engineering behind production AI systems, with focus on things tutorials usually skip. My goal is to break down complex engineering concepts into clear, actionable insights for developers, data professionals, and AI builders. Passionate about designing intelligent systems, scalable data platforms, and production-grade AI applications.

Originally published on Medium. Reposted here for the Hashnode community.

Consider this. You are an equity analyst or an engineer building an AI-powered financial research tool, and you are trying to find critical evidence buried across thousands of pages of corporate disclosures. You sit down before market opens and type a broad conceptual financial question like:

“What supply chain bottlenecks could disrupt advanced chip manufacturing?”

If your retrieval engine is backed by a modern dense vector embedding model (like bge-small-en-v1.5), it handles this query effortlessly. It understands the underlying semantics: surfacing passages from Form 20-F disclosures discussing cleanroom equipment lead times, raw silicon wafer constraints, and regional geopolitical tensions in East Asia.

The words in the filing are completely different from your query, but the conceptual meaning aligns. The filing never used the phrase “disrupt advanced chip manufacturing,” but the meaning was identical. The dense vector model understood the concept and retrieved the right answer.

The Next Query (Lexical)

Ten minutes later, you search for a specific regulatory accounting disclosure:

“Accounting Standards Codification ASC 606 revenue recognition”

Now, watch what happens to that same dense vector model: it completely stumbles.

Why does this happen? In corporate finance and statutory auditing, getting “semantically close” is not good enough. An equity analyst searching for ASC 606 needs the exact statutory standard that governs Revenue from Contracts with Customers. She does not want ASC 842 (which governs leases) or ASC 718 (which governs stock-based compensation).

To a dense embedding model, all three of these standards look virtually identical: they are all dense blocks of formal accounting policy text discussing financial compliance, balance sheet obligations, and GAAP standards. Because the embedding model compresses an entire 500-word paragraph into a fixed 384-dimensional mathematical vector, highly specific alphanumeric tokens like ASC 606 get diluted across the embedding dimensions. The vector model ends up returning a generic disclosure on general accounting estimates or a lease footnote, completely missing the specific revenue standard the analyst urgently needed.

                        [ 500 Words of Financial Text ]
                                     │
                                     ▼
                        [ Dense Vector Embedding ]
                                     │
                                     ▼
                        [ 384 Floating Point Numbers ]
                                     │
                                     ▼
   Rare Tokens ("ASC 606", "H200", "MI300") get washed out across 384 dimensions!

And the Opposite Wall: Vocabulary Mismatch

At this point, you might think: “If dense vectors struggle with exact alphanumeric codes, why not just stick with traditional keyword search like BM25?”

For those unfamiliar, BM25 (Best Matching 25) is the classic probabilistic keyword ranking algorithm in information retrieval. Unlike AI embedding models, BM25 does not attempt to understand semantic meaning. Instead, it relies on statistical word matching: it scores a document based on how frequently your search terms appear in a passage (Term Frequency) balanced against how rare those words are across the entire document corpus (Inverse Document Frequency).

(As we will see later, Qdrant natively supports BM25 as a sparse vector representation, allowing us to run exact keyword scoring directly inside the vector database without managing a separate text index.)

To see where BM25 breaks, I tested it against another common research question:

“What were the primary legal disputes facing automated driving systems?”

BM25 returned zero relevant results from Tesla’s official filings.

Why did BM25 fail so completely? Because corporate securities lawyers do not write in casual conversational English. In Tesla’s Form 10-K, legal liabilities are drafted with formal statutory terminology: “vehicular litigation and regulatory subpoenas concerning Autopilot and Full Self-Driving capabilities.”

Because there was zero exact word overlap between the analyst’s phrasing (“legal disputes facing automated driving systems”) and the company’s legal disclosure (“litigation concerning Autopilot”), BM25 was completely blind to the document.

Query says: "Automated driving legal disputes"
Filing says: "Vehicular litigation concerning Autopilot functionality"
       
BM25 Keyword Match: ─────────► [ 0 Matching Words ] ──────► ZERO HITS
Dense Vector Match: ─────────► [ Close Semantic Meaning ] ─► RETRIEVED!

The Central Problem

This brings us to the fundamental challenge that every engineer faces when building search over complex financial disclosures:

Financial search requires both understanding meaning (semantic) and matching exact language (lexical).

If your system only looks at meaning (semantics), it fails on exact accounting codes, product models, and ticker symbols. If your system only looks at keywords (lexical approach), it fails whenever people use natural phrasing to describe complex legal and financial terms.

This is the exact reason why hybrid search is essential. Instead of relying on a single retrieval method, I built a system that combines multiple search signals, keyword precision, semantic meaning, and learned vocabulary expansion into one unified pipeline.

What I Built to Tackle This Problem

To tackle this challenge systematically, I built an end-to-end reference implementation that is 100% local, self-contained, and uses zero paid external APIs.

The system pulls its dataset directly from the official U.S. Securities and Exchange Commission (SEC) EDGAR System via the public SEC EDGAR Submissions API. Using an automated ingestion pipeline equipped with compliant rate-limiting (8 requests/sec) and custom SEC identity headers, the system programmatically downloads 47 official filings — including Annual Reports (Form 10-K), Quarterly Reports (Form 10-Q), and Foreign Issuer Reports (Form 20-F) — across 10 major technology enterprises: NVIDIA, Apple, Microsoft, Alphabet, Amazon, Meta, Tesla, AMD, Intel, and TSMC.

Screenshot of the SEC Filings page
💡
Screenshot of the SEC EDGAR (Electronic Data Gathering, Analysis, and Retrieval) Filings page from where we sourced our data | It’s open-source

In total, the pipeline cleans, structures, and converts raw SEC HTML filings into 8,609 contextual chunks containing 2.54 million words of high-density corporate intelligence.

Using FastEmbed, the pipeline generates dense semantic embeddings, lexical BM25 token frequencies, and learned sparse (SPLADE) representations locally on standard CPU hardware.

All three vector spaces are stored in Qdrantan open-source, high-performance vector database engineered to store, filter, and search multi-vector payloads with ultra-low latency.

By indexing all three representations into a single unified Qdrant collection, we execute native, server-side Reciprocal Rank Fusion (RRF) directly inside the database engine, eliminating multi-database network overhead. On top of that, I added a downstream Stage-2 neural reranker and multi-column table parser to pinpoint the exact numerical row from financial statements.

The entire project is open-source on GitHub, complete with an ingestion pipeline, five core retrieval configurations, an empirical benchmarking suite, and an interactive local web dashboard.

screenshot of the UI dashboard of the project running on local host 8000
💡
The interactive web dashboard running on localhost:8000, comparing all 5 retrieval methods and Stage-2 reranking in real time

Technology Stack & Design Principles

I designed this project to be completely self-contained. Anyone should be able to clone the repository and run it locally on an ordinary laptop without needing a GPU or expensive cloud API keys. In financial environments, sending confidential analyst queries or proprietary research over the wire to third-party embedding endpoints often violates data privacy and compliance policies. Running everything locally also eliminates network round-trip latency during candidate retrieval.

The entire stack runs on Python 3.10+ (tested and verified on Python 3.14). Local embedding generation is handled by FastEmbed using ONNX runtime with automated CPU thread pooling. This engine generates 384-dimensional dense vectors using BAAI/bge-small-en-v1.5, sparse lexical term frequencies using Qdrant/bm25, and 30,522-token neural expansion vectors using prithivida/Splade_PP_en_v1.

For vector management and search, I selected Qdrant 1.14+. It stores all three vector representations (dense, bm25, sparse) inside a single collection named financial_docs and executes server-side Reciprocal Rank Fusion natively in Rust.

The system can connect to a running Qdrant Docker container or seamlessly fall back to an embedded on-disk storage directory at ./qdrant_data. Data validation is enforced through Pydantic v2.7+ schemas, document parsing is handled via BeautifulSoup4 + lxml with custom table preservation, and the web interface is served locally by FastAPI and Uvicorn. The codebase is fully verified by an automated Pytest suite containing 44 passing unit, integration, and end-to-end tests.

.
├── config/
│   ├── companies.yaml                  # Target company metadata (CIKs, tickers, sectors, forms)
│   └── default.yaml                    # System configuration (Qdrant, models, chunking, rate limits)
├── data/
│   ├── benchmark_report.md             # Complete generated benchmark report with case studies
│   ├── processed/                      # Ingested & normalized corpus
│   │   ├── chunks.jsonl                # 8,609 contextualized chunks
│   │   ├── documents.jsonl             # 47 parsed SEC document models
│   │   └── manifest.json               # Corpus metadata manifest
│   └── raw/edgar/                      # Local cached SEC HTML/iXBRL filings
├── docs/
│   ├── adr/                            # Architecture Decision Records
│   │   ├── 001-qdrant-selection.md     # ADR-001: Unified Qdrant multi-vector selection
│   │   ├── 002-qdrant-bm25-architecture.md # ADR-002: Native BM25 sparse vectors
│   │   ├── 003-corpus-strategy.md      # ADR-003: Corpus scope & section parsing
│   │   ├── 004-chunking-strategy.md    # ADR-004: Sentence-aware contextual chunking
│   │   ├── 005-embedding-models.md     # ADR-005: Local CPU ONNX embedding models
│   │   ├── 006-server-side-fusion.md   # ADR-006: Server-side RRF fusion strategy
│   │   ├── 007-evaluation-design.md    # ADR-007: Evaluation metrics & golden dataset
│   │   └── 008-downstream-reranking-pipeline.md # ADR-008: Stage-2 cross-feature reranking
│   ├── architecture.md                 # Full system architecture specification
│   └── debugging_and_architecture_guide.md # Diagnostic runbooks and engine internals
├── evaluation/
│   └── golden_dataset.yaml             # 25 hand-curated multi-category financial test queries
├── src/hybrid_search/
│   ├── chunking/
│   │   └── chunker.py                  # Token-aware sliding chunker with metadata header injection
│   ├── embeddings/
│   │   ├── bm25.py                     # FastEmbed Qdrant/bm25 lexical sparse generator
│   │   ├── dense.py                    # FastEmbed BAAI/bge-small-en-v1.5 (384d) dense generator
│   │   └── sparse.py                   # FastEmbed prithivida/Splade_PP_en_v1 sparse generator
│   ├── evaluation/
│   │   ├── benchmark.py                # BenchmarkRunner across golden dataset
│   │   ├── latency.py                  # LatencyTracker (p50, p95, p99 profiling)
│   │   ├── metrics.py                  # IR Metrics (Recall@K, Precision@K, MRR, NDCG@K, Hit Rate)
│   │   └── report.py                   # Rich CLI formatting & Markdown report exporter
│   ├── filtering/
│   │   └── metadata_filter.py          # Translates MetadataFilter into native Qdrant filter AST
│   ├── indexing/
│   │   └── qdrant_index.py             # Qdrant collection manager & multithreaded batch indexer
│   ├── ingestion/
│   │   ├── downloader.py               # Token-bucket rate limited (8 req/s) SEC downloader
│   │   ├── edgar_client.py             # SEC Submissions API client & URL resolver
│   │   ├── html_parser.py              # BeautifulSoup iXBRL parser preserving table rows
│   │   ├── normalizer.py               # Financial symbol, number & unicode normalizer
│   │   ├── pipeline.py                 # End-to-end ingestion pipeline orchestrator
│   │   └── section_extractor.py        # Proximity-based TOC-filtering section extractor
│   ├── models/
│   │   ├── document.py                 # Document, DocumentSection, Chunk Pydantic models
│   │   ├── filter.py                   # MetadataFilter Pydantic model
│   │   └── search.py                   # SearchQuery, SearchResult Pydantic models
│   ├── retrieval/
│   │   ├── base.py                     # BaseRetriever abstract class & result mapping
│   │   ├── bm25_dense_retriever.py     # Configuration 3: BM25 + Dense Hybrid (RRF)
│   │   ├── bm25_retriever.py           # Configuration 1: Pure BM25 Lexical
│   │   ├── dense_retriever.py          # Configuration 2: Pure Dense Semantic
│   │   ├── engine.py                   # Unified SearchEngine interface
│   │   ├── extractor.py                # Multi-column table fact extractor & text highlighter
│   │   ├── reranker.py                 # Stage-2 Cross-Feature Neural Reranker
│   │   ├── sparse_dense_retriever.py   # Configuration 5: SPLADE + Dense Hybrid (RRF)
│   │   └── sparse_retriever.py         # Configuration 4: Pure SPLADE Neural Sparse
│   ├── api.py                          # FastAPI REST API server (/api/status, /api/search, /api/benchmark)
│   ├── cli.py                          # Rich CLI commands (ingest, index, search, benchmark, info, serve)
│   └── config.py                       # Pydantic v2 application configuration loaders
├── tests/
│   ├── conftest.py                     # Pytest fixtures and mock objects
│   ├── e2e/
│   │   └── test_smoke.py               # End-to-end pipeline smoke test
│   ├── integration/
│   │   ├── test_embedding_pipeline.py  # Embedding generators integration tests
│   │   ├── test_qdrant_index.py        # Qdrant index manager tests
│   │   └── test_retrieval_pipeline.py  # All 5 retrievers & reranker integration tests
│   └── unit/
│       ├── test_api.py                 # REST API endpoints unit tests
│       ├── test_chunker.py             # Sentence splitting & chunking tests
│       ├── test_config.py              # Configuration loading tests
│       ├── test_edgar_client.py        # SEC EDGAR client tests
│       ├── test_html_parser.py         # iXBRL/HTML table parsing tests
│       ├── test_latency.py             # Latency statistics tests
│       ├── test_metadata_filter.py     # Qdrant filter translation tests
│       ├── test_metrics.py             # IR metrics computation tests
│       ├── test_models.py              # Pydantic data models validation tests
│       ├── test_normalizer.py          # Text normalization tests
│       └── test_section_extractor.py   # Section extraction & TOC filtering tests
├── CONTRIBUTING.md                     # Open source contribution guidelines
├── docker-compose.yml                  # Optional Qdrant Docker Compose configuration
├── index.html                          # Interactive Single-Page Web Dashboard UI
├── pyproject.toml                      # Package build configuration & dependencies
└── README.md

Why I Chose Qdrant for This Project

When building a hybrid search engine, most developers make the mistake of setting up two different databases: Elasticsearch or OpenSearch for keyword search, and a vector database for embeddings.

That creates a lot of operational pain. You have to keep two databases in sync, write custom code to merge results in Python, and send multiple requests (latency overhead) over your network for every single user query.

I chose Qdrant because it solves this problem cleanly:

▣ First, Multi-Vector Support in One Place: Qdrant lets you store dense vectors, BM25 sparse vectors, and SPLADE sparse vectors inside the same collection under the same document ID. You maintain one index, one schema, and one point of truth.

▣ Second, Native Server-Side Rank Fusion: Qdrant runs Reciprocal Rank Fusion (RRF) directly inside its Rust engine. Instead of pulling top-50 results from Elasticsearch and top-50 results from a vector store and merging them in Python, you send one search request, Qdrant runs the sparse inverted index search and dense HNSW graph traversal in parallel, fuses their ranks on the server in under 1ms, and sends back the final ranked list. You don’t have to write custom merging code in Python.

▣ Third, Payload Indexing and Fast Pre-Filtering: In finance, you constantly need to filter by company ticker, fiscal year or document type (for example, --ticker AAPL --form 10-K). Qdrant filters documents during graph traversal rather than pruning candidates after the search, preventing the recall collapse that commonly plagues post-filtering vector setups. So, your search stays fast and doesn't lose recall.

▣ Fourth, Seamless Local Development: With Qdrant, you can build and test your entire project locally using embedded on-disk storage (./qdrant_data) without needing to manage complex database clusters and then deploy the exact same code to production in Docker or Qdrant Cloud.


High-Level Architecture

The diagram below presents the complete end-to-end system architecture, showing how raw regulatory filings move from initial ingestion all the way to candidate retrieval, reranking, and evaluation. It serves as our roadmap for this project:

The pipeline follows 7 clear steps:

  1. SEC EDGAR Ingestion: Automatically downloads raw HTML/iXBRL filings, strips out navigational noise and table-of-contents links, and extracts primary statutory sections such as Business, Risk Factors, MD&A, and Financial Notes.

  2. Deterministic Chunking: Segments long documents into ~500-token chunks with 100-token overlaps, prepending structured context headers to every segment.

  3. Local Embedding Generation: Generates dense semantic vectors, BM25 token frequencies, and SPLADE learned sparse activations locally on CPU.

  4. Unified Storage: Stores all three vector representations inside a single point schema within Qdrant, alongside indexed metadata payloads.

  5. Qdrant-Native Retrieval: Runs any of the 5 retrieval configurations (bm25 ,dense,bm25+dense ,sparse ,sparse+dense).

  6. Stage-2 Reranking & Fact Extraction: Passes top candidate chunks through a multi-column table parser and composite neural reranker to isolate exact numerical metrics.

  7. Evaluation & Benchmarking: Scores retrieval accuracy and latency across a golden test suite of financial queries.

The Financial Corpus & Context-Enriched Chunking

4.1 Ingested Dataset

To build a realistic testbed for enterprise financial research, I ingested 47 official SEC filings covering fiscal years 2023, 2024, and 2025 across ten enterprise technology companies representing diverse business models and reporting structures.

4.2 Why Financial Corpora Are Particularly Challenging

If you have worked with plain text or blogs, SEC filings will surprise you with their complexity. Searching financial filings is fundamentally harder than searching typical web pages, documentation, or news articles.

Public companies use heavily standardized legal phrasing across their filings. Sections describing accounting principles, revenue recognition criteria, and general macroeconomic risks read almost identically whether written by Apple, Microsoft, or Intel. Naive vector search frequently retrieves the correct legal concept from the wrong company simply because the regular text language is indistinguishable.

Furthermore, critical financial information is locked inside multi-column tables. A standard financial table displays line items such as segment revenues, gross margins, and effective tax rates with numerical values spanning three consecutive fiscal years across separate columns. When basic text chunkers flatten these tables into unstructured text, the spatial alignment between column headers and numerical cells is lost.

SEC filings are also filled with Table of Contents link storms, embedded iXBRL tags, and dense footnote markers that break naive parsing logic. Even more challenging is temporal ambiguity: an annual 10-K filing for FY2024 frequently includes extended discussions and comparative tables for FY2023 and FY2022. Without explicit temporal anchoring, search models easily mistake prior-year numbers for current-period results.

💡
A collage of screenshots from SEC filings highlights the type of data we’re working with on this project | Table of Contents, links, separators, embedded iXBRL tags, and dense footnote markers. This kind of data is challenging to break into chunks and retrieve accurately from millions or even billions of embeddings

4.3 Indexing Volume & The Context Header Injection Pattern

After parsing and cleaning, the ingestion engine produced 8,609 chunks containing 2,542,471 words (~2.54M words), which I indexed in batches of 100 using FastEmbed’s local CPU ONNX pipeline.

💡
Don’t focus on the time it took for indexing; you’ll complete this much faster.

During early testing, I discovered a major failure mode: when a financial table is split into a 500-token chunk, the chunk often says something like: “Total net sales were $96,169 million compared to $85,200 million in the prior year.”

If you look at that chunk by itself, you have no idea which company it belongs to or what fiscal year it describes. To fix this, I created the Context Header Injection Pattern.

Every chunk produced by the chunker has a concise, structured metadata header. Before sending any chunk to the embedding models, I prepend a clean metadata header directly to the text:

# From src/hybrid_search/chunking/chunker.py
# Prepending a clear context header to every text chunk
context_prefix = (
    f"[{document.company} ({document.ticker}) | {document.document_type} {document.fiscal_period} | "
    f"Section: {section.section_name}]\n\n"
)
enriched_text = context_prefix + text_piece

chunk = Chunk(
    chunk_id=chunk_id,
    document_id=document.document_id,
    company=document.company,
    ticker=document.ticker,
    cik=document.cik,
    document_type=document.document_type,
    filing_date=document.filing_date,
    fiscal_period=document.fiscal_period,
    section=section.section_name,
    sector=document.sector or "Unknown",
    geography=document.geography or "US",
    text=enriched_text,
    chunk_index=chunk_idx,
    source_url=document.source_url,
    accession_number=document.accession_number,
)

Why this code matters: This single architectural decision had the largest positive impact on retrieval quality across the entire project. By adding this short header at the top of the text, the embedding models always know the company name, ticker symbol, filing type, and fiscal period. A passage from Apple’s FY2024 report is now clearly distinct from Apple’s FY2023 report, even if the financial table wording is identical.

4.4 The Wider Enterprise Scope

While this reference implementation focuses on SEC filings, in an enterprise financial setup, you can easily apply this exact same pipeline to the broader financial intelligence ecosystem including:

  • Earnings call audio transcripts

  • Investor slide decks

  • Real-time market news

  • Company ESG reports

  • Equity research reports


    Storing Everything in Qdrant: The Unified Multi-Vector Collection

    Before running any searches, let’s look at how all three embedding types (dense, bm25, sparse) live together inside one single Qdrant collection.

    5.1 Collection Creation: Dense + BM25 + SPLADE in One Place

    # From src/hybrid_search/indexing/qdrant_index.py
    # Creating a single collection with dense and two sparse vector spaces
    
    self.client.create_collection(
        collection_name="financial_docs",
        vectors_config={
            "dense": models.VectorParams(
                size=384,
                distance=models.Distance.COSINE,
            )
        },
        sparse_vectors_config={
            "bm25": models.SparseVectorParams(
                modifier=models.Modifier.IDF,
                index=models.SparseIndexParams(on_disk=False),
            ),
            "sparse": models.SparseVectorParams(
                modifier=models.Modifier.IDF,
                index=models.SparseIndexParams(on_disk=False),
            ),
        },
    )
    

    Walking through this code:

    • dense (384 dimensions, Cosine distance): Uses an HNSW graph index for semantic search. Cosine distance works well because our BGE embeddings are normalized.

    • bm25 (Sparse vector with Modifier.IDF): Our local FastEmbed generator computes the term frequencies for each word, and Qdrant automatically calculates and applies dynamic IDF weights across the corpus via Modifier.IDF.

    • sparse (SPLADE learned sparse vector with Modifier.IDF): Holds the 30,522-token neural expansion weights, also weighted on the server.

    5.2 Payload Indexing for Metadata Pre-Filtering

    To allow users to filter by ticker, year, or filing type without slowing down retrieval, I indexed the metadata fields in Qdrant:

# Creating payload indexes for high-speed metadata pre-filtering

for field, schema in [
    ("company", models.PayloadSchemaType.KEYWORD),
    ("ticker", models.PayloadSchemaType.KEYWORD),
    ("document_type", models.PayloadSchemaType.KEYWORD),
    ("filing_date", models.PayloadSchemaType.DATETIME),
    ("fiscal_period", models.PayloadSchemaType.KEYWORD),
    ("section", models.PayloadSchemaType.KEYWORD),
    ("sector", models.PayloadSchemaType.KEYWORD),
    ("geography", models.PayloadSchemaType.KEYWORD),
]:
    self.client.create_payload_index(
        collection_name="financial_docs",
        field_name=field,
        field_schema=schema,
    )

Why this code matters: When someone searches with --ticker AAPL --form 10-K, Qdrant narrows down the search space before doing vector comparisons directly during HNSW graph traversal. This avoids the problem of post-filtering, where relevant documents get thrown away after ranking.

5.3 Batch Upsert: Three Vectors Per Point

During ingestion, each contextual chunk is upserted as a single point in Qdrant containing all three vector representations along with its complete metadata payload:

# From src/hybrid_search/indexing/qdrant_index.py
# Storing all three vectors and metadata in a single Qdrant point
point_id = self.hash_to_int_id(chunk.chunk_id)
vector = {
    "dense": chunk.dense_vector,
    "bm25": models.SparseVector(
        indices=chunk.bm25_indices,
        values=chunk.bm25_values,
    ),
    "sparse": models.SparseVector(
        indices=chunk.sparse_indices,
        values=chunk.sparse_values,
    ),
}
payload = {
    "chunk_id": chunk.chunk_id,
    "document_id": chunk.document_id,
    "company": chunk.company,
    "ticker": chunk.ticker,
    "document_type": chunk.document_type,
    "filing_date": chunk.filing_date,
    "fiscal_period": chunk.fiscal_period,
    "section": chunk.section,
    "text": chunk.text,
}
points.append(models.PointStruct(id=point_id, vector=vector, payload=payload))
self.client.upsert(collection_name="financial_docs", points=points)

5.4 Three Local Embedding Generators

To feed data into the collection, I wrote three simple generator classes using FastEmbed.

The dense generator prefixes queries with retrieval instructions before generating 384-dimensional normalized vectors.

# Dense: src/hybrid_search/embeddings/dense.py
class DenseEmbeddingGenerator:
    def __init__(self, model_name="BAAI/bge-small-en-v1.5"):
        self._model = TextEmbedding(model_name=model_name, threads=os.cpu_count())

    def embed_query(self, query: str) -> List[float]:
        # BGE models use a short prompt prefix for queries
        query_text = f"Represent this sentence for searching relevant passages: {query}"
        return next(self.model.embed([query_text])).tolist()

The BM25 generator tokenizes text into vocabulary indices and raw term frequencies.

# BM25 Sparse: src/hybrid_search/embeddings/bm25.py
class BM25EmbeddingGenerator:
    def __init__(self, model_name="Qdrant/bm25"):
        self._model = SparseTextEmbedding(model_name=model_name, threads=os.cpu_count())

    def embed_query(self, query: str) -> Tuple[List[int], List[float]]:
        emb = next(self.model.embed([query]))
        return emb.indices.tolist(), emb.values.tolist()

The SPLADE sparse generator runs a masked language model forward pass to produce vocabulary indices with learned importance weights.

# SPLADE Sparse: src/hybrid_search/embeddings/sparse.py
class SpladeEmbeddingGenerator:
    def __init__(self, model_name="prithivida/Splade_PP_en_v1"):
        self._model = SparseTextEmbedding(model_name=model_name, threads=os.cpu_count())

    def embed_query(self, query: str) -> Tuple[List[int], List[float]]:
        emb = next(self.model.embed([query]))
        return emb.indices.tolist(), emb.values.tolist()

Because FastEmbed manages ONNX execution locally with multi-core thread pooling, generating query embeddings takes between 10ms and 28ms on standard CPU hardware without external API calls or GPU dependencies.


Method 1: BM25 Lexical Search (The Exact-Match Baseline)

Let’s start testing our retrieval methods one by one, beginning with the simplest and fastest: pure BM25 lexical search.

6.1 What BM25 Does

BM25 (Best Matching 25) scores documents based on how often search words appear in the document compared to how common those words are across the entire dataset.

In technical terms, it calculates document relevance using Term Frequency (TF), Inverse Document Frequency (IDF), and document length normalization.

In our setup, FastEmbed tokenizes the query into sparse token indices, and Qdrant computes the BM25 score on the server with dynamic IDF weighting using Modifier.IDF.

BM25 excels at matching exact alphanumeric identifiers, statutory codification codes, and specific product models without risk of neural hallucination. Because it assigns heavy weight to rare tokens, unique terms like ASC 606 or H200 receive high relevance scores. It is also fast and lightweight on the CPU.

6.2 Data Flow Diagram

dataflow diagram of bm25 lexical search method.

6.3 Implementation

# From src/hybrid_search/retrieval/bm25_retriever.py

class BM25Retriever(BaseRetriever):
    def search(self, query, k=10, filters=None, prefetch_k=50):
        # 1. Convert query to token indices and frequencies
        q_indices, q_values = self.generator.embed_query(query)
        qdrant_filter = QdrantFilterBuilder.build_filter(filters)

        # 2. Query Qdrant's sparse index
        sparse_vector = models.SparseVector(indices=q_indices, values=q_values)
        response = self.client.query_points(
            collection_name=self.collection_name,
            query=sparse_vector,
            using="bm25",
            query_filter=qdrant_filter,
            limit=k,
        )
        return self._points_to_search_results(response.points, self.name, query=query)

6.4 Where BM25 Wins (Exact Codification)

When querying statutory accounting rules:

python -m hybrid_search.cli search \
  --query "Accounting Standards Codification ASC 606 revenue recognition" \
  --method bm25 --ticker TSLA -k 3 -v
💡
Terminal output showing BM25 achieving Rank #1 on exact verbatim alignment | Exact regulatory codes or codified acronyms are preferably retrieved

Terms like "Codification", "ASC", revenue, recognition and "606" are rare across general corporate corpora but dense in standard accounting disclosures. Since all these words are present in the target passage, BM25 scores it with high lexical precision.

6.5 Where BM25 Fails (Vocabulary Mismatch)

When querying general conceptual themes:

python -m hybrid_search.cli search \
  --query "What were the primary legal disputes facing automated driving systems?" \
  --method bm25 --ticker TSLA -k 3 -v
💡
Terminal output showing BM25 failing to return any relevant hits. All retrieved chunks are vague and irrelevant

Because the query used colloquial phrasing (“legal disputes facing automated driving systems”), BM25 matched irrelevant high-frequency words like “facing”, “systems”, and “driving”, leading straight into general supply chain manufacturing risks rather than legal disclosures. The complete absence of overlapping keywords leaves BM25 blind to the filing.

Takeaway: BM25 is the fastest retrieval method in our stack (12.4ms median) and unbeatable for exact statutory codes and product SKUs. But the moment a user’s query phrasing diverges from the document’s words, it completely fails.


Method 2: Dense Vector Search (Semantic Understanding)

Now let’s look at dense vector search, which addresses the exact weaknesses of BM25.

7.1 What Dense Retrieval Does

Dense embeddings convert text passages into continuous numbers (384-dimensions) where nearby vectors in geometric space share similar meanings. Using bge-small-en-v1.5, queries and chunks are mapped into vectors where cosine similarity measures topical relevance, evaluated via Qdrant's HNSW index across the dense vector space.

7.2 Data Flow Diagram

7.3 Implementation

# From src/hybrid_search/retrieval/dense_retriever.py

class DenseRetriever(BaseRetriever):
    def search(self, query, k=10, filters=None, prefetch_k=50):
        # 1. Create 384-dimensional query vector
        query_vector = self.generator.embed_query(query)
        qdrant_filter = QdrantFilterBuilder.build_filter(filters)

        # 2. Search Qdrant dense vector space using cosine distance
        response = self.client.query_points(
            collection_name=self.collection_name,
            query=query_vector,
            using="dense",
            query_filter=qdrant_filter,
            limit=k,
        )
        return self._points_to_search_results(response.points, self.name, query=query)

7.4 Where Dense Retrieval Wins (Broad Thematic Risk)

When querying broad operational themes:

python -m hybrid_search.cli search \
  --query "What supply chain bottlenecks could disrupt advanced chip manufacturing?" \
  --method dense --ticker TSM -k 3 -v
💡
Terminal output showing Dense retrieval finding TSMC risk text

Retrieved chunk: “Our operations and ongoing expansion plans depend on our ability to obtain necessary equipment…..limited supply and/or long delivery cycles… ongoing trade tensions could result in increased prices for, or even unavailability of, key equipment, through delay or denial of necessary export licenses…...silicon wafers, gases, chemicals, and photoresist…..”

The document never contains the literal word “bottleneck”, yet the bi-encoder projects the latent meaning of “disrupt manufacturing” and “supply chain bottlenecks” into the same geometric embedding space as “long delivery cycles”, “unavailability of key equipment”, and “shortages of silicon wafers and photoresist”.

Dense retrieval excels at synthesizing diffuse, qualitative risk factor narratives where concepts span multiple paragraphs without rigid terminology.

7.5 Where Dense Retrieval Fails (Exact SKU Blindness)

When querying specific hardware models:

python -m hybrid_search.cli search \
  --query "Find disclosures regarding MI300 accelerator shipments and architecture" \
  --method dense --ticker AMD -k 3 -v
💡
Terminal output showing Dense search confusing MI300 and retrieving irrelevant GPU/CPU disclosures

Retrieved Excerpt: “Additionally, we make certain voluntary disclosures in this report and on our website, which are informed by various standards and frameworks (including standards for the measurement of underlying data)…”

Fixed-dimensional dense embeddings (384-d) compress full sentences into a single vector. Specific alphanumeric identifiers like "MI300" are split into subword tokens with low semantic energy.

Common linguistic framing words in the query (“disclosures”, “architecture”, “shipments”) dominate the vector projection. As a result, the model retrieved generic corporate disclosure policy statements rather than actual product disclosures regarding AMD Instinct MI300 GPUs.

7.6 Summary of Findings So Far

We now have two distinct retrieval methods with complementary strengths and weaknesses:

  • BM25: Highly effective on exact alphanumeric codes and product names, but blind to paraphrasing and synonyms.

  • Dense: Highly effective on conceptual themes and broad inquiries, but prone to diluting rare terms and specific identifiers.

This naturally leads to our next architectural question: What happens if we combine them into a single unified search and how do we do that?


The Hybrid Search Idea & The Rank Fusion Problem

8.1 The Basic Architecture

                   User Query
                       │
              ┌────────┴────────┐
              ↓                  ↓
         BM25 / Lexical     Dense Vector
              │                  │
              ↓                  ↓
        Top-K results       Top-K results
              │                  │
              └────────┬─────────┘
                       ↓
                  Fusion / Ranking
                       ↓
                  Final results

BM25 finds documents that use the right words. Dense vectors find documents that express the right ideas. Hybrid search combines both to eliminate each other’s blind spots.

8.2 The Ranking Disagreement Problem

Suppose a user issues a query where BM25 and Dense retrieval return differing results:

  • BM25 returns: Document A (Rank 1), Document B (Rank 2), Document C (Rank 3).

  • Dense returns: Document D (Rank 1), Document B (Rank 2), Document F (Rank 3).

Document B appears near the top of both lists, but neither method ranked it #1. How do we combine these rankings into an optimal unified list?

8.3 Why Score Addition Doesn’t Work

You might think about adding or multiplying raw scores from both systems. But this fails because the underlying score distributions are incompatible:

  • BM25 scores are unbounded positive numbers (often ranging from 0 to 35+ depending on document length and term rarity).

  • Cosine similarity scores are always bounded between −1.0 and +1.0 (typically clustering between 0.60 and 0.88 for relevant passages).

If you add them, the BM25 score completely overpowers the dense score. Normalizing scores (like Min-Max scaling) is fragile because a single outlier score in one channel can distort the entire ranking for that query batch.

8.4 Reciprocal Rank Fusion (RRF)

Reciprocal Rank Fusion (RRF) solves this challenge by looking at rank positions instead of raw scores:

$$\text{RRF}(d) = \sum_{m \in M} \frac{1}{k + \text{rank}_m(d)}$$

Where M represents the set of retrieval channels,

$$\operatorname{rank}_m(d)$$

is the 1-based position of document d in channel m, and k=60 is the standard smoothing constant used by Qdrant.

Let’s look at Document B from our example after applying RRF:

  • Document B rank in BM25: 2

  • Document B rank in Dense: 2

$$\text{RRF}(\text{Doc B}) = \frac{1}{60 + 2} + \frac{1}{60 + 2} = \frac{1}{62} + \frac{1}{62} \approx 0.03225$$

Because Document B was recognized by both retrieval channels, its combined reciprocal score pushes it to Rank #1 overall. Qdrant runs RRF in native Rust directly on the server in less than 1 millisecond.

Method 3: BM25 + Dense Hybrid (Server-Side RRF)

Now, let’s combine BM25 and Dense search using Qdrant’s server-side fusion.

9.1 Data Flow Diagram

9.2 Implementation

In Qdrant, executing a hybrid search with server-side rank fusion requires only a single API call:

# From src/hybrid_search/retrieval/bm25_dense_retriever.py
# Single-call hybrid search combining BM25 and Dense with server-side RRF

class BM25DenseRetriever(BaseRetriever):
    def search(self, query, k=10, filters=None, prefetch_k=50):
        # 1. Generate both query embeddings locally
        dense_vector = self.dense_gen.embed_query(query)
        bm25_indices, bm25_values = self.bm25_gen.embed_query(query)
        qdrant_filter = QdrantFilterBuilder.build_filter(filters)

        bm25_sparse_vector = models.SparseVector(indices=bm25_indices, values=bm25_values)

        # 2. Execute both searches and fuse them server-side in Qdrant
        response = self.client.query_points(
            collection_name=self.collection_name,
            prefetch=[
                models.Prefetch(
                    query=bm25_sparse_vector,
                    using="bm25",
                    limit=prefetch_k,
                    filter=qdrant_filter,
                ),
                models.Prefetch(
                    query=dense_vector,
                    using="dense",
                    limit=prefetch_k,
                    filter=qdrant_filter,
                ),
            ],
            query=models.FusionQuery(fusion=models.Fusion.RRF),
            limit=k,
        )
        return self._points_to_search_results(response.points, self.name, query=query)

Qdrant retrieves the top 50 candidates from the bm25 sparse index and top 50 candidates from the dense HNSW index concurrently, applies RRF fusion across both candidate pools in native Rust, and returns the top kk results directly to the application.

9.3 Where BM25 + Dense Wins (Named Entity + Conceptual Scrutiny)

When querying inquiries combining specific company entities with broad themes:

python -m hybrid_search.cli search \
  --query "What legal proceedings and antitrust investigations did Meta face from regulators?" \
  --method bm25_dense --ticker META -k 3 -v
💡
Terminal output showing BM25+Dense hybrid search surfacing Meta regulatory investigations with high confidence

Excerpt on Rank#1 scored high in both BM25 and Dense simultaneously because it contains a high density of overarching legal and regulatory terms (“legal proceedings”, “antitrust”, “regulators”, “statutory regimes”, “penalties or damages”) combined with strong dense semantic representation of corporate legal liability.

While Rank#1 gives the overall exposure summary, Rank#2 pinpoints the exact named antitrust action (the FTC lawsuit seeking divestiture of Instagram and WhatsApp).

BM25 anchors on specific legal keywords, while Dense captures the conceptual theme of legal investigations. RRF combines both signals and obtains the most favoured candidate.

9.4 Where BM25 + Dense Fails (Table Row Precision)

When querying exact financial metrics from tables:

python -m hybrid_search.cli search \
  --query "How much did Microsoft spend under the Stock Repurchase Program in fiscal year 2024?" \
  --method bm25_dense --ticker MSFT -k 3 -v
💡
Terminal output showing narrative text ranked above table rows

The hybrid search retrieves narrative paragraphs discussing share repurchase authorizations but fails to rank the exact numerical table row from the financial statement schedule above the descriptive text.

First-stage RRF cannot differentiate the 2024 tabular row from 2023 or 2025 tables without a cross-encoder or structured year filter.

Takeaway: Combining BM25 and Dense search produced a massive recall jump from ~81% to 96.00%. But BM25 is still just a basic keyword counter that cannot recognize financial synonyms.

What if our keyword search could also understand related terms? The next section covers that.


Learned Sparse Vectors: Where SPLADE Sits Between BM25 and Dense

10.1 What Sparse Vectors Represent

Unlike dense embeddings that compress everything into 384 numbers:

dense:   [0.13, -0.42, 0.08, ...] (384 dimensions)

A sparse vector representation maps text across a large vocabulary dictionary (30,522 tokens), with only a small subset of relevant terms holding non-zero weights:

sparse:  {"capex": 2.41, "nvidia": 3.12, "h200": 2.85, "supply": 1.74} (30,522 words)

This retains the interpretability and exact-term specificity of keyword search while operating within vector database indexes.

10.2 SPLADE: Learned Sparse Retrieval

prithivida/Splade_PP_en_v1 is a masked language model trained to predict term importance and performs neural term expansion.

When SPLADE sees "capital expenditure", it doesn't just activate the words capital and expenditure. It also automatically activates related semantically related financial terms like capex, investments, and infrastructure. When it encounters "graphics processing units", it activates CUDA, TensorRT, and accelerators.

Unlike BM25, which only counts the exact words on the page, SPLADE learns which words are related from its training data and dynamically expands queries without manual thesaurus configuration.

10.3 Where Sparse Retrieval Sits

Sparse retrieval sits right in the middle between classical keyword search (BM25) and Dense semantic search:

  • Like BM25, it is interpretable; you can look at exactly which words were activated and their assigned weights.

  • Like Dense vectors, it has semantic flexibility; it bridges vocabulary gaps through learned term expansion (neural).

  • It operates natively within Qdrant’s sparse inverted index infrastructure without requiring separate search clusters.

10.4 Honest Trade-Offs: BM25 vs SPLADE


Method 4: SPLADE Sparse Search (Neural Lexical Retrieval)

11.1 Data Flow Diagram

11.2 Implementation

# From src/hybrid_search/retrieval/sparse_retriever.py
class SparseRetriever(BaseRetriever):
    def search(self, query, k=10, filters=None, prefetch_k=50):
        # 1. Generate SPLADE sparse activations
        q_indices, q_values = self.generator.embed_query(query)
        qdrant_filter = QdrantFilterBuilder.build_filter(filters)

        # 2. Search Qdrant's 'sparse' vector space
        sparse_vector = models.SparseVector(indices=q_indices, values=q_values)
        response = self.client.query_points(
            collection_name=self.collection_name,
            query=sparse_vector,
            using="sparse",
            query_filter=qdrant_filter,
            limit=k,
        )
        return self._points_to_search_results(response.points, self.name, query=query)

11.3 Where SPLADE Wins (Vocabulary Expansion)

When searching technical platform terminology:

python -m hybrid_search.cli search \
  --query "How does NVIDIA expand its computing platform through graphics processing units and networking architecture?" \
  --method sparse --ticker NVDA -k 3 -v
💡
Terminal output showing accurate retrieved excerpts for the search query

The query asks about both “graphics processing units” and “networking architecture”. The Rank 1 chunk (from the 10-Q MD&A) directly discusses both the Graphics segment (Blackwell architecture) and the Compute & Networking segment (OEMs, CSPs, hyperscale deployment).

SPLADE expanded the query to latent vocabulary tokens like blackwell, oem, hyperscale, compute, networking, and architecture, causing this recent quarterly MD&A chunk to barely edge out Rank 2.

Because SPLADE is based on term weighting (even with neural expansion), a chunk containing multiple high-frequency mentions of both segments (Graphics + Compute & Networking) can score marginally higher than a pure qualitative definition.

11.4 Where SPLADE Fails (Temporal Confusion)

When querying specific fiscal reporting periods:

python -m hybrid_search.cli search \
  --query "What were Apple's primary hardware products and platform services described in fiscal year 2024?" \
  --method sparse --ticker AAPL -k 3 -v
💡
Terminal output showing SPLADE pulling in multi-year discussions against what is asked in the search query

Because SPLADE expands into broad clusters of related product terms, it can experience rank degradation by pulling in multi-year discussions instead of strictly isolating the FY2024 section.

Apple describes its hardware products (iPhone, Mac, iPad, Wearables) with virtually identical sparse terminology every fiscal year.

SPLADE matched the expanded tokens (hardware, platform, services, 2024) against retrospective MD&A comparison references in the FY2025 10-K rather than recognizing that the user sought primary disclosures from the FY2024 filing. Term weighting alone cannot resolve document vintage without metadata filtering.


Method 5: Sparse + Dense Hybrid (The High-Recall Candidate Generator)

Now we reach the top of our Stage-1 retrieval ladder: combining SPLADE’s learned sparse vectors with dense semantic embeddings using Qdrant’s server-side RRF fusion.

12.1 Data Flow Diagram

12.2 Implementation

# From src/hybrid_search/retrieval/sparse_dense_retriever.py

class SparseDenseRetriever(BaseRetriever):
    def search(self, query, k=10, filters=None, prefetch_k=50):

        # 1. Generate dense and SPLADE sparse embeddings
        dense_vector = self.dense_gen.embed_query(query)
        sparse_indices, sparse_values = self.sparse_gen.embed_query(query)
        qdrant_filter = QdrantFilterBuilder.build_filter(filters)

        sparse_vector = models.SparseVector(indices=sparse_indices, values=sparse_values)

        # 2. Server-side RRF fusion across SPLADE and Dense vector spaces
        response = self.client.query_points(
            collection_name=self.collection_name,
            prefetch=[
                models.Prefetch(
                    query=sparse_vector,
                    using="sparse",
                    limit=prefetch_k,
                    filter=qdrant_filter,
                ),
                models.Prefetch(
                    query=dense_vector,
                    using="dense",
                    limit=prefetch_k,
                    filter=qdrant_filter,
                ),
            ],
            query=models.FusionQuery(fusion=models.Fusion.RRF),
            limit=k,
        )
        return self._points_to_search_results(response.points, self.name, query=query)

12.3 Where Sparse + Dense Wins (High-Recall Candidate Retrieval)

When querying multi-faceted financial questions:

python -m hybrid_search.cli search \
  --query "How did data center GPU demand drive Nvidia's compute and networking revenue in fiscal year 2024?" \
  --method sparse_dense --ticker NVDA -k 3 -v
💡
Terminal output showing accurate chunk retrieved for the search query | Combining sparse (lexical precision) and dense (semantic intent)

SPLADE (Sparse) activates domain terms like “Data Center compute”, “Networking revenue”, “InfiniBand”, “NVLink”, while Dense captures the narrative explanation of revenue growth. Together, they achieve 0.9600 Recall@10.

Unlike pure BM25 (which misses qualitative causal descriptions) or pure Dense (which can miss specific sub-segment nomenclature like Compute & Networking vs Graphics), Sparse + Dense Hybrid surfaces both the quantitative segment results (+244% compute growth) and the architectural demand drivers (GB200/GB300 NVLink fabrics and CSP AI infrastructure ramp).

12.4 Where Sparse + Dense Falls Short (Table Row Ordering) and Stage-1 Retrieval Reaches Its Limit

Despite achieving 96% recall, all single-stage retrieval methods encounter a structural ceiling when handling financial tables:

python -m hybrid_search.cli search \
  --query "What was Apple's total Services revenue in fiscal year 2024 compared to 2023?" \
  --method sparse_dense --ticker AAPL -k 3 -v
💡
Terminal output showing revenue table row ranked below narrative

First-stage bi-encoders and SPLADE sparse models prioritize cohesive narrative sentences (“during 2024 compared to 2023”) over raw tabular markdown rows.

The actual financial statement table containing the exact breakdown (chunk_0052) was pushed down to rank 4/5, replaced at rank 1 by an SG&A narrative expense chunk that happened to contain similar comparative prose.

Vector models evaluate document chunks as unified blocks. They cannot understand the internal spatial arrangement of multi-year tables, which is why downstream applications need a dedicated Stage-2 extraction and reranking layer.


Downstream Precision: Stage-2 Neural Reranking & Table Fact Extraction

To solve this final problem, I added a Stage-2 pipeline: Qdrant (Stage-1) retrieves the top 20 candidate chunks from a high recall candidate pool, and a neural reranker + table extractor pinpoints the exact answer.

13.1 The Retrieval-to-Extraction Gap

Stage-1 hybrid search gets 96% recall. But financial analysts don’t want to read a 500-word paragraph to find a single figure. They need the exact factual row:

Services (1) -> 2024: $96,169M | 2023: $85,200M | 2022: $78,129M

To deliver this, Stage-2 does two things:

  1. Multi-Column Table Parsing: Identifying fiscal year column headers and binding row values to their respective years.

  2. Cross-Feature Scoring: Reranking candidate chunks based on exact metric alignment, filing type intent, and temporal freshness.

13.2 Data Flow Diagram

13.3 Multi-Column Table Parsing & Composite Reranking Implementation

When candidate chunks enter Stage-2, the table parser scans for tabular pipe delimiters, maps multi-year column headers to numerical values, and isolates the target line item:

# From src/hybrid_search/retrieval/extractor.py & reranker.py
# 1. Table fact extraction mapping multi-year columns to row labels
if "|" in chunk_body and values and years:
    parts = [f"{y}: ${v}M" for y, v in zip(years, values)]
    extracted_answer = f"{best_label} -> " + " | ".join(parts)

# 2. Composite cross-feature reranking score
cross_score = (
    0.15 * normalized_base_score +   # Original Qdrant RRF retrieval score
    0.25 * answer_keyword_coverage + # Exact keyword match within extracted answer
    0.10 * full_text_coverage +      # Broad document keyword overlap
    0.15 * metric_bonus +            # Monetary and numerical alignment
    0.10 * table_fact_bonus +        # Bonus if structured table row was aligned
    0.15 * filing_intent_score +     # Boost 10-K for annual queries, 10-Q for quarterly
    0.10 * temporal_recency_bonus    # Fiscal year freshness match
)# From src/hybrid_search/retrieval/extractor.py & reranker.py
# 1. Table fact extraction mapping multi-year columns to row labels
if "|" in chunk_body and values and years:
    parts = [f"{y}: ${v}M" for y, v in zip(years, values)]
    extracted_answer = f"{best_label} -> " + " | ".join(parts)

# 2. Composite cross-feature reranking score
cross_score = (
    0.15 * normalized_base_score +   # Original Qdrant RRF retrieval score
    0.25 * answer_keyword_coverage + # Exact keyword match within extracted answer
    0.10 * full_text_coverage +      # Broad document keyword overlap
    0.15 * metric_bonus +            # Monetary and numerical alignment
    0.10 * table_fact_bonus +        # Bonus if structured table row was aligned
    0.15 * filing_intent_score +     # Boost 10-K for annual queries, 10-Q for quarterly
    0.10 * temporal_recency_bonus    # Fiscal year freshness match
)

13.5 Where Stage-2 Reranking Wins (Tabular Precision)

python -m hybrid_search.cli search \
  --query "What was Google Cloud revenue in fiscal year 2023?" \
  --method rerank --ticker GOOGL -k 3 -v

The Stage-2 Reranker scores candidate chunks through joint cross-feature attention, prioritizing structured tabular alignment over conversational filler.

Rather than treating the table as an unstructured blob of text, the neural reranker explicitly evaluates the intersection of the entity row (Google Cloud) with the target fiscal column (2023: $33,088M). It elevates the exact segment revenue schedule directly to Rank 1 and pinpoints the clean ground-truth answer.


Side-by-Side: All Methods on the Same Query

To observe how each retrieval method behaves in practice, we can execute all six configurations simultaneously on a single query:

python -m hybrid_search.cli search \
--query "What was Google Cloud revenue in fiscal year 2023?" \
--method all --ticker GOOGL -k 3
💡
Terminal output showing all 6 retrieval methods on the same query
💡
Screenshots from the web dashboard (frontend)

GitHub Repository:

https://github.com/satyam671/financial-hybrid-search-qdrant


Empirical Benchmarks: Quality & Latency

15.1 Evaluation Methodology

To evaluate retrieval performance objectively, I created a curated golden benchmark dataset (evaluation/golden_dataset.yaml) containing 25 hand-annotated multi-category queries with verified ground-truth chunk IDs.

The evaluation suite calculates standard Information Retrieval (IR) metrics:

  • Recall@10: Did the search find all relevant documents in the top 10?

$$\mathrm{Recall@K} = \frac{|\mathrm{Relevant} \cap \mathrm{Retrieved@K}|} {|\mathrm{Relevant}|}$$

  • Precision@10: What fraction of the top 10 results were actually relevant?

$$\mathrm{Precision@K} = \frac{|\mathrm{Relevant} \cap \mathrm{Retrieved@K}|} {K}$$

  • MRR (Mean Reciprocal Rank): The reciprocal rank of the first relevant document, measuring how close to Rank #1 was the first correct answer.

$$\mathrm{MRR} = \frac{1}{|Q|} \sum_{q \in Q} \frac{1}{\mathrm{rank}_{\mathrm{first}}(q)}$$

  • NDCG@10: Normalized Discounted Cumulative Gain, penalizing relevant documents ranked lower in the list. How well-ordered were the results?

    $$\mathrm{DCG@K} = \sum_{i=1}^{K} \frac{rel_i}{\log_2(i+1)}, \qquad \mathrm{NDCG@K} = \frac{\mathrm{DCG@K}} {\mathrm{IDCG@K}}$$

  • Hit Rate@10: A binary metric indicating whether at least one relevant document appeared in the top 10.

    $$\mathrm{HitRate@K} = \mathbb{I} \left( |\mathrm{Relevant} \cap \mathrm{Retrieved@K}| > 0 \right)$$

The query suite spans five realistic financial research categories:

  • Category A (Exact Terms): Alphanumeric codes (H200), standards (ASC 606).

  • Category B (Semantic Inquiries): Conceptual questions with completely different phrasing.

  • Category C (Mixed Queries): Specific company names combined with broad financial themes.

  • Category D (Rare Technical Terms/Jargons): Advanced packaging and semiconductor fabrication terms (such as CoWoS).

  • Category E (Long Questions): Multi-clause natural language analyst prompts.

15.2 Retrieval Quality Results (K=10)

Retrieval Recall@10 Comparison:
BM25 (Lexical)       [████████████████████░░░░░] 0.8133
Dense (BGE-Small)    [█████████████████████░░░░] 0.8267
BM25 + Dense (RRF)   [████████████████████████░] 0.9600  (+15% Absolute Gain!)
Sparse + Dense (RRF) [████████████████████████░] 0.9600  (+15% Absolute Gain!)
Stage-2 Reranker     [████████████████████████░] 0.9600
Mean Reciprocal Rank (MRR):
BM25 (Lexical)       [█████████████████████░░░░] 0.8400
Dense (BGE-Small)    [██████████████████████░░░] 0.8667
BM25 + Dense (RRF)   [████████████████████████░] 0.9600
Sparse + Dense (RRF) [████████████████████████░] 0.9600
Stage-2 Reranker     [█████████████████████████] 1.0000  (PERFECT RANK #1)

15.3 Local CPU Latency Profile

All latency metrics were recorded on an off-the-shelf multi-core CPU in milliseconds across repeated query runs:

  • p50​ (Median execution time)

  • p95​ (95th percentile tail latency)

  • Mean

15.4 Key Empirical Takeaways

The empirical data demonstrates four major conclusions:

▣ First, hybrid retrieval is non-negotiable for financial search. Both hybrid configurations (BM25 + Dense and Sparse + Dense) boosted Recall@10 from ~81% to 96.00%, representing a +15% absolute gain over any single-vector approach.

▣ Second, BM25 + Dense and Sparse + Dense achieve identical recall with different operational profiles. BM25 + Dense is 35% faster on CPU (25.8ms vs 39.2ms) because BM25 tokenization avoids neural forward passes. Sparse + Dense provides automatic vocabulary expansion, eliminating the need to maintain manual synonym dictionaries.

▣ Third, Qdrant server-side RRF introduces zero practical latency overhead. Executing rank fusion in native Rust takes under 1ms. The latency delta between single-vector and hybrid search stems almost entirely from generating two query embeddings.

▣ Fourth, Stage-2 reranking achieves perfect Rank #1 precision. The reranker lifted MRR to 1.0000 and NDCG@10 to 0.9312, guaranteeing that the exact numerical answering fact is always positioned at the very top of the results.


Where Each Approach Works Best: An Honest Decision Framework

No single retrieval method is universally optimal across every search scenario. The right architecture depends on your specific latency constraints and query profiles. Here is how to choose the right architecture for your needs:

┌──────────────────────────────────────┬───────────────────────────────┬────────────────────────────────────────────────────────┐
│ Search Requirement                   │ Optimal Architecture          │ Key Rationale                                          │
├──────────────────────────────────────┼───────────────────────────────┼────────────────────────────────────────────────────────┤
│ Exact Statutory & SKU Lookups        │ BM25 Lexical                  │ Zero hallucination risk; fastest speed (12.4ms).       │
│ Fast Thematic Browsing               │ Dense Vector                  │ Lowest compute cost for conceptual search.             │
│ General Enterprise Search            │ BM25 + Dense (RRF)            │ Best balance of speed (25.8ms) and 96% recall.         │
│ Complex Domain Synonym Inquiries     │ SPLADE + Dense (RRF)          │ Automates synonym expansion without manual rules.      │
│ Mission-Critical Financial RAG       │ Sparse + Dense + Stage-2      │ Guarantees exact table facts at Rank #1 (MRR: 1.0000). │
└──────────────────────────────────────┴───────────────────────────────┴────────────────────────────────────────────────────────┘

When evaluating vector databases for hybrid search architectures, four technical capabilities are critical:

  1. Multi-Vector Co-Location: The ability to store dense vectors, lexical sparse vectors, and learned sparse vectors inside a single collection under a unified point schema.

  2. Native Server-Side Fusion: The ability to execute Reciprocal Rank Fusion inside the database engine, avoiding network latency and client-side merge code.

  3. Payload Indexing & Pre-Filtering: The ability to filter points during HNSW graph traversal rather than pruning candidates after retrieval.

  4. Local Portability: Support for embedded on-disk storage during local development and seamless transition to containerized or cloud environments for production deployment.


Conclusion & Community Call to Action

Different Signals Answer Different Questions

The central lesson of this project is that information retrieval in complex domains like financial intelligence cannot be solved with a single vector space:

  • BM25 asks: “Does this document contain the exact statutory words?”

  • SPLADE asks: “What related technical terms should be expanded?”

  • Dense Vectors ask: “Does this document share the same conceptual meaning?”

  • Hybrid Fusion (RRF) asks: “Which documents satisfy both keyword and semantic criteria?”

  • Stage-2 Reranking asks: “Which exact table cell answers the question?”

Building effective financial search is about combining these complementary signals into a layered architecture where each layer addresses the limitations of the previous one.


Help Build the Production Version: Join the Project!

This reference implementation is currently a working beta version. While it proves the hybrid search architecture across 47 SEC filings, there is so much more we can build together as a developer community.

I welcome all types of contributions to help take this project to full production readiness:

  • Expanding the Corpus: Adding S-1 filings, earnings call transcripts, 8-K event disclosures, and international annual reports.

  • Advanced Table Parsers: Enhancing table extraction algorithms to handle nested sub-headers, currency unit conversions, and complex footnote annotations.

  • Model Experimentation: Evaluating new sparse and dense embedding models such as BGE-M3, ColBERT late interaction, and domain-tuned SPLADE weights.

  • UI Enhancements: Expanding the local FastAPI dashboard with interactive time series financial chart visualizations.

How to contribute:

  1. Fork the repository on GitHub:

https://github.com/satyam671/financial-hybrid-search-qdrant

  1. Explore open issues or propose new features.

  2. Submit a Pull Request with your improvements!

# Clone and run the project locally
git clone https://github.com/satyam671/financial-hybrid-search-qdrant.git
cd financial-hybrid-search-qdrant
pip install -e .

# Launch the interactive web dashboard on localhost:8000
python -m hybrid_search.cli serve --port 8000

References

  1. Qdrant Vector Database Documentation: https://qdrant.tech/documentation/

  2. FastEmbed Local Embedding Engine: https://github.com/qdrant/fastembed

  3. SPLADE: Sparse Lexical and Expansion Model: Formal et al., SPLADE v2: Sparse Lexical and Expansion Model for Information Retrieval, 2021.

  4. Reciprocal Rank Fusion in Information Retrieval: Cormack, Clarke, and Buettcher, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods, SIGIR 2009.

  5. BAAI General Embedding (BGE): Xiao et al., C-Pack: Packaged Resources To Advance General Chinese Embedding, 2023.

  6. SEC EDGAR Submissions API: U.S. Securities and Exchange Commission, https://www.sec.gov/edgar/sec-api-documentation


Let’s Connect

If you’re building data infrastructure, AI systems, or developer-facing products and need someone who understands both the engineering and the writing side, then I might be the person you can contact. I take on freelance projects and can help professionally with developer side workflows.

I write about Data and AI Engineering, Agentic AI, LLMs, MCP, RAG, AI systems architecture, and modern developer workflows, and things tutorials usually skip without sounding like documentations**.** I’m also open to project-based consulting, technical writing, guest posts and strategic collaborations.

Feel free to connect with me on LinkedIn or via email. I’d love to hear about what you’re building. Follow and subscribe to emails for more such posts.

And if you liked this article, make sure to upvote, repost it with your thoughts so that more people discover it and leave your comments down.

#AI #RAG #HybridSearch #InformationRetrieval #VectorSearch #Qdrant #AIEngineering #ArtificialIntelligence #LLMs #VectorDB #BM25 #SparseVectors #DenseVectors #MachineLearning #DataScience