Skip to main content
doc0blade47/mem0documentation home
Sign in

Multi-Signal Retrieval

Last updated Oct 11, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Multi-Signal Retrieval combines vector similarity, BM25 keyword matching, entity boosting, and threshold filtering to surface relevant memories. By integrating multiple scoring dimensions, the retrieval engine resolves semantic nuance that single-pass vector search often misses.

Sources: mem0/memory/main.py:1648-1701, mem0/utils/scoring.py:60-139

Hybrid Search Architecture

BM25 Parameter Selection and Score Normalization

Query-length-adaptive sigmoid parameters are retrieved via get_bm25_params, which adjusts midpoint and steepness based on the term count of the lemmatized query to account for higher raw BM25 score distributions in longer inputs.

def get_bm25_params(query: str, *, lemmatized: Optional[str] = None) -> tuple:
    """Get BM25 sigmoid parameters based on query length.
 
    Longer queries tend to have higher raw BM25 scores, so we adjust
    the sigmoid midpoint and steepness accordingly.
 
    Returns:
        (midpoint, steepness) for sigmoid normalization.
    """
    if lemmatized is None:
        from mem0.utils.lemmatization import lemmatize_for_bm25
 
        lemmatized = lemmatize_for_bm25(query)
    num_terms = len(lemmatized.split()) if lemmatized else 1
 
    if num_terms <= 3:
        return 5.0, 0.7
    elif num_terms <= 6:
        return 7.0, 0.6
    elif num_terms <= 9:
        return 9.0, 0.5
    elif num_terms <= 15:
        return 10.0, 0.5
    else:
        return 12.0, 0.5
 

Sources: mem0/utils/scoring.py:16-41

Raw unbounded BM25 scores are converted to a normalized range of [0, 1] via a logistic sigmoid function using normalize_bm25.

def normalize_bm25(raw_score: float, midpoint: float, steepness: float) -> float:
    """Normalize BM25 score to [0, 1] using logistic sigmoid.
 
    Args:
        raw_score: Raw BM25 score (unbounded, typically 0-20+).
        midpoint: Score at which sigmoid outputs 0.5.
        steepness: Controls how quickly sigmoid transitions.
 
    Returns:
        Normalized score in range [0, 1].
    """
    return 1.0 / (1.0 + math.exp(-steepness * (raw_score - midpoint)))
 

Sources: mem0/utils/scoring.py:43-55

Additive Scoring and Threshold Gates

Candidate memories are ranked and combined in score_and_rank. The threshold parameter gates the semantic score prior to combining signals, excluding candidates below the threshold even if lexical or entity boosts would otherwise raise them.

def score_and_rank(
    semantic_results: List[Dict[str, Any]],
    bm25_scores: Dict[str, float],
    entity_boosts: Dict[str, float],
    threshold: float,
    top_k: int,
    explain: bool = False,
) -> List[Dict[str, Any]]:
    """Score candidates additively and return top-k results.
 
    For each candidate:
        semantic_score is taken from the result's score field.
        combined = (semantic + bm25 + entity_boost) / max_possible
 
    Threshold gates the semantic score BEFORE combining -- candidates
    below the threshold are excluded even if BM25/entity would boost them.
 
    The divisor adapts based on which signals are active:
        - Semantic only: max_possible = 1.0
        - Semantic + BM25: max_possible = 2.0
        - Semantic + BM25 + entity: max_possible = 2.5
        - Semantic + entity (no BM25): max_possible = 1.5
 
    Args:
        semantic_results: Candidate memories from vector search.
        bm25_scores: Normalized keyword scores keyed by memory ID.
        entity_boosts: Entity-link boosts keyed by memory ID.
        threshold: Minimum semantic score required before hybrid scoring.
        top_k: Maximum number of results to return.
        explain: Include score_details in each result when true.
 
    Returns:
        List of scored result dicts sorted by combined score descending.
    """
    has_bm25 = bool(bm25_scores)
    has_entity = bool(entity_boosts)
 
    max_possible = 1.0
    if has_bm25:
        max_possible += 1.0
    if has_entity:
        max_possible += ENTITY_BOOST_WEIGHT
 

Sources: mem0/utils/scoring.py:60-102

The divisor (max_possible) adapts dynamically depending on which active signals contribute to the score calculation:

