/** * Smart metadata filtering for vector search * Filters DURING search to ensure relevant results * Simple API that just works without configuration */ import { SearchResult, HNSWNoun, HNSWNounWithMetadata } from '../coreTypes.js' import { BrainyError } from '../errors/brainyError.js' /** * Brainy Field Operators (BFO) - Our own field query system * Designed for performance, clarity, and patent independence */ export interface BrainyFieldOperators { // Equality operators (canonical + long-form aliases) eq?: any equals?: any ne?: any notEquals?: any // Comparison operators (canonical + long-form aliases) greaterThan?: any gt?: any greaterThanOrEqual?: any gte?: any lessThan?: any lt?: any lessThanOrEqual?: any lte?: any between?: [any, any] // Array/Set operators oneOf?: any[] in?: any[] // documented alias for oneOf noneOf?: any[] contains?: any excludes?: any hasAll?: any[] length?: number // Existence operators exists?: boolean missing?: boolean // Pattern operators matches?: string | RegExp startsWith?: string endsWith?: string // Logical operators allOf?: MetadataFilter[] anyOf?: MetadataFilter[] not?: MetadataFilter } /** * Metadata filter definition */ export interface MetadataFilter { [key: string]: any | BrainyFieldOperators } /** * Options for metadata filtering */ export interface MetadataFilterOptions { metadata?: MetadataFilter scoring?: { vectorWeight?: number metadataWeight?: number metadataBoosts?: Record number)> } } /** * Value-level operators (the keys inside `{ field: { : operand } }`) — the * complete documented set, kept in lockstep with the `matchesQuery` switch below * and the metadata-index path. `in` is the documented alias for `oneOf`. */ const VALUE_OPERATORS = new Set([ 'equals', 'eq', 'notEquals', 'ne', 'greaterThan', 'gt', 'greaterThanOrEqual', 'gte', 'lessThan', 'lt', 'lessThanOrEqual', 'lte', 'between', 'oneOf', 'in', 'noneOf', 'contains', 'excludes', 'hasAll', 'length', 'exists', 'missing', 'matches', 'startsWith', 'endsWith' ]) /** Filter-level logical operators (siblings of field names). */ const LOGICAL_OPERATORS = new Set(['allOf', 'anyOf', 'not']) /** * Validate a `where` filter's operators up front, throwing a typed * `BrainyError('INVALID_QUERY')` on the first unrecognized operator — so a typo * like `{ subtype: { notIn: [...] } }` fails LOUD instead of silently matching * nothing (the invisible-degrade class). Called by `find()` before either the * index path or the in-memory matcher runs, so the throw fires even when the * result set is empty (the index path would otherwise return `[]` without ever * invoking the matcher). Nested fields use dot notation (`{ 'a.b': v }`), so a * field's object value carries operators, never sub-field names. * * @param filter - the user-supplied `where` clause (validated raw, before any * internal field injection like visibility or `type`→`noun`). * @throws BrainyError('INVALID_QUERY') naming the bad operator + the valid set. */ export function validateWhereFilter(filter: unknown): void { if (!filter || typeof filter !== 'object' || Array.isArray(filter)) return for (const [key, value] of Object.entries(filter as Record)) { if (LOGICAL_OPERATORS.has(key)) { if (key === 'not') { validateWhereFilter(value) } else if (Array.isArray(value)) { for (const sub of value) validateWhereFilter(sub) } continue } // A field key. Its value is a scalar/array (equality) — nothing to validate — // or an object of value-operators, every key of which must be recognized. if (value && typeof value === 'object' && !Array.isArray(value)) { for (const op of Object.keys(value as Record)) { if (!VALUE_OPERATORS.has(op)) { throw new BrainyError( `Unknown filter operator "${op}" on field "${key}". Valid operators: ` + `${[...VALUE_OPERATORS].sort().join(', ')}. For nested fields use dot ` + `notation, e.g. { '${key}.subfield': value }.`, 'INVALID_QUERY' ) } } } } } /** * Check if a value matches a query with operators */ function matchesQuery(value: any, query: any): boolean { // Direct equality check if (typeof query !== 'object' || query === null || Array.isArray(query)) { return value === query } // Check for Brainy Field Operators (BFO) for (const [op, operand] of Object.entries(query)) { switch (op) { // Equality operators case 'equals': case 'eq': if (value !== operand) return false break case 'notEquals': case 'ne': // Special handling: if value is undefined and operand is not undefined, // they are not equal (so the condition passes) // This ensures items without a 'deleted' field match 'deleted !== true' if (value === operand) return false // If value is undefined and operand is not, they're not equal (pass) // If both are undefined, they're equal (fail, handled above) break // Comparison operators case 'greaterThan': case 'gt': if (typeof value !== 'number' || typeof operand !== 'number' || !(value > operand)) return false break case 'gte': case 'greaterThanOrEqual': if (typeof value !== 'number' || typeof operand !== 'number' || !(value >= operand)) return false break case 'lessThan': case 'lt': if (typeof value !== 'number' || typeof operand !== 'number' || !(value < operand)) return false break case 'lte': case 'lessThanOrEqual': if (typeof value !== 'number' || typeof operand !== 'number' || !(value <= operand)) return false break case 'between': if (!Array.isArray(operand) || operand.length !== 2) return false if (typeof value !== 'number' || !(value >= operand[0] && value <= operand[1])) return false break // Array/Set operators case 'oneOf': case 'in': // documented alias for oneOf if (!Array.isArray(operand) || !operand.includes(value)) return false break case 'noneOf': if (!Array.isArray(operand) || operand.includes(value)) return false break case 'contains': if (!Array.isArray(value) || !value.includes(operand)) return false break case 'excludes': if (!Array.isArray(value) || value.includes(operand)) return false break case 'hasAll': if (!Array.isArray(value) || !Array.isArray(operand)) return false for (const item of operand) { if (!value.includes(item)) return false } break case 'length': if (!Array.isArray(value) || value.length !== operand) return false break // Existence operators case 'exists': if ((value !== undefined) !== operand) return false break case 'missing': if ((value === undefined) !== operand) return false break // Pattern operators case 'matches': const regex = typeof operand === 'string' ? new RegExp(operand) : operand as RegExp if (!(regex instanceof RegExp) || !regex.test(String(value))) return false break case 'startsWith': if (typeof value !== 'string' || !value.startsWith(String(operand))) return false break case 'endsWith': if (typeof value !== 'string' || !value.endsWith(String(operand))) return false break default: // Unknown operator. The old behavior treated any unknown key as a // nested-object field name (an UNDOCUMENTED fallback — dot notation // `{ 'a.b': v }` is the supported nested form), which silently swallowed // operator typos: `{ x: { notIn: [...] } }` matched nothing instead of // erroring. Fail loud. find() validates the whole where clause up front // (validateWhereFilter), so a typo throws even on an empty result set; // this is the belt-and-suspenders for any matcher caller that bypasses // that path. throw new BrainyError( `Unknown filter operator "${op}". Valid operators: ` + `${[...VALUE_OPERATORS].sort().join(', ')}. For nested fields use dot ` + `notation, e.g. { 'address.city': 'NYC' }.`, 'INVALID_QUERY' ) } } return true } /** * Get nested value from object using dot notation */ function getNestedValue(obj: any, path: string): any { 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: any, filter: MetadataFilter): boolean { if (!filter || Object.keys(filter).length === 0) { return true } for (const [key, query] of Object.entries(filter)) { // Handle logical operators if (key === 'allOf') { if (!Array.isArray(query)) return false for (const subFilter of query) { if (!matchesMetadataFilter(metadata, subFilter)) return false } continue } if (key === 'anyOf') { 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: any, filter: MetadataFilter, scoring?: MetadataFilterOptions['scoring'] ): number { 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: SearchResult[], filter: MetadataFilter, scoring?: MetadataFilterOptions['scoring'] ): SearchResult[] { 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: SearchResult[], filter: MetadataFilter ): SearchResult[] { if (!filter || Object.keys(filter).length === 0) { return results } return results.filter(result => matchesMetadataFilter(result.metadata, filter) ) } /** * Filter nouns by metadata before search * Takes HNSWNounWithMetadata which includes metadata field */ export function filterNounsByMetadata( nouns: HNSWNounWithMetadata[], filter: MetadataFilter ): HNSWNounWithMetadata[] { if (!filter || Object.keys(filter).length === 0) { return nouns } return nouns.filter(noun => matchesMetadataFilter(noun.metadata, filter) ) } /** * Aggregate search results for faceted search */ export interface FacetConfig { field: string limit?: number } export interface FacetResult { [value: string]: number } export interface AggregationResult { results: SearchResult[] facets: Record } export function aggregateSearchResults( results: SearchResult[], facets: Record ): AggregationResult { const facetResults: Record = {} for (const [facetName, config] of Object.entries(facets)) { const counts: Record = {} 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 } }