Skip to content

Retrieval and context

MemHouse runs independent candidate generators in parallel, fuses their ranks, and may rerank the fused head under one wall-clock deadline.

flowchart LR
    Q[Query + scope + profile] --> F{Fan out<br/>under a deadline}
    F --> S1[Semantic<br/>pgvector ANN]
    F --> S2[Lexical<br/>PostgreSQL FTS]
    F --> S3[Temporal]
    F --> S4[SalienceRecency]
    F --> S5[EntityMatch]
    S1 & S2 & S3 & S4 & S5 --> EX[RelationExpand<br/>one hop]
    EX --> FUSION[Score-aware fusion]
    FUSION --> RR{Rerank?}
    RR -->|thorough profile| M[Model-backed rerank<br/>of the fused head]
    RR -->|otherwise| OUT
    M --> OUT[Ranked candidates<br/>+ contributed, empty, and dropped strategies]

Filtering happens before candidates leave retrieval

Each strategy applies Account, scope, lifecycle, expiry, provisional-subject, and source filters inside its query. Active knowledge whose expires_at is not later than the read time is lifecycle-hidden even before the hourly sweeper moves it to the expired state. The API does not post-filter candidates.

A read is performed for a peer

Personal knowledge belongs to its subject, so a read needs a reader. search, ask, get_context, and the knowledge listing all accept peer_key, which names the peer the results are read for. The key is trusted as supplied, the same as on ingest.

The caller Reads for And sees
Names peer_key That peer Public and internal statements, that peer's own statements, statements about the scope rather than about a person, and anything promoted to scope or account level
A machine credential naming no peer Its own Peer The same as a named reader
A peerless credential naming no peer Nobody Public statements only
A password session naming no peer Itself The same as a named reader
Server-side work — projection rebuild, dream-time, evaluation Nobody in particular The whole corpus, narrowed only by lifecycle

Promotion above peer level is the consent record: an above-peer proposal waits until its subject agrees, so a scope-level statement has already been agreed to. See governance.

Naming a reader borrows nothing from it. Scope authorization stays the calling credential's. A peer_key that names no peer is an error, not a fallback to the caller. The server-side posture comes from the absence of an authenticated identity, never from the request, so a caller cannot ask for the whole corpus.

A machine credential reads as its own Peer by default when its authenticated actor has one. This makes knowledge from its observations visible after governance accepts and indexes it. A peerless credential reads public statements only. Neither posture widens scope authorization. Use peer_key when the credential reads for a person.

A shared projection is filtered before it is built. A scope card or an entity card carries shareable statements only — public or internal, about the scope, or promoted — so a personal peer-level statement never reaches a shared projection.

How semantic retrieval embeds a query

The configured embedder can set a model-specific query prefix. MemHouse adds it only to query embeddings. Stored statements and document chunks remain unchanged. For BGE retrieval models, set the prefix to Represent this sentence for searching relevant passages:. Exact repeated queries reuse a bounded node-local vector cache keyed by Account, embedder identity, and a text digest. The cache contains no raw query text and is discarded on restart. Change the retrieval profile version when you change the prefix, so evaluation reports identify the ranking configuration.

How the lexical strategy reads your query

Plain text matches statements sharing any of its terms, ranked by how many of them a statement covers and how closely together. Ask a full question: it does not need every content word to appear in one statement.

For English questions, the lexical analyzer (lexical-question-v2) removes a small reviewed set of interrogative boilerplate. It keeps the remaining query terms, names, dates, negation, and quoted text. It does not expand terms with a hand-written synonym list. A statement that also places two of your terms within eight words of each other earns a bounded bonus, so a sentence that answers the question outranks one that merely mentions the same words. Only the highest-ranked matches compete for that bonus; a statement far down the list cannot be promoted by it. The analyzer version appears in the content-free operator diagnostic, so a ranking can be reproduced without recording query text.

Three operators override that, following PostgreSQL websearch syntax.

Syntax Meaning
"exact phrase" Only statements containing that phrase, in order
-term Excludes statements containing the term
a or b Either term

Using any of them switches the whole query to websearch parsing, where bare terms must all appear in one statement.

Why fusion, and why you must not re-sort

Each strategy scores in its own space: cosine distance, full-text rank, time relevance, salience, mention confidence. Fusion makes them comparable by mapping each returned list's score range from 0 to 1. A singleton or tied list maps to 1 because it has no observed tail.

Each strategy contribution is 95% normalized score and 5% reciprocal-rank tie-break. The profile weight then scales the contribution. The final fusion_score is the weighted mean across configured strategies, so it stays between 0 and 1. The rank term uses the profile's rrf_k, which defaults to 15.

The returned order is the answer

Re-sorting the returned candidates by a raw per-strategy score compares numbers from different scoring spaces and silently degrades results.

Strategy disagreement is computed before fusion, so it measures what the strategies actually thought rather than an artefact of the merge. Fusion always emits a ranked list, including from lists that are all bad, so a fused rank cannot say "nothing was found".

Three per-strategy outcomes

