feat: Introduce Meilisearch (#10755)
* wip * wip * Update SearchService.ts * Update SearchService.ts * wip * wip * Update SearchService.ts * Update CHANGELOG.md * wip * Update SearchService.ts * Update docker-compose.yml.example
This commit is contained in:
		| @@ -50,6 +50,7 @@ import { WebhookService } from './WebhookService.js'; | ||||
| import { ProxyAccountService } from './ProxyAccountService.js'; | ||||
| import { UtilityService } from './UtilityService.js'; | ||||
| import { FileInfoService } from './FileInfoService.js'; | ||||
| import { SearchService } from './SearchService.js'; | ||||
| import { ChartLoggerService } from './chart/ChartLoggerService.js'; | ||||
| import FederationChart from './chart/charts/federation.js'; | ||||
| import NotesChart from './chart/charts/notes.js'; | ||||
| @@ -171,6 +172,8 @@ const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', u | ||||
| const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService }; | ||||
| const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; | ||||
| const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; | ||||
| const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; | ||||
|  | ||||
| const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; | ||||
| const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; | ||||
| const $NotesChart: Provider = { provide: 'NotesChart', useExisting: NotesChart }; | ||||
| @@ -295,6 +298,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		WebhookService, | ||||
| 		UtilityService, | ||||
| 		FileInfoService, | ||||
| 		SearchService, | ||||
| 		ChartLoggerService, | ||||
| 		FederationChart, | ||||
| 		NotesChart, | ||||
| @@ -413,6 +417,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$WebhookService, | ||||
| 		$UtilityService, | ||||
| 		$FileInfoService, | ||||
| 		$SearchService, | ||||
| 		$ChartLoggerService, | ||||
| 		$FederationChart, | ||||
| 		$NotesChart, | ||||
| @@ -532,6 +537,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		WebhookService, | ||||
| 		UtilityService, | ||||
| 		FileInfoService, | ||||
| 		SearchService, | ||||
| 		FederationChart, | ||||
| 		NotesChart, | ||||
| 		UsersChart, | ||||
| @@ -649,6 +655,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$WebhookService, | ||||
| 		$UtilityService, | ||||
| 		$FileInfoService, | ||||
| 		$SearchService, | ||||
| 		$FederationChart, | ||||
| 		$NotesChart, | ||||
| 		$UsersChart, | ||||
|   | ||||
| @@ -46,6 +46,7 @@ import { bindThis } from '@/decorators.js'; | ||||
| import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; | ||||
| import { RoleService } from '@/core/RoleService.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { SearchService } from '@/core/SearchService.js'; | ||||
|  | ||||
| const mutedWordsCache = new MemorySingleCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); | ||||
|  | ||||
| @@ -198,6 +199,7 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
| 		private apRendererService: ApRendererService, | ||||
| 		private roleService: RoleService, | ||||
| 		private metaService: MetaService, | ||||
| 		private searchService: SearchService, | ||||
| 		private notesChart: NotesChart, | ||||
| 		private perUserNotesChart: PerUserNotesChart, | ||||
| 		private activeUsersChart: ActiveUsersChart, | ||||
| @@ -728,17 +730,9 @@ export class NoteCreateService implements OnApplicationShutdown { | ||||
|  | ||||
| 	@bindThis | ||||
| 	private index(note: Note) { | ||||
| 		if (note.text == null || this.config.elasticsearch == null) return; | ||||
| 		/* | ||||
| 	es!.index({ | ||||
| 		index: this.config.elasticsearch.index ?? 'misskey_note', | ||||
| 		id: note.id.toString(), | ||||
| 		body: { | ||||
| 			text: normalizeForSearch(note.text), | ||||
| 			userId: note.userId, | ||||
| 			userHost: note.userHost, | ||||
| 		}, | ||||
| 	});*/ | ||||
| 		if (note.text == null && note.cw == null) return; | ||||
| 		 | ||||
| 		this.searchService.indexNote(note); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
							
								
								
									
										166
									
								
								packages/backend/src/core/SearchService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								packages/backend/src/core/SearchService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,166 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { In } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { Config } from '@/config.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { Note } from '@/models/entities/Note.js'; | ||||
| import { User } from '@/models/index.js'; | ||||
| import type { NotesRepository } from '@/models/index.js'; | ||||
| import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import type { Index, MeiliSearch } from 'meilisearch'; | ||||
|  | ||||
| type K = string; | ||||
| type V = string | number | boolean; | ||||
| type Q = | ||||
| 	{ op: '=', k: K, v: V } | | ||||
| 	{ op: '!=', k: K, v: V } | | ||||
| 	{ op: '>', k: K, v: number } | | ||||
| 	{ op: '<', k: K, v: number } | | ||||
| 	{ op: '>=', k: K, v: number } | | ||||
| 	{ op: '<=', k: K, v: number } | | ||||
| 	{ op: 'and', qs: Q[] } | | ||||
| 	{ op: 'or', qs: Q[] } | | ||||
| 	{ op: 'not', q: Q }; | ||||
|  | ||||
| function compileValue(value: V): string { | ||||
| 	if (typeof value === 'string') { | ||||
| 		return `'${value}'`; // TODO: escape | ||||
| 	} else if (typeof value === 'number') { | ||||
| 		return value.toString(); | ||||
| 	} else if (typeof value === 'boolean') { | ||||
| 		return value.toString(); | ||||
| 	} | ||||
| 	throw new Error('unrecognized value'); | ||||
| } | ||||
|  | ||||
| function compileQuery(q: Q): string { | ||||
| 	switch (q.op) { | ||||
| 		case '=': return `(${q.k} = ${compileValue(q.v)})`; | ||||
| 		case '!=': return `(${q.k} != ${compileValue(q.v)})`; | ||||
| 		case '>': return `(${q.k} > ${compileValue(q.v)})`; | ||||
| 		case '<': return `(${q.k} < ${compileValue(q.v)})`; | ||||
| 		case '>=': return `(${q.k} >= ${compileValue(q.v)})`; | ||||
| 		case '<=': return `(${q.k} <= ${compileValue(q.v)})`; | ||||
| 		case 'and': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' AND ') })`; | ||||
| 		case 'or': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' OR ') })`; | ||||
| 		case 'not': return `(NOT ${compileQuery(q.q)})`; | ||||
| 		default: throw new Error('unrecognized query operator'); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @Injectable() | ||||
| export class SearchService { | ||||
| 	private meilisearchNoteIndex: Index | null = null; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.meilisearch) | ||||
| 		private meilisearch: MeiliSearch | null, | ||||
|  | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
|  | ||||
| 		private queryService: QueryService, | ||||
| 		private idService: IdService, | ||||
| 	) { | ||||
| 		if (meilisearch) { | ||||
| 			this.meilisearchNoteIndex = meilisearch.index('notes'); | ||||
| 			this.meilisearchNoteIndex.updateSettings({ | ||||
| 				searchableAttributes: [ | ||||
| 					'text', | ||||
| 					'cw', | ||||
| 				], | ||||
| 				sortableAttributes: [ | ||||
| 					'createdAt', | ||||
| 				], | ||||
| 				filterableAttributes: [ | ||||
| 					'createdAt', | ||||
| 					'userId', | ||||
| 					'userHost', | ||||
| 					'channelId', | ||||
| 				], | ||||
| 				typoTolerance: { | ||||
| 					enabled: false, | ||||
| 				}, | ||||
| 				pagination: { | ||||
| 					maxTotalHits: 10000, | ||||
| 				}, | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async indexNote(note: Note): Promise<void> { | ||||
| 		if (this.meilisearch) { | ||||
| 			this.meilisearchNoteIndex!.addDocuments([{ | ||||
| 				id: note.id, | ||||
| 				createdAt: note.createdAt.getTime(), | ||||
| 				userId: note.userId, | ||||
| 				userHost: note.userHost, | ||||
| 				channelId: note.channelId, | ||||
| 				cw: note.cw, | ||||
| 				text: note.text, | ||||
| 			}], { | ||||
| 				primaryKey: 'id', | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async searchNote(q: string, me: User | null, opts: { | ||||
| 		userId?: Note['userId'] | null; | ||||
| 		channelId?: Note['channelId'] | null; | ||||
| 	}, pagination: { | ||||
| 		untilId?: Note['id']; | ||||
| 		sinceId?: Note['id']; | ||||
| 		limit?: number; | ||||
| 	}): Promise<Note[]> { | ||||
| 		if (this.meilisearch) { | ||||
| 			const filter: Q = { | ||||
| 				op: 'and', | ||||
| 				qs: [], | ||||
| 			}; | ||||
| 			if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() }); | ||||
| 			if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() }); | ||||
| 			if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId }); | ||||
| 			if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId }); | ||||
| 			const res = await this.meilisearchNoteIndex!.search(q, { | ||||
| 				sort: ['createdAt:desc'], | ||||
| 				matchingStrategy: 'all', | ||||
| 				attributesToRetrieve: ['id', 'createdAt'], | ||||
| 				filter: compileQuery(filter), | ||||
| 				limit: pagination.limit, | ||||
| 			}); | ||||
| 			if (res.hits.length === 0) return []; | ||||
| 			return await this.notesRepository.findBy({ | ||||
| 				id: In(res.hits.map(x => x.id)), | ||||
| 			}); | ||||
| 		} else { | ||||
| 			const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId); | ||||
|  | ||||
| 			if (opts.userId) { | ||||
| 				query.andWhere('note.userId = :userId', { userId: opts.userId }); | ||||
| 			} else if (opts.channelId) { | ||||
| 				query.andWhere('note.channelId = :channelId', { channelId: opts.channelId }); | ||||
| 			} | ||||
|  | ||||
| 			query | ||||
| 				.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }) | ||||
| 				.innerJoinAndSelect('note.user', 'user') | ||||
| 				.leftJoinAndSelect('note.reply', 'reply') | ||||
| 				.leftJoinAndSelect('note.renote', 'renote') | ||||
| 				.leftJoinAndSelect('reply.user', 'replyUser') | ||||
| 				.leftJoinAndSelect('renote.user', 'renoteUser'); | ||||
|  | ||||
| 			this.queryService.generateVisibilityQuery(query, me); | ||||
| 			if (me) this.queryService.generateMutedUserQuery(query, me); | ||||
| 			if (me) this.queryService.generateBlockedUserQuery(query, me); | ||||
|  | ||||
| 			return await query.take(pagination.limit).getMany(); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 syuilo
					syuilo