mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
75cf6baca4
commit
0df3131fff
@@ -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;
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user