Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,17 @@ Measured on an Apple M2 Max (64 GB). The harnesses are the proof — run them yo
| `IAI_MCP_PYTHON` | — | Absolute path to the venv Python (for the MCP host config) |
| `IAI_MCP_RECALL_CONCURRENCY` | `2` | Maximum cued `memory_recall` calls dispatched concurrently by the socket daemon |
| `IAI_MCP_RECALL_SLOT_WAIT_SEC` | `0.25` | How long an overflow cued recall waits for a slot before returning `_degraded: recall_busy` |

The old `IAI_MCP_EMBED_MODEL` knob is gone — the embedder is a single built-in English-only model. There are many internal tuning knobs (`IAI_MCP_*`), but you shouldn't need to touch them.
| `IAI_MCP_EMBED_PROVIDER` | `native` | `native` for built-in BGE or `http` for a replaceable loopback provider |
| `IAI_MCP_EMBED_URL` | — | Loopback endpoint or base URL for the `http` provider |
| `IAI_MCP_EMBED_DIM` | `384` | Vector dimension; required for the `http` provider |
| `IAI_MCP_EMBED_MODEL_ID` | — | Model identifier; required for the `http` provider |
| `IAI_MCP_EMBED_TIMEOUT_SEC` | `30` | Local provider request timeout |

The built-in Rust BGE model remains the zero-configuration default. Setting the
provider to `http` replaces it completely: the native model is not constructed,
downloaded, or run. This makes multilingual and domain-specific embedders
possible without adding a Python ML stack to iai-mcp. See
[`docs/EMBEDDERS.md`](docs/EMBEDDERS.md) for the protocol and migration steps.

---

