Skip to content

Long-term memory backends

DMF separates the LTMHook contract from its concrete storage backends. The backend is normally selected through [ltm] in dmf_settings.toml; direct construction remains available for custom applications and tests.

Backend overview

Backend Retrieval Persistence Intended use
FileLTMHook No semantic search Append-only JSONL Development and audit trails
Chroma embedded Semantic search Local Chroma directory Single-process applications
Chroma server Semantic search Managed by the Chroma service Multiple clients and service deployments
Qdrant Local Mode Semantic search Volatile process memory Isolated local runs and tests
Qdrant server Semantic search Managed by the Qdrant service Multiple clients and service deployments
NullLTMHook No No Disabled LTM and isolated tests

Chroma embedded remains the default connection mode. Existing configurations that do not define chroma_mode continue to use PersistentClient and chroma_path.

Canonical imports

from dmf.memory.ltm_hooks import ChromaLTMHook, FileLTMHook, QdrantLTMHook
from dmf.memory.ltm_hooks.chroma_client import (
    ChromaConnectionConfig,
    ChromaConnectionMode,
)
from dmf.memory.ltm_hooks.qdrant_client import (
    QdrantConnectionConfig,
    QdrantConnectionMode,
)
from dmf.models import RecallFilter

The same hook classes remain re-exported from dmf and dmf.memory.

Qdrant Local Mode

Install the optional dependency before constructing or configuring the Qdrant backend:

pip install 'dmf-memory[qdrant]'

Minimal configuration:

[ltm]
enabled = true
storage_type = "qdrant"
qdrant_mode = "memory"
collection_name = "dmf_memory"
cards_enabled = false
cards_collection_name = "dmf_cards"
recall_limit = 5
distance_threshold = 0.7

qdrant_mode = "memory" builds QdrantClient(":memory:"). Each client owns a separate in-memory store, even when the same collection names are used. Data is not written to disk and disappears when the Python process exits. Persistent Local Mode remains outside scope; use server mode for durable data.

Direct construction is available for tests and custom applications:

from dmf.memory import QdrantLTMHook
from dmf.memory.ltm_hooks.qdrant_client import (
    QdrantConnectionConfig,
    QdrantConnectionMode,
)
from dmf.utils.config import VectorConfig

hook = QdrantLTMHook(
    collection_name="dmf_memory",
    connection=QdrantConnectionConfig(mode=QdrantConnectionMode.MEMORY),
    vector_config=VectorConfig(vector_dim=768),
)

Qdrant server

Server mode uses Qdrant's HTTP API and leaves persistence and backups under the service operator's control:

[ltm]
enabled = true
storage_type = "qdrant"
qdrant_mode = "server"
qdrant_host = "localhost"
qdrant_port = 6333
qdrant_ssl = false
qdrant_api_key_env = ""
qdrant_timeout = 5
collection_name = "dmf_memory"

Direct construction uses the same hook:

connection = QdrantConnectionConfig(
    mode=QdrantConnectionMode.SERVER,
    host="localhost",
    port=6333,
)
hook = QdrantLTMHook(connection=connection)

For authenticated deployments, keep the API key out of TOML:

qdrant_api_key_env = "DMF_QDRANT_API_KEY"
export DMF_QDRANT_API_KEY="your-api-key"

The variable is read only for an active Qdrant server backend. Missing or empty values fail before client construction, and the key is excluded from connection representations. TLS certificate verification remains enabled when qdrant_ssl = true. This release uses REST and does not expose gRPC transport, custom CA, or automatic application-level retry settings.

An already constructed Qdrant client can be passed with client=.... Injected clients are used unchanged; their lifecycle, isolation, and persistence remain the caller's responsibility.

Qdrant raw and card collections

Raw LTM records remain canonical. Qdrant stores them in collection_name; the payload includes raw_record as a JSON mapping, plus top-level fields used for filtering such as record_id, interaction_id, role, and created_at.

When cards_enabled = true, projected memory cards are stored in the separate cards_collection_name collection. The raw and card collection names must be distinct. Cards initially use the source record vector and include source raw metadata so card recall can apply the same backend-neutral filters. clear() deletes both raw points and projected card points while preserving the two collections. The optional JSONL card audit remains append-only.

