diff --git a/docs/cli/me-memory.md b/docs/cli/me-memory.md index d30e2f7..3b0ca3b 100644 --- a/docs/cli/me-memory.md +++ b/docs/cli/me-memory.md @@ -81,6 +81,7 @@ me memory search [query] [options] | `--meta ` | Metadata filter as JSON. | | `--limit ` | Max results (default: 10). | | `--candidate-limit ` | Pre-RRF candidate pool size. | +| `--semantic-threshold ` | Minimum semantic similarity score, 0-1. | | `--temporal-contains ` | Memory must contain this point in time. | | `--temporal-overlaps ` | Memory must overlap this range (`start,end`). | | `--temporal-within ` | Memory must be within this range (`start,end`). | diff --git a/docs/cli/me-serve.md b/docs/cli/me-serve.md index 4bc1fc2..5463002 100644 --- a/docs/cli/me-serve.md +++ b/docs/cli/me-serve.md @@ -54,7 +54,7 @@ The UI talks to whichever engine is active for the current server — same resol ``` - **Tree** (left): ltree paths as collapsible nodes, memories as leaves. Right-click a node for a context menu (delete memory / delete subtree). -- **Search** (top): simple hybrid search by default; flip to Advanced for every field accepted by `memory.search` (semantic, fulltext, grep, tree, meta, temporal, limit, candidateLimit, weights, orderBy). +- **Search** (top): simple hybrid search by default; flip to Advanced for every field accepted by `memory.search` (semantic, fulltext, grep, tree, meta, temporal, limit, candidateLimit, semanticThreshold, weights, orderBy). - **Viewer / Editor** (right): rendered Markdown with syntax highlighting, or the Monaco editor with YAML frontmatter + body. The copy button copies the Markdown source with frontmatter. Save is disabled until you make a valid change. The read-only metadata panel sits below. - **URL state**: filter fields and the selected memory id are reflected in the URL, so any view can be shared or bookmarked. diff --git a/docs/mcp/me_memory_search.md b/docs/mcp/me_memory_search.md index bad9858..2197ace 100644 --- a/docs/mcp/me_memory_search.md +++ b/docs/mcp/me_memory_search.md @@ -16,6 +16,7 @@ Supports three search modes: **semantic** (meaning-based), **fulltext** (keyword | `temporal` | `object \| null` | no | Temporal filter. Omit or pass `null` to skip. | | `weights` | `object \| null` | no | Weights for hybrid search ranking. Omit or pass `null` for defaults. | | `candidateLimit` | `integer \| null` | no | Candidates per search mode before RRF fusion. Omit or pass `null` for default (30). | +| `semanticThreshold` | `number \| null` | no | Minimum semantic similarity score (0-1) for vector candidates. Omit or pass `null` to skip. | | `limit` | `integer \| null` | no | Maximum number of results. Omit or pass `null` for default (10). Max: 1000. | | `order_by` | `string \| null` | no | Sort direction for filter-only searches: `"asc"` or `"desc"`. Default: `"desc"`. Omit or pass `null` for default. | @@ -83,7 +84,8 @@ See [Tree filter syntax](../concepts.md#tree-filter-syntax) for the full referen ```json { "semantic": "how does authentication work", - "limit": 10 + "limit": 10, + "semanticThreshold": 0.7 } ``` diff --git a/docs/typescript-client.md b/docs/typescript-client.md index ebc7fd2..376cbf8 100644 --- a/docs/typescript-client.md +++ b/docs/typescript-client.md @@ -155,6 +155,7 @@ const { results, total } = await me.memory.search({ // Tuning limit: 10, // max results (1-1000) candidateLimit: 30, // candidates per mode before RRF fusion + semanticThreshold: 0.7, // optional min semantic score (0-1) weights: { semantic: 0.7, fulltext: 0.3 }, orderBy: "desc", // for filter-only queries (no search) }); diff --git a/packages/cli/commands/memory.ts b/packages/cli/commands/memory.ts index c9e6cf4..6d0405f 100644 --- a/packages/cli/commands/memory.ts +++ b/packages/cli/commands/memory.ts @@ -220,6 +220,7 @@ function createMemorySearchCommand(): Command { .option("--meta ", "metadata filter (JSON)") .option("--limit ", "max results", "10") .option("--candidate-limit ", "pre-RRF candidate pool size") + .option("--semantic-threshold ", "minimum semantic score (0-1)") .option("--temporal-contains ", "memory must contain this point") .option("--temporal-overlaps ", "memory must overlap (start,end)") .option("--temporal-within ", "memory must be within (start,end)") @@ -322,6 +323,8 @@ function createMemorySearchCommand(): Command { if (weights) params.weights = weights; if (opts.candidateLimit) params.candidateLimit = Number.parseInt(opts.candidateLimit, 10); + if (opts.semanticThreshold) + params.semanticThreshold = Number.parseFloat(opts.semanticThreshold); if (opts.orderBy) params.orderBy = opts.orderBy; const result = await engine.memory.search( diff --git a/packages/cli/mcp/server.ts b/packages/cli/mcp/server.ts index bdf5907..c9ea69b 100644 --- a/packages/cli/mcp/server.ts +++ b/packages/cli/mcp/server.ts @@ -204,6 +204,15 @@ Docs: ${docUrl("me_memory_search")}`, .describe( "Candidates per search mode before RRF fusion (0 = default 30)", ), + semanticThreshold: z + .number() + .min(0) + .max(1) + .optional() + .nullable() + .describe( + "Minimum semantic similarity score (0-1) for vector candidates", + ), limit: z .number() .int() @@ -250,6 +259,7 @@ Docs: ${docUrl("me_memory_search")}`, ? args.candidateLimit : undefined, limit: args.limit && args.limit > 0 ? args.limit : undefined, + semanticThreshold: args.semanticThreshold ?? undefined, orderBy: (args.order_by as "asc" | "desc" | null | undefined) ?? undefined, }); diff --git a/packages/engine/ops/memory.ts b/packages/engine/ops/memory.ts index 896d9c0..ad13142 100644 --- a/packages/engine/ops/memory.ts +++ b/packages/engine/ops/memory.ts @@ -316,10 +316,18 @@ async function buildSemanticQuery( params: FilterParams & { embedding: number[]; limit: number; + semanticThreshold?: number; }, ): Promise { - const { clauses, values } = buildCommonFilters(params, 2); // $1=embedding, $2=limit - + const hasSemanticThreshold = typeof params.semanticThreshold === "number"; + const { clauses, values } = buildCommonFilters( + params, + hasSemanticThreshold ? 3 : 2, + ); // $1=embedding, $2=limit, optional $3=semanticThreshold + + const semanticThresholdClause = hasSemanticThreshold + ? "AND (1 - (embedding <=> $1::halfvec)) >= $3" + : "AND (embedding <=> $1::halfvec) < 1.0"; const whereClause = clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : ""; // Format embedding as PostgreSQL array literal @@ -333,7 +341,7 @@ async function buildSemanticQuery( (1 - (embedding <=> $1::halfvec)) as score FROM ${schema}.memory WHERE embedding IS NOT NULL - AND (embedding <=> $1::halfvec) < 1.0 + ${semanticThresholdClause} ${whereClause} ORDER BY score DESC, created_at DESC LIMIT $2 @@ -342,6 +350,7 @@ async function buildSemanticQuery( return sql.unsafe(query, [ embeddingLiteral, params.limit, + ...(hasSemanticThreshold ? [params.semanticThreshold] : []), ...values, ]); } @@ -626,6 +635,7 @@ export function memoryOps(ctx: OpsContext) { temporal, limit = 10, candidateLimit = 30, + semanticThreshold, weights = { fulltext: 1.0, semantic: 1.0 }, orderBy = "desc", } = params; @@ -651,6 +661,7 @@ export function memoryOps(ctx: OpsContext) { tree, temporal, limit: candidateLimit, + semanticThreshold, }), ]); @@ -689,6 +700,7 @@ export function memoryOps(ctx: OpsContext) { tree, temporal, limit, + semanticThreshold, }); results = rows.map(rowToSearchResult); } else { diff --git a/packages/engine/types.ts b/packages/engine/types.ts index 3071c29..66bb0cf 100644 --- a/packages/engine/types.ts +++ b/packages/engine/types.ts @@ -210,6 +210,8 @@ export interface SearchParams { limit?: number; /** Candidates per search mode before RRF fusion */ candidateLimit?: number; + /** Minimum semantic similarity score (0-1) for vector candidates */ + semanticThreshold?: number; /** Weights for hybrid search */ weights?: SearchWeights; /** Sort direction for filter-only searches */ diff --git a/packages/protocol/engine/memory.ts b/packages/protocol/engine/memory.ts index 0be6535..da65f12 100644 --- a/packages/protocol/engine/memory.ts +++ b/packages/protocol/engine/memory.ts @@ -92,6 +92,7 @@ export const memorySearchParams = z.object({ temporal: temporalFilterSchema.optional().nullable(), limit: z.number().int().min(1).max(1000).optional(), candidateLimit: z.number().int().min(1).max(1000).optional(), + semanticThreshold: z.number().min(0).max(1).optional().nullable(), weights: searchWeightsSchema.optional().nullable(), orderBy: z.enum(["asc", "desc"]).optional(), }); diff --git a/packages/server/rpc/engine/memory.ts b/packages/server/rpc/engine/memory.ts index bf9b939..c877b34 100644 --- a/packages/server/rpc/engine/memory.ts +++ b/packages/server/rpc/engine/memory.ts @@ -318,6 +318,7 @@ async function memorySearch( temporal: parseTemporalFilter(params.temporal), limit: params.limit, candidateLimit: params.candidateLimit, + semanticThreshold: params.semanticThreshold ?? undefined, weights: params.weights ?? undefined, orderBy: params.orderBy, }); diff --git a/packages/web/src/api/queries.ts b/packages/web/src/api/queries.ts index 7fba9ca..c3e2e99 100644 --- a/packages/web/src/api/queries.ts +++ b/packages/web/src/api/queries.ts @@ -171,6 +171,9 @@ export function normalizeSearchParams( if (typeof params.candidateLimit === "number") { out.candidateLimit = params.candidateLimit; } + if (typeof params.semanticThreshold === "number") { + out.semanticThreshold = params.semanticThreshold; + } const hasAnyFilter = out.semantic !== undefined || diff --git a/packages/web/src/components/search/AdvancedSearchPanel.tsx b/packages/web/src/components/search/AdvancedSearchPanel.tsx index bd90bee..86735dc 100644 --- a/packages/web/src/components/search/AdvancedSearchPanel.tsx +++ b/packages/web/src/components/search/AdvancedSearchPanel.tsx @@ -121,7 +121,7 @@ export function AdvancedSearchPanel() { setAdvanced({ limit: v })} - placeholder="1000 (default)" + placeholder="50 semantic-only; otherwise 1000" min={1} max={1000} /> @@ -137,6 +137,20 @@ export function AdvancedSearchPanel() { /> + + setAdvanced({ semanticThreshold: v })} + placeholder="optional min score" + step="0.01" + min={0} + max={1} + /> +

+ Filters semantic candidates before ranking. Higher is stricter. +

+
+ { }, limit: "500", candidateLimit: "200", + semanticThreshold: "0.72", weightsSemantic: "0.7", weightsFulltext: "0.3", orderBy: "desc", diff --git a/packages/web/src/lib/url-state.ts b/packages/web/src/lib/url-state.ts index 326907b..2b2b6f7 100644 --- a/packages/web/src/lib/url-state.ts +++ b/packages/web/src/lib/url-state.ts @@ -42,6 +42,7 @@ export function encodeUrlState( if (a.temporal.end) p.set("temporal_end", a.temporal.end); if (a.limit) p.set("limit", a.limit); if (a.candidateLimit) p.set("candidate_limit", a.candidateLimit); + if (a.semanticThreshold) p.set("semantic_threshold", a.semanticThreshold); if (a.weightsSemantic) p.set("weights_semantic", a.weightsSemantic); if (a.weightsFulltext) p.set("weights_fulltext", a.weightsFulltext); if (a.orderBy) p.set("order_by", a.orderBy); @@ -80,6 +81,7 @@ export function decodeUrlState(search: string): { }, limit: p.get("limit") ?? "", candidateLimit: p.get("candidate_limit") ?? "", + semanticThreshold: p.get("semantic_threshold") ?? "", weightsSemantic: p.get("weights_semantic") ?? "", weightsFulltext: p.get("weights_fulltext") ?? "", orderBy: coerceOrderBy(p.get("order_by")), diff --git a/packages/web/src/store/filter.test.ts b/packages/web/src/store/filter.test.ts index 10bfbe8..05b6d6d 100644 --- a/packages/web/src/store/filter.test.ts +++ b/packages/web/src/store/filter.test.ts @@ -99,6 +99,31 @@ describe("mergeMetaJsonFilter", () => { }); describe("selectSearchParams", () => { + test("uses a smaller default limit for advanced semantic-only search", () => { + expect(selectSearchParams(withAdvanced({ semantic: "hello" })).limit).toBe( + 50, + ); + }); + + test("preserves explicit limits for advanced semantic-only search", () => { + expect( + selectSearchParams(withAdvanced({ semantic: "hello", limit: "10" })) + .limit, + ).toBe(10); + }); + + test("passes semantic threshold through only when semantic search is set", () => { + expect( + selectSearchParams( + withAdvanced({ semantic: "hello", semanticThreshold: "0.72" }), + ).semanticThreshold, + ).toBe(0.72); + expect( + selectSearchParams(withAdvanced({ semanticThreshold: "0.72" })) + .semanticThreshold, + ).toBeUndefined(); + }); + test("converts datetime-local temporal contains filters to offset ISO datetimes", () => { const start = "2026-01-01T12:34:56"; @@ -159,6 +184,7 @@ describe("summarizeFilter (advanced mode)", () => { tree: "work.*", limit: "50", candidateLimit: "25", + semanticThreshold: "0.72", orderBy: "asc", }), ); @@ -169,6 +195,7 @@ describe("summarizeFilter (advanced mode)", () => { `tree: work.*`, `limit: 50`, `candidateLimit: 25`, + `semanticThreshold: 0.72`, `order: asc`, ]); }); diff --git a/packages/web/src/store/filter.ts b/packages/web/src/store/filter.ts index 994881a..946f79c 100644 --- a/packages/web/src/store/filter.ts +++ b/packages/web/src/store/filter.ts @@ -33,9 +33,11 @@ export interface AdvancedFilter { start: string; end: string; }; - /** Empty string means "use default (1000)". */ + /** Empty string means "use default (1000, or 50 for semantic-only)". */ limit: string; candidateLimit: string; + /** Minimum vector similarity score (0-1) for semantic candidates. */ + semanticThreshold: string; weightsSemantic: string; weightsFulltext: string; orderBy: "" | "asc" | "desc"; @@ -67,6 +69,7 @@ export const EMPTY_ADVANCED: AdvancedFilter = { temporal: { mode: "overlaps", start: "", end: "" }, limit: "", candidateLimit: "", + semanticThreshold: "", weightsSemantic: "", weightsFulltext: "", orderBy: "", @@ -147,11 +150,20 @@ export function selectSearchParams(state: FilterState): MemorySearchParams { if (temporal) params.temporal = temporal; const limit = parseIntOrUndef(a.limit); - if (limit !== undefined) params.limit = limit; + if (limit !== undefined) { + params.limit = limit; + } else if (params.semantic && !params.fulltext) { + params.limit = 50; + } const candidateLimit = parseIntOrUndef(a.candidateLimit); if (candidateLimit !== undefined) params.candidateLimit = candidateLimit; + const semanticThreshold = parseFloatInRangeOrUndef(a.semanticThreshold, 0, 1); + if (semanticThreshold !== undefined && params.semantic) { + params.semanticThreshold = semanticThreshold; + } + const ws = parseFloatOrUndef(a.weightsSemantic); const wf = parseFloatOrUndef(a.weightsFulltext); if (ws !== undefined || wf !== undefined) { @@ -197,6 +209,16 @@ function parseFloatOrUndef(s: string): number | undefined { return Number.isFinite(n) ? n : undefined; } +function parseFloatInRangeOrUndef( + s: string, + min: number, + max: number, +): number | undefined { + const n = parseFloatOrUndef(s); + if (n === undefined) return undefined; + return n >= min && n <= max ? n : undefined; +} + /** * Human-readable summary of the active filter, used by the collapsed * search bar. @@ -238,6 +260,9 @@ export function summarizeFilter(state: FilterState): { if (a.candidateLimit.trim()) { chips.push(`candidateLimit: ${a.candidateLimit.trim()}`); } + if (a.semantic.trim() && a.semanticThreshold.trim()) { + chips.push(`semanticThreshold: ${a.semanticThreshold.trim()}`); + } const ws = a.weightsSemantic.trim(); const wf = a.weightsFulltext.trim();