Expand Down
4 changes: 2 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ per-release log see [`CHANGELOG.md`](../CHANGELOG.md).
|---|---|
| Python | 3.11–3.12 (CPython) |
| OS | macOS or Linux. The daemon uses `fcntl.flock` and Unix-socket IPC. Windows support is in beta. WSL2 works as a Linux target. |
| RAM | 8+ GB comfortable. The `bge-small-en-v1.5` embedder occupies ~600 MB resident once loaded. |
| Disk | ~5 GB free for model weights + store + WAL. Model weights live in `~/.cache/huggingface/` (~130 MB). |
| RAM | 8+ GB comfortable. The default `bge-small-en-v1.5` embedder occupies ~600 MB resident once loaded; external-provider usage depends on the selected model. |
| Disk | ~5 GB free for model weights + store + WAL. Default model weights live in `~/.cache/huggingface/` (~130 MB); external-provider usage varies. |
| Toolchain (source build only) | A Rust toolchain is needed when compiling the native extension from source. On Linux, `libssl-dev` and `pkg-config` (or your distro's equivalents). |

The native extension (`iai_mcp_native` — the embedder, graph algorithms, and
Expand Down
74 changes: 74 additions & 0 deletions docs/EMBEDDERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Replaceable embedding providers

iai-mcp uses the native Rust `bge-small-en-v1.5` embedder by default. A local
HTTP provider can replace it completely for multilingual, domain-specific, or
shared-model deployments. iai-mcp does not import or construct the native
embedder when the HTTP provider is selected.

## Configuration

```bash
export IAI_MCP_EMBED_PROVIDER=http
export IAI_MCP_EMBED_URL=http://127.0.0.1:4488
export IAI_MCP_EMBED_DIM=1024
export IAI_MCP_EMBED_MODEL_ID=your-model-id
export IAI_MCP_EMBED_TIMEOUT_SEC=30
```

Only unauthenticated loopback HTTP URLs are accepted. The model service stays
local, and iai-mcp adds no ML framework dependency. Multiple iai-mcp processes
can share one warm model service instead of loading one model copy per store.

## Protocol

iai-mcp sends `POST /embed` unless `IAI_MCP_EMBED_URL` already ends in
`/embed`:

```json
{
"texts": ["What should be recalled?"],
"input_type": "query"
}
```

`input_type` is either `query` for retrieval cues or `document` for memories.
The provider owns tokenization, prefixes, pooling, normalization, batching, and
model loading. This distinction supports asymmetric models without teaching
iai-mcp about any specific model family.

The response is:

```json
{
"model": "your-model-id",
"dimensions": 1024,
"vectors": [[0.01, -0.02, 0.03]]
}
```

iai-mcp rejects a wrong model identifier, vector count, dimension, non-numeric
value, or non-finite value.

## Migrating an existing store

Changing a model invalidates every stored vector, even when the old and new
models use the same dimension. Stop the daemon, make a store backup, configure
the new provider, and run:

```bash
iai-mcp migrate --reembed-to-configured-provider --dry-run
iai-mcp migrate --reembed-to-configured-provider
```

The migration flushes pending in-process writes, stages a complete replacement
table, preserves storage-only fields and encrypted payloads, keeps the previous
records table, and updates the persisted dimension. Restart iai-mcp before
recall so its vector indexes reopen with the new dimension, then run:

```bash
iai-mcp doctor
```

Starting a populated store with a provider whose dimension differs from the
store fails fast and points to the migration command. This prevents mixed-model
or mixed-dimension indexes from being used silently.
6 changes: 3 additions & 3 deletions mcp-wrapper/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const toolSchemas: Record<ToolName, ToolSchema> = {
type: "string",
description:
"Natural-language query to match against stored memories. " +
"Embedded server-side via bge-small-en-v1.5 (384d) unless " +
"Embedded server-side by the configured provider unless " +
"`cue_embedding` is supplied.",
},
budget_tokens: {
Expand All @@ -74,7 +74,7 @@ export const toolSchemas: Record<ToolName, ToolSchema> = {
items: { type: "number" },
description:
"Optional pre-computed embedding vector for the cue " +
"(EMBED_DIM=384 floats; bge-small-en-v1.5). " +
"(its dimension must match the current store). " +
"When omitted, the daemon embeds the cue server-side. " +
"Used by memory_contradict and tests that need byte-stable embeddings.",
},
Expand Down Expand Up @@ -167,7 +167,7 @@ export const toolSchemas: Record<ToolName, ToolSchema> = {
items: { type: "number" },
description:
"Optional pre-computed embedding vector for the contradicting " +
"fact (EMBED_DIM=384 floats; bge-small-en-v1.5). When omitted, " +
"fact (its dimension must match the current store). When omitted, " +
"the daemon embeds new_fact server-side.",
},
},
Expand Down
4 changes: 2 additions & 2 deletions src/iai_mcp/brainview.py
Original file line number Diff line number Diff line change
Expand Up @@ -1189,9 +1189,9 @@ def search_direct(self, query: str, k: int = 12) -> dict:
if not query:
return {"hits": []}
try:
from iai_mcp.embed import embedder_for_store
from iai_mcp.embed import embed_query, embedder_for_store

emb = embedder_for_store(self.store).embed(query[:512])
emb = embed_query(embedder_for_store(self.store), query[:512])
raw = self.store.query_similar(list(emb), k=max(1, min(int(k), 24)))
except Exception as exc: # noqa: BLE001 -- search is navigation, degrade to empty
logger.warning("brainview search failed: %s", exc)
Expand Down
13 changes: 11 additions & 2 deletions src/iai_mcp/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,13 +402,22 @@ def _build_parser() -> argparse.ArgumentParser:
"embedding column is rewritten -- literal_surface is never touched."
),
)
m.add_argument(
"--reembed-to-configured-provider",
action="store_true",
help=(
"Re-embed every record with the configured embedding provider, "
"including dimension changes. Uses the crash-safe staging-table "
"migration and retains the previous table until cleanup."
),
)
m.add_argument(
"--reembed-batch-size",
type=int,
default=256,
help=(
"Records per id-ordered window for --reembed-from-text. Bounds "
"memory; embed calls are streamed within each window. Default 256."
"Records per batch for --reembed-from-text or "
"--reembed-to-configured-provider. Default 256."
),
)
m.add_argument(
Expand Down
41 changes: 34 additions & 7 deletions src/iai_mcp/cli/_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ def cmd_migrate(args: argparse.Namespace) -> int:
from iai_mcp import cli as _cli
from iai_mcp.store import MemoryStore
store = MemoryStore()
verbose = bool(getattr(args, "verbose", False))

def _progress(i: int, n: int) -> None:
if verbose:
print(f"[{i + 1}/{n}] migrating...")

# --reembed-from-text owns --resume / --rollback when combined with it:
# resume continues the reembed from its last committed window, rollback
Expand All @@ -124,13 +129,40 @@ def cmd_migrate(args: argparse.Namespace) -> int:
)
return 0

if bool(getattr(args, "reembed_to_configured_provider", False)):
from iai_mcp.embed import Embedder
from iai_mcp.migrate import migrate_reembed_to_current_dim

target = Embedder()
dry_run = bool(getattr(args, "dry_run", False))
batch_size = int(getattr(args, "reembed_batch_size", 256))
result = migrate_reembed_to_current_dim(
store,
target,
dry_run=dry_run,
progress=_progress,
force=True,
batch_size=batch_size,
)
prefix = "[dry-run] would re-embed" if dry_run else "re-embedded"
count = result.get("would_update", result.get("updated", 0))
print(
f"{prefix} {count} records from {result['source_dim']}d to "
f"{result['target_dim']}d with {target.model_key}"
)
if result.get("restart_required"):
print(
"restart IAE before recall so the vector indexes reopen at the new dimension"
)
return 0

if bool(getattr(args, "rollback", False)):
from iai_mcp import migrate
return migrate._rollback(store.db, store)
if bool(getattr(args, "resume", False)):
from iai_mcp import migrate
from iai_mcp.embed import embedder_for_store
target = embedder_for_store(store)
from iai_mcp.embed import Embedder
target = Embedder()
return migrate._resume(store.db, store, target)

if bool(getattr(args, "rederive_timestamps", False)):
Expand Down Expand Up @@ -171,11 +203,6 @@ def cmd_migrate(args: argparse.Namespace) -> int:
from_v = int(getattr(args, "from_", 1))
to_v = int(getattr(args, "to", 2))
dry_run = bool(getattr(args, "dry_run", False))
verbose = bool(getattr(args, "verbose", False))

def _progress(i: int, n: int) -> None:
if verbose:
print(f"[{i + 1}/{n}] migrating...")

