diff --git a/models/server-contact/src/index.ts b/models/server-contact/src/index.ts index 7120f70dcd..49adc09336 100644 --- a/models/server-contact/src/index.ts +++ b/models/server-contact/src/index.ts @@ -46,7 +46,14 @@ export function createModel (builder: Builder): void { component: contact.component.Avatar, props: ['avatar', 'name'] }, - title: { props: ['name'] } + title: { props: ['name'] }, + scoring: [ + { + attr: 'space', + value: contact.space.Employee as string, + boost: 2 + } + ] }, getSearchTitle: serverContact.function.ContactNameProvider }) diff --git a/server/core/src/fulltext.ts b/server/core/src/fulltext.ts index ecfb93df2d..97f48e6793 100644 --- a/server/core/src/fulltext.ts +++ b/server/core/src/fulltext.ts @@ -44,7 +44,7 @@ import core, { import { MinioService } from '@hcengineering/minio' import { FullTextIndexPipeline } from './indexer' import { createStateDoc, isClassIndexable } from './indexer/utils' -import { mapSearchResultDoc } from './mapper' +import { mapSearchResultDoc, getScoringConfig } from './mapper' import type { FullTextAdapter, WithFind, IndexedDoc } from './types' /** @@ -246,7 +246,10 @@ export class FullTextIndex implements WithFind { } async searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise { - const resultRaw = await this.adapter.searchString(query, options) + const resultRaw = await this.adapter.searchString(query, { + ...options, + scoring: getScoringConfig(this.hierarchy, query.classes ?? []) + }) const result: SearchResult = { ...resultRaw, diff --git a/server/core/src/mapper.ts b/server/core/src/mapper.ts index ddb4c1a111..6c7e69d9b6 100644 --- a/server/core/src/mapper.ts +++ b/server/core/src/mapper.ts @@ -2,7 +2,7 @@ import { Hierarchy, Ref, RefTo, Class, Doc, SearchResultDoc, docKey } from '@hce import { getResource } from '@hcengineering/platform' import plugin from './plugin' -import { IndexedDoc, SearchPresenter, ClassSearchConfigProps } from './types' +import { IndexedDoc, SearchPresenter, ClassSearchConfigProps, SearchScoring } from './types' interface IndexedReader { get: (attribute: string) => any @@ -106,6 +106,17 @@ export async function updateDocWithPresenter (hierarchy: Hierarchy, doc: Indexed } } +export function getScoringConfig (hierarchy: Hierarchy, classes: Ref>[]): SearchScoring[] { + let results: SearchScoring[] = [] + for (const _class of classes) { + const searchPresenter = findSearchPresenter(hierarchy, _class) + if (searchPresenter?.searchConfig.scoring !== undefined) { + results = results.concat(searchPresenter?.searchConfig.scoring) + } + } + return results +} + /** * @public */ diff --git a/server/core/src/types.ts b/server/core/src/types.ts index 45aad7358f..a22a27e188 100644 --- a/server/core/src/types.ts +++ b/server/core/src/types.ts @@ -189,7 +189,10 @@ export interface FullTextAdapter { remove: (id: Ref[]) => Promise updateMany: (docs: IndexedDoc[]) => Promise - searchString: (query: SearchQuery, options: SearchOptions) => Promise + searchString: ( + query: SearchQuery, + options: SearchOptions & { scoring?: SearchScoring[] } + ) => Promise search: ( _classes: Ref>[], @@ -350,6 +353,15 @@ export type ClassSearchConfigProps = string | Record */ export type ClassSearchConfigProperty = string | { tmpl?: string, props: ClassSearchConfigProps[] } +/** + * @public + */ +export interface SearchScoring { + attr: string + value: string + boost: number +} + /** * @public */ @@ -358,6 +370,7 @@ export interface ClassSearchConfig { iconConfig?: { component: any, props: ClassSearchConfigProps[] } title: ClassSearchConfigProperty shortTitle?: ClassSearchConfigProperty + scoring?: SearchScoring[] } /** diff --git a/server/elastic/src/adapter.ts b/server/elastic/src/adapter.ts index 16205fd142..f50bda2768 100644 --- a/server/elastic/src/adapter.ts +++ b/server/elastic/src/adapter.ts @@ -27,7 +27,13 @@ import { SearchQuery, SearchOptions } from '@hcengineering/core' -import type { EmbeddingSearchOption, FullTextAdapter, SearchStringResult, IndexedDoc } from '@hcengineering/server-core' +import type { + EmbeddingSearchOption, + FullTextAdapter, + SearchStringResult, + SearchScoring, + IndexedDoc +} from '@hcengineering/server-core' import { Client, errors as esErr } from '@elastic/elasticsearch' import { Domain } from 'node:domain' @@ -105,24 +111,50 @@ class ElasticAdapter implements FullTextAdapter { return this._metrics } - async searchString (query: SearchQuery, options: SearchOptions): Promise { + async searchString ( + query: SearchQuery, + options: SearchOptions & { scoring?: SearchScoring[] } + ): Promise { try { const elasticQuery: any = { query: { - bool: { - must: { - simple_query_string: { - query: query.query, - analyze_wildcard: true, - flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE', - default_operator: 'and', - fields: [ - 'searchTitle^15', // boost - 'searchShortTitle^15', - '*' // Search in all other fields without a boost - ] + function_score: { + query: { + bool: { + must: { + simple_query_string: { + query: query.query, + analyze_wildcard: true, + flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE', + default_operator: 'and', + fields: [ + 'searchTitle^50', // boost + 'searchShortTitle^50', + '*' // Search in all other fields without a boost + ] + } + } } - } + }, + functions: [ + { + script_score: { + script: { + source: "Math.max(0, ((doc['modifiedOn'].value / 1000 - 1672531200) / 2592000))" + /* + Give more score for more recent objects. 1672531200 is the start of 2023 + 2592000 is a month. The idea is go give 1 point for each month. For objects + older than Jan 2023 it will give just zero. + Better approach is to use gauss function, need to investigate futher how be + map modifiedOn, need to tell elastic that this is a date. + + But linear function is perfect to conduct an experiment + */ + } + } + } + ], + boost_mode: 'sum' } }, size: options.limit ?? DEFAULT_LIMIT @@ -146,7 +178,21 @@ class ElasticAdapter implements FullTextAdapter { } if (filter.length > 0) { - elasticQuery.query.bool.filter = filter + elasticQuery.query.function_score.query.bool.filter = filter + } + + if (options.scoring !== undefined) { + const scoringTerms: any[] = options.scoring.map((scoringOption): any => { + return { + term: { + [`${scoringOption.attr}.keyword`]: { + value: scoringOption.value, + boost: scoringOption.boost + } + } + } + }) + elasticQuery.query.function_score.query.bool.should = scoringTerms } const result = await this.client.search({