reader_posture reports peer, public_only, or internal. This content-free diagnostic explains which authorization posture narrowed candidates. It does not reveal whether hidden rows would match for another reader.

When lexical terms match active knowledge but reader visibility removes every match, retrieval_outcomes adds candidate_filter with reason class authorization_filtered. It returns no hidden ids, text, scores, or counts. Strategies skipped by their applicability check report not_applicable with reason class applicability.

A response separates strategies that contributed candidates, strategies that ran and found nothing, and strategies that were dropped — disabled, timed out, or failed. Collapsing the middle case into either of the others hides the run worth knowing about: salience_recency reads no query text, so applicability must keep it from turning a text-search miss into a recency page. disagreement.query_dependent_empty is the flag for exactly that state.

Temporal runs only when you pass as_of. It filters to dated text matches and ranks them by distance from that instant. SalienceRecency does not add a general recency list. That keeps the visible head based on evidence about the question. Blank context fallback may still use salience-recency.

Profiles

A profile is a named, versioned bundle: which strategies run, their fusion weights and rank constant, whether the head is reranked, and the deadline.

Profile Strategies Rerank Deadline Used by
fast semantic, salience-recency no 100 ms The only profile allowed to run live when context assembly misses its projection cache
balanced semantic, lexical, temporal, entity-match no 300 ms Default for search
thorough all six, including one-hop relation expansion yes 1500 ms Default for ask
minimal direct/derived semantic lanes and lexical no 300 ms Experimental; requires MEMHOUSE_EXPERIMENTAL_MINIMAL_RECALL=true

The minimal profile deliberately skips temporal and salience seeds, entity matching, relation expansion, and reranking. It does not delete or change their data while experimental. Selection is explicit and observable in the ordinary profile fields, and disabling the feature flag restores the existing profiles without a migration.

Its semantic strategy embeds the query once and searches two independently bounded lists: directly extracted memory and derived deduction/consolidation memory. The lists are interleaved by lane rank, direct first on an exact tie, then enter ordinary fusion with lexical results. Each semantic candidate records its lane, operation, lane rank, and cosine distance so matched evaluation can measure the trade-off without inferring it from prose.

The two lists read RecallDocument, a non-authoritative projection copied from governed Knowledge after embedding. It is excluded from Account archives and is rebuilt with the other retrieval caches. Every lookup joins canonical Knowledge, applies Account, scope, reader, lifecycle, deletion, expiry, and embedding-identity filters before ranking, and requires the projection's source watermark to equal the current Knowledge timestamp. A lifecycle change is therefore invisible immediately; refresh then removes the stale row. Hard erasure cascades to the projection in the same database transaction.

Profiles inherit down the scope tree, nearest-wins, so a scope can tighten or loosen retrieval without a global change. The profile version travels back with every result as profile_version.

deadline_ms covers strategy execution and reranking. Late strategies are dropped, not retried, and reported. Larger deadlines trade latency for recall.

A reranking profile reserves the rerank allowance before the strategies start, and the result reports the reservation as reserved_rerank_ms. Reranking decides which candidates you see; expansion mostly decides which ones you do not. So the strategies are the stage that gives way under time pressure, and a slow one costs recall rather than costing the ordering.

An operator-level allowlist can switch off an expensive strategy across the whole deployment: a strategy absent from it never runs, whatever a profile asks for.

Raw per-request strategy overrides are internal and evaluation-only; external callers cannot select strategies directly.

Evidence lineage is not reasoning text

POST /api/v1/lineage projects existing provenance and typed knowledge relations into a bounded graph. It can connect a deduction or consolidation result back to governed knowledge and immutable observations, but it does not store or expose the model's private rationale. Scope, lifecycle, and expiry filtering happen before a node is returned; a visible derived statement never grants access to a hidden source. An expired knowledge root returns the same opaque not-found result as any other unavailable root, and an expired relation endpoint is reported only as a lifecycle-hidden reference without its id or content.

A split synthesis deduction reuses its durable prompt identity to report the typed operation reasoning_synthesis. The version string itself remains private provenance and is not returned in the graph.

Traversal has depth, per-node fan-out, and total-node budgets. Stable ordering, cycle termination, and opaque missing or lifecycle-hidden references make the same request reproducible without turning hidden content into a diagnostic. Use lineage to inspect evidence. Use lifecycle and audit records to inspect governed state changes. Use answer text only as a cited explanation over those records.

Stable identity is a live projection

POST /api/v1/stable-profile selects a small allowlist of stable, direct, source-backed facts about the chosen reader. It is rebuilt from canonical knowledge on every call and uses no model, so expiry, deletion, retraction, supersession, or a scope change cannot leave a second profile store behind.

The profile keeps contradictory identity claims side by side. It excludes transient state, preferences, behavioral summaries, inferred claims, and sensitive traits. Each entry links to its governed knowledge id and direct source ids; consumers cite that knowledge rather than the projection. Search can include this orientation with include_identity_profile without changing rank. A bounded adaptive Ask may admit those same governed knowledge rows as candidates through its profile tool; it never cites the projection as a separate fact.

Entities are internal

