---
title: Diagnose poor retrieval
description: Where to look when search returns the wrong thing, nothing errors, and the store looks fine, plus the full error-code table.
---

Three commands cover almost every case:

```bash
memhtml status           # HEAD, dirty paths, counts by type, edges, index freshness, embedder state
memhtml index status     # the watermark, the vector space, per-table row counts
memhtml doctor
```

## The four symptoms, and what each one means

`indexFresh: false` calls for `memhtml index update --embed`. The index describes one commit, and "fresh" means that commit is HEAD (`apps/cli/src/operations.ts:1524`). A stale index returns old answers rather than wrong ones, and the memory you wrote an hour ago is absent from them.

`embedderUp: false` means one of two things: the stored vectors came from a different embedding model than the configured one, or the index holds no vectors at all (`apps/cli/src/operations.ts:1530`). The field is read off the stored watermark rather than by calling Bedrock, so it never fails for a reason unrelated to the corpus. Compare `embedModel` against `configuredEmbedModel` on `memhtml index status`. If they differ, the fix is in [rebuild the index](/learn/operations/rebuild-the-index/). If they agree and `embeddings` is 0, run `memhtml index rebuild --embed`.

`degraded: true` on a search response means the vector arm did not fire. Search still works: it ranked with three of its four arms, using full-text search, recency, and salience while vector similarity sat out. The `vectorCoverage` field beside it says which of two things happened. A low value, under `MEMHTML_VECTOR_COVERAGE_FLOOR` (default `0.95`), means only that share of the index's chunks carry a vector and search dropped the arm on purpose: a sparse vector plane is worse than none, because the few embedded files collect a vector rank on top of their recency rank and outrank every exact match, and the arm did fire so nothing said `degraded`. That was the shape of a production incident where an `index rebuild --no-embed` followed by incremental updates left 2 percent of chunks embedded, all on the newest files, and every query returned the newest files. Run `memhtml index embed` to backfill the missing vectors, or `memhtml index rebuild --embed`. A high value with `degraded: true` means the query embedder returned nothing: check `MEMHTML_AWS_REGION` and the Bedrock credential. If `MEMHTML_EMBED=off` is set, that is your answer and someone chose it.

`memhtml doctor` reports the same ratio as `vectorCoverage` and flips `healthy` to false under `vectorCoverageLow`, naming the remedy in `vectorCoverageRemedy`. The finding applies only when the vector plane is in use, meaning some vector exists or an embedder is configured. A store run with `MEMHTML_EMBED=off` and no vectors is the deliberate lexical-only configuration and stays healthy. A sleep run warns below the same floor and refuses to run below `0.5`, the way it refuses a mixed vector space.

Quality feels wrong while nothing errors, so run `memhtml eval discriminate`. That command is the discrimination gate: it checks that each probe query ranks its target fact above deliberately wrong versions of the same fact. It tells you whether the ranking stack is broken or the corpus never held the answer, which reading search output cannot. See [check the discrimination gate](/learn/operations/check-the-discrimination-gate/).

## A search returns an empty result, never a driver error

Query text goes through `sanitizeFtsQuery` before it reaches `MATCH` (`packages/index/src/fts-query.ts:59`), because several forms common in prose are hard driver errors in SQLite's full-text search rather than empty results:

* an apostrophe, as in `don't`;
* a `type:name` entity reference such as `service:checkout-api`, which is exactly the form a hit's `entities` publishes;
* a leading hyphen, which the full-text search engine reads as negation.

A query that errors means something bypassed the sanitizer. Report that as a bug, and leave your own side unsanitized.

## A long query finds less than a short one

It should not. The lexical arm tries the query's words as all-required first and, when no memory in scope holds every word, as any-of ranked by bm25 (`packages/index/src/fts-query.ts:97`, `packages/index/src/retrieval.ts:328`). A sentence with one proper noun in it finds that noun's memory even when the rest of the sentence appears nowhere in the corpus. If a long query still misses, the words it shares with the memory are not in that memory's title, gist, or body, which is all the lexical index holds; confirm with `memhtml search "<the one word you are sure of>"`. To demand adjacency rather than any-of, quote the span: `"drain the vip"` matches those three words in that order and nothing else.

## The tree is dirty and sleep refuses

Preflight calls `requireCleanTree()` (`packages/sleep/src/phases/preflight.ts:22`) and fails with `ERR_DIRTY_TREE`, listing the paths. A phase that read the index while the tree held uncommitted edits would curate a corpus nobody else has. The refusal lands in phase one, so it costs nothing.