Qdrant point IDs are deterministic UUIDv5 values derived from the DMF raw record ID or card ID. The original DMF identity stays in the payload. This makes repeated archive calls idempotent while keeping raw points and card points in separate UUID namespaces.

Qdrant scoring and thresholds

Qdrant uses COSINE distance and returns a similarity score for matching points. DMF preserves that score as RawRecallHit.similarity_score and reports distance = 1.0 - similarity_score without clamping.

The existing distance_threshold setting remains the public configuration surface. For Qdrant queries, DMF converts it to a minimum Qdrant score:

score_threshold = 1.0 - distance_threshold

With the default distance_threshold = 0.7, Qdrant returns records with similarity score at least 0.3.

Recall filters

RecallFilter is shared across Chroma, Qdrant, and temporal-memory recall:

from dmf.models import RecallFilter

recall_filter = RecallFilter(
    roles=("user",),
    interaction_id_min=10,
    interaction_id_max=50,
    excluded_record_ids=("raw:obsolete",),
)
hits = hook.search_raw(query_vector, k=5, recall_filter=recall_filter)

Supported fields:

Field Meaning
record_ids Include only these raw record IDs. For card recall this matches source_record_id.
excluded_record_ids Exclude these raw record IDs. For card recall this excludes matching sources.
roles Match raw role; for cards this uses the source raw role.
interaction_id_min / interaction_id_max Inclusive source interaction ID range.
created_at_min / created_at_max Inclusive source timestamp range.
card_kinds Match projected card kind when searching cards.

String tuple values are stripped and must be non-empty and unique. Range minimums must not exceed their maximums.

In Qdrant Local Mode, payload indexes are not created. Qdrant may warn that index creation is a no-op, and metadata filters are evaluated by local full-scan. This is expected for the in-memory backend.

Server collection creation tolerates another client winning the same bootstrap race, then validates the resulting vector size and COSINE distance normally.

Qdrant errors

If the Qdrant extra is missing, constructing a Qdrant client raises an actionable ModuleNotFoundError that points to:

pip install 'dmf-memory[qdrant]'

When an existing collection has incompatible vector settings, such as the wrong dimension, named vectors, or a non-COSINE distance, initialization raises ValueError. DMF never deletes or recreates an incompatible collection automatically. If raw and card collection names are identical, initialization also raises ValueError.

Local Qdrant service

The repository includes compose.qdrant.yml, pinned to Qdrant 1.18.2 with a persistent named volume and readiness check.

make qdrant-up
make test-integration-qdrant
make qdrant-down

To select another local port, pass the same value to all commands:

QDRANT_PORT=16333 make qdrant-up
QDRANT_PORT=16333 make test-integration-qdrant
QDRANT_PORT=16333 make qdrant-down

The integration test creates unique raw and card collections, verifies that a second client observes the archived data, and deletes both collections. The standard make test command does not require Docker.

Embedded Chroma

Use embedded mode when DMF owns the local Chroma data directory.

[ltm]
enabled = true
storage_type = "chroma"
chroma_mode = "embedded"
chroma_path = "data/ltm_chroma"
collection_name = "dmf_memory"

Direct construction remains backward compatible:

from dmf.memory import ChromaLTMHook

hook = ChromaLTMHook(persist_directory="data/ltm_chroma")

The directory is created automatically. Network retry is not installed because embedded mode does not use the HTTP client.

Chroma server

Server mode contacts a separately managed Chroma service and does not create chroma_path.

[ltm]
enabled = true
storage_type = "chroma"
chroma_mode = "server"
chroma_host = "localhost"
chroma_port = 8000
chroma_ssl = false
chroma_tenant = "default_tenant"
chroma_database = "default_database"
chroma_auth_token_env = ""
collection_name = "dmf_memory"

Direct construction uses an explicit connection object:

from dmf.memory import ChromaLTMHook
from dmf.memory.ltm_hooks.chroma_client import (
    ChromaConnectionConfig,
    ChromaConnectionMode,
)

connection = ChromaConnectionConfig(
    mode=ChromaConnectionMode.SERVER,
    host="localhost",
    port=8000,
)
hook = ChromaLTMHook(connection=connection)

An already constructed Chroma client can be passed with client=.... Injected clients are used unchanged, so their connection lifecycle and resilience remain the caller's responsibility.

Authentication

The TOML contains only the name of the environment variable holding the token:

