fix(backend): 메일함 안 검색을 찾기 GIN 하나로 거르고 미리 보기 · 본문 강조를 고친다

14단계 실사용 시험(2026-11-06)에서 나온 것을 고친다.

- 1만 통 메일함 안 검색이 1.1초였다. 계획기가 메일함만 담은 GIN(메일함 id
  가 계정마다 같은 꼴이라 posting 이 회사 전체)으로 그 메일함 줄을 다 읽고
  묶음 배열을 줄마다 풀었다. 직원 · 묶음 · 메일함을 한 색인에 담은 GIN
  하나로 바꾸고 옛 GIN 둘을 지운다(마이그레이션 SearchIndexUserGramsMailboxes
  — 큰 메일함 15ms · 작은 메일함 1ms · 전체 15ms)
- 한 글자 낱말뿐이면 본문을 찾지 않는데 본문 글자를 강조했다 — 본문 강조는
  본문을 찾은 두 글자 이상 낱말만
- 답장의 결과 미리 보기가 원문 인용 조각이었다 — 인용 줄 밖의 맞은 곳을
  먼저, 인용 안에서만 맞고 다른 칸에서도 맞았으면 본문 첫머리, 인용 조각은
  줄 앞 `>` 를 뗀다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 01:15:43 +09:00
parent 5cece8ebf6
commit 0090a161ec
18 changed files with 839 additions and 74 deletions
+126 -1
View File
@@ -130,8 +130,14 @@ import {
import { MailSearchBuildService } from '@/modules/mail-search/mail-search-build.service';
import type { SearchScope } from '@/modules/mail-search/mail-search.types';
import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository';
import { INDEX_FORMAT } from '@/modules/mail-search/mail-search.constants';
import { PLANNER_SETTINGS } from '@/modules/mail-search/mail-search-query.service';
import {
INDEX_FORMAT,
SEARCH_CHECK_ROWS_LIMIT,
} from '@/modules/mail-search/mail-search.constants';
import { toIndexRow } from '@/modules/mail-search/index-document';
import { planSearch } from '@/modules/mail-search/search-plan';
import { candidatesSql } from '@/modules/mail-search/search-sql';
import {
SystemSettingsService,
type SystemSettingsPatch,
@@ -8082,6 +8088,16 @@ describe('응답 계약 (e2e)', () => {
mailboxIds: [SEARCH_EXCLUDED_MAILBOX],
receivedAt: '2026-11-01T01:00:00Z',
}),
// 답장 — 내 글 아래에 원문 인용(> 줄)이 있다 (미리 보기 고르기 — 2026-11-06 사용자 관점 점검)
indexDocument({
id: 'm-reply',
subject: 'RE: 해오름 프로젝트 안내',
bodyText:
'네, 받았습니다. 금요일까지 확인하겠습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.',
preview:
'네, 받았습니다. 금요일까지 확인하겠습니다. > 원본 메일 > 해오름 프로젝트 R9A51 자료를 보냅니다.',
receivedAt: '2026-10-30T01:00:00Z',
}),
];
interface ListItem {
@@ -8202,6 +8218,102 @@ describe('응답 계약 (e2e)', () => {
expect(await idsOf({ q: '층' })).toEqual(['m-floor']);
// 두 글자 넘는 낱말과 함께면 본문에서도 확인한다
expect(await idsOf({ q: '층 회의실' })).toEqual(['m-meeting']);
// 본문을 찾지 않았으니 미리 보기에도 강조하지 않는다 — '공' 은 m-floor 의 제목과 본문에 있다 (2026-11-06 실사용 시험)
const found = dataOf<Page<ListItem>>(
await list({ q: '공' }).expect(200),
).items;
expect(found.map((item) => item.id)).toEqual(['m-contract', 'm-floor']);
expect(found[1].searchHighlight).toEqual({
subject: [[3, 4]],
preview: { text: '', ranges: [] },
});
});
it('미리 보기는 답장의 원문 인용(> 줄)을 피한다 — 인용 안에서만 맞고 제목에서도 맞으면 인용 앞의 글, 인용에만 있는 말은 > 를 뗀 인용 조각 (2026-11-06 사용자 관점 점검)', async () => {
const [byTitle] = dataOf<Page<ListItem>>(
await list({ q: '해오름' }).expect(200),
).items;
expect(byTitle.id).toBe('m-reply');
expect(byTitle.searchHighlight).toEqual({
subject: [[4, 7]],
preview: {
text: '네, 받았습니다. 금요일까지 확인하겠습니다.…',
ranges: [],
},
});
const [byQuote] = dataOf<Page<ListItem>>(
await list({ q: 'R9A51' }).expect(200),
).items;
expect(byQuote.id).toBe('m-reply');
expect(byQuote.searchHighlight?.preview).toEqual({
text: '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
ranges: [[16, 21]],
});
});
it('메일함 범위의 후보는 찾기 GIN 하나로 — 주소 · 묶음 · 메일함을 색인 조건으로 맞추고, 메일함만으로 줄을 모아 묶음을 줄마다 풀지 않는다 (실제 PostgreSQL — 2026-11-06 실사용 시험)', async () => {
// 메일함만 담은 GIN 은 없다 — 있으면 계획기가 그것만으로 메일함의 줄(회사 전체 posting)을 읽고 묶음 배열을 줄마다 풀 수 있다
const gins: { indexname: string }[] = await dataSource.query(
`SELECT indexname FROM pg_indexes WHERE tablename = 'mail_search_messages' AND indexdef ILIKE '%USING gin%' ORDER BY indexname`,
);
expect(gins.map((row) => row.indexname)).toEqual([
'IDX_mail_search_messages_refs',
'IDX_mail_search_messages_user_grams_mailboxes',
]);
interface PlanNode {
'Index Name'?: string;
'Index Cond'?: string;
Filter?: string;
'Recheck Cond'?: string;
Plans?: PlanNode[];
}
const nodesOf = (node: PlanNode): PlanNode[] => [
node,
...(node.Plans ?? []).flatMap(nodesOf),
];
const scope: SearchScope = {
mailboxId: 'inbox',
excludedMailboxIds: [],
excludedKeywords: ['$ara-sending'],
};
for (const text of ['계약', '계약서 갱신']) {
const query = candidatesSql(
ME,
scope,
planSearch({ text, hasAttachment: false }),
null,
SEARCH_CHECK_ROWS_LIMIT + 1,
);
// 서비스와 같은 계획기 설정(SET LOCAL — 트랜잭션 안)으로
const rows: { 'QUERY PLAN': unknown }[] = await dataSource.transaction(
async (manager) => {
for (const setting of PLANNER_SETTINGS.bitmap) {
await manager.query(setting);
}
return manager.query(
`EXPLAIN (FORMAT JSON) ${query.sql}`,
query.params,
);
},
);
const raw = rows[0]['QUERY PLAN'];
const [explained] = (
typeof raw === 'string' ? JSON.parse(raw) : raw
) as { Plan: PlanNode }[];
const nodes = nodesOf(explained.Plan);
const scans = nodes.filter((node) => node['Index Name']);
expect(scans.map((node) => node['Index Name'])).toEqual([
'IDX_mail_search_messages_user_grams_mailboxes',
]);
expect(scans[0]['Index Cond']).toContain('mailbox_ids');
expect(scans[0]['Index Cond']).toContain('grams');
// 묶음은 줄마다 보지 않는다 — 걸러 내기(Filter)에 묶음이 없다
expect(nodes.map((node) => node.Filter ?? '').join(' ')).not.toContain(
'grams',
);
}
});
it('쪽 넘기기 — 다음 쪽은 커서의 자리부터, 다음 쪽의 total 은 null · 다른 검색의 커서는 400 VAL_001', async () => {
@@ -8500,6 +8612,19 @@ describe('응답 계약 (e2e)', () => {
expect(detail.htmlBody).not.toContain('<mark>로고');
});
it('한 글자 낱말뿐이면 본문에는 강조하지 않는다 — 제목은 그대로 (2026-11-06 실사용 시험)', async () => {
// '든' 은 제목(그림이 든 메일)에, '안' 은 본문(안녕하세요)에 있다
const detail = dataOf<ReadDetail>(
await read({ q: '든 안' }).expect(200),
);
expect(detail.searchHighlight).toEqual({
subject: [[4, 5]],
attachments: {},
textBody: [],
});
expect(detail.htmlBody).not.toContain('<mark>');
});
it('images=show 와 함께 — 중계 주소로 바꾼 본문에 강조, 제목 조건은 제목에', async () => {
const detail = dataOf<ReadDetail>(
await read({ images: 'show', subject: '그림', q: '안녕' }).expect(