229 lines
7.5 KiB
JavaScript
229 lines
7.5 KiB
JavaScript
|
|
/**
|
||
|
|
* Smart metadata filtering for vector search
|
||
|
|
* Filters DURING search to ensure relevant results
|
||
|
|
* Simple API that just works without configuration
|
||
|
|
*/
|
||
|
|
/**
|
||
|
|
* Check if a value matches a query with operators
|
||
|
|
*/
|
||
|
|
function matchesQuery(value, query) {
|
||
|
|
// Direct equality check
|
||
|
|
if (typeof query !== 'object' || query === null || Array.isArray(query)) {
|
||
|
|
return value === query;
|
||
|
|
}
|
||
|
|
// Check for MongoDB-style operators
|
||
|
|
for (const [op, operand] of Object.entries(query)) {
|
||
|
|
switch (op) {
|
||
|
|
case '$eq':
|
||
|
|
if (value !== operand)
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$ne':
|
||
|
|
if (value === operand)
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$gt':
|
||
|
|
if (typeof value !== 'number' || typeof operand !== 'number' || !(value > operand))
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$gte':
|
||
|
|
if (typeof value !== 'number' || typeof operand !== 'number' || !(value >= operand))
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$lt':
|
||
|
|
if (typeof value !== 'number' || typeof operand !== 'number' || !(value < operand))
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$lte':
|
||
|
|
if (typeof value !== 'number' || typeof operand !== 'number' || !(value <= operand))
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$in':
|
||
|
|
if (!Array.isArray(operand) || !operand.includes(value))
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$nin':
|
||
|
|
if (!Array.isArray(operand) || operand.includes(value))
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$exists':
|
||
|
|
if ((value !== undefined) !== operand)
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$regex':
|
||
|
|
const regex = typeof operand === 'string' ? new RegExp(operand) : operand;
|
||
|
|
if (!(regex instanceof RegExp) || !regex.test(String(value)))
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$includes':
|
||
|
|
if (!Array.isArray(value) || !value.includes(operand))
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
case '$all':
|
||
|
|
if (!Array.isArray(value) || !Array.isArray(operand))
|
||
|
|
return false;
|
||
|
|
for (const item of operand) {
|
||
|
|
if (!value.includes(item))
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case '$size':
|
||
|
|
if (!Array.isArray(value) || value.length !== operand)
|
||
|
|
return false;
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
// Unknown operator, treat as field name
|
||
|
|
if (!matchesFieldQuery(value, op, operand))
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Check if a field matches a query
|
||
|
|
*/
|
||
|
|
function matchesFieldQuery(obj, field, query) {
|
||
|
|
const value = getNestedValue(obj, field);
|
||
|
|
return matchesQuery(value, query);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Get nested value from object using dot notation
|
||
|
|
*/
|
||
|
|
function getNestedValue(obj, path) {
|
||
|
|
const parts = path.split('.');
|
||
|
|
let current = obj;
|
||
|
|
for (const part of parts) {
|
||
|
|
if (current === null || current === undefined) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
current = current[part];
|
||
|
|
}
|
||
|
|
return current;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Check if metadata matches the filter
|
||
|
|
*/
|
||
|
|
export function matchesMetadataFilter(metadata, filter) {
|
||
|
|
if (!filter || Object.keys(filter).length === 0) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
for (const [key, query] of Object.entries(filter)) {
|
||
|
|
// Handle logical operators
|
||
|
|
if (key === '$and') {
|
||
|
|
if (!Array.isArray(query))
|
||
|
|
return false;
|
||
|
|
for (const subFilter of query) {
|
||
|
|
if (!matchesMetadataFilter(metadata, subFilter))
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (key === '$or') {
|
||
|
|
if (!Array.isArray(query))
|
||
|
|
return false;
|
||
|
|
let matched = false;
|
||
|
|
for (const subFilter of query) {
|
||
|
|
if (matchesMetadataFilter(metadata, subFilter)) {
|
||
|
|
matched = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!matched)
|
||
|
|
return false;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (key === '$not') {
|
||
|
|
if (matchesMetadataFilter(metadata, query))
|
||
|
|
return false;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
// Handle field queries
|
||
|
|
const value = getNestedValue(metadata, key);
|
||
|
|
if (!matchesQuery(value, query)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Calculate metadata boost score
|
||
|
|
*/
|
||
|
|
export function calculateMetadataScore(metadata, filter, scoring) {
|
||
|
|
if (!scoring || !scoring.metadataBoosts) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
let score = 0;
|
||
|
|
for (const [field, boost] of Object.entries(scoring.metadataBoosts)) {
|
||
|
|
const value = getNestedValue(metadata, field);
|
||
|
|
if (typeof boost === 'function') {
|
||
|
|
score += boost(value, filter);
|
||
|
|
}
|
||
|
|
else if (value !== undefined) {
|
||
|
|
// Check if the field matches the filter
|
||
|
|
const fieldFilter = filter[field];
|
||
|
|
if (fieldFilter && matchesQuery(value, fieldFilter)) {
|
||
|
|
score += boost;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return score;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Apply compound scoring to search results
|
||
|
|
*/
|
||
|
|
export function applyCompoundScoring(results, filter, scoring) {
|
||
|
|
if (!scoring || (!scoring.vectorWeight && !scoring.metadataWeight)) {
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
const vectorWeight = scoring.vectorWeight ?? 1.0;
|
||
|
|
const metadataWeight = scoring.metadataWeight ?? 0.0;
|
||
|
|
return results.map(result => {
|
||
|
|
const metadataScore = calculateMetadataScore(result.metadata, filter, scoring);
|
||
|
|
const combinedScore = (result.score * vectorWeight) + (metadataScore * metadataWeight);
|
||
|
|
return {
|
||
|
|
...result,
|
||
|
|
score: combinedScore
|
||
|
|
};
|
||
|
|
}).sort((a, b) => b.score - a.score); // Re-sort by combined score
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Filter search results by metadata
|
||
|
|
*/
|
||
|
|
export function filterSearchResultsByMetadata(results, filter) {
|
||
|
|
if (!filter || Object.keys(filter).length === 0) {
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
return results.filter(result => matchesMetadataFilter(result.metadata, filter));
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Filter nouns by metadata before search
|
||
|
|
*/
|
||
|
|
export function filterNounsByMetadata(nouns, filter) {
|
||
|
|
if (!filter || Object.keys(filter).length === 0) {
|
||
|
|
return nouns;
|
||
|
|
}
|
||
|
|
return nouns.filter(noun => matchesMetadataFilter(noun.metadata, filter));
|
||
|
|
}
|
||
|
|
export function aggregateSearchResults(results, facets) {
|
||
|
|
const facetResults = {};
|
||
|
|
for (const [facetName, config] of Object.entries(facets)) {
|
||
|
|
const counts = {};
|
||
|
|
for (const result of results) {
|
||
|
|
const value = getNestedValue(result.metadata, config.field);
|
||
|
|
if (value !== undefined) {
|
||
|
|
const key = String(value);
|
||
|
|
counts[key] = (counts[key] || 0) + 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Sort by count and apply limit
|
||
|
|
const sorted = Object.entries(counts)
|
||
|
|
.sort((a, b) => b[1] - a[1])
|
||
|
|
.slice(0, config.limit || 10);
|
||
|
|
facetResults[facetName] = Object.fromEntries(sorted);
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
results,
|
||
|
|
facets: facetResults
|
||
|
|
};
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=metadataFilter.js.map
|