Active SignalsMax Possible Divisor (max_possible)
Semantic only1.0
Semantic + BM252.0
Semantic + entity (no BM25)1.5
Semantic + BM25 + entity2.5

Sources: mem0/utils/scoring.py:77-101

Vector Store Delegation and Chat Retrieval

Vector search backends such as GoogleMatchingEngine handle similarity searches by embedding the query vector and executing search, subsequently wrapping payloads into Document and score pairs.

def similarity_search_with_score(
    self,
    query: str,
    k: int = 5,
    filter: Optional[Dict] = None,
) -> List[Tuple[Document, float]]:
    """Return documents most similar to query with scores."""
    logger.debug("Starting similarity search with score")
    logger.debug("Query: %s", query)
    logger.debug("k: %d", k)
    logger.debug("Filter: %s", filter)
 
    embedding = self.embedder.embed_query(query)
    results = self.search(query=query, vectors=embedding, top_k=k, filters=filter)
 
    docs_and_scores = [
        (Document(page_content=result.payload.get("text", ""), metadata=result.payload), result.score)
        for result in results
    ]
    logger.debug("Found %d results", len(docs_and_scores))
    return docs_and_scores
 

Sources: mem0/vector_stores/vertex_ai_vector_search.py:609-630

Note

GoogleMatchingEngine explicitly does not support index reset operations, logging a warning and returning immediately when reset is called.

def reset(self):
    """
    Reset the Google Matching Engine index.
    """
    logger.warning("Reset operation is not supported for Google Matching Engine")
    pass

Sources: mem0/vector_stores/vertex_ai_vector_search.py:642-647

Chat proxy completions utilize _fetch_relevant_memories to pass the last 6 message turns from the conversation history into the client's search API.

def _fetch_relevant_memories(self, messages, user_id, agent_id, run_id, filters, top_k):
    # Currently, only pass the last 6 messages to the search API to prevent long query
    message_input = [f"{message['role']}: {message['content']}" for message in messages][-6:]
    # TODO: Make it better by summarizing the past conversation
    return self.mem0_client.search(
        query="\n".join(message_input),
        user_id=user_id,
        agent_id=agent_id,
        run_id=run_id,
        filters=filters,
        top_k=top_k,
    )

Sources: mem0/proxy/main.py:163-174

Lexical Lemmatization Pipeline

spaCy-Based Text Normalization

Query strings and payload texts are processed by lemmatize_for_bm25 in mem0/utils/lemmatization.py to standardize token forms for BM25 matching. The pipeline loads a spaCy model via get_nlp_lemma and converts the input text to lowercase before tokenization.

def lemmatize_for_bm25(text: str) -> str:
    """Lemmatize text for BM25 matching.
 
    Returns space-joined lemmas for full-text search. Falls back to
    the original text if spaCy is unavailable.
    """
    from mem0.utils.spacy_models import get_nlp_lemma
 
    nlp = get_nlp_lemma()
    if nlp is None:
        return text
 
    doc = nlp(text.lower())

Sources: mem0/utils/lemmatization.py:22-34

Tokens identified as punctuation (token.is_punct) or stopwords (token.is_stop) are filtered out. For remaining tokens, the lemmatizer extracts token.lemma_ and verifies that it is alphanumeric via lemma.isalnum().

Note

If the spaCy lemma model returns None, lemmatize_for_bm25 skips tokenization entirely and returns the raw input text unmodified.

if nlp is None:
    return text

Sources: mem0/utils/lemmatization.py:31-32

Ambiguity Handling for -ing Forms

To prevent context-dependent lemmatization discrepancies (such as treating "meeting" as a noun versus a verb), the pipeline inspects the raw token text. If token.text ends with "ing", differs from the computed lemma, and is alphanumeric, the original surface form is appended to the token list alongside its lemma.

# This handles noun/verb ambiguity (meeting/meet, attending/attend).
if token.text.endswith("ing") and token.text != lemma and token.text.isalnum():
    tokens.append(token.text)

Sources: mem0/utils/lemmatization.py:46-48

The resulting filtered tokens are joined with single spaces and returned as a unified string for full-text keyword indexing and search evaluation.

return " ".join(tokens)

Sources: mem0/utils/lemmatization.py:50-50

BM25 Scoring and Normalization

