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:
diegosouzapw
2026-05-28 21:46:54 -03:00
parent 958418f9d9
commit feb0e983eb
3 changed files with 272 additions and 8 deletions

View File

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

View File

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

View File

@@ -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> {