fix(memory): enabling Qdrant activates it as the engine + inline guidance (#5597) (#5741)

* fix(memory): enabling Qdrant now activates it as the engine + inline guidance (#5597)

Enabling Qdrant in the Engine tab was inert: retrieval only routes to Qdrant when
memoryVectorStore === "qdrant" (the default "auto" never selects it), and the card
only wrote qdrantEnabled — nothing set the engine selector, and there is no UI for
it. So users configured Qdrant, saw "enabled", but it was never actually used.

- PUT /api/settings/qdrant now sets memoryVectorStore alongside the toggle:
  enable -> "qdrant", disable -> "auto". Editing other fields leaves it untouched.
- Add inline guidance to QdrantConfigCard: a Tier-1-vs-Tier-2 banner + per-field
  help (host, collection, embedding model). Note there is no "vector dimension" or
  "distance metric" field: dimension is auto-detected from the embedder, distance
  is always Cosine.
- Document the real behavior in MEMORY.md: engine gate, no back-fill of existing
  memories, dimension auto-detect, Cosine-only, API-key-only auth.

Tests: tests/integration/qdrant-routes.test.ts — enable->qdrant, disable->auto, and
field-edit-without-enabled leaves the engine untouched (TDD: red -> green).

Closes #5597

* fix(memory): invalidate memory-settings cache on Qdrant toggle (#5597)

The PUT handler wrote memoryVectorStore to the DB but retrieval reads through
getMemorySettings(), a module-level cache. Without busting it, the engine switch
did not take effect until a process restart (the DB said qdrant, retrieval kept
routing to sqlite-vec). Now calls invalidateMemorySettingsCache() after the write,
mirroring src/app/api/settings/memory/route.ts.

Regression test warms the cache, toggles via the route, and asserts
getMemorySettings().vectorStore flips to qdrant (fails without the invalidate call).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-01 01:15:46 -03:00
committed by GitHub
parent 5d74f1d02a
commit 521772c082
5 changed files with 149 additions and 2 deletions

View File

@@ -223,7 +223,13 @@ chronological order if the FTS table is missing or the FTS query throws.
### Optional: Qdrant (vector store tier 2)
`src/lib/memory/qdrant.ts` implements an optional Qdrant integration as tier 2
vector store. Enabled via `qdrantEnabled` in settings / toggle in Engine tab.
vector store. Retrieval only routes to Qdrant when the engine selector
`memoryVectorStore === "qdrant"` — the default `"auto"` (and `"sqlite-vec"`)
**never** select Qdrant. The Engine-tab toggle sets **both** `qdrantEnabled` and
`memoryVectorStore` together: enabling makes Qdrant the primary store, disabling
resets to `"auto"` (#5597 — before that fix, enabling was inert because nothing
wrote the engine selector). If Qdrant is unreachable or returns nothing, retrieval
falls back to sqlite-vec → FTS5.
- `upsertSemanticMemoryPoint()` — embed `key + content` with the configured
embedding model, ensure the collection exists (creates cosine-distance
@@ -251,6 +257,26 @@ routes under `src/app/api/settings/qdrant/` are all wired as of v3.8.6:
| `/api/settings/qdrant/cleanup` | `POST` | Remove expired / old points |
| `/api/settings/qdrant/embedding-models` | `GET` | List available embedding models |
**Behavior notes (what to expect):**
- **Engine selection** — enabling Qdrant in the Engine tab makes it the primary
store (sets `memoryVectorStore="qdrant"`); disabling resets to `"auto"` (#5597).
- **No back-fill** — only memories created/updated **after** Qdrant is enabled are
written to it (fire-and-forget dual-write). Pre-existing SQLite memories are **not**
migrated; "Reindex Now" rebuilds the sqlite-vec index only, not Qdrant.
- **Vector dimension is auto-detected** from the actual embedding on first use — there
is no dimension field to fill in. Changing the embedding model after a collection
exists is **not** auto-handled: the existing collection is left untouched, dimension-
mismatched writes/searches fail and fall back to sqlite-vec. Recreate the collection
(new name, or delete it in Qdrant) to switch embedders.
- **Distance metric** — always **Cosine** (hardcoded on collection creation; not
configurable).
- **Auth** — API key only (sent as the `api-key` header; optional for unauthenticated
local Docker). JWT/RBAC are not used.
- **Config fields** — the UI exposes `host`, `port`, `collection`, `embeddingModel`,
`apiKey`. `vectorSize` / `hnswEfConstruct` are env/DB only and `vectorSize` is not
used for collection creation (dimension comes from the embedding).
### Vector quantization (int8 — opt-in, both backends)
Both vector backends support **opt-in int8 quantization** to cut the memory

View File

@@ -205,6 +205,11 @@ export default function QdrantConfigCard() {
</span>
</div>
{/* Tier 1 vs Tier 2 guidance */}
<div className="mb-4 p-3 rounded-lg bg-emerald-500/5 border border-emerald-500/20 text-xs text-text-muted leading-relaxed">
{t("qdrant.banner")}
</div>
{/* Enable toggle + test connection */}
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
<div>
@@ -271,6 +276,7 @@ export default function QdrantConfigCard() {
placeholder="http://127.0.0.1"
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
<p className="text-[11px] text-text-muted mt-1.5">{t("qdrant.hostHelp")}</p>
</div>
<div className="p-3 rounded-lg bg-surface/30 border border-border/30">
<label className="text-xs font-medium block mb-1.5">{t("qdrant.portLabel")}</label>
@@ -292,6 +298,7 @@ export default function QdrantConfigCard() {
placeholder="omniroute_memory"
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
<p className="text-[11px] text-text-muted mt-1.5">{t("qdrant.collectionHelp")}</p>
</div>
<div className="p-3 rounded-lg bg-surface/30 border border-border/30">
<label className="text-xs font-medium block mb-1.5">
@@ -319,6 +326,7 @@ export default function QdrantConfigCard() {
placeholder="openai/text-embedding-3-small"
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
<p className="text-[11px] text-text-muted mt-1.5">{t("qdrant.embeddingModelHelp")}</p>
</div>
<div className="p-3 rounded-lg bg-surface/30 border border-border/30 md:col-span-2">
<label className="text-xs font-medium block mb-1.5">

View File

@@ -4,6 +4,7 @@ import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { QdrantSettingsUpdateSchema } from "@/shared/schemas/qdrant";
import { getQdrantConfig, normalizeQdrantConfig } from "@/lib/memory/qdrant";
import { updateSettings, getSettings } from "@/lib/localDb";
import { invalidateMemorySettingsCache } from "@/lib/memory/settings";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
function maskApiKey(apiKey: string | null): { hasApiKey: boolean; apiKeyMasked: string | null } {
@@ -68,7 +69,14 @@ export async function PUT(request: NextRequest) {
try {
const updates: Record<string, unknown> = {};
if (body.enabled !== undefined) updates.qdrantEnabled = body.enabled;
if (body.enabled !== undefined) {
updates.qdrantEnabled = body.enabled;
// #5597: enabling Qdrant here was inert — retrieval only routes to Qdrant when
// memoryVectorStore === "qdrant" (the default "auto" never selects it), and no UI
// wrote that. Activate/deactivate the engine alongside the toggle so "enabled"
// actually means "Qdrant is the primary vector store" (matching the UI copy).
updates.memoryVectorStore = body.enabled ? "qdrant" : "auto";
}
if (body.host !== undefined) updates.qdrantHost = body.host;
if (body.port !== undefined) updates.qdrantPort = body.port;
if (body.collection !== undefined) updates.qdrantCollection = body.collection;
@@ -80,6 +88,10 @@ export async function PUT(request: NextRequest) {
}
const newSettings = (await updateSettings(updates)) as Record<string, unknown>;
// #5597 follow-up: bust the module-level memory-settings cache so the retrieval
// layer (getMemorySettings) picks up the new vectorStore/qdrant config without a
// process restart — mirrors src/app/api/settings/memory/route.ts.
invalidateMemorySettingsCache();
return NextResponse.json(buildQdrantSettingsResponse(newSettings));
} catch (err: unknown) {
const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));

View File

@@ -3425,6 +3425,10 @@
"description": "Optional Qdrant integration for scalable semantic search",
"enableLabel": "Enable Qdrant",
"enableDesc": "When enabled, Qdrant is used as the primary vector store",
"banner": "Tier 2 vector store — an external, scalable alternative to the built-in sqlite-vec (Tier 1). Enable it only if you have a very large memory set or want shared memory across instances; most users are fine on sqlite-vec. When enabled it becomes the primary store and automatically falls back to sqlite-vec if unreachable.",
"hostHelp": "Local Docker: http://localhost:6333 · Qdrant Cloud: your cluster URL",
"collectionHelp": "Any name — OmniRoute creates it on first use",
"embeddingModelHelp": "Sets the vector dimension automatically on first use. Existing memories are not back-filled, and changing the model after data exists needs a fresh collection.",
"testConnection": "Test connection",
"testing": "Testing...",
"statusActive": "Active",

View File

@@ -27,6 +27,7 @@ process.env.API_KEY_SECRET = "test-secret-qdrant-routes";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const memorySettings = await import("../../src/lib/memory/settings.ts");
// ── Route imports ──
const qdrantSettingsRoute = await import("../../src/app/api/settings/qdrant/route.ts");
@@ -49,6 +50,9 @@ async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
// #5597 follow-up: the memory-settings cache is a module-level singleton that
// survives per-test DB resets — bust it so each test starts from a clean read.
memorySettings.invalidateMemorySettingsCache();
}
async function makeAuthRequest(
@@ -143,6 +147,99 @@ test("PUT /api/settings/qdrant — updates settings and returns new masked shape
assert.strictEqual(body.apiKey, undefined, "raw apiKey must not be in response");
});
// ── #5597: enabling Qdrant must also activate it as the engine ──
// Regression: retrieval only routes to Qdrant when memoryVectorStore === "qdrant"
// (retrieval.ts:342/470/694). The card only wrote `qdrantEnabled` and never the
// engine selector, so enabling Qdrant was inert — it stayed on the default "auto"
// (which never selects Qdrant). Enabling now also sets memoryVectorStore=qdrant.
test("PUT enabled=true also activates Qdrant as the engine (memoryVectorStore=qdrant)", async () => {
const req = await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
enabled: true,
host: "qdrant-server",
collection: "c",
});
const res = await qdrantSettingsRoute.PUT(req as any);
assert.strictEqual(res.status, 200);
const s = (await localDb.getSettings()) as Record<string, unknown>;
assert.strictEqual(
s.memoryVectorStore,
"qdrant",
"enabling Qdrant must select it as the active vector store, else it stays inert"
);
});
test("PUT enabled=false resets the engine back to auto (sqlite-vec)", async () => {
await qdrantSettingsRoute.PUT(
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
enabled: true,
host: "qdrant-server",
collection: "c",
})) as any
);
await qdrantSettingsRoute.PUT(
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
enabled: false,
})) as any
);
const s = (await localDb.getSettings()) as Record<string, unknown>;
assert.strictEqual(
s.memoryVectorStore,
"auto",
"disabling Qdrant must fall back to auto (sqlite-vec), not stay on qdrant"
);
});
test("PUT without the enabled field must not change memoryVectorStore", async () => {
// User already on qdrant; editing only the collection must not reset the engine.
await localDb.updateSettings({ memoryVectorStore: "qdrant", qdrantEnabled: true });
await qdrantSettingsRoute.PUT(
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
collection: "renamed",
})) as any
);
const s = (await localDb.getSettings()) as Record<string, unknown>;
assert.strictEqual(
s.memoryVectorStore,
"qdrant",
"editing other fields must leave the engine selection untouched"
);
});
// #5597 follow-up: writing memoryVectorStore to the DB is not enough — retrieval reads
// through getMemorySettings(), a module-level cache. The PUT handler must invalidate it
// so the engine switch takes effect without a process restart.
test("PUT enabled=true invalidates the memory-settings cache (retrieval sees qdrant, no restart)", async () => {
// Warm the cache with the pre-toggle value (default auto → not qdrant).
const before = await memorySettings.getMemorySettings();
assert.notStrictEqual(
before.vectorStore,
"qdrant",
"precondition: cache warmed with a non-qdrant vectorStore"
);
const res = await qdrantSettingsRoute.PUT(
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
enabled: true,
host: "qdrant-server",
collection: "c",
})) as any
);
assert.strictEqual(res.status, 200);
// Without the cache invalidation, getMemorySettings() would still return the stale
// "auto" value and retrieval would keep routing to sqlite-vec until a restart.
const after = await memorySettings.getMemorySettings();
assert.strictEqual(
after.vectorStore,
"qdrant",
"PUT must invalidate the memory-settings cache so retrieval routes to Qdrant without a restart"
);
});
test("PUT /api/settings/qdrant — 400 invalid settings (invalid port type in strict schema)", async () => {
const req = await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
port: "not-a-number",