Query-Length-Adaptive Sigmoid Parameters

Raw BM25 scores are unbounded and typically range from zero past twenty depending on corpus characteristics and term frequency distributions. To scale these lexical values into a standardized range, Mem0 applies a logistic sigmoid normalization function. Because longer search queries inherently accumulate higher raw term frequency scores, the engine adapts its normalization midpoint and steepness dynamically based on the term count of the lemmatized query string.

def get_bm25_params(query: str, *, lemmatized: Optional[str] = None) -> tuple:
    """Get BM25 sigmoid parameters based on query length.
 
    Longer queries tend to have higher raw BM25 scores, so we adjust
    the sigmoid midpoint and steepness accordingly.
 
    Returns:
        (midpoint, steepness) for sigmoid normalization.
    """
    if lemmatized is None:
        from mem0.utils.lemmatization import lemmatize_for_bm25
 
        lemmatized = lemmatize_for_bm25(query)
    num_terms = len(lemmatized.split()) if lemmatized else 1
 
    if num_terms <= 3:
        return 5.0, 0.7
    elif num_terms <= 6:
        return 7.0, 0.6
    elif num_terms <= 9:
        return 9.0, 0.5
    elif num_terms <= 15:
        return 10.0, 0.5
    else:
        return 12.0, 0.5
 

Sources: mem0/utils/scoring.py:16-41

The query term count determines the sigmoid configuration thresholds according to specific length boundaries:

Query Term Count (num_terms)MidpointSteepness
1 to 35.00.7
4 to 67.00.6
7 to 99.00.5
10 to 1510.00.5
Greater than 1512.00.5

Sources: mem0/utils/scoring.py:31-40

Note

If the optional lemmatized query parameter is omitted when calling get_bm25_params, the function invokes lemmatize_for_bm25 directly on the raw query string to compute term lengths accurately.

if lemmatized is None:
    from mem0.utils.lemmatization import lemmatize_for_bm25
 
    lemmatized = lemmatize_for_bm25(query)

Sources: mem0/utils/scoring.py:25-28

Logistic Sigmoid Transformation

Once the midpoint and steepness parameters are resolved for a given query, normalize_bm25 transforms the raw score into a bounded interval between zero and one using the logistic function formula.

def normalize_bm25(raw_score: float, midpoint: float, steepness: float) -> float:
    """Normalize BM25 score to [0, 1] using logistic sigmoid.
 
    Args:
        raw_score: Raw BM25 score (unbounded, typically 0-20+).
        midpoint: Score at which sigmoid outputs 0.5.
        steepness: Controls how quickly sigmoid transitions.
 
    Returns:
        Normalized score in range [0, 1].
    """
    return 1.0 / (1.0 + math.exp(-steepness * (raw_score - midpoint)))

Sources: mem0/utils/scoring.py:43-54

def normalize_bm25(raw_score: float, midpoint: float, steepness: float) -> float:
    return 1.0 / (1.0 + math.exp(-steepness * (raw_score - midpoint)))

Sources: mem0/utils/scoring.py:43-54

Entity Boosting Mechanisms

Entity Weight Configuration and Additive Scoring

Candidate memories passing the semantic threshold can be reinforced through entity-linking boosts keyed by memory identifier strings. The scoring engine defines a constant weight modifier for entity associations, setting ENTITY_BOOST_WEIGHT to 0.5 to calibrate how strongly matched entity links influence the final aggregated score alongside semantic and BM25 signals.

ENTITY_BOOST_WEIGHT = 0.5

Sources: mem0/utils/scoring.py:57-57

During the score_and_rank execution, the engine dynamically determines the normalization divisor (max_possible) based on which active signals are present in the current retrieval pass. This divisor ensures that combined scores remain bounded correctly between zero and one regardless of whether lexical or entity signals are enabled.

Active SignalsDivisor (max_possible) FormulaMaximum Possible Score
Semantic only1.01.0
Semantic + BM251.0 + 1.02.0
Semantic + Entity1.0 + ENTITY_BOOST_WEIGHT1.5
Semantic + BM25 + Entity1.0 + 1.0 + ENTITY_BOOST_WEIGHT2.5

Sources: mem0/utils/scoring.py:77-82, mem0/utils/scoring.py:97-102

Warning

