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 --> RRF[Weighted reciprocal-rank fusion]
RRF --> 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, provisional-subject, and source filters inside its query. The API does not post-filter candidates.
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-v1) removes a
small reviewed set of interrogative boilerplate and adds only the explicit
destress / stress / relax / calming / therapeutic synonym group. It
keeps names, dates, negation, and quoted text. 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. Those numbers are not comparable.
Fusion merges ranks, not scores. A candidate at rank r contributes
weight / (k + r), with the baseline-compatible k = 60.
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¶
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: temporal and salience_recency read no query
text, so applicability must keep them from turning a text-search miss into a
recency page.
disagreement.query_dependent_empty is the flag for exactly that state.
For ordinary text searches, Temporal runs only when you pass as_of, and
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, and explicit historical reads retain temporal recall.
Profiles¶
A profile is a named, versioned bundle: which strategies run, their fusion weights, 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 |
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.
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.
Entities are internal¶
Dream-time entity resolution links aliases such as "Dana", "Dana R.", and "our copy lead" across validated statements.
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.
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¶
ask retrieves with the thorough profile, restricts retrieval to knowledge
items so that citations are governed statements, and answers over what it
found. It does not refuse: it states what the retrieved statements make most
probable and reports answer_confidence, an integer from 0 to 100, for its own
certainty.
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.