Skip to content

feat(embeddings): opt-in Ollama embedding backend for GPU acceleration - #982

Open
felipetruman wants to merge 2 commits into
MemPalace:developfrom
felipetruman:feat/ollama-gpu-embeddings
Open

feat(embeddings): opt-in Ollama embedding backend for GPU acceleration#982
felipetruman wants to merge 2 commits into
MemPalace:developfrom
felipetruman:feat/ollama-gpu-embeddings

Conversation

@felipetruman

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in OllamaEmbeddingFunction path to the ChromaDB backend. When EMBEDDING_PROVIDER=ollama is set, MemPalace routes all embedding generation through a local Ollama server — typically GPU-accelerated on machines with a supported GPU (AMD via ROCm, NVIDIA via CUDA, Apple Silicon).

Default behavior is unchanged: without the env var, ChromaDB's bundled DefaultEmbeddingFunction (ONNX MiniLM, 384-dim, CPU) is used.

Why

On a machine with a local GPU, the default ONNX MiniLM running on CPU becomes the bottleneck for mempalace mine on large projects — on my workstation (AMD Radeon RX 9060 XT, 16 GB VRAM), mining ~/freedomdigitalhub with MiniLM/CPU was still running after 2 hours. A persistent Ollama+nomic-embed-text server on the same GPU generates embeddings at ~14 ms/embed warm, turning hour-scale mines into minute-scale ones.

What changed

  • New get_embedding_function() in mempalace/backends/chroma.py — pure env-var gated factory.
  • ChromaBackend.get_collection, get_or_create_collection, and create_collection now forward the embedding function to ChromaDB.
  • mcp_server._get_collection (which bypasses ChromaBackend for its own inode-based cache) also forwards the EF, keeping writes and reads consistent.

Environment variables (all optional)

