Files
diegosouzapw--omniroute/src/lib/memory/qdrant.ts
T
2026-07-13 13:39:12 +08:00

522 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getSettings } from "@/lib/db/settings";
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;
port: number;
apiKey: string | null;
collection: string;
embeddingModel: string;
quantization: QdrantQuantization;
vectorSize: number;
hnswEfConstruct: number;
};
/**
* 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 {
// Env-var fallbacks (cluster profile: docker compose --profile memory).
// Settings-table values take precedence when present, so users who configure
// Qdrant via the Settings UI are not overridden by the container hostname.
const envHost = typeof process.env.QDRANT_HOST === "string" ? process.env.QDRANT_HOST.trim() : "";
const envPortRaw = process.env.QDRANT_PORT;
const envPort =
typeof envPortRaw === "string" && envPortRaw.trim().length > 0
? Math.round(Number(envPortRaw) || 6333)
: undefined;
const envApiKey =
typeof process.env.QDRANT_API_KEY === "string" && process.env.QDRANT_API_KEY.trim().length > 0
? process.env.QDRANT_API_KEY.trim()
: undefined;
const envCollection =
typeof process.env.QDRANT_COLLECTION === "string" && process.env.QDRANT_COLLECTION.trim().length > 0
? process.env.QDRANT_COLLECTION.trim()
: undefined;
const host =
(typeof settings.qdrantHost === "string" ? settings.qdrantHost.trim() : "") || envHost || "";
const portRaw = settings.qdrantPort;
const port =
typeof portRaw === "number" && Number.isFinite(portRaw)
? Math.round(portRaw)
: typeof portRaw === "string"
? Math.round(Number(portRaw) || 6333)
: envPort ?? 6333;
const apiKey =
(typeof settings.qdrantApiKey === "string" && settings.qdrantApiKey.trim().length > 0
? settings.qdrantApiKey.trim()
: null) ?? envApiKey ?? null;
const collection =
(typeof settings.qdrantCollection === "string" && settings.qdrantCollection.trim().length > 0
? settings.qdrantCollection.trim()
: null) ?? envCollection ?? "omniroute_memory";
const embeddingModel =
(typeof settings.qdrantEmbeddingModel === "string" &&
settings.qdrantEmbeddingModel.trim().length > 0
? settings.qdrantEmbeddingModel.trim()
: null) ??
(typeof process.env.QDRANT_EMBEDDING_MODEL === "string" &&
process.env.QDRANT_EMBEDDING_MODEL.trim().length > 0
? process.env.QDRANT_EMBEDDING_MODEL.trim()
: null) ??
"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";
// Vector size + HNSW ef_construct are deployment-time constants. They are
// read here so the env vars documented in .env.example (QDRANT_VECTOR_SIZE,
// QDRANT_HNSW_EF_CONSTRUCT) have a single source of truth and don't get
// flagged as DOC_ONLY by check-env-doc-sync.
const vectorSizeRaw =
typeof settings.qdrantVectorSize === "number"
? settings.qdrantVectorSize
: typeof settings.qdrantVectorSize === "string"
? Number(settings.qdrantVectorSize)
: Number(process.env.QDRANT_VECTOR_SIZE) || 1536;
const vectorSize = Number.isFinite(vectorSizeRaw) && vectorSizeRaw > 0 ? vectorSizeRaw : 1536;
const hnswEfRaw =
typeof settings.qdrantHnswEfConstruct === "number"
? settings.qdrantHnswEfConstruct
: typeof settings.qdrantHnswEfConstruct === "string"
? Number(settings.qdrantHnswEfConstruct)
: Number(process.env.QDRANT_HNSW_EF_CONSTRUCT) || 128;
const hnswEfConstruct = Number.isFinite(hnswEfRaw) && hnswEfRaw > 0 ? hnswEfRaw : 128;
return {
enabled,
host,
port,
apiKey,
collection,
embeddingModel,
quantization,
vectorSize,
hnswEfConstruct,
};
}
export async function getQdrantConfig(): Promise<QdrantConfig> {
const settings = (await getSettings()) as Record<string, unknown>;
return normalizeQdrantConfig(settings);
}
function baseUrl(cfg: QdrantConfig): string {
const host = cfg.host.replace(/\/+$/, "");
const withProto =
host.startsWith("http://") || host.startsWith("https://") ? host : `http://${host}`;
try {
const url = new URL(withProto);
if (!url.port) url.port = String(cfg.port);
return url.toString().replace(/\/+$/, "");
} catch {
return `${withProto}:${cfg.port}`;
}
}
async function qdrantFetch(cfg: QdrantConfig, path: string, init?: RequestInit): Promise<Response> {
const headers: Record<string, string> = {
"content-type": "application/json",
...(init?.headers as Record<string, string> | undefined),
};
if (cfg.apiKey) headers["api-key"] = cfg.apiKey;
return fetch(`${baseUrl(cfg)}${path}`, {
...init,
headers,
});
}
export async function checkQdrantHealth(): Promise<{
ok: boolean;
latencyMs: number;
error?: string;
}> {
const cfg = await getQdrantConfig();
const start = Date.now();
if (!cfg.enabled || !cfg.host) {
return { ok: false, latencyMs: 0, error: "not_configured" };
}
try {
const res = await qdrantFetch(cfg, "/readyz", { method: "GET" });
const latencyMs = Date.now() - start;
if (!res.ok) {
const text = await res.text().catch(() => "");
return { ok: false, latencyMs, error: text.slice(0, 200) || `HTTP ${res.status}` };
}
return { ok: true, latencyMs };
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}
async function ensureCollection(cfg: QdrantConfig, vectorSize: number): Promise<void> {
const getRes = await qdrantFetch(cfg, `/collections/${encodeURIComponent(cfg.collection)}`, {
method: "GET",
});
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) {
const text = await createRes.text().catch(() => "");
throw new Error(text.slice(0, 300) || `Failed to create collection (${createRes.status})`);
}
}
async function getCollectionVectorName(cfg: QdrantConfig): Promise<string | null> {
const res = await qdrantFetch(cfg, `/collections/${encodeURIComponent(cfg.collection)}`, {
method: "GET",
});
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as any;
const vectors = data?.result?.config?.params?.vectors;
if (!vectors || typeof vectors !== "object" || Array.isArray(vectors)) {
return null;
}
// Unnamed/single-vector config: { size, distance, ... } (not a named map)
if (
Object.prototype.hasOwnProperty.call(vectors, "size") &&
(typeof vectors.size === "number" || typeof vectors.size === "string")
) {
return null;
}
const names = Object.keys(vectors);
if (names.length === 0) return null;
return names[0] || null;
}
async function embedText(cfg: QdrantConfig, text: string): Promise<number[]> {
const modelStr = cfg.embeddingModel.trim();
if (!modelStr.includes("/")) {
throw new Error(`Invalid embedding model '${modelStr}'. Use provider/model format.`);
}
const res = await createEmbeddingResponse({
model: modelStr,
input: text,
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt.slice(0, 300) || `Embeddings request failed (${res.status})`);
}
const data = (await res.json().catch(() => null)) as any;
const vec = data?.data?.[0]?.embedding;
if (!Array.isArray(vec) || vec.length === 0) {
throw new Error("Embedding response missing vector");
}
return vec as number[];
}
export async function upsertSemanticMemoryPoint(input: {
id: string;
apiKeyId: string;
sessionId: string;
key: string;
content: string;
metadata: JsonRecord;
createdAt: string;
expiresAt: string | null;
}): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
const cfg = await getQdrantConfig();
if (!cfg.enabled || !cfg.host) return { ok: false, latencyMs: 0, error: "not_configured" };
const start = Date.now();
try {
const vector = await embedText(cfg, `${input.key}\n\n${input.content}`);
await ensureCollection(cfg, vector.length);
const vectorName = await getCollectionVectorName(cfg);
const createdAtUnix = Math.floor(new Date(input.createdAt).getTime() / 1000);
const expiresAtUnix = input.expiresAt
? Math.floor(new Date(input.expiresAt).getTime() / 1000)
: null;
const payload = {
kind: "omniroute_memory",
memoryId: input.id,
apiKeyId: input.apiKeyId || "",
sessionId: input.sessionId || "",
type: "semantic",
key: input.key || "",
content: input.content || "",
metadata: input.metadata || {},
createdAtUnix,
expiresAtUnix,
};
const res = await qdrantFetch(
cfg,
`/collections/${encodeURIComponent(cfg.collection)}/points?wait=true`,
{
method: "PUT",
body: JSON.stringify({
points: [
{
id: input.id,
vector: vectorName ? { [vectorName]: vector } : vector,
payload,
},
],
}),
}
);
const latencyMs = Date.now() - start;
if (!res.ok) {
const text = await res.text().catch(() => "");
return { ok: false, latencyMs, error: text.slice(0, 300) || `HTTP ${res.status}` };
}
return { ok: true, latencyMs };
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function searchSemanticMemory(
query: string,
topK = 5,
scope?: { apiKeyId?: string; sessionId?: string | null }
): Promise<{
ok: boolean;
latencyMs: number;
results?: Array<{ id: string; score: number; payload?: JsonRecord }>;
error?: string;
}> {
const cfg = await getQdrantConfig();
if (!cfg.enabled || !cfg.host) return { ok: false, latencyMs: 0, error: "not_configured" };
const start = Date.now();
try {
const vector = await embedText(cfg, query);
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`,
{
method: "POST",
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" } },
...(scope?.apiKeyId ? [{ key: "apiKeyId", match: { value: scope.apiKeyId } }] : []),
...(scope?.sessionId
? [{ key: "sessionId", match: { value: String(scope.sessionId) } }]
: []),
],
},
with_payload: true,
}),
}
);
const latencyMs = Date.now() - start;
if (!res.ok) {
const text = await res.text().catch(() => "");
return { ok: false, latencyMs, error: text.slice(0, 300) || `HTTP ${res.status}` };
}
const data = (await res.json().catch(() => null)) as any;
const result = Array.isArray(data?.result) ? data.result : [];
return {
ok: true,
latencyMs,
results: result.map((r: any) => ({
id: String(r.id),
score: typeof r.score === "number" ? r.score : 0,
payload: r.payload && typeof r.payload === "object" ? (r.payload as JsonRecord) : undefined,
})),
};
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function deleteSemanticMemoryPoint(
id: string
): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
const cfg = await getQdrantConfig();
if (!cfg.enabled || !cfg.host) return { ok: false, latencyMs: 0, error: "not_configured" };
const start = Date.now();
try {
const res = await qdrantFetch(
cfg,
`/collections/${encodeURIComponent(cfg.collection)}/points/delete?wait=true`,
{
method: "POST",
body: JSON.stringify({ points: [id] }),
}
);
const latencyMs = Date.now() - start;
if (!res.ok) {
const text = await res.text().catch(() => "");
return { ok: false, latencyMs, error: text.slice(0, 300) || `HTTP ${res.status}` };
}
return { ok: true, latencyMs };
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function cleanupSemanticMemoryPoints(input: {
retentionDays: number;
}): Promise<{ ok: boolean; deletedCount: number; latencyMs: number; error?: string }> {
const cfg = await getQdrantConfig();
if (!cfg.enabled || !cfg.host)
return { ok: false, deletedCount: 0, latencyMs: 0, error: "not_configured" };
const retentionDays =
typeof input.retentionDays === "number" && Number.isFinite(input.retentionDays)
? Math.max(1, Math.min(3650, Math.round(input.retentionDays)))
: 30;
const start = Date.now();
try {
const nowUnix = Math.floor(Date.now() / 1000);
const cutoffUnix = nowUnix - retentionDays * 24 * 60 * 60;
const filter: Record<string, unknown> = {
must: [{ key: "kind", match: { value: "omniroute_memory" } }],
should: [
{ key: "expiresAtUnix", range: { lt: nowUnix } },
{ key: "createdAtUnix", range: { lt: cutoffUnix } },
],
};
// Count first (so we can show an actual number in the dashboard)
const countRes = await qdrantFetch(
cfg,
`/collections/${encodeURIComponent(cfg.collection)}/points/count`,
{
method: "POST",
body: JSON.stringify({ filter, exact: true }),
}
);
if (!countRes.ok) {
const text = await countRes.text().catch(() => "");
return {
ok: false,
deletedCount: 0,
latencyMs: Date.now() - start,
error: text.slice(0, 300) || `HTTP ${countRes.status}`,
};
}
const countData = (await countRes.json().catch(() => null)) as any;
const toDelete =
typeof countData?.result?.count === "number" && Number.isFinite(countData.result.count)
? Math.max(0, Math.round(countData.result.count))
: 0;
if (toDelete === 0) {
return { ok: true, deletedCount: 0, latencyMs: Date.now() - start };
}
const delRes = await qdrantFetch(
cfg,
`/collections/${encodeURIComponent(cfg.collection)}/points/delete?wait=true`,
{
method: "POST",
body: JSON.stringify({
filter,
}),
}
);
if (!delRes.ok) {
const text = await delRes.text().catch(() => "");
return {
ok: false,
deletedCount: 0,
latencyMs: Date.now() - start,
error: text.slice(0, 300) || `HTTP ${delRes.status}`,
};
}
return { ok: true, deletedCount: toDelete, latencyMs: Date.now() - start };
} catch (err) {
return {
ok: false,
deletedCount: 0,
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}