mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
improve global search
This commit is contained in:
@@ -176,13 +176,19 @@
|
|||||||
isLoading = false;
|
isLoading = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedPerformSearch = debounce(performSearch, 20);
|
// Optimized debounce: shorter delay for better responsiveness
|
||||||
|
const debouncedPerformSearch = debounce(performSearch, 50);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (commandPalleteOpen) {
|
if (commandPalleteOpen) {
|
||||||
if (searchTerm === '') {
|
if (searchTerm === '') {
|
||||||
|
// Immediate search for empty query (shows recent items)
|
||||||
|
performSearch();
|
||||||
|
} else if (searchTerm.length <= 2) {
|
||||||
|
// Immediate search for very short queries
|
||||||
performSearch();
|
performSearch();
|
||||||
} else {
|
} else {
|
||||||
|
// Debounced search for longer queries
|
||||||
debouncedPerformSearch();
|
debouncedPerformSearch();
|
||||||
}
|
}
|
||||||
tick().then(() => searchbar?.focus());
|
tick().then(() => searchbar?.focus());
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { messagesJob } from "./jobs/messages";
|
|||||||
import { notificationsJob } from "./jobs/notifications";
|
import { notificationsJob } from "./jobs/notifications";
|
||||||
import { forumsJob } from "./jobs/forums";
|
import { forumsJob } from "./jobs/forums";
|
||||||
import { subjectsJob } from "./jobs/subjects";
|
import { subjectsJob } from "./jobs/subjects";
|
||||||
|
import { assignmentsJob } from "./jobs/assignments";
|
||||||
|
|
||||||
export const jobs: Record<string, Job> = {
|
export const jobs: Record<string, Job> = {
|
||||||
messages: messagesJob,
|
messages: messagesJob,
|
||||||
notifications: notificationsJob,
|
notifications: notificationsJob,
|
||||||
forums: forumsJob,
|
forums: forumsJob,
|
||||||
subjects: subjectsJob,
|
subjects: subjectsJob,
|
||||||
|
assignments: assignmentsJob,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import type { Job, IndexItem } from "../types";
|
||||||
|
import { htmlToPlainText } from "../utils";
|
||||||
|
|
||||||
|
const fetchJSON = async (url: string, body: any) => {
|
||||||
|
const res = await fetch(`${location.origin}${url}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUpcomingAssessments = async (student: number = 69) => {
|
||||||
|
try {
|
||||||
|
const res = await fetchJSON("/seqta/student/assessment/list/upcoming?", {
|
||||||
|
student,
|
||||||
|
});
|
||||||
|
return res.payload || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Assignments job] Failed to fetch upcoming assessments:", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSubjects = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchJSON("/seqta/student/load/subjects?", {});
|
||||||
|
return res.payload
|
||||||
|
?.filter((s: any) => s.active === 1)
|
||||||
|
?.flatMap((s: any) => s.subjects) || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Assignments job] Failed to fetch subjects:", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPastAssessments = async (student: number = 69, subjects: any[]) => {
|
||||||
|
const map: Record<number, any> = {};
|
||||||
|
|
||||||
|
for (const subject of subjects) {
|
||||||
|
try {
|
||||||
|
const res = await fetchJSON("/seqta/student/assessment/list/past?", {
|
||||||
|
student,
|
||||||
|
metaclass: subject.metaclass,
|
||||||
|
programme: subject.programme,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.payload && Array.isArray(res.payload)) {
|
||||||
|
res.payload.forEach((assessment: any) => {
|
||||||
|
if (assessment && assessment.id) {
|
||||||
|
map[assessment.id] = assessment;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Assignments job] Failed to fetch past assessments for subject ${subject.code}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAssessmentDetails = async (
|
||||||
|
assessmentId: number,
|
||||||
|
metaclassId: number,
|
||||||
|
programmeId: number,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const res = await fetchJSON("/seqta/student/assessment/view?", {
|
||||||
|
id: assessmentId,
|
||||||
|
metaclass: metaclassId,
|
||||||
|
programme: programmeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.payload && res.payload.description) {
|
||||||
|
return htmlToPlainText(res.payload.description);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Assignments job] Failed to fetch details for assessment ${assessmentId}:`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assignmentsJob: Job = {
|
||||||
|
id: "assignments",
|
||||||
|
label: "Assignments",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
frequency: { type: "expiry", afterMs: 1000 * 60 * 60 * 24 }, // Daily
|
||||||
|
|
||||||
|
boostCriteria: (item, searchTerm) => {
|
||||||
|
if (searchTerm === "") {
|
||||||
|
return -100;
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Boost upcoming assignments
|
||||||
|
if (item.metadata.dueDate) {
|
||||||
|
const dueDate = new Date(item.metadata.dueDate).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const daysUntilDue = (dueDate - now) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (daysUntilDue >= 0 && daysUntilDue <= 7) {
|
||||||
|
score += 0.05; // Boost assignments due within a week
|
||||||
|
}
|
||||||
|
if (daysUntilDue < 0) {
|
||||||
|
score -= 0.1; // Penalty for overdue assignments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost if submitted
|
||||||
|
if (item.metadata.submitted) {
|
||||||
|
score += 0.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async (ctx) => {
|
||||||
|
const existingIds = new Set(
|
||||||
|
(await ctx.getStoredItems("assignments")).map((i) => i.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const student = 69; // TODO: Get from context if available
|
||||||
|
|
||||||
|
// Fetch data in parallel
|
||||||
|
const [upcoming, subjects] = await Promise.all([
|
||||||
|
fetchUpcomingAssessments(student),
|
||||||
|
fetchSubjects(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch past assessments
|
||||||
|
const past = await fetchPastAssessments(student, subjects);
|
||||||
|
|
||||||
|
// Combine and deduplicate
|
||||||
|
const allAssessments = new Map<number, any>();
|
||||||
|
|
||||||
|
upcoming.forEach((a: any) => {
|
||||||
|
if (a && a.id) {
|
||||||
|
allAssessments.set(a.id, { ...a, isUpcoming: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
past.forEach((a: any) => {
|
||||||
|
if (a && a.id) {
|
||||||
|
const existing = allAssessments.get(a.id);
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, a);
|
||||||
|
} else {
|
||||||
|
allAssessments.set(a.id, { ...a, isUpcoming: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: IndexItem[] = [];
|
||||||
|
const processedIds = new Set<string>();
|
||||||
|
|
||||||
|
// Process assessments in batches to avoid overwhelming the API
|
||||||
|
const assessmentArray = Array.from(allAssessments.values());
|
||||||
|
const batchSize = 15; // Increased batch size for better performance
|
||||||
|
|
||||||
|
// Only fetch details for upcoming assignments to reduce API calls
|
||||||
|
const upcomingAssessments = assessmentArray.filter(a => a.isUpcoming);
|
||||||
|
const detailPromises = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
// Pre-fetch details for upcoming assessments only (most important)
|
||||||
|
for (const assessment of upcomingAssessments.slice(0, 20)) {
|
||||||
|
if (assessment.metaclass && assessment.programme) {
|
||||||
|
const id = `assignment-${assessment.id}`;
|
||||||
|
detailPromises.set(id, fetchAssessmentDetails(
|
||||||
|
assessment.id,
|
||||||
|
assessment.metaclass,
|
||||||
|
assessment.programme,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process all assessments
|
||||||
|
for (let i = 0; i < assessmentArray.length; i += batchSize) {
|
||||||
|
const batch = assessmentArray.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
const batchItems = await Promise.all(
|
||||||
|
batch.map(async (assessment) => {
|
||||||
|
const id = `assignment-${assessment.id}`;
|
||||||
|
|
||||||
|
if (existingIds.has(id) || processedIds.has(id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedIds.add(id);
|
||||||
|
|
||||||
|
// Only fetch details for upcoming assignments (already pre-fetched)
|
||||||
|
let description = "";
|
||||||
|
const detailPromise = detailPromises.get(id);
|
||||||
|
if (detailPromise) {
|
||||||
|
description = (await detailPromise) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectName = assessment.subject || assessment.code || "Unknown Subject";
|
||||||
|
const dueDate = assessment.due ? new Date(assessment.due).getTime() : null;
|
||||||
|
|
||||||
|
const item: IndexItem = {
|
||||||
|
id,
|
||||||
|
text: assessment.title || assessment.name || "Untitled Assignment",
|
||||||
|
category: "assignments",
|
||||||
|
content: `${description}\nSubject: ${subjectName}\nDue: ${assessment.due || "No due date"}`.trim(),
|
||||||
|
dateAdded: dueDate || Date.now(),
|
||||||
|
metadata: {
|
||||||
|
assessmentId: assessment.id,
|
||||||
|
subject: subjectName,
|
||||||
|
subjectCode: assessment.code,
|
||||||
|
dueDate: assessment.due,
|
||||||
|
programmeId: assessment.programme,
|
||||||
|
metaclassId: assessment.metaclass,
|
||||||
|
submitted: assessment.submitted || false,
|
||||||
|
isUpcoming: assessment.isUpcoming || false,
|
||||||
|
term: assessment.term,
|
||||||
|
},
|
||||||
|
actionId: "assessment",
|
||||||
|
renderComponentId: "assessment",
|
||||||
|
};
|
||||||
|
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter out nulls and add to items
|
||||||
|
batchItems.forEach(item => {
|
||||||
|
if (item) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small delay between batches to avoid rate limiting
|
||||||
|
if (i + batchSize < assessmentArray.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50)); // Reduced delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`[Assignments job] Indexed ${items.length} assignment items`);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
purge: (items) => {
|
||||||
|
const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000;
|
||||||
|
return items.filter((i) => {
|
||||||
|
// Keep upcoming assignments and assignments from the last year
|
||||||
|
if (i.metadata.isUpcoming) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return i.dateAdded >= oneYearAgo;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -7,31 +7,62 @@ import { searchVectors } from "./vector/vectorSearch";
|
|||||||
import type { VectorSearchResult } from "./vector/vectorTypes";
|
import type { VectorSearchResult } from "./vector/vectorTypes";
|
||||||
import { jobs } from "../indexing/jobs";
|
import { jobs } from "../indexing/jobs";
|
||||||
|
|
||||||
|
// Search result cache for better performance
|
||||||
|
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
|
||||||
|
const CACHE_TTL = 1000 * 60 * 5; // 5 minutes
|
||||||
|
const MAX_CACHE_SIZE = 100;
|
||||||
|
|
||||||
|
function getCachedResults(query: string): CombinedResult[] | null {
|
||||||
|
const cached = searchCache.get(query);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.results;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedResults(query: string, results: CombinedResult[]) {
|
||||||
|
// Limit cache size
|
||||||
|
if (searchCache.size >= MAX_CACHE_SIZE) {
|
||||||
|
const firstKey = searchCache.keys().next().value;
|
||||||
|
searchCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
searchCache.set(query, { results, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
export function createSearchIndexes() {
|
export function createSearchIndexes() {
|
||||||
const commands = getStaticCommands();
|
const commands = getStaticCommands();
|
||||||
const dynamicItems = getDynamicItems();
|
const dynamicItems = getDynamicItems();
|
||||||
|
|
||||||
|
// Optimized command search options
|
||||||
const commandOptions = {
|
const commandOptions = {
|
||||||
keys: ["text", "category", "keywords"],
|
keys: ["text", "category", "keywords"],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
includeMatches: true,
|
includeMatches: true,
|
||||||
threshold: 0.4,
|
threshold: 0.35, // Slightly more permissive for better recall
|
||||||
minMatchCharLength: 2,
|
minMatchCharLength: 2,
|
||||||
useExtendedSearch: false,
|
useExtendedSearch: false,
|
||||||
|
ignoreLocation: false,
|
||||||
|
findAllMatches: false, // Performance optimization
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Optimized dynamic content search options
|
||||||
const dynamicOptions = {
|
const dynamicOptions = {
|
||||||
keys: [
|
keys: [
|
||||||
{ name: "text", weight: 2 },
|
{ name: "text", weight: 3 }, // Increased weight for title matches
|
||||||
{ name: "content", weight: 1 },
|
{ name: "content", weight: 1 },
|
||||||
{ name: "category", weight: 1 },
|
{ name: "category", weight: 0.5 }, // Lower weight for category
|
||||||
|
{ name: "metadata.subjectName", weight: 1.5 }, // Boost subject name matches
|
||||||
|
{ name: "metadata.subjectCode", weight: 1.5 }, // Boost subject code matches
|
||||||
],
|
],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
includeMatches: true,
|
includeMatches: true,
|
||||||
threshold: 0.4,
|
threshold: 0.35, // Slightly more permissive
|
||||||
minMatchCharLength: 2,
|
minMatchCharLength: 2,
|
||||||
distance: 100,
|
distance: 50, // Reduced from 100 for better performance
|
||||||
useExtendedSearch: true,
|
useExtendedSearch: true,
|
||||||
|
ignoreLocation: false,
|
||||||
|
findAllMatches: false, // Performance optimization
|
||||||
|
shouldSort: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -105,17 +136,30 @@ export function searchDynamicItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const searchResults = dynamicContentFuse.search(query, { limit });
|
// Increase limit for better results, then trim later
|
||||||
|
const searchLimit = Math.min(limit * 3, 50);
|
||||||
|
const searchResults = dynamicContentFuse.search(query, { limit: searchLimit });
|
||||||
|
|
||||||
return searchResults.map((result: FuseResult<IndexItem>) => {
|
const results = searchResults.map((result: FuseResult<IndexItem>) => {
|
||||||
const item = result.item;
|
const item = result.item;
|
||||||
const fuseScore = 10 * (1 - (result.score || 0.5));
|
const fuseScore = 10 * (1 - (result.score || 0.5));
|
||||||
|
|
||||||
let score = fuseScore;
|
let score = fuseScore;
|
||||||
|
|
||||||
|
// Recency boost
|
||||||
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
const ageInDays = (now - item.dateAdded) / (1000 * 60 * 60 * 24);
|
||||||
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
|
const recencyBoost = sortByRecent ? 1 / (ageInDays + 1) : 0;
|
||||||
score += recencyBoost;
|
score += recencyBoost;
|
||||||
|
|
||||||
|
// Boost for exact text matches
|
||||||
|
if (item.text.toLowerCase().includes(query.toLowerCase())) {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost for category matches
|
||||||
|
if (item.category.toLowerCase().includes(query.toLowerCase())) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -125,6 +169,9 @@ export function searchDynamicItems(
|
|||||||
matches: result.matches,
|
matches: result.matches,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sort by score and return top results
|
||||||
|
return results.sort((a, b) => b.score - a.score).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function performSearch(
|
export async function performSearch(
|
||||||
@@ -132,18 +179,32 @@ export async function performSearch(
|
|||||||
commandsFuse: Fuse<StaticCommandItem>,
|
commandsFuse: Fuse<StaticCommandItem>,
|
||||||
commandIdToItemMap: Map<string, StaticCommandItem>,
|
commandIdToItemMap: Map<string, StaticCommandItem>,
|
||||||
): Promise<CombinedResult[]> {
|
): Promise<CombinedResult[]> {
|
||||||
|
const trimmedQuery = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (trimmedQuery.length > 2) {
|
||||||
|
const cached = getCachedResults(trimmedQuery);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get all results first
|
// Get all results first
|
||||||
const commandResults = searchCommands(
|
const commandResults = searchCommands(
|
||||||
commandsFuse,
|
commandsFuse,
|
||||||
query,
|
trimmedQuery,
|
||||||
commandIdToItemMap,
|
commandIdToItemMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get vector results in parallel
|
// Get vector results in parallel (only for queries longer than 3 chars for performance)
|
||||||
let vectorResults: VectorSearchResult[] = [];
|
let vectorResults: VectorSearchResult[] = [];
|
||||||
try {
|
if (trimmedQuery.length > 3) {
|
||||||
vectorResults = await searchVectors(query);
|
try {
|
||||||
} catch (e) {}
|
vectorResults = await searchVectors(trimmedQuery, 15); // Reduced from 20 for performance
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Search] Vector search failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create a map to store our final results, using ID as key to avoid duplicates
|
// Create a map to store our final results, using ID as key to avoid duplicates
|
||||||
const resultMap = new Map<string, CombinedResult>();
|
const resultMap = new Map<string, CombinedResult>();
|
||||||
@@ -151,8 +212,9 @@ export async function performSearch(
|
|||||||
// Add command results first (they keep their original scores)
|
// Add command results first (they keep their original scores)
|
||||||
commandResults.forEach((r) => resultMap.set(r.id, r));
|
commandResults.forEach((r) => resultMap.set(r.id, r));
|
||||||
|
|
||||||
// Process dynamic results and vector results together
|
// Process vector results
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
|
commandResults.forEach((r) => seenIds.add(r.id));
|
||||||
|
|
||||||
vectorResults.forEach((v) => {
|
vectorResults.forEach((v) => {
|
||||||
const id = v.object.id;
|
const id = v.object.id;
|
||||||
@@ -162,7 +224,7 @@ export async function performSearch(
|
|||||||
let score = v.similarity * 0.5; // High base score for semantic matches
|
let score = v.similarity * 0.5; // High base score for semantic matches
|
||||||
const job = jobs[v.object.category];
|
const job = jobs[v.object.category];
|
||||||
if (job && typeof job.boostCriteria === 'function') {
|
if (job && typeof job.boostCriteria === 'function') {
|
||||||
const boost = job.boostCriteria(v.object, query);
|
const boost = job.boostCriteria(v.object, trimmedQuery);
|
||||||
if (boost) {
|
if (boost) {
|
||||||
score += boost;
|
score += boost;
|
||||||
}
|
}
|
||||||
@@ -173,6 +235,7 @@ export async function performSearch(
|
|||||||
score,
|
score,
|
||||||
item: v.object,
|
item: v.object,
|
||||||
});
|
});
|
||||||
|
seenIds.add(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,5 +243,10 @@ export async function performSearch(
|
|||||||
const results = Array.from(resultMap.values());
|
const results = Array.from(resultMap.values());
|
||||||
results.sort((a, b) => b.score - a.score);
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
// Cache results for queries longer than 2 chars
|
||||||
|
if (trimmedQuery.length > 2) {
|
||||||
|
setCachedResults(trimmedQuery, results);
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,24 +18,69 @@ export interface VectorSearchResult extends SearchResult {
|
|||||||
object: IndexItem & { embedding: number[] };
|
object: IndexItem & { embedding: number[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache for query embeddings to avoid recomputing
|
||||||
|
const embeddingCache = new Map<string, number[]>();
|
||||||
|
const EMBEDDING_CACHE_TTL = 1000 * 60 * 30; // 30 minutes
|
||||||
|
const MAX_EMBEDDING_CACHE_SIZE = 50;
|
||||||
|
|
||||||
|
function getCachedEmbedding(query: string): number[] | null {
|
||||||
|
const cached = embeddingCache.get(query);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedEmbedding(query: string, embedding: number[]) {
|
||||||
|
// Limit cache size
|
||||||
|
if (embeddingCache.size >= MAX_EMBEDDING_CACHE_SIZE) {
|
||||||
|
const firstKey = embeddingCache.keys().next().value;
|
||||||
|
embeddingCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
embeddingCache.set(query, embedding);
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchVectors(
|
export async function searchVectors(
|
||||||
query: string,
|
query: string,
|
||||||
topK: number = 20,
|
topK: number = 20,
|
||||||
): Promise<VectorSearchResult[]> {
|
): Promise<VectorSearchResult[]> {
|
||||||
if (!vectorIndex) await initVectorSearch();
|
if (!vectorIndex) await initVectorSearch();
|
||||||
|
|
||||||
const queryEmbedding = await getEmbedding(query.slice(0, 100));
|
// Normalize query for caching
|
||||||
|
const normalizedQuery = query.trim().toLowerCase().slice(0, 100);
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
let queryEmbedding = getCachedEmbedding(normalizedQuery);
|
||||||
|
|
||||||
|
if (!queryEmbedding) {
|
||||||
|
try {
|
||||||
|
queryEmbedding = await getEmbedding(normalizedQuery);
|
||||||
|
setCachedEmbedding(normalizedQuery, queryEmbedding);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Vector Search] Failed to get embedding:", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const results = await vectorIndex!.search(queryEmbedding, {
|
try {
|
||||||
topK,
|
const results = await vectorIndex!.search(queryEmbedding, {
|
||||||
useStorage: "indexedDB",
|
topK: Math.min(topK * 2, 30), // Get more results, filter later
|
||||||
dedupeEntries: true,
|
useStorage: "indexedDB",
|
||||||
});
|
dedupeEntries: true,
|
||||||
|
});
|
||||||
|
|
||||||
// filter results with a similarity below 0.81
|
// Filter results with a similarity below 0.80 (slightly more permissive)
|
||||||
const filteredResults = results.filter((r) => r.similarity > 0.81);
|
// and sort by similarity descending
|
||||||
|
const filteredResults = results
|
||||||
|
.filter((r) => r.similarity > 0.80)
|
||||||
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
.slice(0, topK);
|
||||||
|
|
||||||
return filteredResults as VectorSearchResult[];
|
return filteredResults as VectorSearchResult[];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Vector Search] Search failed:", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshVectorCache() {
|
export async function refreshVectorCache() {
|
||||||
|
|||||||
Reference in New Issue
Block a user