diff --git a/CLAUDE.md b/CLAUDE.md index 2f32b231..99b7260d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,6 +263,7 @@ JIT_ENRICHMENT_ENABLED=true # Run entity/summary extraction in # Search scoring weights (all floats, see docs/ENVIRONMENT_VARIABLES.md) SEARCH_WEIGHT_VECTOR=0.35 # Semantic similarity via Qdrant SEARCH_WEIGHT_KEYWORD=0.35 # Keyword/TF-IDF matching +SEARCH_WEIGHT_METADATA=0.35 # Metadata sidecar match score SEARCH_WEIGHT_RELATION=0.25 # Graph relationship boost SEARCH_WEIGHT_TAG=0.20 # Tag overlap scoring SEARCH_WEIGHT_EXACT=0.20 # Exact phrase in metadata diff --git a/app.py b/app.py index 776ed07f..58347e14 100644 --- a/app.py +++ b/app.py @@ -179,6 +179,7 @@ def _parse_viewer_allowed_origins() -> Any: from automem.search.runtime_keywords import load_keyword_runtime from automem.search.runtime_recall_helpers import ( _graph_keyword_search, + _metadata_keyword_search, _result_passes_filters, _vector_filter_only_tag_search, _vector_search, diff --git a/automem/api/recall.py b/automem/api/recall.py index 7097cb73..7a0b48aa 100644 --- a/automem/api/recall.py +++ b/automem/api/recall.py @@ -15,6 +15,7 @@ FILTERABLE_RELATIONS, RECALL_ADAPTIVE_FLOOR, RECALL_EXPANSION_LIMIT, + RECALL_METADATA_SEARCH_ENABLED, RECALL_MIN_SCORE, RECALL_RELATION_LIMIT, canonicalize_relation_type, @@ -1417,6 +1418,7 @@ def handle_recall( expansion_limit_default: Optional[int] = None, on_access: Optional[Callable[[List[str]], None]] = None, jit_enrich_fn: Optional[Callable[[str, Dict[str, Any]], Optional[Dict[str, Any]]]] = None, + metadata_keyword_search: Optional[Callable[..., List[Dict[str, Any]]]] = None, ): query_start = time.perf_counter() query_text = (request.args.get("query") or "").strip() @@ -1666,6 +1668,27 @@ def _run_single_query( ) local_results.extend(graph_matches[:remaining_slots]) + if ( + graph is not None + and metadata_keyword_search is not None + and query_str + and RECALL_METADATA_SEARCH_ENABLED + ): + metadata_slots = max(1, min(per_query_limit, 10)) + metadata_matches = metadata_keyword_search( + graph, + query_str, + metadata_slots, + local_seen, + start_time=start_time, + end_time=end_time, + tag_filters=tag_filters, + tag_mode=tag_mode, + tag_match=tag_match, + exclude_tags=exclude_tags, + ) + local_results.extend(metadata_matches) + tags_only_request = ( not query_str and not (embedding_param and embedding_param.strip()) @@ -2139,6 +2162,7 @@ def create_recall_blueprint( summarize_relation_node: Callable[[Dict[str, Any]], Dict[str, Any]] | None = None, on_access: Optional[Callable[[List[str]], None]] = None, jit_enrich_fn: Optional[Callable[[str, Dict[str, Any]], Optional[Dict[str, Any]]]] = None, + metadata_keyword_search: Optional[Callable[..., List[Dict[str, Any]]]] = None, ) -> Blueprint: bp = Blueprint("recall", __name__) @@ -2170,6 +2194,7 @@ def recall_memories() -> Any: expansion_limit_default=RECALL_EXPANSION_LIMIT, on_access=on_access, jit_enrich_fn=jit_enrich_fn, + metadata_keyword_search=metadata_keyword_search, ) @bp.route("/startup-recall", methods=["GET"]) diff --git a/automem/api/runtime_bootstrap.py b/automem/api/runtime_bootstrap.py index a15da06b..5972eacc 100644 --- a/automem/api/runtime_bootstrap.py +++ b/automem/api/runtime_bootstrap.py @@ -68,6 +68,7 @@ def register_blueprints( consolidation_tick_seconds: int, consolidation_history_limit: int, require_api_token_fn: Callable[[], None], + metadata_keyword_search_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, ) -> None: health_bp = create_health_blueprint( get_memory_graph_fn, @@ -106,6 +107,7 @@ def register_blueprints( summarize_relation_node_fn, update_last_accessed_fn, jit_enrich_fn=jit_enrich_fn, + metadata_keyword_search=metadata_keyword_search_fn, ) memory_bp = create_memory_blueprint_full( diff --git a/automem/api/runtime_recall_routes.py b/automem/api/runtime_recall_routes.py index b80dfdf5..a2d9b403 100644 --- a/automem/api/runtime_recall_routes.py +++ b/automem/api/runtime_recall_routes.py @@ -28,6 +28,7 @@ def recall_memories( emit_event_fn: Any, utc_now_fn: Any, abort_fn: Any, # noqa: ARG001 - kept for DI compatibility + metadata_keyword_search_fn: Any = None, ) -> Any: query_start = perf_counter_fn() query_text = (request_obj.args.get("query") or "").strip() @@ -66,6 +67,7 @@ def recall_memories( default_expand_relations=default_expand_relations, relation_limit=recall_relation_limit, expansion_limit_default=recall_expansion_limit, + metadata_keyword_search=metadata_keyword_search_fn, ) elapsed_ms = int((perf_counter_fn() - query_start) * 1000) diff --git a/automem/config.py b/automem/config.py index d5153acb..432972f9 100644 --- a/automem/config.py +++ b/automem/config.py @@ -139,6 +139,13 @@ RECALL_EXPANSION_LIMIT = int(os.getenv("RECALL_EXPANSION_LIMIT", "25")) RECALL_MIN_SCORE = float(os.getenv("RECALL_MIN_SCORE", "0.0")) RECALL_ADAPTIVE_FLOOR = os.getenv("RECALL_ADAPTIVE_FLOOR", "true").lower() in ("true", "1", "yes") +RECALL_METADATA_SEARCH_ENABLED = os.getenv( + "RECALL_METADATA_SEARCH_ENABLED", "true" +).lower() not in { + "0", + "false", + "no", +} # Memory content size limits (governs auto-summarization on store) # Soft limit: Content above this triggers auto-summarization @@ -447,6 +454,7 @@ def expand_relation_query_types(relation_types: Iterable[str]) -> list[str]: # Search weighting parameters (can be overridden via environment variables) SEARCH_WEIGHT_VECTOR = float(os.getenv("SEARCH_WEIGHT_VECTOR", "0.35")) SEARCH_WEIGHT_KEYWORD = float(os.getenv("SEARCH_WEIGHT_KEYWORD", "0.35")) +SEARCH_WEIGHT_METADATA = float(os.getenv("SEARCH_WEIGHT_METADATA", "0.35")) SEARCH_WEIGHT_TAG = float(os.getenv("SEARCH_WEIGHT_TAG", "0.2")) SEARCH_WEIGHT_IMPORTANCE = float(os.getenv("SEARCH_WEIGHT_IMPORTANCE", "0.1")) SEARCH_WEIGHT_CONFIDENCE = float(os.getenv("SEARCH_WEIGHT_CONFIDENCE", "0.05")) diff --git a/automem/runtime_wiring.py b/automem/runtime_wiring.py index 672b7d5b..9b709e72 100644 --- a/automem/runtime_wiring.py +++ b/automem/runtime_wiring.py @@ -77,6 +77,7 @@ def wire_recall_and_blueprints( consolidation_tick_seconds=module.CONSOLIDATION_TICK_SECONDS, consolidation_history_limit=module.CONSOLIDATION_HISTORY_LIMIT, require_api_token_fn=module.require_api_token, + metadata_keyword_search_fn=module._metadata_keyword_search, ) diff --git a/automem/search/runtime_recall_helpers.py b/automem/search/runtime_recall_helpers.py index be2c0788..40e2d03b 100644 --- a/automem/search/runtime_recall_helpers.py +++ b/automem/search/runtime_recall_helpers.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import Any, Callable, Dict, List, Optional +import json +import re +import unicodedata +from typing import Any, Callable, Dict, Iterable, List, Optional from flask import abort, request from qdrant_client import QdrantClient @@ -17,11 +20,324 @@ _logger: Any = None _collection_name: str = "" +METADATA_SEARCH_FIELDS = ( + "source", + "source_agent", + "source_agents", + "repo", + "project", + "tool", + "surface", + "applies_to", + "trigger", + "provider", + "model", + "entities", +) +# Safety guard: these must never become searchable even if a future edit adds +# them to METADATA_SEARCH_FIELDS. None of them are in the whitelist today. +METADATA_SKIP_FIELDS = { + "original_content", + "enrichment", + "semantic_neighbors", + "patterns_detected", +} +METADATA_FIELD_ALIASES: dict[str, tuple[str, ...]] = { + "source": ("source",), + "source_agent": ("source agent", "source agents"), + "source_agents": ("source agents", "source agent"), + "repo": ("repo", "repository"), + "project": ("project",), + "tool": ("tool",), + "surface": ("surface",), + "applies_to": ("applies to", "apply to"), + "trigger": ("trigger",), + "provider": ("provider",), + "model": ("model",), + "entities": ("entity", "entities"), +} +METADATA_QUERY_STOPWORDS = { + "all", + "any", + "about", + "by", + "find", + "for", + "from", + "in", + "me", + "memory", + "memories", + "of", + "on", + "please", + "show", + "that", + "the", + "to", + "with", +} +METADATA_FIELD_TOKENS = { + "source", + "agent", + "agents", + "repo", + "repository", + "project", + "tool", + "surface", + "applies", + "apply", + "trigger", + "provider", + "model", + "entity", + "entities", + "metadata", +} +METADATA_PREFILTER_MAX_TERMS = 12 +METADATA_SCAN_LIMIT_MIN = 200 +METADATA_SCAN_LIMIT_MAX = 1000 +MAX_METADATA_STRING_LENGTH = 96 +MAX_METADATA_ARRAY_LENGTH = 12 + def _normalize_tags(tags: List[Any]) -> List[str]: return [str(tag).strip().lower() for tag in tags if isinstance(tag, str) and str(tag).strip()] +def _parse_metadata_for_search(value: Any) -> Dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + decoded = json.loads(value) + except json.JSONDecodeError: + return {} + return decoded if isinstance(decoded, dict) else {} + return {} + + +def _ascii_search_text(value: Any) -> str: + text = unicodedata.normalize("NFKD", str(value)) + text = text.encode("ascii", "ignore").decode("ascii") + text = re.sub(r"[^A-Za-z0-9]+", " ", text) + return re.sub(r"\s+", " ", text).strip().lower() + + +def _search_tokens(value: Any) -> set[str]: + normalized = _ascii_search_text(value) + return {token for token in re.findall(r"[a-z0-9]+", normalized) if len(token) >= 2} + + +def _ordered_search_tokens(value: Any) -> list[str]: + normalized = _ascii_search_text(value) + seen: set[str] = set() + tokens: list[str] = [] + for token in re.findall(r"[a-z0-9]+", normalized): + if len(token) < 2 or token in seen: + continue + seen.add(token) + tokens.append(token) + return tokens + + +def _iter_scalar_metadata_values(value: Any) -> Iterable[str]: + if isinstance(value, str): + stripped = value.strip() + if stripped and len(stripped) <= MAX_METADATA_STRING_LENGTH: + yield stripped + return + if isinstance(value, (int, float)) and not isinstance(value, bool): + yield str(value) + return + if isinstance(value, (list, tuple, set)): + values = list(value) + if len(values) > MAX_METADATA_ARRAY_LENGTH: + return + for item in values: + yield from _iter_scalar_metadata_values(item) + + +def _iter_metadata_search_values(metadata: Dict[str, Any]) -> Iterable[tuple[str, str]]: + for field in METADATA_SEARCH_FIELDS: + if field in METADATA_SKIP_FIELDS or field not in metadata: + continue + raw = metadata.get(field) + if field == "entities": + if not isinstance(raw, dict): + continue + for category, values in raw.items(): + category_text = str(category).strip().lower() + if not category_text: + continue + # Entity people are content-derived and noisy personal names; + # they are always excluded from sidecar matching. + if category_text == "people": + continue + if isinstance(values, dict): + continue + for item in _iter_scalar_metadata_values(values): + yield f"entities.{category_text}", item + continue + if isinstance(raw, dict): + continue + for item in _iter_scalar_metadata_values(raw): + yield field, item + + +def _metadata_prefilter_terms(query_text: str) -> list[str]: + terms = _ordered_search_tokens(query_text) + value_terms = [ + term + for term in terms + if term not in METADATA_FIELD_TOKENS and term not in METADATA_QUERY_STOPWORDS + ] + return value_terms[:METADATA_PREFILTER_MAX_TERMS] + + +def _requested_metadata_fields(query_text: str) -> set[str]: + normalized = _ascii_search_text(query_text) + if not normalized: + return set() + + padded = f" {normalized} " + requested: set[str] = set() + phrase_fields: set[str] = set() + for field, aliases in METADATA_FIELD_ALIASES.items(): + for alias in aliases: + alias_text = _ascii_search_text(alias) + if " " in alias_text and f" {alias_text} " in padded: + requested.add(field) + phrase_fields.add(field) + + tokens = set(normalized.split()) + for field, aliases in METADATA_FIELD_ALIASES.items(): + if field in phrase_fields: + continue + for alias in aliases: + alias_text = _ascii_search_text(alias) + if " " in alias_text: + continue + if alias_text in tokens: + if alias_text == "source" and ( + "source_agent" in requested or "source_agents" in requested + ): + continue + requested.add(field) + + return requested + + +def _metadata_field_requested(field: str, requested_fields: set[str]) -> bool: + if not requested_fields: + return True + base = field.split(".", 1)[0] + if base in {"source_agent", "source_agents"}: + return bool({"source_agent", "source_agents"} & requested_fields) + if base == "entities": + return "entities" in requested_fields + return base in requested_fields + + +def _metadata_value_has_strong_evidence( + *, + value_hits: set[str], + value_tokens: set[str], + query_value_tokens: set[str], + exact_hit: bool, + field_requested: bool, + requested_fields: set[str], +) -> bool: + if len(value_tokens) > 1 and len(value_hits) >= min(2, len(value_tokens)): + return True + + if len(value_hits) != 1: + return False + + hit = next(iter(value_hits)) + if field_requested and requested_fields and exact_hit and len(hit) >= 3: + return True + if len(hit) < 5: + return False + if field_requested and requested_fields: + return True + return exact_hit and len(query_value_tokens) <= 3 + + +def _metadata_match_score(query_text: str, metadata: Dict[str, Any]) -> tuple[float, list[str]]: + query_tokens = _search_tokens(query_text) + if not query_tokens: + return 0.0, [] + query_value_tokens = { + token + for token in query_tokens + if token not in METADATA_FIELD_TOKENS + and token not in METADATA_QUERY_STOPWORDS + and len(token) >= 3 + } + if not query_value_tokens: + return 0.0, [] + + requested_fields = _requested_metadata_fields(query_text) + normalized_query = _ascii_search_text(query_text) + matched_values: list[str] = [] + best_score = 0.0 + + for field, value in _iter_metadata_search_values(metadata): + value_text = _ascii_search_text(value) + value_tokens = _search_tokens(value) + if not value_text or not value_tokens: + continue + + value_hits = query_value_tokens & value_tokens + exact_hit = value_text in normalized_query + if not value_hits: + continue + + field_requested = _metadata_field_requested(field, requested_fields) + # Generated entities are frequently content-derived; keep them out of the + # general sidecar path unless the query explicitly asks for entity metadata. + if field.startswith("entities.") and "entities" not in requested_fields: + continue + # Repo queries are high-cardinality and often share owner/suffix tokens + # like "verygoodplugins" and "mcp"; require the value to cover the + # requested repo terms unless the full normalized value is present. + if field == "repo" and "repo" in requested_fields and not exact_hit: + repo_query_tokens = query_value_tokens - value_tokens + if repo_query_tokens: + continue + if requested_fields and not field_requested and len(value_hits) < 2: + continue + if not _metadata_value_has_strong_evidence( + value_hits=value_hits, + value_tokens=value_tokens, + query_value_tokens=query_value_tokens, + exact_hit=exact_hit, + field_requested=field_requested, + requested_fields=requested_fields, + ): + continue + + value_ratio = len(value_hits) / max(len(value_tokens), 1) + query_ratio = len(value_hits) / max(len(query_value_tokens), 1) + score = min( + 1.0, + 0.15 + + (0.45 * value_ratio) + + (0.20 * query_ratio) + + (0.15 if exact_hit else 0.0) + + (0.20 if requested_fields and field_requested else 0.0), + ) + if requested_fields and not field_requested: + score *= 0.6 + if score > best_score: + best_score = score + matched_values.append(f"{field}: {value}") + + return best_score, matched_values + + def configure_recall_helpers( *, parse_iso_datetime: Callable[[Any], Optional[Any]], @@ -382,6 +698,135 @@ def _graph_keyword_search( return matches +def _metadata_keyword_search( + graph: Any, + query_text: str, + limit: int, + seen_ids: set[str], + start_time: Optional[str] = None, + end_time: Optional[str] = None, + tag_filters: Optional[List[str]] = None, + tag_mode: str = "any", + tag_match: str = "prefix", + exclude_tags: Optional[List[str]] = None, +) -> List[Dict[str, Any]]: + prepare_tag_filters = _prepare_tag_filters + build_graph_tag_predicate = _build_graph_tag_predicate + serialize_node = _serialize_node + fetch_relations = _fetch_relations + logger = _logger + if ( + prepare_tag_filters is None + or build_graph_tag_predicate is None + or serialize_node is None + or fetch_relations is None + or logger is None + ): + raise RuntimeError("recall helpers are not configured") + + normalized = query_text.strip().lower() + if not normalized or normalized == "*" or limit <= 0: + return [] + + keywords = _metadata_prefilter_terms(normalized) + if not keywords: + return [] + + try: + base_where = ["m.metadata IS NOT NULL", "coalesce(m.archived, false) = false"] + scan_limit = min(max(limit * 25, METADATA_SCAN_LIMIT_MIN), METADATA_SCAN_LIMIT_MAX) + params: Dict[str, Any] = {"limit": scan_limit, "keywords": keywords} + if start_time: + base_where.append("m.timestamp >= $start_time") + params["start_time"] = start_time + if end_time: + base_where.append("m.timestamp <= $end_time") + params["end_time"] = end_time + if tag_filters: + normalized_filters = prepare_tag_filters(tag_filters) + if normalized_filters: + base_where.append(build_graph_tag_predicate(tag_mode, tag_match)) + params["tag_filters"] = normalized_filters + + query = f""" + MATCH (m:Memory) + WHERE {' AND '.join(base_where)} + WITH m, toLower(m.metadata) AS metadata_text + UNWIND $keywords AS kw + WITH m, metadata_text, kw, + CASE WHEN metadata_text CONTAINS kw THEN 1 ELSE 0 END AS kw_score + WITH m, SUM(kw_score) AS score + WHERE score > 0 + RETURN m, score + ORDER BY score DESC, m.importance DESC, m.timestamp DESC + LIMIT $limit + """ + result = graph.query(query, params) + except Exception: + logger.exception("Graph metadata keyword search failed") + return [] + + candidates: List[Dict[str, Any]] = [] + for row in getattr(result, "result_set", []) or []: + node = row[0] + data = serialize_node(node) + memory_id = str(data.get("id")) if data.get("id") is not None else None + if not memory_id or memory_id in seen_ids: + continue + if data.get("archived"): + continue + metadata = _parse_metadata_for_search(data.get("metadata")) + match_score, _ = _metadata_match_score(query_text, metadata) + if match_score <= 0: + continue + + record: Dict[str, Any] = { + "id": memory_id, + "score": match_score, + "match_score": match_score, + "match_type": "metadata", + "source": "graph", + "memory": data, + "relations": [], + "score_components": {"metadata": match_score}, + } + if not _result_passes_filters( + record, + start_time, + end_time, + tag_filters, + tag_mode, + tag_match, + exclude_tags, + ): + continue + candidates.append(record) + + candidates.sort( + key=lambda record: ( + float(record.get("match_score") or 0.0), + float((record.get("memory") or {}).get("importance") or 0.0), + str((record.get("memory") or {}).get("timestamp") or ""), + ), + reverse=True, + ) + + matches: List[Dict[str, Any]] = [] + for record in candidates: + memory_id = str(record.get("id") or "") + if not memory_id or memory_id in seen_ids: + continue + seen_ids.add(memory_id) + # Relations are fetched only for kept records — candidates can number in + # the hundreds while the trimmed result is at most `limit`. + record["relations"] = fetch_relations(graph, memory_id) + matches.append(record) + if len(matches) >= limit: + break + + return matches + + def _vector_filter_only_tag_search( qdrant_client: Optional[QdrantClient], tag_filters: Optional[List[str]], diff --git a/automem/utils/scoring.py b/automem/utils/scoring.py index 20991a19..dbc3b2e5 100644 --- a/automem/utils/scoring.py +++ b/automem/utils/scoring.py @@ -9,6 +9,7 @@ SEARCH_WEIGHT_EXACT, SEARCH_WEIGHT_IMPORTANCE, SEARCH_WEIGHT_KEYWORD, + SEARCH_WEIGHT_METADATA, SEARCH_WEIGHT_RECENCY, SEARCH_WEIGHT_RELATION, SEARCH_WEIGHT_RELEVANCE, @@ -167,6 +168,12 @@ def _compute_metadata_score( content_hits = sum(1 for token in tokens if token in content_tokens) keyword_component = content_hits / len(tokens) + metadata_component = ( + float(result.get("match_score", 0.0) or 0.0) + if result.get("match_type") == "metadata" + else 0.0 + ) + relation_component = 0.0 if result.get("match_type") == "relation": relation_component = float( @@ -187,6 +194,7 @@ def _compute_metadata_score( final = ( SEARCH_WEIGHT_VECTOR * vector_component + SEARCH_WEIGHT_KEYWORD * keyword_component + + SEARCH_WEIGHT_METADATA * metadata_component + SEARCH_WEIGHT_RELATION * relation_component + SEARCH_WEIGHT_TAG * tag_score + SEARCH_WEIGHT_IMPORTANCE * importance_score @@ -200,6 +208,7 @@ def _compute_metadata_score( components = { "vector": vector_component, "keyword": keyword_component, + "metadata": metadata_component, "relation": relation_component, "tag": tag_score, "importance": importance_score, diff --git a/docs/API.md b/docs/API.md index 6ed390d2..c2864fab 100644 --- a/docs/API.md +++ b/docs/API.md @@ -60,6 +60,7 @@ Recall - `current_only` remains supported for backward compatibility and wins over `state_mode` when both resolve to a value. A malformed `state_mode` is still rejected with `400` even when `current_only` is supplied — the value is validated before precedence is applied. - `state_debug=true` includes suppression/replacement details in `state_filter`. - **Context hints**: `context`, `language`, `active_path`, `context_tags`, `context_types`, `priority_ids` + - **Metadata sidecar search**: Text queries can admit bounded metadata candidates when the query strongly matches whitelisted metadata values. This is enabled by default and controlled by `RECALL_METADATA_SEARCH_ENABLED`; no extra request parameter is required. - **Graph expansion**: - `expand_relations` - Follow graph edges from seed results to related memories - `expand_entities` - Multi-hop reasoning via entity tags (finds "Amanda → Rachel" then "Rachel's job") diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 0edbce2b..6f7e6ac4 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -313,6 +313,7 @@ Controls how different factors are weighted in memory recall scoring. |----------|-------------|---------|-------| | `SEARCH_WEIGHT_VECTOR` | Semantic similarity | `0.35` | Vector search via Qdrant | | `SEARCH_WEIGHT_KEYWORD` | Keyword matching | `0.35` | Graph keyword hits plus content-token fallback for vector-sourced results | +| `SEARCH_WEIGHT_METADATA` | Metadata sidecar match | `0.35` | Candidates admitted via the metadata sidecar channel (see `RECALL_METADATA_SEARCH_ENABLED`) | | `SEARCH_WEIGHT_RELATION` | Graph relationship boost | `0.25` | Memories connected via edges | | `SEARCH_WEIGHT_TAG` | Tag matching | `0.20` | Tag overlap scoring | | `SEARCH_WEIGHT_EXACT` | Exact phrase match | `0.20` | Full query in metadata | @@ -351,6 +352,7 @@ Background worker that checks FalkorDB ↔ Qdrant consistency. |----------|-------------|---------| | `RECALL_RELATION_LIMIT` | Max related memories per seed in graph expansion | `5` | | `RECALL_EXPANSION_LIMIT` | Total max expansion results (relations + entities) | `25` | +| `RECALL_METADATA_SEARCH_ENABLED` | Enable bounded metadata sidecar recall candidates | `true` | --- diff --git a/docs/METADATA_BEHAVIOR.md b/docs/METADATA_BEHAVIOR.md new file mode 100644 index 00000000..185e496d --- /dev/null +++ b/docs/METADATA_BEHAVIOR.md @@ -0,0 +1,86 @@ +# Metadata Behavior + +This document describes the current end-to-end behavior of the `metadata` object +on memories. It is intentionally a product/runtime spec, not an experiment note. + +## Storage + +- `POST /memory` accepts `metadata` only when it is a JSON object. Missing + metadata is stored as `{}`; non-object metadata is rejected with `400`. +- FalkorDB stores metadata as a JSON string on `m.metadata`. +- Qdrant stores metadata as a parsed object in the point payload. +- If server-side content summarization runs, the original content audit fields + are added to metadata: `original_content`, `was_summarized`, and + `original_length`. +- Metadata is not included in the primary content embedding. The stored vector + remains an embedding of the memory content. + +## Recall Response Shape + +- Recall results include parsed `memory.metadata` when the graph or Qdrant + payload provides it. +- `json` and detailed recall formats expose the same memory metadata object in + result payloads; malformed graph metadata is treated as an empty or raw parsed + value depending on the caller path. +- Final scoring can use metadata terms as weak evidence for candidates that are + already present from another channel. + +## Search + +- Metadata search is an additive `/recall` candidate channel. It does not change + the HTTP API and does not require a user-visible metadata mode. +- The sidecar channel is enabled by default with + `RECALL_METADATA_SEARCH_ENABLED=true`. Set it to `false`, `0`, or `no` to + restore baseline behavior without metadata-sidecar candidates. +- Metadata search runs only for text queries and is bounded to a small candidate + budget. Results are deduplicated against vector and keyword candidates before + final ranking. +- Candidate admission requires value evidence against whitelisted metadata + fields. Field words such as `repo` or `source agent` are scoring context, not a + hard trigger. +- Searchable metadata fields are `source`, `source_agent`, `source_agents`, + `repo`, `project`, `tool`, `surface`, `applies_to`, `trigger`, `provider`, + `model`, and structured `entities`. +- Structured `entities` are searched only when the query has entity-field + context. Enrichment-generated entities are often derived from content, so they + are not used as a general hidden-metadata sidecar signal. +- Entity people are excluded from metadata sidecar search by default to avoid + noisy personal-name matching even when entity-field context is present. +- The sidecar skips `original_content`, `enrichment`, `semantic_neighbors`, + `patterns_detected`, dict-valued non-entity fields, long strings, large arrays, + and unstructured entity lists. +- Tag filters, exclude-tag filters, time windows, archive state, and current + state suppression still apply to metadata candidates. +- Metadata candidates expose `match_type: "metadata"` and populate + `score_components.metadata`. + +## Update + +- `PATCH /memory/{id}` preserves existing metadata when the request omits the + `metadata` field. +- When a patch includes `metadata`, the provided object replaces the previous + metadata object. There is no deep merge for arbitrary user metadata today. +- If content changes, the vector is regenerated from the new content only. If + content is unchanged, the existing vector is preserved while Qdrant payload + metadata is refreshed. + +## Enrichment + +- Async and just-in-time enrichment parse existing metadata and merge generated + entity data into `metadata.entities`. +- Enrichment also writes operational details under `metadata.enrichment`. +- Enrichment can add entity tags and tag prefixes, but metadata sidecar search + does not depend on those generated tags. + +## Consolidation + +- Decay updates `relevance_score`; forget may archive or delete memories and + sync those state changes to Qdrant. +- Creative consolidation adds graph relationships. Identity consolidation + deduplicates Entity nodes and can synthesize entity identity summaries. +- Cluster consolidation may create `MetaMemory` nodes and `SUMMARIZES` + relationships. +- Current consolidation does not merge two ordinary memory records into one + replacement record, so there is no general-purpose memory-metadata merge policy + yet. If future consolidation introduces memory merging, provenance fields such + as `source_agents` should be merged intentionally rather than overwritten. diff --git a/tests/test_metadata_recall_search.py b/tests/test_metadata_recall_search.py new file mode 100644 index 00000000..ff4dc15f --- /dev/null +++ b/tests/test_metadata_recall_search.py @@ -0,0 +1,415 @@ +import json +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import Mock + +import app +import automem.api.recall as recall_api +from automem.api.recall import handle_recall +from automem.search.runtime_recall_helpers import _metadata_keyword_search, configure_recall_helpers +from automem.utils.scoring import _compute_metadata_score +from automem.utils.text import _extract_keywords +from tests.support.fake_graph import FakeGraph + + +def _serialize_node(node): + return dict(getattr(node, "properties", node)) + + +def _configure_metadata_helpers(fetch_relations=None) -> None: + configure_recall_helpers( + parse_iso_datetime=lambda value: ( + datetime.fromisoformat(str(value).replace("Z", "+00:00")) if value else None + ), + prepare_tag_filters=lambda tags: [ + str(tag).strip().lower() for tag in (tags or []) if isinstance(tag, str) and tag.strip() + ], + build_graph_tag_predicate=lambda _mode, _match: "true", + build_qdrant_tag_filter=lambda *_args, **_kwargs: None, + serialize_node=_serialize_node, + fetch_relations=fetch_relations or (lambda *_args, **_kwargs: []), + extract_keywords=_extract_keywords, + coerce_embedding=lambda _value: None, + generate_real_embedding=lambda _text: [0.1, 0.2, 0.3], + logger=SimpleNamespace(exception=lambda *_args, **_kwargs: None), + collection_name="memories", + ) + + +def test_handle_recall_runs_metadata_sidecar_for_strong_non_field_queries() -> None: + calls = [] + + def metadata_keyword_search(_graph, query_text, *_args, **_kwargs): + calls.append(query_text) + return [ + { + "id": "meta-1", + "score": 0.8, + "match_score": 0.8, + "match_type": "metadata", + "source": "graph", + "memory": { + "id": "meta-1", + "content": "Recall planning note.", + "tags": [], + "metadata": {"source_agent": "hub-developer"}, + }, + "relations": [], + } + ] + + with app.app.test_request_context("/recall?query=hub-developer%20run%20notes&limit=5"): + response = handle_recall( + get_memory_graph=lambda: object(), + get_qdrant_client=lambda: None, + normalize_tag_list=lambda value: value if isinstance(value, list) else [], + normalize_timestamp=lambda value: value, + parse_time_expression=lambda _value: (None, None), + extract_keywords=_extract_keywords, + compute_metadata_score=lambda result, _query, _tokens, _context: ( + float(result["match_score"]), + {"metadata": float(result["match_score"])}, + ), + result_passes_filters=lambda *_args, **_kwargs: True, + graph_keyword_search=lambda *_args, **_kwargs: [], + vector_search=lambda *_args, **_kwargs: [], + vector_filter_only_tag_search=lambda *_args, **_kwargs: [], + metadata_keyword_search=metadata_keyword_search, + recall_max_limit=50, + logger=Mock(), + ) + + data = response.get_json() + assert calls == ["hub-developer run notes"] + assert data["results"][0]["id"] == "meta-1" + assert data["results"][0]["match_type"] == "metadata" + + +def test_handle_recall_skips_metadata_sidecar_when_disabled() -> None: + metadata_keyword_search = Mock(return_value=[]) + previous = recall_api.RECALL_METADATA_SEARCH_ENABLED + recall_api.RECALL_METADATA_SEARCH_ENABLED = False + + try: + with app.app.test_request_context("/recall?query=hub-developer%20run%20notes"): + handle_recall( + get_memory_graph=lambda: object(), + get_qdrant_client=lambda: None, + normalize_tag_list=lambda value: value if isinstance(value, list) else [], + normalize_timestamp=lambda value: value, + parse_time_expression=lambda _value: (None, None), + extract_keywords=_extract_keywords, + compute_metadata_score=lambda result, _query, _tokens, _context: ( + float(result.get("match_score", 0.0)), + {}, + ), + result_passes_filters=lambda *_args, **_kwargs: True, + graph_keyword_search=lambda *_args, **_kwargs: [], + vector_search=lambda *_args, **_kwargs: [], + vector_filter_only_tag_search=lambda *_args, **_kwargs: [], + metadata_keyword_search=metadata_keyword_search, + recall_max_limit=50, + logger=Mock(), + ) + finally: + recall_api.RECALL_METADATA_SEARCH_ENABLED = previous + + metadata_keyword_search.assert_not_called() + + +def test_metadata_match_type_uses_metadata_score_component() -> None: + score, components = _compute_metadata_score( + { + "match_type": "metadata", + "match_score": 0.8, + "memory": { + "content": "Recall planning note.", + "metadata": {"source_agent": "hub-developer"}, + }, + }, + "memories with source agent hub-developer", + ["memories", "source", "agent", "hub-developer"], + ) + + assert components["metadata"] == 0.8 + assert components["keyword"] == 0.0 + assert score > 0 + + +def test_metadata_keyword_search_matches_whitelisted_hidden_metadata() -> None: + _configure_metadata_helpers() + graph = FakeGraph() + graph.memories["meta-1"] = { + "id": "meta-1", + "content": "Recall planning note.", + "tags": ["automem"], + "metadata": json.dumps( + { + "source_agent": "hub-developer", + "original_content": "source agent forbidden-noise", + "entities": {"people": ["Hub Developer"], "organizations": ["AutoMem"]}, + } + ), + "importance": 0.4, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + graph.memories["people-only"] = { + "id": "people-only", + "content": "Unrelated note.", + "tags": ["automem"], + "metadata": {"entities": {"people": ["Hub Developer"]}}, + "importance": 0.9, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + results = _metadata_keyword_search( + graph, + "memories with source agent hub-developer", + 10, + set(), + tag_filters=["automem"], + ) + + assert [result["id"] for result in results] == ["meta-1"] + assert results[0]["match_type"] == "metadata" + assert results[0]["score_components"]["metadata"] > 0 + + +def test_metadata_keyword_search_does_not_require_field_words() -> None: + _configure_metadata_helpers() + graph = FakeGraph() + graph.memories["meta-1"] = { + "id": "meta-1", + "content": "Recall planning note.", + "tags": ["automem"], + "metadata": {"source_agent": "hub-developer"}, + "importance": 0.4, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + results = _metadata_keyword_search( + graph, + "hub developer", + 10, + set(), + tag_filters=["automem"], + ) + + assert [result["id"] for result in results] == ["meta-1"] + + +def test_metadata_keyword_search_fetches_relations_only_for_returned_results() -> None: + relation_calls: list[str] = [] + + def counting_fetch_relations(_graph, memory_id, *_args, **_kwargs): + relation_calls.append(str(memory_id)) + return [] + + _configure_metadata_helpers(fetch_relations=counting_fetch_relations) + graph = FakeGraph() + for index in range(8): + graph.memories[f"meta-{index}"] = { + "id": f"meta-{index}", + "content": "Recall planning note.", + "tags": ["automem"], + "metadata": {"source_agent": "hub-developer"}, + "importance": 0.4, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + results = _metadata_keyword_search( + graph, + "hub developer", + 3, + set(), + tag_filters=["automem"], + ) + + assert len(results) == 3 + assert sorted(relation_calls) == sorted(result["id"] for result in results) + + +def test_metadata_keyword_search_uses_field_words_as_scoring_context() -> None: + _configure_metadata_helpers() + graph = FakeGraph() + graph.memories["generic-entity"] = { + "id": "generic-entity", + "content": "General MCP note.", + "tags": ["automem"], + "metadata": {"entities": {"organizations": ["MCP"]}}, + "importance": 0.95, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + graph.memories["repo-target"] = { + "id": "repo-target", + "content": "Unrelated implementation note.", + "tags": ["automem"], + "metadata": {"repo": "verygoodplugins/streamdeck-mcp"}, + "importance": 0.2, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + results = _metadata_keyword_search( + graph, + "repo verygoodplugins streamdeck mcp", + 10, + set(), + tag_filters=["automem"], + ) + + assert [result["id"] for result in results] == ["repo-target"] + + +def test_metadata_keyword_search_rejects_partial_repo_field_matches() -> None: + _configure_metadata_helpers() + graph = FakeGraph() + graph.memories["other-repo"] = { + "id": "other-repo", + "content": "WhatsApp implementation note.", + "tags": ["automem"], + "metadata": {"repo": "verygoodplugins/whatsapp-mcp"}, + "importance": 0.95, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + graph.memories["repo-target"] = { + "id": "repo-target", + "content": "Stream Deck implementation note.", + "tags": ["automem"], + "metadata": {"repo": "verygoodplugins/streamdeck-mcp"}, + "importance": 0.2, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + results = _metadata_keyword_search( + graph, + "repo verygoodplugins streamdeck mcp", + 10, + set(), + tag_filters=["automem"], + ) + + assert [result["id"] for result in results] == ["repo-target"] + + +def test_metadata_keyword_search_allows_short_values_when_field_is_explicit() -> None: + _configure_metadata_helpers() + graph = FakeGraph() + graph.memories["repo-target"] = { + "id": "repo-target", + "content": "Unrelated implementation note.", + "tags": ["automem"], + "metadata": {"repo": "mcp"}, + "importance": 0.2, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + graph.memories["generic-entity"] = { + "id": "generic-entity", + "content": "General MCP note.", + "tags": ["automem"], + "metadata": {"entities": {"organizations": ["MCP"]}}, + "importance": 0.95, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + results = _metadata_keyword_search( + graph, + "repo mcp", + 10, + set(), + tag_filters=["automem"], + ) + + assert [result["id"] for result in results] == ["repo-target"] + + +def test_metadata_keyword_search_skips_entities_without_entity_field_context() -> None: + _configure_metadata_helpers() + graph = FakeGraph() + graph.memories["entity-only"] = { + "id": "entity-only", + "content": "Unrelated note.", + "tags": ["automem"], + "metadata": {"entities": {"organizations": ["Root Cause"]}}, + "importance": 0.95, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + results = _metadata_keyword_search( + graph, + "root cause", + 10, + set(), + tag_filters=["automem"], + ) + + assert results == [] + + +def test_metadata_keyword_search_allows_entities_with_entity_field_context() -> None: + _configure_metadata_helpers() + graph = FakeGraph() + graph.memories["entity-only"] = { + "id": "entity-only", + "content": "Unrelated note.", + "tags": ["automem"], + "metadata": {"entities": {"organizations": ["Root Cause"]}}, + "importance": 0.95, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + results = _metadata_keyword_search( + graph, + "entities organizations root cause", + 10, + set(), + tag_filters=["automem"], + ) + + assert [result["id"] for result in results] == ["entity-only"] + + +def test_metadata_keyword_search_rejects_weak_single_token_noise() -> None: + _configure_metadata_helpers() + graph = FakeGraph() + graph.memories["weak"] = { + "id": "weak", + "content": "Unrelated operational note.", + "tags": ["automem"], + "metadata": {"source": "blog"}, + "importance": 0.9, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + results = _metadata_keyword_search( + graph, + "Which blog posts were recently published?", + 10, + set(), + tag_filters=["automem"], + ) + + assert results == [] + + +def test_metadata_keyword_search_respects_tag_filters() -> None: + _configure_metadata_helpers() + graph = FakeGraph() + for memory_id, tags in (("allowed", ["automem"]), ("blocked", ["other"])): + graph.memories[memory_id] = { + "id": memory_id, + "content": "Recall planning note.", + "tags": tags, + "metadata": {"repo": "verygoodplugins/automem"}, + "importance": 0.5, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + results = _metadata_keyword_search( + graph, + "memories from repo verygoodplugins automem", + 10, + set(), + tag_filters=["automem"], + ) + + assert [result["id"] for result in results] == ["allowed"]