// --------------------------------------------------------------------------- // Memory Graph — v1 hybrid Qdrant search over graph nodes // --------------------------------------------------------------------------- import { getConfig } from "../../../../../config/loader.js"; import { usesConceptPageMemory } from "../../../../../config/memory-v3-gate.js"; import { isQdrantBreakerOpen } from "../../../../../persistence/embeddings/qdrant-circuit-breaker.js"; import { withQdrantBreaker } from "../../../../../persistence/embeddings/qdrant-circuit-breaker.js"; import { getQdrantClient, type QdrantSearchResult, type QdrantSparseVector, } from "../../../../../persistence/embeddings/qdrant-client.js"; import { getLogger } from "../../logging.js"; // Distinct from the all-tier `graph/graph-search.ts` module, which owns the // bare `graph-search` scope. The tier split left both emitting under one name; // log scopes are observable, so the v1 half takes its own. const log = getLogger("v1-graph-search"); export interface GraphSearchResult { nodeId: string; score: number; text: string; } /** * Semantic search across graph nodes in Qdrant. Returns scored node IDs * that the caller can hydrate from the graph store. * * Filters to `target_type: "graph_node"`. */ export async function searchGraphNodes( queryVector: number[], limit: number, sparseVector?: QdrantSparseVector, dateRange?: { afterMs?: number; beforeMs?: number }, ): Promise { // Concept-page memory owns the read path when active. The v1 `memory` // collection is in active retirement and a corrupted sparse segment can // OOM-crash the shared Qdrant process — short-circuiting here keeps v1 // background work and stale callers from taking it down. if (usesConceptPageMemory(getConfig().memory)) { return []; } if (isQdrantBreakerOpen()) { log.warn("Qdrant circuit breaker open, skipping graph search"); return []; } const client = getQdrantClient(); const mustNot: Record[] = [ { key: "_meta", match: { value: true } }, ]; // Use hybrid search (dense + sparse with RRF fusion) when a non-empty // sparse vector is available; otherwise fall back to dense-only search. if (sparseVector && sparseVector.indices.length > 0) { const must: Record[] = [ { key: "target_type", match: { value: "graph_node" } }, ]; if (dateRange?.afterMs != null) { must.push({ key: "created_at", range: { gte: dateRange.afterMs } }); } if (dateRange?.beforeMs != null) { must.push({ key: "created_at", range: { lte: dateRange.beforeMs } }); } const filter = { must, must_not: mustNot }; // RRF fuses per-modality top-N. A small prefetch (e.g. limit*3) silently // truncates good matches when the query is wordy or low-similarity, so // give RRF a meaningful candidate window with a generous floor. const prefetchLimit = Math.max(limit * 10, 200); const results: QdrantSearchResult[] = await withQdrantBreaker(() => client.hybridSearch({ denseVector: queryVector, sparseVector, filter, limit, prefetchLimit, }), ); return results.map((r) => ({ nodeId: r.payload.target_id, score: r.score, text: r.payload.text, })); } // Dense-only fallback const denseMusts: Record[] = [ { key: "target_type", match: { value: "graph_node" }, }, ]; if (dateRange?.afterMs != null) { denseMusts.push({ key: "created_at", range: { gte: dateRange.afterMs } }); } if (dateRange?.beforeMs != null) { denseMusts.push({ key: "created_at", range: { lte: dateRange.beforeMs } }); } const filter: Record = { must: denseMusts, must_not: mustNot, }; const results: QdrantSearchResult[] = await withQdrantBreaker(async () => { return client.search(queryVector, limit, filter); }); return results.map((r) => ({ nodeId: r.payload.target_id, score: r.score, text: r.payload.text, })); }