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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `embedding_query_prefix` / `embedding_document_prefix` config keys (env:
`AI_MEMORY_EMBEDDING_QUERY_PREFIX` / `AI_MEMORY_EMBEDDING_DOCUMENT_PREFIX`)
for the `openai` and `openai-compat` embedders: an optional string
prepended to query / document text before the existing truncation, so
truncation still bounds the whole input. Asymmetric embedding models need
a query-side instruction their publisher specifies; the OpenAI-compatible
`/v1/embeddings` wire format has no field for it.
`nvidia/Nemotron-3-Embed-1B-BF16` and base E5 models use a simple
`"query: "` / `"passage: "` pair; instruction-tuned E5 variants and
Qwen3-Embedding instead need a task-instruction string on the query side
only (documents stay plain). Empty by default — no behaviour change when
unset, and not trimmed (nor is a present-but-empty env-var override, which
now clears a `config.toml` value), so a publisher's trailing space is
preserved. A non-empty `embedding_document_prefix` is folded into the
stored embedding identity (`Embedder::model_identity`), so a document
prefix change makes retrieval, backfill, and cleanup treat existing pages
as stale and re-embed them automatically — an empty prefix keeps the
pre-existing identity, so upgrading installs need no migration, and a
query-only prefix change never needs a rebuild. See
`docs/llm-providers.md`.

### Fixed
- `memory_query`'s vector stream called the generic `Embedder::embed`
instead of `embed_query` on the configured embedder, so a
query/document-asymmetric embedder (Google's task-typed embeddings, or
the new query/document prefixes above) embedded the search query on the
document side instead of the query side.
- `companions/ai-memory-macos/build.sh` no longer fails on machines whose
active developer directory is Command Line Tools only: SwiftUI `@State`
needs the `SwiftUIMacros` plugin shipped with full Xcode, so the script
Expand Down
12 changes: 12 additions & 0 deletions bin/ai-memory
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,18 @@ do
fi
done

# These two are presence-based, not non-empty-based like the loop above: an
# operator sets one to the empty string to deliberately clear a
# config.toml-configured prefix without editing the file (see
# Config::load's figment overlay), and a present-but-empty value must reach
# the container for that to work — `[ -n ]` above would drop it, making the
# wrapper indistinguishable from the var never having been set at all.
for var in AI_MEMORY_EMBEDDING_QUERY_PREFIX AI_MEMORY_EMBEDDING_DOCUMENT_PREFIX; do
if [ -n "${!var+x}" ]; then
ENV_ARGS+=(-e "${var}")
fi
done

# The wrapper itself runs the CLI inside a short-lived helper container, while
# the README server runs in the long-lived ai-memory container and publishes
# 127.0.0.1:49374 on the host. Inside a normal bridge-network helper,
Expand Down
24 changes: 24 additions & 0 deletions bin/ai-memory.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,30 @@ foreach ($Name in @(
}
}

# Presence-based, not non-empty-based like the loop above: an operator sets
# one of these to the empty string to deliberately clear a
# config.toml-configured prefix without editing the file (see
# Config::load's figment overlay), and a present-but-empty value must reach
# the container for that to work — `IsNullOrEmpty` above would drop it,
# making the wrapper indistinguishable from the variable never having been
# set at all. `GetEnvironmentVariable` returns `$null` only when the
# variable is truly unset, and `""` when it is set-but-empty, so a `-ne
# $null` check is exactly the presence test needed here.
#
# An operator's own `$env:NAME = ''` additionally needs PowerShell 7.5+
# (first built on .NET 9) to leave a set-but-empty variable rather than
# deleting it; not exercised on a real pwsh runtime.
# https://learn.microsoft.com/en-us/dotnet/api/system.environment.setenvironmentvariable
# https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-75
foreach ($Name in @(
"AI_MEMORY_EMBEDDING_QUERY_PREFIX",
"AI_MEMORY_EMBEDDING_DOCUMENT_PREFIX"
)) {
if ($null -ne [Environment]::GetEnvironmentVariable($Name)) {
$DockerArgs += @("-e", $Name)
}
}

# Docker Desktop gives Windows no host networking for Linux containers, so a
# thin-client command (status, search, bootstrap, ...) reaches the loopback-
# published server from this helper container through Docker Desktop's host
Expand Down
12 changes: 8 additions & 4 deletions crates/ai-memory-cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1633,7 +1633,7 @@ async fn start_maintenance_scheduler(
.as_ref()
.map(|e| ai_memory_consolidate::EmbeddingCoord {
provider: e.provider().to_string(),
model: e.model().to_string(),
model: e.model_identity(),
dim: e.dim(),
});

Expand Down Expand Up @@ -2367,11 +2367,15 @@ async fn configure_embedder(
}
Err(e) => return Err(e).context("building embedder from config"),
};
// Not `.model()`: the running triple must match what pages are
// actually stored under, which a configured document prefix changes.
// See `Embedder::model_identity`.
let configured_model_identity = embedder.model_identity();
let mismatch = store
.reader
.embedding_meta_for_mismatch(
embedder.provider().into(),
embedder.model().into(),
configured_model_identity.clone(),
embedder.dim(),
)
.await?;
Expand All @@ -2383,7 +2387,7 @@ async fn configure_embedder(
tracing::warn!(
stored = ?mismatch,
configured_provider = embedder.provider(),
configured_model = embedder.model(),
configured_model = %configured_model_identity,
configured_dim = embedder.dim(),
"stored embeddings use a different (provider, model, dim) than configured; \
hybrid search ignores stale rows until pages are re-embedded — \
Expand Down Expand Up @@ -3999,7 +4003,7 @@ mod tests {
ws,
project,
embedder.provider().to_string(),
embedder.model().to_string(),
embedder.model_identity(),
embedder.dim(),
)
.await
Expand Down
Loading