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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-18 15:02:56 -03:00
committed by GitHub
parent 75cf6baca4
commit 0df3131fff
4 changed files with 97 additions and 2 deletions

View File

@@ -24,6 +24,7 @@ function buildQdrantSettingsResponse(settings: Record<string, unknown>) {
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;

View File

@@ -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 } });
});
});

View File

@@ -3,6 +3,16 @@ import { createEmbeddingResponse } from "@/lib/embeddings/service";
type JsonRecord = Record<string, unknown>;
/**
* 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<string, unknown> | 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<string, unknown> | undefined {
if (quantization === "none") return undefined;
return { quantization: { rescore: true } };
}
export function normalizeQdrantConfig(settings: Record<string, unknown>): QdrantConfig {
const host = typeof settings.qdrantHost === "string" ? settings.qdrantHost.trim() : "";
const portRaw = settings.qdrantPort;
@@ -35,8 +78,14 @@ export function normalizeQdrantConfig(settings: Record<string, unknown>): 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<QdrantConfig> {
@@ -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" } },

View File

@@ -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();