```bash
git -C "$MEMHTML_ROOT" status --porcelain
memhtml index update --embed      # the indexer DOES read dirty paths
# then commit or stash, and re-run
```

The indexer reads the dirty tree so your edit is searchable immediately, and sleep refuses the same tree so curation never runs against a state only you can see.

## Empty results with a cause outside the ranker

Before you suspect the ranker, read three fields off the search envelope:

* `scopeEmpty: true` means a scope was named, it narrowed the query, and nothing survived. Look for a typo in a `--workspace` or an `--entity` value. The field is never true for an unscoped empty result. Read `archivedMatches` beside it: a non-zero count means the scope still resolves to memories in `archive/`, and `archived[].supersededBy` names what replaced each, so retry with `--include-archived` or read the superseding path rather than concluding the record was never written.
* `entityScope` echoes the scope back, so you can attribute an empty result. An `--entity` scope that matches nothing returns no hits and says so, and it never widens on its own.
* `hits: []` with `degraded: false` means the corpus does not contain it. Try `--include-archived`: eviction is a `git mv`, so an archived memory still exists and search excludes it by default.

Also remember that search excludes `task` memories by default. Use `memhtml task list` for the task working set, which is a direct indexed scan with blockers rather than ranked retrieval.

## Error codes

```
ERR_UNKNOWN_COMMAND  ERR_MISSING_ARGUMENT  ERR_INVALID_FLAG           ERR_UNEXPECTED_ARGUMENT
ERR_REPO_REQUIRED    ERR_PATH_NOT_FOUND    ERR_INVALID_MEMORY         ERR_DUPLICATE_CONTENT
ERR_WRITE_CONFLICT   ERR_DIRTY_TREE        ERR_INDEX_STALE            ERR_EMBED_MODEL_MISMATCH
ERR_MODEL_UNAVAILABLE ERR_STORAGE          ERR_GIT                    ERR_DISCRIMINATION_FAILED
ERR_UNKNOWN          ERR_REBUILD_NO_EMBED_REFUSED
```

Twenty-one codes (`apps/cli/src/envelope.ts:67`), append-only: a shipped code keeps its meaning forever and is never removed.

`ERR_REPO_REQUIRED` is the exit-2 refusal `MEMHTML_REFUSE_ENV_ROOT` produces for a call that opens a repo without `--repo`; see [configure the environment](/learn/operations/configure-the-environment/#memhtml_refuse_env_root-closes-the-environment-door).

`ERR_UNEXPECTED_ARGUMENT` (`apps/cli/src/envelope.ts:74`) is a positional past what the command declares, and it carries its own code rather than reusing a neighbour's because it is a different mistake from either. It is not `ERR_INVALID_FLAG`, since the offending token is not a flag; and it is not `ERR_MISSING_ARGUMENT`, since the argument is surplus rather than absent, so the fix is dropping a word rather than adding one. `memhtml read a.html b.html` reads one memory, and without this code it would say nothing at all about the second.

`ERR_REBUILD_NO_EMBED_REFUSED` is `memhtml index rebuild --no-embed` over a store that already carries vectors. The call is declined because a store with vectors was embedded on purpose and a `--no-embed` rebuild would leave every new or changed chunk without one; the count is in the prose and in a WARN on stderr. Add `--force` to run it anyway, or run `memhtml index rebuild --embed`. [Rebuild the index](/learn/operations/rebuild-the-index/) has the reasoning.

Branch on `code` and never on the `error` prose, which changes freely as the wording improves. Most failures carry `suggestions`, and those are commands you can run (`apps/cli/src/errors.ts:136`):

```json
{
  "apiVersion": "1",
  "error": "no memory at areas/inbox/nope.html",
  "code": "ERR_PATH_NOT_FOUND",
  "suggestions": [
    "memhtml search <what you were looking for>",
    "memhtml list"
  ]
}
```

The suite checks every `memhtml …` suggestion against the command table (`apps/cli/tests/cli.test.ts:392`), so a renamed command cannot leave a suggestion naming a command that no longer exists. A typo gets a candidate back rather than a dead end:

```json
{
  "apiVersion": "1",
  "error": "unknown command: serch x",
  "code": "ERR_UNKNOWN_COMMAND",
  "suggestions": ["search"]
}
```

That one exits 2, which marks a usage error you fix by changing the call. `ERR_PATH_NOT_FOUND` exits 1, which marks a runtime failure you fix by changing the repo or the environment.

For an AI agent that distinction is the whole triage. Exit 2 means re-read `memhtml manifest` and re-issue a corrected call. Exit 1 means the same call will fail identically until something outside the call changes.