The threshold check is applied strictly to the vector search semantic_score before hybrid combining takes place. Candidates falling below the configured threshold are discarded immediately, preventing BM25 or entity boosts from rescuing low-similarity memories.

Threshold gates the semantic score BEFORE combining -- candidates
below the threshold are excluded even if BM25/entity would boost them.

Sources: mem0/utils/scoring.py:74-75

Explanation and Score Auditing

When the explain flag is enabled on score_and_rank, each returned result dictionary includes a score_details sub-dictionary that breaks down individual component contributions. This audit dictionary exposes the exact intermediate values computed during entity boosting and normalization.

if explain:
    scored_result["score_details"] = {
        "semantic_score": semantic_score,
        "bm25_score": bm25_score,
        "entity_boost": entity_boost,
        "raw_score": raw_combined,
        "max_possible_score": max_possible,
        "final_score": combined,
        "threshold": threshold,
    }

Sources: mem0/utils/scoring.py:126-135

scored_result["score_details"] = {
    "semantic_score": semantic_score,
    "bm25_score": bm25_score,
    "entity_boost": entity_boost,
    "raw_score": raw_combined,
    "max_possible_score": max_possible,
    "final_score": combined,
    "threshold": threshold,
}

Sources: mem0/utils/scoring.py:127-134

Tip

The final combined score is capped at 1.0 using min(raw_combined / max_possible, 1.0) to guarantee that score distributions do not violate upper bounds when multiple signals peak simultaneously.

raw_combined = semantic_score + bm25_score + entity_boost
combined = min(raw_combined / max_possible, 1.0)

Sources: mem0/utils/scoring.py:118-119

Temporal Decay and Recency Weighting

Additive Hybrid Scoring Components

The hybrid scoring architecture in mem0/utils/scoring.py combines semantic similarity scores from vector search with normalized BM25 keyword matching scores and entity-linking boosts. Each candidate memory's identifier is checked against active signal dictionaries to aggregate raw component values before normalization.

mem_id_str = str(mem_id)
bm25_score = bm25_scores.get(mem_id_str, 0.0)
entity_boost = entity_boosts.get(mem_id_str, 0.0)
 
raw_combined = semantic_score + bm25_score + entity_boost

Sources: mem0/utils/scoring.py:114-118

Note

Candidate memory entries lacking an id field or failing to meet the minimum semantic threshold are excluded from score aggregation entirely, regardless of their auxiliary keyword or entity boost values.

mem_id = result.get("id")
if mem_id is None:
    continue
 
semantic_score = result.get("score") or 0.0
if semantic_score < threshold:
    continue

Sources: mem0/utils/scoring.py:106-112

Score Auditing and Explanation Details

When explain=True is passed to score_and_rank, the resulting dictionary contains a comprehensive breakdown of every individual contribution and scaling factor used during score computation.

if explain:
    scored_result["score_details"] = {
        "semantic_score": semantic_score,
        "bm25_score": bm25_score,
        "entity_boost": entity_boost,
        "raw_score": raw_combined,
        "max_possible_score": max_possible,
        "final_score": combined,
        "threshold": threshold,
    }

Sources: mem0/utils/scoring.py:126-135

Multi-Signal Rank Fusion

Final Score Aggregation and Ranking

The score_and_rank function aggregates the individual components by summing the semantic similarity score, the normalized BM25 keyword score, and the entity boost. The raw combined sum is divided by the dynamic max_possible denominator and clamped to an upper bound of 1.0.

raw_combined = semantic_score + bm25_score + entity_boost
combined = min(raw_combined / max_possible, 1.0)

Sources: mem0/utils/scoring.py:118-119

Once all valid candidates are processed, the collection of scored result dictionaries is sorted in descending order by their final normalized score, and truncated to the requested top_k limit.

scored.sort(key=lambda x: x["score"], reverse=True)
return scored[:top_k]

Sources: mem0/utils/scoring.py:138-139

Caution

Candidates that do not contain an id attribute are skipped silently during iteration, and those falling below the configured similarity threshold are filtered out before combining occurs.

mem_id = result.get("id")
if mem_id is None:
    continue
 
semantic_score = result.get("score") or 0.0
if semantic_score < threshold:
    continue

Sources: mem0/utils/scoring.py:106-112

Was this page helpful?