Variable Default Meaning
EMBEDDING_PROVIDER (unset) Set to `ollama` to enable
OLLAMA_URL `http://localhost:11434\` Base URL of the Ollama server
OLLAMA_EMBED_MODEL `nomic-embed-text` Model tag for `/api/embeddings`
OLLAMA_EMBED_TIMEOUT `60` HTTP timeout in seconds

Compatibility

This is opt-in. Existing palaces are not touched unless the user sets EMBEDDING_PROVIDER=ollama.

Because nomic-embed-text produces 768-dim vectors and MiniLM produces 384-dim, switching providers on an existing palace requires mempalace nuke + remine. The docs should make this explicit — happy to add a note in a follow-up commit or in this PR, whichever reviewers prefer.

Verification

End-to-end on the reporter's workstation (Python 3.14, chromadb 1.5.7, ROCm, AMD RX 9060 XT):

  • `get_embedding_function()` returns an `OllamaEmbeddingFunction` with the expected base URL / model / timeout
  • Fresh `mempalace init` + `mempalace mine --limit 20` produced 425 drawers, all in 768-dim collections (verified: `SELECT dimension FROM collections` on `chroma.sqlite3` → `768` for both `mempalace_drawers` and `mempalace_closets`)
  • `mempalace search` round-trips correctly and returns semantically ranked matches
  • VRAM during mine: ~2.33 GB (nomic-embed-text loaded)

Test plan

  • Default path still works (no env → Chroma default, 384-dim)
  • Opt-in path works (env set → Ollama, 768-dim)
  • Write + read use same EF (search works)
  • CI (reviewers: please run the existing test suite)

🤖 Generated with Claude Code

…ation

Adds `get_embedding_function()` in `backends.chroma` that returns an
`OllamaEmbeddingFunction` when `EMBEDDING_PROVIDER=ollama`, and `None`
otherwise (preserving the current `DefaultEmbeddingFunction` behavior).

The embedding function is threaded through every ChromaDB collection
accessor — `ChromaBackend.get_collection`, `get_or_create_collection`,
`create_collection`, and `mcp_server._get_collection` — so the same EF
is applied on both write and read paths, as ChromaDB requires.

Environment variables (all optional; safe defaults):

  EMBEDDING_PROVIDER   set to "ollama" to enable (default: unset → Chroma default)
  OLLAMA_URL           base URL (default: http://localhost:11434)
  OLLAMA_EMBED_MODEL   model tag (default: nomic-embed-text)
  OLLAMA_EMBED_TIMEOUT seconds (default: 60)

Motivation: on machines with a local GPU (e.g. AMD Radeon via ROCm), a
persistent Ollama server is dramatically faster than the bundled ONNX
MiniLM that runs on CPU — ~14 ms/embed warm vs. hundreds of ms/embed,
and embedding-heavy operations like `mempalace mine` become minutes
instead of hours.

Trade-off: nomic-embed-text produces 768-dim vectors vs. MiniLM's
384-dim, so switching providers on an existing palace is not
backward-compatible — users must `mempalace nuke` and remine. This is
opt-in and gated behind an env var, so existing palaces are unaffected.

Verified end-to-end:
  - `get_embedding_function()` returns OllamaEmbeddingFunction with the
    correct base URL / model / timeout
  - A 20-file mine into a fresh palace produced 425 embeddings, all
    768-dim (confirmed via `SELECT dimension FROM collections` on
    chroma.sqlite3)
  - `mempalace search` round-trips against the new collection and
    returns semantically ranked results
  - VRAM usage on AMD Radeon RX 9060 XT: 2.33 GB (nomic loaded)

Tested on chromadb 1.5.7, Python 3.14, Linux (ROCm).
Copilot AI review requested due to automatic review settings April 17, 2026 22:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an environment-variable gated embedding backend selection so MemPalace can route ChromaDB embedding generation through a local Ollama server (typically GPU-accelerated) while keeping the existing Chroma default embedding behavior unchanged unless explicitly enabled.

Changes:

  • Introduces get_embedding_function() factory in the Chroma backend to select Ollama vs default based on env vars.
  • Passes the resolved embedding function through Chroma collection creation/access paths in ChromaBackend.
  • Ensures mcp_server._get_collection() also uses the same embedding function for consistency with the backend.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.

File Description
mempalace/backends/chroma.py Adds env-gated embedding function factory and forwards EF into Chroma collection APIs.
mempalace/mcp_server.py Updates MCP server’s direct Chroma collection access to use the same embedding function.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread mempalace/mcp_server.py Outdated
Comment on lines 219 to 226
ef = get_embedding_function()
if create:
_collection_cache = ChromaCollection(
client.get_or_create_collection(
_config.collection_name, metadata={"hnsw:space": "cosine"}
_config.collection_name,
metadata={"hnsw:space": "cosine"},
embedding_function=ef, # type: ignore[arg-type]
)

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new # type: ignore[arg-type] on embedding_function=ef is masking a real typing mismatch introduced by allowing ef to be None. Prefer avoiding the ignore by only passing embedding_function when ef is not None (e.g., build a kwargs dict), or by adjusting get_embedding_function()'s return type / local typing so the call sites stay type-safe.

Copilot uses AI. Check for mistakes.
Comment thread mempalace/mcp_server.py
Comment on lines 218 to 220
client = _get_client()
ef = get_embedding_function()
if create:

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because _get_collection() wraps the whole block in except Exception: return None, any misconfiguration in get_embedding_function() (e.g., invalid OLLAMA_EMBED_TIMEOUT, missing Ollama EF in older chromadb installs) will be silently swallowed and later surfaces as "no palace" behavior. Consider at least logging the exception (or narrowing the exception handling) so embedding-provider configuration errors are debuggable.

Copilot uses AI. Check for mistakes.
Comment thread mempalace/backends/chroma.py Outdated
Comment on lines +151 to +162
client = self._client(palace_path)
ef = get_embedding_function()
if create:
collection = client.get_or_create_collection(
collection_name, metadata={"hnsw:space": "cosine"}
collection_name,
metadata={"hnsw:space": "cosine"},
embedding_function=ef,
)
else:
collection = client.get_collection(collection_name)
collection = client.get_collection(
collection_name, embedding_function=ef
)

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ef = get_embedding_function() can be None, but embedding_function=ef is always passed into get_or_create_collection() / get_collection(). To avoid type mismatches (and potential runtime issues if chromadb doesn't accept None here), consider only including the embedding_function kwarg when ef is not None and otherwise letting Chroma use its default behavior.

Copilot uses AI. Check for mistakes.
Comment on lines 179 to 183
collection = self._client(palace_path).create_collection(
collection_name, metadata={"hnsw:space": hnsw_space}
collection_name,
metadata={"hnsw:space": hnsw_space},
embedding_function=get_embedding_function(),
)

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_collection(..., embedding_function=get_embedding_function()) will pass None through when Ollama is not enabled. If the intent is "use Chroma's default embedding function", consider conditionally omitting the kwarg when get_embedding_function() returns None for consistency with older/newer chromadb versions and to keep typing clean.

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +36
def get_embedding_function():
"""Return an embedding function based on env, or ``None`` for Chroma's default.

Set ``EMBEDDING_PROVIDER=ollama`` to route embeddings through a local
Ollama server (GPU-accelerated). Tunables:

- ``OLLAMA_URL`` (default ``http://localhost:11434``) - base URL
- ``OLLAMA_EMBED_MODEL`` (default ``nomic-embed-text``)
- ``OLLAMA_EMBED_TIMEOUT`` seconds (default ``60``)

Returning ``None`` keeps ChromaDB's ``DefaultEmbeddingFunction`` (ONNX
MiniLM, 384 dims, CPU) for backward compatibility with existing palaces.
"""
provider = os.environ.get("EMBEDDING_PROVIDER", "").lower()
if provider != "ollama":
return None
from chromadb.utils.embedding_functions import OllamaEmbeddingFunction

return OllamaEmbeddingFunction(
url=os.environ.get("OLLAMA_URL") or "http://localhost:11434",
model_name=os.environ.get("OLLAMA_EMBED_MODEL") or "nomic-embed-text",
timeout=int(os.environ.get("OLLAMA_EMBED_TIMEOUT") or "60"),
)

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_embedding_function() is newly introduced but currently has no automated coverage. Consider adding unit tests that (1) default to None when EMBEDDING_PROVIDER is unset/other values, and (2) with EMBEDDING_PROVIDER=ollama reads URL/model/timeout defaults + overrides from env so regressions are caught without requiring a live Ollama server (can assert the returned object type/attributes or that it is callable).

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +36
return OllamaEmbeddingFunction(
url=os.environ.get("OLLAMA_URL") or "http://localhost:11434",
model_name=os.environ.get("OLLAMA_EMBED_MODEL") or "nomic-embed-text",
timeout=int(os.environ.get("OLLAMA_EMBED_TIMEOUT") or "60"),
)

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timeout=int(os.environ.get("OLLAMA_EMBED_TIMEOUT") or "60") will raise ValueError for a non-integer env value, and that error will be fairly opaque to users. Consider validating/parsing with a clearer exception message (e.g., "OLLAMA_EMBED_TIMEOUT must be an integer number of seconds") or falling back to the default when parsing fails.

Copilot uses AI. Check for mistakes.
Comment thread mempalace/backends/chroma.py Outdated
Returning ``None`` keeps ChromaDB's ``DefaultEmbeddingFunction`` (ONNX
MiniLM, 384 dims, CPU) for backward compatibility with existing palaces.
"""
provider = os.environ.get("EMBEDDING_PROVIDER", "").lower()

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

provider = os.environ.get("EMBEDDING_PROVIDER", "").lower() doesn't handle leading/trailing whitespace (e.g., " ollama"), which can make the opt-in flag unexpectedly fail. Consider normalizing with .strip().lower() before comparing.

Suggested change
provider = os.environ.get("EMBEDDING_PROVIDER", "").lower()
provider = os.environ.get("EMBEDDING_PROVIDER", "").strip().lower()

Copilot uses AI. Check for mistakes.
Addresses MemPalace#982 (review)

1. EMBEDDING_PROVIDER now normalized with .strip().lower() so leading/
   trailing whitespace no longer silently disables the opt-in flag.

2. OLLAMA_EMBED_TIMEOUT parsing moved into a dedicated _parse_timeout()
   helper that raises ValueError with a clear message naming the env var
   instead of a bare ValueError from int(), and treats empty/whitespace
   as "fall back to 60" rather than crashing.

3. New _ef_kwargs() helper returns {"embedding_function": ef} when an EF
   is configured and {} otherwise. All ChromaBackend + mcp_server call
   sites now spread that dict via ** so:
     - the embedding_function kwarg is never present on the default path
       (was passed as None before)
     - the previous # type: ignore[arg-type] annotations are gone
     - older chromadb builds that reject embedding_function=None keep
       working without change
   Unrelated Pyright diagnostics that pre-date this PR are left alone.

4. _get_collection()'s broad except now logs via logger.debug(...,
   exc_info=True) so embedding-provider misconfigurations (bad
   OLLAMA_EMBED_TIMEOUT, chromadb without OllamaEmbeddingFunction, server
   unreachable, ...) are diagnosable instead of silently surfacing as
   "no palace" later. The broad except is intentional — palace-not-found
   must stay non-fatal.

5. New tests/test_embeddings_ollama.py (32 assertions across 25 tests),
   all passing, covering:
     - provider-disabled returns None (unset, empty, whitespace, other)
     - provider-enabled case/whitespace normalization
     - URL/model/timeout defaults + env overrides with whitespace trim
     - bad OLLAMA_EMBED_TIMEOUT raises helpful ValueError
     - _parse_timeout valid/invalid parametrised paths
     - _ef_kwargs empty vs populated + spread-into-dict invariant
     - no cached module-level state across toggles
   No Ollama server is required; tests only inspect the EF object's
   attributes and the kwargs dict.
@felipetruman
felipetruman requested a review from igorls as a code owner April 17, 2026 22:43
@felipetruman

Copy link
Copy Markdown
Contributor Author

Thanks for the review @copilot-pull-request-reviewer. All 7 comments addressed in 06956aa. Item-by-item:

# File:Line Comment Resolution
1 mcp_server.py:226 # type: ignore[arg-type] masking None mismatch Removed. New _ef_kwargs() helper returns {} when EF is not configured; call sites spread it via ** so embedding_function is never passed as None.
2 mcp_server.py:220 Bare except swallows EF misconfig Now logger.debug("_get_collection failed", exc_info=True) before returning None. Kept broad on purpose — palace-not-found must stay non-fatal — but misconfigs are diagnosable now.
3 chroma.py:162 embedding_function=ef can be None Same **_ef_kwargs() pattern applied to get_collection / get_or_create_collection.
4 chroma.py:183 create_collection same issue Same pattern applied; also keeps older chromadb releases that reject embedding_function=None happy.
5 chroma.py:36 No unit tests New tests/test_embeddings_ollama.py — 25 tests, all passing, no Ollama server required. Covers provider-disabled / enabled / normalization / env overrides / bad-timeout / _parse_timeout valid+invalid / _ef_kwargs empty+populated / spread invariant / no cached state.
6 chroma.py:36 int(...) on OLLAMA_EMBED_TIMEOUT raises opaque ValueError Extracted to _parse_timeout() which names the env var in the error ("OLLAMA_EMBED_TIMEOUT must be an integer number of seconds, got 'foo'") and falls back to 60 on empty/whitespace.
7 chroma.py:27 EMBEDDING_PROVIDER not stripped Now .strip().lower(). Also applied .strip() to OLLAMA_URL / OLLAMA_EMBED_MODEL values for consistency.

Classification: all 7 fell into "quality / design" — no blocking critical bugs. One design pattern change (the _ef_kwargs() helper) made the same improvement apply to all 3 collection accessor call sites + the MCP server bypass, instead of four separate fixes.

CI: no checks report back on this PR — let me know if there's something I should wire up locally.

@igorls igorls added enhancement New feature or request performance Performance improvements labels Apr 24, 2026
@Qodo-Free-For-OSS

Copy link
Copy Markdown

Hi, get_embedding_function() selects the env var value before stripping, so whitespace-only OLLAMA_URL or OLLAMA_EMBED_MODEL becomes an empty string and is passed into OllamaEmbeddingFunction, causing embedding requests to fail at runtime.

Severity: action required | Category: correctness

How to fix: Validate stripped URL/model strings

Agent prompt to fix - you can give this to your LLM of choice:

Issue description

get_embedding_function() currently computes url and model_name as (env or default).strip(). If the env var is present but whitespace-only, the value becomes an empty string and gets passed to OllamaEmbeddingFunction, causing runtime failures.

Issue Context

This occurs only when EMBEDDING_PROVIDER=ollama.

Fix Focus Areas

  • mempalace/backends/chroma.py[33-55]

Suggested fix

  • Read env vars, strip them, then if the result is empty, fall back to defaults (or raise a clear ValueError).
    • Example approach:
      • raw_url = os.environ.get("OLLAMA_URL")
      • url = (raw_url or "").strip() or _DEFAULT_OLLAMA_URL
      • similarly for model_name
  • Consider adding a unit test for whitespace-only env values to ensure defaults are used (or error is raised).

We noticed a couple of other issues in this PR as well - happy to share if helpful.


Spotted by Qodo code review - free for open-source projects.

@igorls

igorls commented May 8, 2026

Copy link
Copy Markdown
Member

Hi, thanks for the contribution.

This PR has merge conflicts with develop, and the branch has not been updated in over 7 days, which puts it before our most recent release. The conflicts are likely against work that landed in that release.

Could you rebase onto develop so we can take another look?

If this change is no longer relevant, feel free to close the PR.

(This message is part of a periodic backlog pass, sent to all open PRs that match this state.)

@igorls igorls added the needs-rebase PR has merge conflicts with develop and needs rebase label May 8, 2026
@Motokiyo

Copy link
Copy Markdown

+1 from a heavy multilingual user — landing this would close a real gap.

I've been monkey-patching the same hook locally since v3.0.0 (FR/EN corpus, 68k drawers). The architectural cleanup in 3.3.4 (embedding.py factory, backends/chroma.py wrapper) made my patch trivial — 17 lines, one file. This PR is essentially the same idea applied at the backend layer.

One suggestion: consider documenting bge-m3 as the recommended model for non-English corpora, or even shipping it as an alternate default. nomic-embed-text (768d, English bias) gives near-random semantic ranking on French / Korean / Japanese content. bge-m3 (1024d, 100+ langs, BAAI) restores meaningful similarity scores — I see queries like "détection de chute pour personnes âgées EHPAD" correctly returning fall_detector.py as the top hit, which MiniLM and nomic-embed-text both rank deep in the noise.

Tested today against v3.3.4 with this PR's approach (re-implemented locally as a mempalace/embedding.py hook, since I can't easily run a development checkout side-by-side with my pip install). Hybrid BM25 + cosine search (also new in 3.3.0, great work!) further compensates for embedding model choice — but exact-name retrieval alone isn't a substitute for proper multilingual semantic similarity.

Happy to share concrete metrics on a 68k-drawer FR/EN palace (latency, recall@k, hybrid score distributions) if it would help reviewers, just ping me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request needs-rebase PR has merge conflicts with develop and needs rebase performance Performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants