Lexical memory search

Use POST /api/v1/memory/search/lexical to retrieve durable memories by BM25. This mode needs text and an authorized scope, with no embedding model or vector. It is useful for identifiers, error codes and exact words in memory content. For combined lexical and semantic evidence, use hybrid retrieval.

Send a lexical query#

{
  "contract_version": 1,
  "mode": "lexical",
  "scope": {"project_id":"project","mission_id":null,"agent_id":"agent","visibility":"shared"},
  "query": {
    "text": "ZX17 retry failure",
    "limit": 10,
    "scan_limit": 10000,
    "scan_bytes_limit": 8388608,
    "after": null,
    "episode_type": null,
    "tag": null
  }
}

Send a platform bearer credential with memory_read and an exact grant for the scope. The response contains contract_version: 1, scope, mode: "lexical", ranking_version: "bm25_v1" and page. Existing durable memories are searchable without an index migration or an embedding attachment.

Read results and coverage#

Each page.hits entry contains its current record and positive raw BM25 score. Scores order results; they are not probabilities. Equal scores use ascending record UUID. matched_records counts matches before result truncation, corpus_records counts visible records passing filters, and scanned_records includes deleted, expired and filtered source rows examined. scanned_bytes counts serialized source records admitted to the scan.

exhaustive is true only when a cursorless request scans the whole authorized scope. next_after is the last scanned source UUID if another row remains. This cursor traverses source rows, not ranked results, and each continuation uses a new snapshot. Continued pages always report exhaustive: false.

BM25 statistics are calculated from the filtered scanned corpus. Do not merge scores from separate pages into a global ranking. Narrow the scope or increase the scan budget, within implementation limits, when a complete ranking is needed. The exclusive cursor prevents duplicate source UUIDs across forward pages, but concurrent writes can change subsequent pages. This is a full scan, not an inverted index or a promise of sublinear query time.

Scoring and validation#

bm25_v1 splits primary, secondary and context text on non-alphanumeric Unicode characters, lowercases terms and deduplicates query terms. It uses no stemming, stopword removal or canonical Unicode normalization. It does not search metadata. Document length includes all tokens in those fields. The fixed formula uses k1 = 1.2, b = 0.75 and positive inverse document frequency:

idf(term) = ln(1 + (N - df(term) + 0.5) / (df(term) + 0.5))
score = sum(idf(term) * tf(term) * 2.2 /
            (tf(term) + 1.2 * (0.25 + 0.75 * length / average_length)))

N and df use only visible records passing the exact scope and query filters. Data in another tenant, project, mission, agent or private subject cannot affect scores. All reads use one RocksDB snapshot and visibility timestamp. There is no cross-scope search cache. Deleted and expired records contribute neither hits nor statistics. Source corruption fails the request instead of dropping a candidate.

Field Accepted values
text 1–4096 UTF-8 bytes with 1–64 distinct lowercase alphanumeric terms
limit 1–100 results
scan_limit 1–10000 source records, default 10000
scan_bytes_limit 1–268435456 bytes, subject to the operator ceiling (default 67108864); request default 8388608
after Optional exclusive source UUID cursor
episode_type, tag Optional exact filters, applied before statistics

The byte budget excludes keys, integrity indexes, allocator overhead and one lookahead row. It is not a process memory limit. A budget that cannot fit its first source row returns 400 rather than a non-advancing cursor. These ceilings bound this implementation; they are not configurable enterprise quotas.

Malformed requests, punctuation-only text, mode mismatch, invalid limits and unknown fields return 400. Missing, revoked or expired credentials return 401; missing capability or scope returns 403; oversized JSON bodies return 413; encountered storage inconsistency returns 500. Use the shared error envelope. JSON requests have a 65536-byte body limit.

Measure retrieval time#

The response includes timing.retrieval_micros, the server wall time spent in the retrieval method. It excludes authentication, blocking-pool queueing, JSON serialization, transport and external embedding generation. Measure the client round trip separately. See the reproducible evaluation workflow for frozen corpora, graded relevance, category regressions and timing limits.

Execution capacity#

The ranking catalog exposes the current server dimension, scan-byte and concurrent retrieval limits. These are operator settings, separate from the immutable ranking profile and from tenant authorization. A byte budget above the configured ceiling returns 400 (retrieval_scan_limit); exhausted retrieval slots return 503 (retrieval_busy). Use bounded backoff and inspect coverage on each successful response. See configure retrieval capacity.