chroma_auth_token_env = "DMF_CHROMA_TOKEN"
export DMF_CHROMA_TOKEN="your-server-token"

DMF reads the variable only when LTM is enabled with the Chroma server backend. If a variable name is configured but its value is missing, empty, or whitespace-only, initialization fails before the client is constructed. The token is sent as a Bearer token and is excluded from configuration representations, logs, and benchmark reports.

Leave chroma_auth_token_env = "" for an unauthenticated local service.

Retry behavior

The server client applies retry at the HTTP layer, including requests made during client bootstrap.

Retries are limited to:

  • httpx.TransportError, including connection and timeout failures;
  • HTTP 502, 503, and 504 responses.

The policy uses exponential backoff with full jitter and at most five total attempts. Client errors, authentication failures, validation errors, and other HTTP responses are not retried. Once the retry budget is exhausted, the error is propagated through Chroma's normal error mapping.

Retry timing is intentionally fixed in this release and is not configurable in TOML.

Memory cards and local files

Raw LTM records remain canonical. When cards are enabled, Chroma uses a separate card collection. The optional JSONL card audit file remains local even when Chroma runs in server mode; set cards_path explicitly when the default path is not appropriate for the client host. clear() removes both raw and projected-card vectors while leaving that append-only audit file untouched.

Local Chroma service

The repository includes compose.chroma.yml with a version-pinned Chroma 0.6.3 service and a persistent named volume.

make chroma-up
make test-integration
make chroma-down

Stopping the service does not delete its volume. To use a different host port, pass the same value to Compose and the test:

CHROMA_PORT=18000 make chroma-up
CHROMA_PORT=18000 make test-integration
CHROMA_PORT=18000 make chroma-down

The standard make test command excludes integration tests and does not require Docker.

Local Ollama functional benchmark

The optional benchmark exercises DMF ingestion, archival, retrieval, context rendering, and an isolated Ollama agent. It is a functional diagnostic and not a model-quality CI gate.

Prerequisites:

  • the Chroma Compose service is running;
  • Ollama is listening on a loopback address;
  • qwen2.5:0.5b, or the model selected by OLLAMA_MODEL, is already installed;
  • the configured embedding assets are already available locally.
make chroma-up
ollama serve  # omit when Ollama is already running
ollama list
OLLAMA_BASE_URL=http://localhost:11434 \
OLLAMA_MODEL=qwen2.5:0.5b \
make benchmark-ltm-local
make chroma-down

The runner never downloads models or images. It accepts only loopback Ollama and vector-server endpoints and writes sanitized reports under the ignored integrationtest/results/ directory.

Qdrant remains in memory by default. With the local service running, select server mode explicitly:

make qdrant-up
make benchmark-ltm-qdrant-server-local
make qdrant-down

The server benchmark reads QDRANT_HOST and QDRANT_PORT and accepts only loopback endpoints.

Version compatibility

The Python dependency supports Chroma >=0.6.3,<0.7.0, and the local Compose image is pinned to 0.6.3. The retry integration depends on Chroma 0.6.x HTTP internals and must be reviewed before upgrading to Chroma 0.7 or later.

The optional Python dependency supports qdrant-client >=1.17,<2.0; the lock currently resolves 1.18.0 and the local service is pinned to Qdrant 1.18.2.

Migrating legacy imports

The old modules remain compatibility shims:

# Deprecated
from dmf.memory.chroma_ltm import ChromaLTMHook
from dmf.memory.ltm_engine import FileLTMHook

They still return the canonical classes but emit DeprecationWarning. Migrate to one of the supported public forms:

from dmf import ChromaLTMHook, FileLTMHook
# or
from dmf.memory import ChromaLTMHook, FileLTMHook
# or
from dmf.memory.ltm_hooks import ChromaLTMHook, FileLTMHook

Troubleshooting

The server path is not created

This is expected in server mode. chroma_path belongs to embedded mode; the server owns its persistence.

Authentication works locally but fails in another environment

Confirm that chroma_auth_token_env names an environment variable available to the DMF process. Do not place the token value directly in TOML.

Startup takes longer while the server is unavailable

Client bootstrap is covered by the bounded retry policy. Inspect warning logs for the attempt number and delay; after five attempts the original failure is propagated.

The integration test is not collected by make test

This is intentional. Run make test-integration after starting the Compose service.