Hybrid Search - Reranking Full-Text and Vector Search Results

Hybrid search combines multiple search methods into a single ranked result set. A common use case combines full-text search, which finds keyword matches, with vector search, which finds semantically similar content.

Hybrid search is useful when exact terminology matters but semantic relevance is also important. For example, a search application might require documents containing a particular product name or technical term while also returning documents that discuss the same concept using different wording. This retrieval pattern is widely used in retrieval-augmented generation (RAG) pipelines.

SingleStore provides both full-text and vector indexes that can accelerate each retrieval method independently. The ranked results from each search method can be combined into a single final ranking using techniques such as reciprocal rank fusion (RRF).

Sparse and Dense Retrieval

Hybrid search, also referred to as hybrid retrieval, combines sparse and dense retrieval methods:

  • Full-text search is a form of sparse retrieval, where documents are represented by the terms they contain and matched on exact keywords and phrases.

  • Vector search is a form of dense retrieval, where documents are represented as embedding vectors and matched by semantic similarity. These embeddings are dense because they have no zero entries.

Refer to Working with Full-text Search and Working with Vector Data for more information.

Reciprocal Rank Fusion

A common approach for combining two or more ranked result sets, such as full-text search and vector search rankings, is reciprocal rank fusion (RRF). RRF is a rank-based reranking method: it uses the rank position of each result rather than the raw scores produced by each search method. This avoids the need to directly combine full-text search scores and vector similarity scores, which are calculated differently and may use different scoring ranges.

The RRF formula for N different ranked lists, where r is the rank of an item in list i, is shown as follows:

In this formula, k is a smoothing factor that reduces the impact of rank differences by making the decrease in weights more gradual. Without a smoothing factor (where k = 0), RRF gives the following weights:

  • Rank 1: 1.0

  • Rank 2: 0.5

  • Rank 3: 0.33

Using a smoothing factor, such as k = 60, produces a more gradual decline:

  • Rank 1: 1/61 ≈ 0.0164

  • Rank 2: 1/62 ≈ 0.0161

  • Rank 10: 1/70 ≈ 0.0143

RRF can be further refined by weighting each ranking differently. For example, weighting full-text search results at 0.7 and vector search results at 0.3 makes exact keyword matches contribute more to the combined score.

Examples

The following examples show how to implement hybrid search in SQL by combining full-text search (VERSION 2), vector search, and RRF.

Hybrid search queries generally follow the same pattern:

  1. Retrieve candidate results using full-text (sparse) retrieval.

  2. Retrieve candidate results using vector (dense) retrieval.

  3. Rank each result set independently.

  4. Rerank the combined results using the RRF formula.

  5. Return the highest-ranked combined results.

In these examples, Common Table Expressions (CTEs, WITH clauses) perform each retrieval method separately, and FULL OUTER JOIN combines their rankings. Example 1 shows an indexed hybrid search over Wikipedia articles. Example 2 combines full-text search, vector search, and a SQL filter.

You can extend this approach using user-defined functions (UDFs), such as SingleStore external functions or Wasm UDFs, or external application code, to implement additional ranking techniques, including late interaction models, cross-encoders, or other reranking approaches. Refer to Choose Between PSQL, Wasm-based, and External Functions for more information.

Example 1 - Hybrid Search of Wikipedia Articles

This example demonstrates indexed hybrid search over a dataset of Wikipedia articles using full-text search and vector search.

SingleStore has created a dataset of 160M vectors and associated paragraphs to simulate vector-based semantic search over all 6.7 million articles in Wikipedia. The video game subset used in this example has real data, with approximately 41,000 vectors from 1,800 articles. The remaining data is sample data. The full dataset, including how to load and generate the vectors, is described in the blog post on ANN search. The dataset is available under the Creative Commons Attribution-ShareAlike License 4.0.

Create a table to store the vectors and paragraphs.

CREATE TABLE vecs(
id BIGINT(20),
url TEXT DEFAULT NULL,
paragraph TEXT DEFAULT NULL,
v VECTOR(1536) NOT NULL,
SHARD KEY(id), KEY(id) USING HASH
);

Load data into this table using the following pipeline.

-- since the bucket is open, you can leave the credentials clause as-is
CREATE OR REPLACE PIPELINE wiki_pipeline AS
load data S3
's3://singlestore-docs-example-datasets/wikipedia-video-game-data/video-game-embeddings.csv'
config '{"region":"us-east-1"}'
credentials '{"aws_access_key_id": "", "aws_secret_access_key": ""}'
SKIP DUPLICATE KEY ERRORS
INTO TABLE vecs
FORMAT csv
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\r\n';
START PIPELINE wiki_pipeline FOREGROUND;

Add a full-text index and a vector index on the table. The vector index is optional – vector similarity queries work with or without indexes. Creating the indexes after loading data is more efficient  than creating them before loading data.

ALTER TABLE vecs ADD FULLTEXT ft_para(paragraph);
ALTER TABLE vecs ADD VECTOR INDEX ivf_v(v)
INDEX_OPTIONS '{"index_type":"IVF_FLAT"}';

Run OPTIMIZE TABLE to ensure the values are indexed.

OPTIMIZE TABLE vecs FLUSH;

The following query searches for rows with information about Super Mario Kart using full-text and vector search:

  • Full-text search finds paragraphs that match the string "super mario kart".

  • Vector search finds paragraphs semantically similar to the introduction of the Super Mario Kart Wikipedia page by comparing embedding vectors.

The variable are set prior to defining the query.

  • @v_mario_kart stores the vector embedding for the Super Mario Kart Wikipedia page.

At a high-level, the query is structured as follows:

  • Full-text search and vector search retrieve candidate results separately using their respective indexes.

  • Each result set is ranked independently.

  • The rankings are combined using the RRF formula, and the top five results are returned.

The query uses CTEs to create intermediate result sets for each stage.

SET @v_mario_kart = (SELECT v FROM vecs
WHERE URL = "https://en.wikipedia.org/wiki/Super_Mario_Kart"
ORDER BY id LIMIT 1);
WITH fts AS ( /* Find top full-text matches. */
SELECT
id,
paragraph,
MATCH(paragraph) AGAINST ("super mario kart") AS SCORE
FROM vecs
WHERE MATCH(paragraph) AGAINST ("super mario kart")
ORDER BY SCORE desc
LIMIT 200
),
vs AS ( /* Find top vector search matches. <*> is dot product. */
SELECT
id,
paragraph,
v <*> @v_mario_kart AS SCORE
FROM vecs
ORDER BY score DESC
LIMIT 200
),
fts_ranked as (
SELECT
*,
RANK() OVER (ORDER BY SCORE DESC) AS fts_rank
FROM fts
),
vs_ranked as (
SELECT
*,
RANK() OVER (ORDER BY SCORE DESC) as vs_rank
FROM vs
)
SELECT
*,
0.7 * (1.0 / (NVL(fts_rank, 1000.0) + 60)) + 0.3 * (1.0 / (NVL(vs_rank, 1000.0) + 60)) as combined_score
FROM fts_ranked f FULL OUTER JOIN vs_ranked v ON f.id = v.id
ORDER BY combined_score DESC
LIMIT 5;

In more detail, this query works as follows:

  • The first two CTEs in the query (fts and vs) create ordered lists of 200 rows that best match the full-text and vector search criteria.

    • The full-text search block (fts) uses inverted index search based on Java Lucene.

    • The vector search block (vs) uses approximate nearest neighbor (ANN) indexing.

  • The next two CTEs use the RANK window function to create ranked lists (fts_ranked and vs_ranked) of these 200 rows.

    • The ranked lists are created in separate CTEs to allow the initial CTEs to use indexed search.

    • If the RANK calculation were performed directly inside the initial fts and vs, it would prevent those CTEs from using index search.

  • The final query block combines the ranked result sets:

    • A FULL OUTER JOIN includes all items appearing in either ranked list. Note that a standard INNER JOIN would discard items that do not appear in both lists.

    • The SELECT clause calculates the final combined_score using RRF and returns the five highest-scoring results.

This query uses a smoothing factor of 60 and weights full-text results at 0.7 and vector search results at 0.3. Any NULL ranks, which can arise from the outer join, are given an artificially high rank of 1000 so they don't contribute meaningfully to the score.

RRF allows strong performance in one search method to offset weaker performance in the other. For example, a result with rank pair (2, 1) receives a strong combined score because it ranks highly in both lists. With the weights used in this query, a result with rank pair (3, 11) still ranks higher overall than one with (5, 8) because the full-text score is weighted more heavily (0.7 versus 0.3).

Example 2 - Hybrid Search of Comments

This example combines hybrid search with a SQL filter. The query retrieves candidate results using full-text search and vector search, restricts both searches to comments in the "Food" category, and then combines the rankings using RRF.

Consider a table of comments that contains:

  • id – an INT id

  • comment – a TEXT field that stores the text of the comment

  • comment_embedding – a VECTOR that stores an embedding capturing the meaning of the comment. as described in Working with Vector Data

  • category – a VARCHAR that stores a category assigned to the comment

Create the comments table and insert insert sample data.

CREATE TABLE comments(id INT,
comment TEXT,
comment_embedding VECTOR(4) NOT NULL,
category VARCHAR(256));
INSERT INTO comments VALUES
(1, "The cafeteria in building 35 has a great salad bar", 
'[0.45, 0.55, 0.495, 0.5]',
    "Food"),
(2, "I love the taco bar in the B16 cafeteria.",
   '[0.01111, 0.01111, 0.1, 0.999]',
    "Food"),
(3, "The B24 restaurant salad bar is quite good.",
    '[0.1, 0.8, 0.2, 0.555]',
"Food");

Add a full-text index to the table and run OPTIMIZE TABLE to ensure the values are indexed.

ALTER TABLE comments ADD FULLTEXT ft_comment(comment);
OPTIMIZE TABLE comments FLUSH;

The following SQL query searches for comments that match the word "restaurant" and are similar to a query vector ('[0.44, 0.554, 0.34, 0.62]') which is intended to represent an embedding of the user's request. In addition, search is restricted to comments in the category "Food".

The following variable are set prior to defining the query.

  • @query_vec stores the vector embedding from the user's request.

Similar to Example 1, the query is structured as follows:

  • Full-text search and vector search retrieve candidate comments separately using their respective indexes.

  • The search is restricted to comments in the "Food" category.

  • Each result set is ranked independently.

  • The rankings are combined using the RRF formula, and the top three results are returned.

The @query_vec variable is cast to VECTOR to ensure that @query_vec is a valid VECTOR and to improve performance.

The @query_vec variable is cast to a VECTOR to ensure that @query_vec is a valid VECTOR and to improve performance.

SET @query_vec = ('[0.44, 0.554, 0.34, 0.62]'):>VECTOR(4);
WITH fts AS ( /* Find top full-text matches. */
SELECT
id,
comment,
MATCH(comment) AGAINST ("restaurant") AS ft_score
FROM comments
WHERE MATCH(comment) AGAINST ("restaurant")
AND category = "Food"
ORDER BY ft_score desc
LIMIT 200
),
vs AS ( /* Find top vector search matches. <*> is dot product. */
SELECT
id,
comment,
@query_vec <*> comment_embedding AS vec_score
FROM comments
WHERE category = "Food"
ORDER BY vec_score desc
LIMIT 200),
fts_ranked as (
SELECT
*,
RANK() OVER (ORDER BY ft_score DESC) AS fts_rank
FROM fts
),
vs_ranked as (
SELECT
*,
RANK() OVER (ORDER BY vec_score DESC) as vs_rank
FROM vs
)
SELECT
*,
0.7 * (1.0 / (NVL(fts_rank, 1000.0) + 60)) + 0.3 * (1.0 / (NVL(vs_rank, 1000.0) + 60)) AS combined_score
FROM fts_ranked f FULL OUTER JOIN vs_ranked v ON f.id = v.id
ORDER BY combined_score DESC
LIMIT 3;
*** 1. row ***
            id: 3
       comment: The B24 restaurant salad bar is quite good.
      ft_score: 0.46706151962280273
      fts_rank: 1
            id: 3
       comment: The B24 restaurant salad bar is quite good.
     vec_score: 0.8993000388145447
       vs_rank: 2
combined_score: 0.016314
*** 2. row ***
            id: NULL
       comment: NULL
      ft_score: NULL
      fts_rank: NULL
            id: 1
       comment: The cafeteria in building 35 has a great salad bar
     vec_score: 0.9810000061988831
       vs_rank: 1
combined_score: 0.005578
*** 3. row ***
            id: NULL
       comment: NULL
      ft_score: NULL
      fts_rank: NULL
            id: 2
       comment: I love the taco bar in the B16 cafeteria.
     vec_score: 0.6644233465194702
       vs_rank: 3
combined_score: 0.005422

In the results, the row with id = 3 has the word "restaurant", so it receives a strong full-text ranking. Because full-text search is weighted more heavily than vector search in this example (0.7 vs. 0.3), this higher full-text ranking contributes more to the final RRF score. If only vector search were used, the comment with id = 1 would rank higher since it has the highest vector similarity score.

Ranking Functions

RRF requires a ranking function in the CTEs. Three ranking functions are available: RANK(), DENSE_RANK(), and ROW_NUMBER(). These functions differ in how they handle ties.

Tied scores are common in full-text search, particularly for short or common query terms. When using RRF, it is important to consider how ties are ranked.

Note

SingleStore recommends using RANK() for the ranking function when using RRF.

The following table describes the ranking functions and their usage.

Function Name

Functionality

Usage

RANK()

Returns the rank of the current row as specified by the ORDER BY clause.

Assigns the same rank to tied rows. Skips subsequent ranks to account for the number of ties.

When ties matter and gaps in ranks are acceptable.

DENSE_RANK()

Returns the rank of the current row as specified by the ORDER BY clause.

Assigns the same rank to tied rows. Does not skip any ranks.

When ties matter and gaps in ranks are not acceptable.

ROW_NUMBER()

Returns the rank of the current row as specified by the ORDER BY clause. 

The assignment of row numbers among ties is non-deterministic.

When a unique ranking number on each output row is needed.

The following table shows an example of the results of the three functions.

Score

RANK()

DENSE_RANK()

ROW_NUMBER()

100

1

1

1

90

2

2

2

90

2

2

3

80

4

3

4

Last modified:

Was this article helpful?

Verification instructions

Note: You must install cosign to verify the authenticity of the SingleStore file.

Use the following steps to verify the authenticity of singlestoredb-server, singlestoredb-toolbox, singlestoredb-studio, and singlestore-client SingleStore files that have been downloaded.

You may perform the following steps on any computer that can run cosign, such as the main deployment host of the cluster.

  1. (Optional) Run the following command to view the associated signature files.

    curl undefined
  2. Download the signature file from the SingleStore release server.

    • Option 1: Click the Download Signature button next to the SingleStore file.

    • Option 2: Copy and paste the following URL into the address bar of your browser and save the signature file.

    • Option 3: Run the following command to download the signature file.

      curl -O undefined
  3. After the signature file has been downloaded, run the following command to verify the authenticity of the SingleStore file.

    echo -n undefined |
    cosign verify-blob --certificate-oidc-issuer https://oidc.eks.us-east-1.amazonaws.com/id/CCDCDBA1379A5596AB5B2E46DCA385BC \
    --certificate-identity https://kubernetes.io/namespaces/freya-production/serviceaccounts/job-worker \
    --bundle undefined \
    --new-bundle-format -
    Verified OK

Try Out This Notebook to See What’s Possible in SingleStore

Get access to other groundbreaking datasets and engage with our community for expert advice.