Dream-time entity resolution links aliases such as "Dana", "Dana R.", and "our copy lead" across validated statements.

The mention index rejects closed-class words, common sentence artefacts, and timezone abbreviations. Exact aliases merge directly. Embedding similarity can select a possible match, but a model must confirm every non-exact merge.

Entity rows and mentions are rebuildable, pipeline-internal caches. The rows themselves reach no surface: no canonical name, alias, or entity id appears in HTTP, MCP, SDK, LiveView, projection, or retrieval output.

One exception, and it is bounded by scope. An entity card may name itself with a wording drawn from that card's own source statements in that card's own scope, and may report a kind recomputed from the same wordings. Both are text the card already returns, so neither carries a name across a scope boundary. The entity row is not read to produce them.

Resolution errors affect accuracy, never scope or Account authorization. Erasure and archive import rebuild entities from surviving governed statements.

Rebuilding a scope holds no database connection while it works

Rebuilds use the ingest pipeline's read → model → write shape. Model calls hold no database connection. The final transaction replaces old mentions and writes rebuilt ones together, so failure leaves the previous index intact.

Cross-scope expansion is authorised twice

Scope relations and shared-entity edges can expand retrieval into a linked scope — but only after both endpoint scopes pass the caller's authorisation. A cross-link never grants access; it only follows access the caller already has.

Expansion uses only the head of the interleaved seed lists. Shared-entity edges ignore entities that occur throughout the visible corpus and cap the neighbours contributed by each seed. These bounds prevent one common entity from turning a small result request into a scan of the whole scope.

Vectors carry an identity

An embedding is stored with its provider, model, version, and dimensions. Those four values together are the vector-space identity.

flowchart LR
    Q[Query embedding<br/>provider · model · version · dims] --> C{Identity matches<br/>the stored vectors?}
    C -->|yes| U[Use them]
    C -->|no| RE[Explicit re-embed path]
    RE --> U

A mismatch never silently substitutes or reuses vectors — the numbers are only comparable within one pinned identity. Bump the embedding version whenever the model artefact, tokenizer, pooling, or dimensions change.

Context assembly is reasoning-free

get_context is a different operation from search. It assembles a budgeted context payload — governed knowledge, a session summary, scope cards, and a peer profile — from projections, and it never calls a generation model.

flowchart LR
    R[POST /api/v1/context] --> P{Projection cached?}
    P -->|hit| A[Assemble within the character budget]
    P -->|miss| F["Fast profile runs live<br/>(fast_fallback = true)"]
    F --> A
    A --> O["Payload + projection_cache_hit + fast_fallback"]

Two diagnostic flags come back with every response: projection_cache_hit says a stored projection was reused, and fast_fallback says the projection was missing and the fastest retrieval profile filled in live.

Projection updates preserve dirty marking, bounded delta compaction, source ids, and PubSub/ETS invalidation. A model call does not belong on this read path.

Ask answers with a confidence

By default, fixed ask retrieves with the thorough profile and restricts retrieval to governed knowledge. A named low, medium, or high effort runs the bounded read-only planner over governed knowledge, stable-profile knowledge, and typed lineage. Exact and semantic source-message tools join that closed set only when the caller explicitly passes include_source_recall: true. Profile and lineage are selection tools: answer candidates remain governed knowledge, or bounded immutable source excerpts with resolvable ids when that permission is present. The answerer states what those retrieved statements make most probable and reports answer_confidence, an integer from 0 to 100, for its own certainty.

Each effort preset reserves provider-backed retrieval calls before they run and caps both query tokens and serialized admitted-evidence tokens. Evidence that would cross the preset's total token ceiling is not admitted to the answer context; the content-free recall diagnostics name the exhausted bound. Medium effort retains two thirds of the base head and high effort retains half, which are eight and six items under the default 12-item answer cap. Each always reserves at least one slot for genuinely new tool evidence before refilling unused space from the original ranked tail. The entire base page is reserved for deduplication while tools run, so a rewritten knowledge query cannot count a base candidate as a new discovery. Every knowledge-tool pass uses the same named retrieval profile as the caller's base pass. recall_evidence records the exact resulting order, while candidates remains the compatible base search payload.

A model answer below 50 also sets abstained. That pair — cited answer, low confidence, abstained == true — is the normal shape for a weakly supported inference. Treat it as a lead to check rather than a conclusion to act on.

One reply is not an attempt at the question: when no retrieved statement survives, the response is an empty citation list, abstained == true, and answer_confidence 0. That reports the state of the index, not the subject. An answer invented from an empty candidate set would be worse than silence. Every model citation is intersected with the retrieved candidate ids before the response leaves the server, and no surviving citation means that empty abstention wins.

A failed model call is a third state, distinct from both a weak inference and an empty index: answer_degraded names the failure, abstained == true, and answer_confidence 0. The retrieved statements are real and still returned, in citations and as plain text in supporting_statements, but answer states that the call failed rather than presenting them as a conclusion. A deployment with no model configured is different again — that is a chosen configuration, not a failure, and answers by concatenating the top statements at a fixed answer_confidence of 40 with answer_degraded left null.