2026-05-17 09:50:59 -07:00
|
|
|
import { and, eq, inArray } from "drizzle-orm";
|
2026-05-15 10:57:17 -07:00
|
|
|
import { db, pg } from "@/lib/db/client";
|
|
|
|
|
import { projects } from "@/lib/db/schema";
|
|
|
|
|
import { embedText } from "@/lib/embedder";
|
2026-05-17 09:50:59 -07:00
|
|
|
import { readableProjectIds } from "@/lib/access";
|
2026-05-15 10:57:17 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Shared search helper. Used by:
|
|
|
|
|
* - the MCP `memory.search` tool (returns rich rank data for the model)
|
|
|
|
|
* - the Web UI memories page (renders human-readable results)
|
|
|
|
|
*
|
|
|
|
|
* Performs three candidate fetches in parallel — pgvector cosine, FTS
|
|
|
|
|
* ts_rank_cd, tag-set overlap — then fuses with Reciprocal Rank Fusion
|
|
|
|
|
* (k=60). Returns top-N with per-source rank info attached.
|
2026-05-17 09:50:59 -07:00
|
|
|
*
|
|
|
|
|
* Sharing model: a user can see memories they OWN (user_id = U) plus
|
|
|
|
|
* project-scope memories under any project that's been shared with one
|
|
|
|
|
* of their groups (any access — ro is enough to read). The three CTEs
|
|
|
|
|
* extend their WHERE clauses accordingly.
|
2026-05-15 10:57:17 -07:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export interface SearchFilters {
|
|
|
|
|
scope?: "project" | "user";
|
|
|
|
|
projectKey?: string;
|
|
|
|
|
tags?: string[];
|
2026-05-17 09:50:59 -07:00
|
|
|
/**
|
|
|
|
|
* Group names the requesting user is a member of. Drives shared-
|
|
|
|
|
* project visibility. An undefined value is treated as `[]` (no
|
|
|
|
|
* shared visibility) — pass through `UserContext.groups`.
|
|
|
|
|
*/
|
|
|
|
|
groupNames?: string[];
|
2026-05-17 17:04:00 -07:00
|
|
|
/**
|
|
|
|
|
* Minimum RRF score a hit must clear. Default `undefined` = no extra
|
|
|
|
|
* filter (current behavior — every fused result is returned). Set to
|
|
|
|
|
* e.g. 0.025 to require at least two rankers to fire at rank 1.
|
|
|
|
|
*/
|
|
|
|
|
minScore?: number;
|
2026-05-15 10:57:17 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SearchHit {
|
|
|
|
|
id: string;
|
|
|
|
|
rank: {
|
|
|
|
|
rrfScore: number;
|
|
|
|
|
vectorRank: number | null;
|
|
|
|
|
ftsRank: number | null;
|
|
|
|
|
tagRank: number | null;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SearchResult {
|
|
|
|
|
hits: SearchHit[];
|
|
|
|
|
debug: { vec: number; fts: number; tag: number };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CANDIDATES = 50;
|
|
|
|
|
const RRF_K = 60;
|
|
|
|
|
|
|
|
|
|
function toVectorLiteral(v: number[]): string {
|
|
|
|
|
return `[${v.join(",")}]`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 09:50:59 -07:00
|
|
|
async function resolveProjectIdForKey(
|
2026-05-15 10:57:17 -07:00
|
|
|
userId: string,
|
2026-05-17 09:50:59 -07:00
|
|
|
groupNames: string[],
|
|
|
|
|
projectKey: string,
|
2026-05-15 10:57:17 -07:00
|
|
|
): Promise<string | null> {
|
2026-05-17 09:50:59 -07:00
|
|
|
// First check owned. Owned wins on key collision (matches
|
|
|
|
|
// project.identify's priority).
|
|
|
|
|
const owned = await db
|
2026-05-15 10:57:17 -07:00
|
|
|
.select({ id: projects.id })
|
|
|
|
|
.from(projects)
|
|
|
|
|
.where(and(eq(projects.userId, userId), eq(projects.key, projectKey)))
|
|
|
|
|
.limit(1);
|
2026-05-17 09:50:59 -07:00
|
|
|
if (owned[0]) return owned[0].id;
|
|
|
|
|
|
|
|
|
|
if (groupNames.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
// Then any shared project with that key. The user is allowed to read
|
|
|
|
|
// it; per-project authorization is enforced by the calling code's IN
|
|
|
|
|
// clause against `accessibleIds`.
|
|
|
|
|
const accessibleIds = await readableProjectIds(userId, groupNames);
|
|
|
|
|
if (accessibleIds.length === 0) return null;
|
|
|
|
|
const shared = await db
|
|
|
|
|
.select({ id: projects.id })
|
|
|
|
|
.from(projects)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(projects.key, projectKey),
|
|
|
|
|
inArray(projects.id, accessibleIds),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.limit(1);
|
|
|
|
|
return shared[0]?.id ?? null;
|
2026-05-15 10:57:17 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function searchMemories(
|
|
|
|
|
userId: string,
|
|
|
|
|
query: string,
|
|
|
|
|
filters: SearchFilters = {},
|
|
|
|
|
limit = 20,
|
|
|
|
|
): Promise<SearchResult> {
|
2026-05-17 17:04:00 -07:00
|
|
|
const { scope, projectKey, tags, groupNames = [], minScore } = filters;
|
2026-05-17 09:50:59 -07:00
|
|
|
const projectId = projectKey
|
|
|
|
|
? await resolveProjectIdForKey(userId, groupNames, projectKey)
|
|
|
|
|
: null;
|
2026-05-15 10:57:17 -07:00
|
|
|
if (projectKey && !projectId) {
|
|
|
|
|
return { hits: [], debug: { vec: 0, fts: 0, tag: 0 } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const queryVec = await embedText(query);
|
|
|
|
|
const vecLit = toVectorLiteral(queryVec);
|
|
|
|
|
|
2026-05-17 09:50:59 -07:00
|
|
|
// Build the user-visibility fragment once: rows the caller owns OR
|
|
|
|
|
// rows whose project_id is in the set of projects shared with this
|
|
|
|
|
// user's groups. When `projectId` is set we've already authorized
|
|
|
|
|
// that single project and can drop the fragment.
|
|
|
|
|
const accessibleProjectIds = projectId
|
|
|
|
|
? null
|
|
|
|
|
: await readableProjectIds(userId, groupNames);
|
|
|
|
|
|
|
|
|
|
// postgres-js's `${array}::uuid[]` interpolates as a Postgres array
|
|
|
|
|
// literal automatically. Empty array works: `= ANY('{}')` is false,
|
|
|
|
|
// which is the right behaviour for "no projects accessible".
|
|
|
|
|
const visibilityFragment = projectId
|
|
|
|
|
? pg`AND project_id = ${projectId}`
|
|
|
|
|
: pg`AND (user_id = ${userId} OR project_id = ANY(${accessibleProjectIds ?? []}::uuid[]))`;
|
|
|
|
|
|
2026-05-15 10:57:17 -07:00
|
|
|
const vecPromise = pg<{ id: string }[]>`
|
|
|
|
|
SELECT id
|
|
|
|
|
FROM memories
|
2026-05-17 09:50:59 -07:00
|
|
|
WHERE deleted_at IS NULL
|
2026-05-15 10:57:17 -07:00
|
|
|
AND embedding IS NOT NULL
|
|
|
|
|
${scope ? pg`AND scope = ${scope}` : pg``}
|
2026-05-17 09:50:59 -07:00
|
|
|
${visibilityFragment}
|
2026-05-15 10:57:17 -07:00
|
|
|
ORDER BY embedding <=> ${vecLit}::vector ASC
|
|
|
|
|
LIMIT ${CANDIDATES}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const ftsPromise = pg<{ id: string }[]>`
|
|
|
|
|
SELECT id
|
|
|
|
|
FROM memories, plainto_tsquery('english', ${query}) AS q
|
2026-05-17 09:50:59 -07:00
|
|
|
WHERE deleted_at IS NULL
|
2026-05-15 10:57:17 -07:00
|
|
|
AND content_tsv @@ q
|
|
|
|
|
${scope ? pg`AND scope = ${scope}` : pg``}
|
2026-05-17 09:50:59 -07:00
|
|
|
${visibilityFragment}
|
2026-05-15 10:57:17 -07:00
|
|
|
ORDER BY ts_rank_cd(content_tsv, q) DESC
|
|
|
|
|
LIMIT ${CANDIDATES}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const tagPromise =
|
|
|
|
|
tags && tags.length > 0
|
|
|
|
|
? pg<{ id: string }[]>`
|
|
|
|
|
SELECT id
|
|
|
|
|
FROM memories
|
2026-05-17 09:50:59 -07:00
|
|
|
WHERE deleted_at IS NULL
|
2026-05-15 10:57:17 -07:00
|
|
|
AND tags && ${tags}::text[]
|
|
|
|
|
${scope ? pg`AND scope = ${scope}` : pg``}
|
2026-05-17 09:50:59 -07:00
|
|
|
${visibilityFragment}
|
2026-05-15 10:57:17 -07:00
|
|
|
ORDER BY cardinality(
|
|
|
|
|
ARRAY(SELECT unnest(tags) INTERSECT SELECT unnest(${tags}::text[]))
|
|
|
|
|
) DESC
|
|
|
|
|
LIMIT ${CANDIDATES}
|
|
|
|
|
`
|
|
|
|
|
: Promise.resolve([] as { id: string }[]);
|
|
|
|
|
|
|
|
|
|
const [vec, fts, tag] = await Promise.all([vecPromise, ftsPromise, tagPromise]);
|
|
|
|
|
|
|
|
|
|
interface Accumulator {
|
|
|
|
|
vectorRank: number | null;
|
|
|
|
|
ftsRank: number | null;
|
|
|
|
|
tagRank: number | null;
|
|
|
|
|
rrfScore: number;
|
|
|
|
|
}
|
|
|
|
|
const scores = new Map<string, Accumulator>();
|
|
|
|
|
const accum = (id: string, rank: number, key: "vectorRank" | "ftsRank" | "tagRank") => {
|
|
|
|
|
const e =
|
|
|
|
|
scores.get(id) ??
|
|
|
|
|
({ vectorRank: null, ftsRank: null, tagRank: null, rrfScore: 0 } as Accumulator);
|
|
|
|
|
e[key] = rank;
|
|
|
|
|
e.rrfScore += 1 / (RRF_K + rank);
|
|
|
|
|
scores.set(id, e);
|
|
|
|
|
};
|
|
|
|
|
vec.forEach((h, i) => accum(h.id, i + 1, "vectorRank"));
|
|
|
|
|
fts.forEach((h, i) => accum(h.id, i + 1, "ftsRank"));
|
|
|
|
|
tag.forEach((h, i) => accum(h.id, i + 1, "tagRank"));
|
|
|
|
|
|
2026-05-17 17:04:00 -07:00
|
|
|
let entries = [...scores.entries()];
|
|
|
|
|
if (typeof minScore === "number" && minScore > 0) {
|
|
|
|
|
entries = entries.filter(([, r]) => r.rrfScore >= minScore);
|
|
|
|
|
}
|
|
|
|
|
const hits = entries
|
2026-05-15 10:57:17 -07:00
|
|
|
.sort(([, a], [, b]) => b.rrfScore - a.rrfScore)
|
|
|
|
|
.slice(0, limit)
|
|
|
|
|
.map(([id, rank]) => ({
|
|
|
|
|
id,
|
|
|
|
|
rank: {
|
|
|
|
|
rrfScore: Number(rank.rrfScore.toFixed(6)),
|
|
|
|
|
vectorRank: rank.vectorRank,
|
|
|
|
|
ftsRank: rank.ftsRank,
|
|
|
|
|
tagRank: rank.tagRank,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
return { hits, debug: { vec: vec.length, fts: fts.length, tag: tag.length } };
|
|
|
|
|
}
|