From 0df3131fff73094c459dd9e8ff320d755c6253a6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:02:56 -0300 Subject: [PATCH] feat(memory): opt-in Qdrant scalar int8 quantization (F4.4 Q1) (#4187) Integrated into release/v3.8.29. Opt-in Qdrant scalar int8 quantization (F4.4 Q1); default 'none' keeps the create body byte-identical. Validated: vitest 9/9, file-size OK, lint clean. --- src/app/api/settings/qdrant/route.ts | 2 + .../memory/__tests__/qdrant-wiring.test.ts | 35 ++++++++++- src/lib/memory/qdrant.ts | 58 ++++++++++++++++++- src/shared/schemas/qdrant.ts | 4 ++ 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/app/api/settings/qdrant/route.ts b/src/app/api/settings/qdrant/route.ts index 08a6f5e72c..78ea9c0dcb 100644 --- a/src/app/api/settings/qdrant/route.ts +++ b/src/app/api/settings/qdrant/route.ts @@ -24,6 +24,7 @@ function buildQdrantSettingsResponse(settings: Record) { port: cfg.port, collection: cfg.collection, embeddingModel: cfg.embeddingModel, + quantization: cfg.quantization, hasApiKey, apiKeyMasked, }; @@ -72,6 +73,7 @@ export async function PUT(request: NextRequest) { if (body.port !== undefined) updates.qdrantPort = body.port; if (body.collection !== undefined) updates.qdrantCollection = body.collection; if (body.embeddingModel !== undefined) updates.qdrantEmbeddingModel = body.embeddingModel; + if (body.quantization !== undefined) updates.qdrantQuantization = body.quantization; if (body.apiKey !== undefined) { // Empty string = remove key updates.qdrantApiKey = body.apiKey === "" ? null : body.apiKey; diff --git a/src/lib/memory/__tests__/qdrant-wiring.test.ts b/src/lib/memory/__tests__/qdrant-wiring.test.ts index d3439d797d..8ea61ab29a 100644 --- a/src/lib/memory/__tests__/qdrant-wiring.test.ts +++ b/src/lib/memory/__tests__/qdrant-wiring.test.ts @@ -14,7 +14,11 @@ */ import { describe, test, expect } from "vitest"; -import { normalizeQdrantConfig } from "../qdrant"; +import { + normalizeQdrantConfig, + buildQuantizationConfig, + searchQuantizationParams, +} from "../qdrant"; describe("normalizeQdrantConfig — defaults & disabled state", () => { test("returns disabled config when settings are empty (no Qdrant configured)", () => { @@ -75,3 +79,32 @@ describe("normalizeQdrantConfig — defaults & disabled state", () => { } }); }); + +describe("Qdrant scalar quantization wiring (Q1 / F4.4)", () => { + test("defaults quantization to 'none' when the setting is missing", () => { + expect(normalizeQdrantConfig({}).quantization).toBe("none"); + }); + + test("reads valid int8 / binary modes; invalid or non-string values fall back to none", () => { + expect(normalizeQdrantConfig({ qdrantQuantization: "int8" }).quantization).toBe("int8"); + expect(normalizeQdrantConfig({ qdrantQuantization: "binary" }).quantization).toBe("binary"); + expect(normalizeQdrantConfig({ qdrantQuantization: "none" }).quantization).toBe("none"); + expect(normalizeQdrantConfig({ qdrantQuantization: "bogus" }).quantization).toBe("none"); + expect(normalizeQdrantConfig({ qdrantQuantization: 5 as unknown }).quantization).toBe("none"); + }); + + test("buildQuantizationConfig: none → undefined (body unchanged), int8 → scalar, binary → binary", () => { + // none must stay undefined so the create body is byte-identical to today (no behavioral change). + expect(buildQuantizationConfig("none")).toBeUndefined(); + expect(buildQuantizationConfig("int8")).toEqual({ + scalar: { type: "int8", always_ram: true, quantile: 0.99 }, + }); + expect(buildQuantizationConfig("binary")).toEqual({ binary: { always_ram: true } }); + }); + + test("searchQuantizationParams: rescore enabled only for a quantized collection", () => { + expect(searchQuantizationParams("none")).toBeUndefined(); + expect(searchQuantizationParams("int8")).toEqual({ quantization: { rescore: true } }); + expect(searchQuantizationParams("binary")).toEqual({ quantization: { rescore: true } }); + }); +}); diff --git a/src/lib/memory/qdrant.ts b/src/lib/memory/qdrant.ts index 7b5282ace2..558deb737c 100644 --- a/src/lib/memory/qdrant.ts +++ b/src/lib/memory/qdrant.ts @@ -3,6 +3,16 @@ import { createEmbeddingResponse } from "@/lib/embeddings/service"; type JsonRecord = Record; +/** + * Vector quantization mode for the memory collection (F4.4 / Q1). + * - `none`: float32 vectors (default, unchanged behavior). + * - `int8`: scalar int8 quantization (~4× less vector RAM, rescore preserves quality). + * - `binary`: binary quantization (up to ~32× less, lossier — opt-in for scale). + */ +export type QdrantQuantization = "none" | "int8" | "binary"; + +const QDRANT_QUANTIZATION_MODES: readonly QdrantQuantization[] = ["none", "int8", "binary"]; + export type QdrantConfig = { enabled: boolean; host: string; @@ -10,8 +20,41 @@ export type QdrantConfig = { apiKey: string | null; collection: string; embeddingModel: string; + quantization: QdrantQuantization; }; +/** + * Build the Qdrant `quantization_config` block for collection creation. + * Returns `undefined` for `none` so the create body stays byte-identical to the + * pre-quantization behavior (no silent change for existing deployments). + * Reference: Qdrant scalar/binary quantization + rescore docs. + */ +export function buildQuantizationConfig( + quantization: QdrantQuantization +): Record | undefined { + switch (quantization) { + case "int8": + return { scalar: { type: "int8", always_ram: true, quantile: 0.99 } }; + case "binary": + return { binary: { always_ram: true } }; + case "none": + default: + return undefined; + } +} + +/** + * Build the Qdrant search `params` block. When the collection is quantized we + * request `rescore: true` so the original vectors refine the quantized shortlist + * (preserves recall). Returns `undefined` for `none` (no `params` sent). + */ +export function searchQuantizationParams( + quantization: QdrantQuantization +): Record | undefined { + if (quantization === "none") return undefined; + return { quantization: { rescore: true } }; +} + export function normalizeQdrantConfig(settings: Record): QdrantConfig { const host = typeof settings.qdrantHost === "string" ? settings.qdrantHost.trim() : ""; const portRaw = settings.qdrantPort; @@ -35,8 +78,14 @@ export function normalizeQdrantConfig(settings: Record): Qdrant ? settings.qdrantEmbeddingModel.trim() : "openai/text-embedding-3-small"; const enabled = settings.qdrantEnabled === true; + const quantizationRaw = settings.qdrantQuantization; + const quantization: QdrantQuantization = + typeof quantizationRaw === "string" && + (QDRANT_QUANTIZATION_MODES as readonly string[]).includes(quantizationRaw) + ? (quantizationRaw as QdrantQuantization) + : "none"; - return { enabled, host, port, apiKey, collection, embeddingModel }; + return { enabled, host, port, apiKey, collection, embeddingModel, quantization }; } export async function getQdrantConfig(): Promise { @@ -104,10 +153,15 @@ async function ensureCollection(cfg: QdrantConfig, vectorSize: number): Promise< }); if (getRes.ok) return; + // Quantization only applies to NEW collections — an existing collection is left + // untouched (the GET above returns early). Switching modes on a populated + // collection is a destructive recreate+reindex that must be an explicit UI action. + const quantizationConfig = buildQuantizationConfig(cfg.quantization); const createRes = await qdrantFetch(cfg, `/collections/${encodeURIComponent(cfg.collection)}`, { method: "PUT", body: JSON.stringify({ vectors: { size: vectorSize, distance: "Cosine" }, + ...(quantizationConfig ? { quantization_config: quantizationConfig } : {}), }), }); if (!createRes.ok) { @@ -247,6 +301,7 @@ export async function searchSemanticMemory( await ensureCollection(cfg, vector.length); const vectorName = await getCollectionVectorName(cfg); + const searchParams = searchQuantizationParams(cfg.quantization); const res = await qdrantFetch( cfg, `/collections/${encodeURIComponent(cfg.collection)}/points/search`, @@ -255,6 +310,7 @@ export async function searchSemanticMemory( body: JSON.stringify({ vector: vectorName ? { name: vectorName, vector } : vector, limit: Math.max(1, Math.min(20, topK)), + ...(searchParams ? { params: searchParams } : {}), filter: { must: [ { key: "kind", match: { value: "omniroute_memory" } }, diff --git a/src/shared/schemas/qdrant.ts b/src/shared/schemas/qdrant.ts index c3b6781ae7..744ec41bfe 100644 --- a/src/shared/schemas/qdrant.ts +++ b/src/shared/schemas/qdrant.ts @@ -1,11 +1,14 @@ import { z } from "zod"; +export const QdrantQuantizationSchema = z.enum(["none", "int8", "binary"]); + export const QdrantSettingsSchema = z.object({ enabled: z.boolean(), host: z.string().min(0), // string vazia OK quando enabled=false port: z.number().int().min(1).max(65535).default(6333), collection: z.string().min(1).default("omniroute_memory"), embeddingModel: z.string().default("openai/text-embedding-3-small"), + quantization: QdrantQuantizationSchema.default("none"), hasApiKey: z.boolean().default(false), apiKeyMasked: z.string().nullable().default(null), }); @@ -17,6 +20,7 @@ export const QdrantSettingsUpdateSchema = z port: z.number().int().min(1).max(65535).optional(), collection: z.string().min(1).optional(), embeddingModel: z.string().min(1).optional(), + quantization: QdrantQuantizationSchema.optional(), apiKey: z.string().optional(), // string vazia = remove }) .strict();