mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
* 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:
committed by
GitHub
parent
5d74f1d02a
commit
521772c082
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user