if from_v == 1 and to_v == 2:
from iai_mcp.migrate import migrate_v1_to_v2
Expand Down
16 changes: 8 additions & 8 deletions src/iai_mcp/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,15 @@ def _crisis_degraded_recall(store: MemoryStore, params: dict) -> dict:
"""
try:
from iai_mcp.cue_router import _classify_cue
from iai_mcp.embed import embedder_for_store
from iai_mcp.embed import embed_query, embedder_for_store
from iai_mcp.pipeline import K_CANDIDATES
from iai_mcp.core._serializers import _hit_to_json
from iai_mcp.types import MemoryHit

cue_mode, _cue_intent, _triggered_pattern = _classify_cue(params.get("cue", ""))

embedder = embedder_for_store(store)
_cue_vec = embedder.embed(params["cue"])
_cue_vec = embed_query(embedder, params["cue"])

_ann_pairs = store.query_similar(_cue_vec, k=K_CANDIDATES)
_candidate_recs: dict = {_r.id: _r for _r, _s in _ann_pairs}
Expand Down Expand Up @@ -368,7 +368,7 @@ def _trace_mark(_name: str) -> None:
mode=cue_mode,
)
else:
from iai_mcp.embed import embedder_for_store
from iai_mcp.embed import embed_query, embedder_for_store
from iai_mcp.pipeline import recall_for_response
try:
from iai_mcp.daemon_state import load_state as _ds_load
Expand Down Expand Up @@ -400,7 +400,7 @@ def _trace_mark(_name: str) -> None:
_encode_ms: "float | None" = None
_encode_t0 = _time.perf_counter()
try:
_cue_vec = embedder.embed(params["cue"])
_cue_vec = embed_query(embedder, params["cue"])
_encode_ms = (_time.perf_counter() - _encode_t0) * 1000.0
_trace_mark("encode")
except Exception as _emb_exc:
Expand Down Expand Up @@ -960,9 +960,9 @@ def _trace_mark(_name: str) -> None:
except Exception as exc: # noqa: BLE001 -- one lane failing must not blank the other
logger.debug("memory_search lexical lane failed: %s", exc)
try:
from iai_mcp.embed import embedder_for_store
from iai_mcp.embed import embed_query, embedder_for_store

vec = embedder_for_store(store).embed(query[:512])
vec = embed_query(embedder_for_store(store), query[:512])
for sem_rank, (rec, cos) in enumerate(store.query_similar(list(vec), k=k)):
rid = str(rec.id)
if rid in merged:
Expand Down Expand Up @@ -1248,7 +1248,7 @@ def _trace_mark(_name: str) -> None:

if method == "memory_temporal_recall":
from iai_mcp.events import flush_event_buffer, query_events
from iai_mcp.embed import embedder_for_store
from iai_mcp.embed import embed_query, embedder_for_store
from iai_mcp.store._store import _normalize_ts_for_compare

try:
Expand Down Expand Up @@ -1284,7 +1284,7 @@ def _trace_mark(_name: str) -> None:
cue_vec: list[float] | None = None
if cue:
embedder = embedder_for_store(store)
cue_vec = embedder.embed(cue)
cue_vec = embed_query(embedder, cue)
record_hits = store.query_similar_temporal(
vec=cue_vec, as_of=as_of_norm, k=limit,
)
Expand Down
1 change: 0 additions & 1 deletion src/iai_mcp/daemon/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,6 @@ def _install_warm_embedder_override(store) -> tuple[object, bool]:
orig_efs = _embed_mod.embedder_for_store
try:
warm = orig_efs(store)

def _held_embedder_for_store(_store):
return warm

Expand Down
4 changes: 2 additions & 2 deletions src/iai_mcp/daemon/_boot_warmup.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,11 @@ def warm_dispatch_surface(store: Any) -> dict:
from iai_mcp import runtime_graph_cache # noqa: F401
from iai_mcp.core import _serializers # noqa: F401
from iai_mcp.cue_router import _classify_cue # noqa: F401
from iai_mcp.embed import embedder_for_store
from iai_mcp.embed import embed_query, embedder_for_store
from iai_mcp.pipeline import K_CANDIDATES # noqa: F401

embedder = embedder_for_store(store)
embedder.embed("boot warm-up probe cue")
embed_query(embedder, "boot warm-up probe cue")
runtime_graph_cache.load_recall_structural(store)
return {"elapsed_ms": (time.perf_counter() - _t0) * 1000.0}
except Exception as exc: # noqa: BLE001 -- warm-up must never break boot
Expand Down
26 changes: 14 additions & 12 deletions src/iai_mcp/doctor/_storage_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,30 +621,32 @@ def check_u_recall_centrality_regression() -> CheckResult:
def check_v_native_embedder() -> CheckResult:
import math

backend = "unknown"
try:
import iai_mcp_native # noqa: F401
from iai_mcp.embed import Embedder
from iai_mcp.embed import Embedder, embed_query

emb = Embedder()
assert emb._backend == "rust", f"backend={emb._backend!r}"
vec = emb.embed("smoke")
assert len(vec) == 384, f"expected 384 dims, got {len(vec)}"
backend = emb._backend
vec = embed_query(emb, "smoke")
assert len(vec) == emb.DIM, f"expected {emb.DIM} dims, got {len(vec)}"
assert all(math.isfinite(float(x)) for x in vec[:3]), (
"non-finite values in output"
)
except Exception as exc: # noqa: BLE001
remedy = (
"rebuild with: cd rust/iai_mcp_native && maturin develop --release"
if backend in {"unknown", "rust"}
else "check IAI_MCP_EMBED_URL and the loopback embedder service"
)
return CheckResult(
name="(v) native Rust embedder",
name="(v) configured embedder",
passed=False,
detail=(
f"{type(exc).__name__}: {exc} — rebuild with: "
"cd rust/iai_mcp_native && maturin develop --release"
),
detail=f"{type(exc).__name__}: {exc} — {remedy}",
)
return CheckResult(
name="(v) native Rust embedder",
name="(v) configured embedder",
passed=True,
detail="encode ok, backend=rust, 384-dim",
detail=f"encode ok, backend={backend}, {emb.DIM}-dim, model={emb.model_key}",
)


Expand Down
Loading
Loading