mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(memory): activate semantic + hybrid + Qdrant tier-2 in chat hot path
Code review of plan 21 found two functional gaps: FAIL #1 — toMemoryRetrievalConfig never forwarded the user query, so the gate `if (config.query && useModernTable)` in retrieval.ts was always false in the chat hot path. semantic/hybrid silently fell back to "ORDER BY created_at DESC LIMIT 100", the pre-plan-21 behaviour. sqlite-vec + RRF only ran in the Playground (retrievePreview, which takes `query` positionally). FAIL #2 — Bug #1 was not closed: searchSemanticMemory (Qdrant) was only imported by /api/settings/qdrant/search, never by retrieval. With vectorStore="qdrant", engineStatus reported backend="qdrant" but retrieveMemories/retrievePreview kept using sqlite-vec — the status diverged from the actual search path. - settings.ts: toMemoryRetrievalConfig(settings, { query? }) accepts and forwards the query. - chatCore.ts: extracts the last user message from body.messages or body.input (Chat + Responses APIs). - retrieval.ts: adds a Qdrant branch in case "semantic", case "hybrid" and retrievePreview; falls through to sqlite-vec on failure or empty results so the §7 "degrades to sqlite-vec / FTS5" contract holds. - engineStatus only reports backend="qdrant" when the user opted in (settings.vectorStore === "qdrant") and Qdrant is healthy. - memories[] tier union includes "qdrant". 331 memory unit tests pass; typecheck:core / typecheck:noimplicit:core / check:cycles clean.
This commit is contained in:
@@ -2224,9 +2224,60 @@ export async function handleChatCore({
|
||||
})
|
||||
) {
|
||||
try {
|
||||
// Plan 21 FAIL #1 fix: extract the last user message and pass it as
|
||||
// `query`. Without this, `config.query` is undefined in retrieveMemories
|
||||
// and the semantic/hybrid branches (sqlite-vec + RRF, and Qdrant
|
||||
// tier-2) never fire from the chat hot path — they only fire in the
|
||||
// Playground (retrievePreview, which gets `query` as a positional arg).
|
||||
const lastUserQuery = ((): string => {
|
||||
function pickFrom(arr: unknown[]): string {
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
const item = arr[i] as Record<string, unknown> | undefined;
|
||||
if (!item) continue;
|
||||
// Chat API: role==="user"; Responses API: role on input items, or
|
||||
// type==="input_text"; some clients omit role entirely on the
|
||||
// first input item — accept those too as a last resort.
|
||||
if (
|
||||
item.role !== undefined &&
|
||||
item.role !== "user"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const content = item.content ?? item.text;
|
||||
if (typeof content === "string" && content.trim().length > 0) {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
const parts: string[] = [];
|
||||
for (const p of content) {
|
||||
if (typeof p === "string") {
|
||||
parts.push(p);
|
||||
} else if (p && typeof p === "object") {
|
||||
const pp = p as Record<string, unknown>;
|
||||
const t = pp.text ?? pp.input_text;
|
||||
if (typeof t === "string") parts.push(t);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) return parts.join(" ").trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
const b = body as Record<string, unknown>;
|
||||
if (Array.isArray(b.messages)) {
|
||||
const r = pickFrom(b.messages);
|
||||
if (r) return r;
|
||||
}
|
||||
if (Array.isArray(b.input)) {
|
||||
const r = pickFrom(b.input);
|
||||
if (r) return r;
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
|
||||
const memories = await retrieveMemories(
|
||||
memoryOwnerId,
|
||||
toMemoryRetrievalConfig(memorySettings)
|
||||
toMemoryRetrievalConfig(memorySettings, { query: lastUserQuery })
|
||||
);
|
||||
if (memories.length > 0) {
|
||||
const injected = injectMemory(
|
||||
|
||||
@@ -7,7 +7,7 @@ import { resolveEmbeddingSource, embed } from "./embedding";
|
||||
import { getVectorStore } from "./vectorStore";
|
||||
import { getMemorySettings } from "./settings";
|
||||
import { stats as embeddingCacheStats } from "./embedding/cache";
|
||||
import { getQdrantConfig, checkQdrantHealth } from "./qdrant";
|
||||
import { getQdrantConfig, checkQdrantHealth, searchSemanticMemory } from "./qdrant";
|
||||
import type { MemoryEngineStatus } from "@/shared/schemas/memory";
|
||||
|
||||
const log = logger("MEMORY_RETRIEVAL");
|
||||
@@ -341,8 +341,13 @@ export async function retrieveMemories(
|
||||
const strategy = normalizedConfig.retrievalStrategy;
|
||||
|
||||
const db = getDbInstance();
|
||||
const memories: Array<{ memory: Memory; score: number; tier: "fts5" | "vector" | "hybrid-rrf" }> =
|
||||
[];
|
||||
// Plan 21 FAIL #2 fix: include "qdrant" in the tier union so that the
|
||||
// Qdrant tier-2 branch in semantic/hybrid below can push hits with that tier.
|
||||
const memories: Array<{
|
||||
memory: Memory;
|
||||
score: number;
|
||||
tier: "fts5" | "vector" | "hybrid-rrf" | "qdrant";
|
||||
}> = [];
|
||||
let totalTokens = 0;
|
||||
|
||||
const useModernTable = hasTable("memories");
|
||||
@@ -405,6 +410,67 @@ export async function retrieveMemories(
|
||||
if (config.query && useModernTable) {
|
||||
const resolution = resolveEmbeddingSource(settings);
|
||||
if (resolution.source !== null) {
|
||||
// Plan 21 FAIL #2 fix (Bug #1): when the user opted into Qdrant
|
||||
// (settings.vectorStore === "qdrant"), route the semantic search to
|
||||
// Qdrant first. If Qdrant is unreachable or returns nothing, fall
|
||||
// through to sqlite-vec — preserving the "degrades to sqlite-vec /
|
||||
// FTS5" contract of §7.
|
||||
if (settings.vectorStore === "qdrant") {
|
||||
try {
|
||||
const qres = await searchSemanticMemory(config.query, 100, {
|
||||
apiKeyId,
|
||||
});
|
||||
if (qres.ok && qres.results && qres.results.length > 0) {
|
||||
const hitIds = qres.results.map((r) => r.id);
|
||||
const hitMemories = fetchMemoriesByIds(hitIds);
|
||||
const scoreMap = new Map(
|
||||
qres.results.map((r) => [r.id, r.score])
|
||||
);
|
||||
let qdrantItems = hitMemories.map((m) => ({
|
||||
memory: m,
|
||||
score: scoreMap.get(m.id) ?? 0,
|
||||
tier: "qdrant" as const,
|
||||
}));
|
||||
if (
|
||||
settings.rerankEnabled &&
|
||||
settings.rerankProviderModel &&
|
||||
config.query
|
||||
) {
|
||||
qdrantItems = (await applyRerank(
|
||||
qdrantItems,
|
||||
config.query,
|
||||
settings.rerankProviderModel
|
||||
)) as typeof qdrantItems;
|
||||
}
|
||||
for (const entry of qdrantItems) {
|
||||
const memoryTokens = estimateTokens(entry.memory.content);
|
||||
if (totalTokens + memoryTokens > maxTokens) {
|
||||
if (memories.length === 0) {
|
||||
memories.push(entry);
|
||||
totalTokens += memoryTokens;
|
||||
}
|
||||
break;
|
||||
}
|
||||
memories.push(entry);
|
||||
totalTokens += memoryTokens;
|
||||
}
|
||||
log.info("memory.retrieval.complete", {
|
||||
apiKeyId,
|
||||
count: memories.length,
|
||||
tier: "qdrant",
|
||||
});
|
||||
return memories.map((e) => e.memory);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn("memory.retrieval.qdrant.fail", {
|
||||
error: sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : String(err)
|
||||
),
|
||||
});
|
||||
// fall through to sqlite-vec degradation
|
||||
}
|
||||
}
|
||||
|
||||
const embeddingResult = await embed(config.query, settings);
|
||||
if ("vector" in embeddingResult) {
|
||||
const vec = getVectorStore();
|
||||
@@ -481,6 +547,66 @@ export async function retrieveMemories(
|
||||
if (config.query && useModernTable) {
|
||||
const resolution = resolveEmbeddingSource(settings);
|
||||
if (resolution.source !== null) {
|
||||
// Plan 21 FAIL #2 fix: tier-2 Qdrant route also covers hybrid strategy.
|
||||
// Qdrant is vector-only (no FTS5 fusion), so this acts as the vector
|
||||
// half of the hybrid contract. If Qdrant is down or empty, fall through
|
||||
// to sqlite-vec's hybrid RRF (FTS5 + vector).
|
||||
if (settings.vectorStore === "qdrant") {
|
||||
try {
|
||||
const qres = await searchSemanticMemory(config.query, 100, {
|
||||
apiKeyId,
|
||||
});
|
||||
if (qres.ok && qres.results && qres.results.length > 0) {
|
||||
const hitIds = qres.results.map((r) => r.id);
|
||||
const hitMemories = fetchMemoriesByIds(hitIds);
|
||||
const scoreMap = new Map(
|
||||
qres.results.map((r) => [r.id, r.score])
|
||||
);
|
||||
let qdrantItems = hitMemories.map((m) => ({
|
||||
memory: m,
|
||||
score: scoreMap.get(m.id) ?? 0,
|
||||
tier: "qdrant" as const,
|
||||
}));
|
||||
if (
|
||||
settings.rerankEnabled &&
|
||||
settings.rerankProviderModel &&
|
||||
config.query
|
||||
) {
|
||||
qdrantItems = (await applyRerank(
|
||||
qdrantItems,
|
||||
config.query,
|
||||
settings.rerankProviderModel
|
||||
)) as typeof qdrantItems;
|
||||
}
|
||||
for (const entry of qdrantItems) {
|
||||
const memoryTokens = estimateTokens(entry.memory.content);
|
||||
if (totalTokens + memoryTokens > maxTokens) {
|
||||
if (memories.length === 0) {
|
||||
memories.push(entry);
|
||||
totalTokens += memoryTokens;
|
||||
}
|
||||
break;
|
||||
}
|
||||
memories.push(entry);
|
||||
totalTokens += memoryTokens;
|
||||
}
|
||||
log.info("memory.retrieval.complete", {
|
||||
apiKeyId,
|
||||
count: memories.length,
|
||||
tier: "qdrant",
|
||||
});
|
||||
return memories.map((e) => e.memory);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn("memory.retrieval.qdrant.fail", {
|
||||
error: sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : String(err)
|
||||
),
|
||||
});
|
||||
// fall through to sqlite-vec hybrid RRF
|
||||
}
|
||||
}
|
||||
|
||||
const embeddingResult = await embed(config.query, settings);
|
||||
if ("vector" in embeddingResult) {
|
||||
const vec = getVectorStore();
|
||||
@@ -654,6 +780,78 @@ export async function retrievePreview(
|
||||
|
||||
if (strategy === "semantic" || strategy === "hybrid") {
|
||||
if (resolution.source !== null && query) {
|
||||
// Plan 21 FAIL #2 fix: when settings.vectorStore === "qdrant",
|
||||
// the Playground must show what production retrieval would actually
|
||||
// see — Qdrant tier-2. Falls through to sqlite-vec on failure.
|
||||
if (settings.vectorStore === "qdrant") {
|
||||
try {
|
||||
const qres = await searchSemanticMemory(
|
||||
query,
|
||||
limit,
|
||||
apiKeyId ? { apiKeyId } : undefined
|
||||
);
|
||||
if (qres.ok && qres.results && qres.results.length > 0) {
|
||||
const hitIds = qres.results.map((r) => r.id);
|
||||
const hitMemories = fetchMemoriesByIds(hitIds).slice(0, limit);
|
||||
const scoreMap = new Map(
|
||||
qres.results.map((r) => [r.id, r.score])
|
||||
);
|
||||
let items: Array<{
|
||||
memory: Memory;
|
||||
score: number;
|
||||
tier: "qdrant";
|
||||
vecScore: number | null;
|
||||
ftsScore: null;
|
||||
}> = hitMemories.map((m) => ({
|
||||
memory: m,
|
||||
score: scoreMap.get(m.id) ?? 0,
|
||||
tier: "qdrant" as const,
|
||||
vecScore: scoreMap.get(m.id) ?? null,
|
||||
ftsScore: null,
|
||||
}));
|
||||
|
||||
if (settings.rerankEnabled && settings.rerankProviderModel) {
|
||||
items = (await applyRerank(
|
||||
items,
|
||||
query,
|
||||
settings.rerankProviderModel
|
||||
)) as typeof items;
|
||||
rerankApplied = true;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (result.length >= limit) break;
|
||||
const tokens = estimateTokens(item.memory.content);
|
||||
if (totalTokens + tokens > maxTokens && result.length > 0) break;
|
||||
result.push({ ...item, tokens });
|
||||
totalTokens += tokens;
|
||||
}
|
||||
|
||||
return {
|
||||
items: result,
|
||||
resolution: {
|
||||
embeddingSource: resolution.source,
|
||||
embeddingModel: resolution.model,
|
||||
vectorStore: "qdrant",
|
||||
strategyUsed: strategy,
|
||||
rerankApplied,
|
||||
fallbackReason: null,
|
||||
},
|
||||
totalTokens,
|
||||
budgetMaxTokens: maxTokens,
|
||||
};
|
||||
}
|
||||
// Qdrant returned nothing — fall through to sqlite-vec for parity
|
||||
// with production (so the Playground reflects the same fallback).
|
||||
fallbackReason = "Qdrant retornou 0 resultados — fallback p/ sqlite-vec";
|
||||
} catch (err: unknown) {
|
||||
fallbackReason = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
log.warn("memory.preview.qdrant.fail", { error: fallbackReason });
|
||||
}
|
||||
}
|
||||
|
||||
const embeddingResult = await embed(query, settings);
|
||||
|
||||
if ("vector" in embeddingResult) {
|
||||
@@ -907,8 +1105,11 @@ export async function engineStatus(): Promise<MemoryEngineStatus> {
|
||||
qdrantLatencyMs = health.latencyMs;
|
||||
qdrantError = health.error ? sanitizeErrorMessage(health.error) : null;
|
||||
|
||||
// If Qdrant is enabled and healthy, report it as the vector store backend
|
||||
if (qdrantHealthy) {
|
||||
// Plan 21 FAIL #2 fix: only claim vectorStore=qdrant when the user
|
||||
// explicitly opted into it (settings.vectorStore === "qdrant"). Before,
|
||||
// the status reported "qdrant" whenever the cluster was healthy even
|
||||
// though the retrieval path always used sqlite-vec — engineStatus lied.
|
||||
if (qdrantHealthy && settings.vectorStore === "qdrant") {
|
||||
vecBackend = "qdrant";
|
||||
vecAvailable = true;
|
||||
vecReason = `Qdrant configurado em ${qdrantCfg.host}:${qdrantCfg.port}`;
|
||||
|
||||
@@ -130,10 +130,13 @@ export function toMemorySettingsUpdates(
|
||||
return updates;
|
||||
}
|
||||
|
||||
export function toMemoryRetrievalConfig(settings: MemorySettings): Partial<MemoryConfig> {
|
||||
export function toMemoryRetrievalConfig(
|
||||
settings: MemorySettings,
|
||||
extra: { query?: string } = {}
|
||||
): Partial<MemoryConfig> & { query?: string } {
|
||||
const enabled = settings.enabled && settings.maxTokens > 0;
|
||||
|
||||
return {
|
||||
const config: Partial<MemoryConfig> & { query?: string } = {
|
||||
enabled,
|
||||
maxTokens: enabled ? settings.maxTokens : 0,
|
||||
retrievalStrategy: settings.strategy === "recent" ? "exact" : settings.strategy,
|
||||
@@ -142,6 +145,15 @@ export function toMemoryRetrievalConfig(settings: MemorySettings): Partial<Memor
|
||||
retentionDays: settings.retentionDays,
|
||||
scope: "apiKey",
|
||||
};
|
||||
|
||||
// Plan 21 FAIL #1 fix: forward the last user message as `query` so that
|
||||
// semantic / hybrid strategies actually exercise the vector store in the
|
||||
// chat hot path (chatCore.ts), not only in the Playground.
|
||||
if (extra.query && extra.query.trim().length > 0) {
|
||||
config.query = extra.query.trim();
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function getMemorySettings(): Promise<MemorySettings> {
|
||||
|
||||
Reference in New Issue
Block a user