Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ DB_USERNAME=postgres
DB_PASSWORD=
DB_NAME=postgres

# Semantic search (server only)
OPEN_AI_KEY=
DEEPSEEK_KEY=

# Redis (the application adds the redis:// protocol)
REDIS_URL=localhost:6379

Expand Down
115 changes: 114 additions & 1 deletion apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,120 @@ pnpm dev:web
`node_modules`를 다시 설치한 경우에만 `prisma:generate`를 다시 실행하면 됩니다.
`pnpm dev:web`은 DB migration을 실행하지 않습니다.

## 기술 스택
## AI 검색

1. Prisma migration `20261109143000_club_semantic_search`와
`20261110000000_club_embedding_3072`를 순서대로 적용합니다.
2. 서버 환경 변수 `OPEN_AI_KEY`를 설정합니다. 로컬은 `.env.local`을 사용합니다.
3. `/admin`의 **검색 정보 갱신** 버튼에서 최초 생성 후 월 1회 갱신합니다.

`schema.prisma`의 `club`에 `embedding`, `embedding_model`, `embedding_source_hash`,
`embedding_updated_at`을 선언합니다. `vector(3072)`는 `Unsupported` 타입으로 관리하고,
벡터 저장·거리 계산은 Prisma의 파라미터 바인딩된 `$executeRaw` / `$queryRaw`로 실행합니다.
일반 조회와 검색 필터는 Prisma Client를 사용합니다.

DB 변경은 저장소 루트에서 `pnpm --dir apps/web exec prisma migrate deploy`로 적용합니다.
기존 DB는 develop의 `0_init` baseline이 등록되어 있어야 합니다. 운영 배포는 기존
Prisma PreSync 작업이 migration을 실행합니다. 개발 배포에는 자동 migration이 없으므로
개발 DB에도 새 앱 배포 전에 적용해야 합니다. `prisma:generate`는 Client 코드만 생성하며
DB를 변경하지 않습니다. 과거 수동 SQL로 네 컬럼을 만든 DB도 이번 migration을 적용할 수 있습니다.

### 1536 → 3072차원 전환

기존 migration은 수정하지 않고 `20261110000000_club_embedding_3072`를 추가했습니다.
새 migration은 `club.embedding`을 `vector(3072)`로 바꾸면서 기존 벡터와 모델·문서 해시·
갱신 시각을 NULL로 초기화합니다. 동아리 원문은 유지됩니다. 기존 1536차원 벡터를
3072차원으로 복원할 수 없으므로 API로 전체 임베딩을 다시 생성해야 합니다.

1. 실행 중인 관리자 임베딩 갱신을 완료하거나 중지합니다.
2. 아래 명령을 저장소 루트에서 실행합니다. `prisma.config.ts`가 읽는
`apps/web/.env.local` 또는 실행 환경의 `DB_HOST`, `DB_PORT`, `DB_USERNAME`,
`DB_PASSWORD`, `DB_NAME`이 적용할 DB를 가리키는지 확인합니다.

```bash
pnpm --dir apps/web prisma:validate
pnpm --dir apps/web exec prisma migrate status
pnpm --dir apps/web exec prisma migrate deploy
pnpm --dir apps/web prisma:generate
```

3. 새 앱을 배포하거나 로컬 서버를 재시작합니다. 이때 검색어 임베딩 캐시도 초기화됩니다.
4. `/admin`의 **검색 정보 갱신**을 실행해 전체 임베딩을 재생성합니다.
재생성이 끝나기 전에는 아직 처리되지 않은 동아리가 의미 검색 결과에 포함되지 않습니다.
문자열 검색은 계속 사용할 수 있습니다.

이번 변경의 SQL은 이미 작성되어 있으므로 별도 `migrate dev` 실행은 필요하지 않습니다.
이후 새로운 스키마 변경을 작성할 때는 개발 DB에서 `prisma migrate dev --create-only --name ...`로
초안을 만들고 필요한 SQL을 편집합니다. 이 프로젝트의 Prisma 7 기준
[사용자 정의 migration 문서](https://www.prisma.io/docs/orm/v7/prisma-migrate/workflows/customizing-migrations)를 참고합니다.

현재 의미 검색은 벡터 인덱스 없이 코사인 거리를 계산합니다. `vector(3072)` 저장과 거리 계산은
가능하지만, `vector`의 HNSW/IVFFlat 인덱스는 최대 2000차원이므로 향후 인덱스를 추가할 때는
`halfvec(3072)` 표현식 인덱스 등 별도 설계가 필요합니다. [pgvector 문서](https://github.com/pgvector/pgvector#what-if-i-want-to-index-vectors-with-more-than-2000-dimensions)

### 검색 API와 갱신 동작

| API | 동작 |
| --- | --- |
| `GET /api/v2/clubs/search?query=컴공` | 기존 문자열 검색과 오타 교정. AI 호출 없음 |
| `GET /api/v2/clubs/ai-search?query=개발하기 좋은 동아리` | 검색문 전처리 후 문자열 검색과 의미 검색 |
| `GET /api/v2/clubs/ai-search/intent?query=회비 없는 개발 동아리` | 전처리 JSON만 확인 |

모든 API는 유효한 bearer token 또는 `x-guest-id` UUID 헤더가 필요하며, 검색문은
1~200자입니다. 두 검색 API는 필터·응답 형식·최근 검색어 저장 규칙을 공유합니다.
AI 검색은 문자열 일치 결과 뒤에 의미 검색 결과를 유사도순으로 추가합니다.
웹과 앱의 기존 검색은 `/search`를 사용하며, AI 검색은 `/ai-search`로 별도 요청합니다.

`DEEPSEEK_KEY`를 설정하면 모든 AI 검색문을 길이나 공백 유무와 관계없이 먼저 해석합니다.
[search-intent.ts](server/service/search/search-intent.ts)에서 DeepSeek의
`deepseek-flash`를 비추론·JSON 출력 모드로 호출해 검색 주제와 명시적 필터를 분리합니다.
예를 들어 `회비 없는 개발 동아리`는 `소프트웨어 개발`과 `hasMembershipFee: false`가 됩니다.
URL에서 선택한 필터를 우선하고, 지원하지 않는 조건은 검색문에 남깁니다.
한 글자·한 단어 검색도 같은 전처리를 거칩니다. 응답과 검색 기록의 `query`는 원문을 유지하며,
의도 변환을 오타 교정으로 표시하지 않습니다.

변환 결과는 최대 500개, 24시간 캐시하며 동시 요청도 공유합니다. 키가 없거나
3초 시간 초과·API 오류·잘못된 JSON 응답이 발생하면 원문으로 검색합니다.
실패는 캐시하지 않습니다. [DeepSeek JSON 출력 문서](https://api-docs.deepseek.com/guides/json_mode/)

`/ai-search/intent`도 같은 전처리 함수를 사용하며, 검색·임베딩·검색 기록 저장은 실행하지 않습니다.
예를 들어 `회비 없는 개발 동아리`의 응답은 다음과 같습니다.

```json
{
"searchQuery": "소프트웨어 개발",
"filters": { "hasMembershipFee": false }
}
```

갱신 코드는 다음 순서로 읽으면 됩니다.

| 단계 | 코드 | 역할 |
| --- | --- | --- |
| 조회 | [embedding-source.ts](server/service/search/embedding-source.ts) | 공개 동아리 정보·소속·최신 모집과 정기 모임·후기 집계 선택 |
| 문서 | [embedding-document.ts](server/service/search/embedding-document.ts) | HTML·빈 값·배열 순서를 정리하고 입력 텍스트와 해시 생성 |
| 임베딩 | [embedding.ts](server/service/search/embedding.ts) | 토큰 분할 → API 호출 → 문서별 가중 합산·정규화 |
| 저장 | [embedding-update.service.ts](server/service/search/embedding-update.service.ts) | 변경된 문서만 생성하고 저장 직전에 원문 재확인 |

문서는 공개 소개·Article·분류·활동 조건·SNS 및 이미지 주소를 포함합니다. 링크나 이미지의
내용은 가져오지 않으며, 식별자·내부 심사 기록·후기 작성자는 제외합니다. 모집 기간·회비 같은
정확한 조건은 SQL 필터로 판단합니다.

모델은 `text-embedding-3-large`, 차원은 기본값인 3072, 입력 버전은 `v2`입니다.
[OpenAI 공식 임베딩 문서](https://developers.openai.com/api/docs/guides/embeddings)

8191토큰을 넘으면 모든 조각을 길이로 가중 합산해 단위 벡터로 만듭니다. 여러 주제를 하나의 벡터에 담으므로
개별 주제의 검색 신호는 약해질 수 있습니다. 의미 검색의 유사도 기준은 0.24입니다.

관리자 API `GET/POST /api/v2/admin/clubs/embeddings`가 20개씩 처리합니다. 모델·입력 버전이나
문서 해시가 달라진 동아리를 갱신하며, 저장 전 재조회에서 원문 변경이 확인되면 건너뜁니다.
중단 후 다시 실행해도 저장된 배치는 유지됩니다. `text-embedding-3-large:3072:v2`와 일치하는
벡터만 검색에 사용하므로 모델이나 차원을 변경한 후에는 전체를 재생성해야 합니다.

검색어 벡터는 최대 500개, 24시간 캐시합니다. API 오류 시에는 문자열 검색을 제공합니다.
앱은 DB 스키마를 변경하거나 갱신 배치를 자동 실행하지 않습니다.

## 기술 스택

### 언어 / 프레임워크
- Typescript
Expand Down
1 change: 1 addition & 0 deletions apps/web/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const config = {
'/api/v2/managers/me/recruitments/:recruitmentId?',
'/api/v2/admin/me',
'/api/v2/admin/clubs',
'/api/v2/admin/clubs/embeddings',
'/api/v2/admin/clubs/histories',
'/api/v2/admin/clubs/manager-requests',
'/api/v2/admin/clubs/manager-requests/:id/status',
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
"clsx": "^1.2.1",
"dayjs": "^1.11.9",
"es-hangul": "^2.3.8",
"htmlparser2": "7.2.0",
"jose": "^5.2.0",
"js-tiktoken": "^1.0.21",
"jsonwebtoken": "^9.0.2",
"leven": "^4.0.0",
"lodash-es": "^4.17.21",
Expand Down
1 change: 1 addition & 0 deletions apps/web/pages/admin/update-embedding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'src/updateEmbedding'
33 changes: 33 additions & 0 deletions apps/web/pages/api/v2/admin/clubs/embeddings/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { ForbiddenError } from 'server/domain/error'
import { Provider } from 'server/provider'
import { EmbeddingUpdateService } from 'server/service/search/embedding-update.service'
import { UserService } from 'server/service/user.service'
import { z } from 'zod'

const batchSchema = z.object({ cursor: z.string().uuid().nullable().default(null) })

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET' && req.method !== 'POST') {
res.setHeader('Allow', 'GET, POST')
return res.status(405).end()
}
res.setHeader('Cache-Control', 'no-store')
try {
// middleware가 검증한 JWT의 sub만 사용합니다.
if (typeof req.headers.user !== 'string') return res.status(401).send('unauthorized')
await Provider.getService(UserService).assertAdminRole(req.headers.user)
const service = Provider.getService(EmbeddingUpdateService)
if (req.method === 'GET') return res.status(200).json(await service.status())

const { cursor } = batchSchema.parse(req.body ?? {})
return res.status(200).json(await service.updateBatch(cursor))
} catch (error) {
if (error instanceof ForbiddenError) return res.status(403).send('Forbidden')
if (error instanceof z.ZodError) return res.status(400).send('invalid cursor')
console.error('update embeddings:', error instanceof Error ? error.message : 'error')
return res
.status(503)
.send('검색 정보 갱신에 실패했습니다. DB 설정과 임베딩 API 상태를 확인해주세요.')
}
}
3 changes: 3 additions & 0 deletions apps/web/pages/api/v2/clubs/ai-search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createClubSearchHandler } from 'server/api/club-search.handler'

export default createClubSearchHandler('ai')
3 changes: 3 additions & 0 deletions apps/web/pages/api/v2/clubs/ai-search/intent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createClubSearchHandler } from 'server/api/club-search.handler'

export default createClubSearchHandler('intent')
145 changes: 2 additions & 143 deletions apps/web/pages/api/v2/clubs/search/index.ts
Original file line number Diff line number Diff line change
@@ -1,144 +1,3 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { BadRequestError, UnauthorizedError } from 'server/domain/error'
import { Provider } from 'server/provider'
import { saveRecentSearchBestEffort } from 'server/service/recent-search.service'
import type {
MinActivityPeriodFilter,
SearchClub,
SearchFilters,
} from 'server/service/search/search.types'
import { SearchService } from 'server/service/search.service'
import { resolveOptionalAuth } from 'server/util/optional-auth'
import { createClubSearchHandler } from 'server/api/club-search.handler'

type ResponseData = {
clubs: SearchClub[]
totalSize: number
query: string
correctedQuery: string | null
isTypoCorrected: boolean
}

const INVALID_FILTER_MESSAGE = 'invalid search filter'

const MIN_ACTIVITY_PERIOD_FILTERS = new Set<MinActivityPeriodFilter>(['0', '1', '2', '3_plus'])

class InvalidSearchFilterError extends Error {}

export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData | string>,
) {
try {
const searchService = Provider.getService(SearchService)

if (req.method == 'GET') {
const auth = await resolveOptionalAuth(req)
const query = req.query.query as string
if (!query) {
return res.status(400).send('query is required')
}

let filters: SearchFilters
let recordRecentSearch: boolean
try {
filters = parseSearchFilters(req.query)
recordRecentSearch = parseOptionalBoolean(req.query.record_recent_search) ?? true
} catch (err) {
if (err instanceof InvalidSearchFilterError) {
return res.status(400).send(err.message)
}
throw err
}

const { clubs, correctedQuery, isTypoCorrected } =
await searchService.searchWithTypoCorrection(query, { filters })
if (recordRecentSearch) {
await saveRecentSearchBestEffort(auth, query)
}
return res.status(200).json({
clubs: clubs,
totalSize: clubs.length,
query,
correctedQuery,
isTypoCorrected,
})
}
} catch (err) {
if (err instanceof UnauthorizedError) {
return res.status(401).send('unauthorized')
}
if (err instanceof BadRequestError) {
return res.status(400).send(err.message)
}
console.error('searchClubs error: ', err)
return res.status(500).send('Internal Server Error')
}
}

function parseSearchFilters(query: NextApiRequest['query']): SearchFilters {
return {
affiliationType: parseAffiliationType(query.affiliation_type),
isRecruiting: parseOptionalBoolean(query.is_recruiting),
recruitType: parseRecruitType(query.recruit_type),
hasMembershipFee: parseOptionalBoolean(query.has_membership_fee),
hasDongbang: parseOptionalBoolean(query.has_dongbang),
isOfficialVerified: parseOptionalBoolean(query.is_official_verified),
minActivityPeriods: parseMinActivityPeriods(query.min_activity_period),
}
}

function parseOptionalBoolean(value: unknown): boolean | undefined {
if (value === undefined) {
return undefined
}
if (value === 'true') {
return true
}
if (value === 'false') {
return false
}
throw new InvalidSearchFilterError(INVALID_FILTER_MESSAGE)
}

function parseAffiliationType(value: unknown): string | undefined {
if (value === undefined || value === '전체') {
return undefined
}
if (value === '중앙동아리') {
return '중앙동아리'
}
if (value === '학과/단과대동아리') {
return '소속동아리'
}
throw new InvalidSearchFilterError(INVALID_FILTER_MESSAGE)
}

function parseRecruitType(value: unknown): '정기' | '상시' | undefined {
if (value === undefined) {
return undefined
}
if (value === '정기' || value === '상시') {
return value
}
throw new InvalidSearchFilterError(INVALID_FILTER_MESSAGE)
}

function parseMinActivityPeriods(value: unknown): MinActivityPeriodFilter[] | undefined {
if (value === undefined) {
return undefined
}

const values = Array.isArray(value) ? value : [value]
const selected = values.map((it) => {
if (typeof it !== 'string' || !MIN_ACTIVITY_PERIOD_FILTERS.has(it as MinActivityPeriodFilter)) {
throw new InvalidSearchFilterError(INVALID_FILTER_MESSAGE)
}
return it as MinActivityPeriodFilter
})

const unique = Array.from(new Set(selected))
if (unique.length === 0) {
return undefined
}
return unique
}
export default createClubSearchHandler('keyword')
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- text-embedding-3-large, dimensions=1536. 생성/갱신은 관리자 화면에서 수동 실행합니다.
-- 기존에 수동 SQL을 적용한 DB도 데이터 삭제 없이 이 migration을 적용할 수 있습니다.
BEGIN;

CREATE EXTENSION IF NOT EXISTS vector;

ALTER TABLE public.club
ADD COLUMN IF NOT EXISTS embedding vector(1536),
ADD COLUMN IF NOT EXISTS embedding_model text,
ADD COLUMN IF NOT EXISTS embedding_source_hash text,
ADD COLUMN IF NOT EXISTS embedding_updated_at timestamptz;

COMMENT ON COLUMN public.club.embedding IS
'공개 동아리 정보와 연관 정보로 만든 검색 문서의 임베딩. 미생성 시 NULL.';
COMMENT ON COLUMN public.club.embedding_model IS
'모델 / 차원 / 문서 입력 버전. 검색어와 동일한 모델·차원의 벡터만 비교한다.';
COMMENT ON COLUMN public.club.embedding_source_hash IS
'정규화한 전체 검색 문서 JSON의 SHA-256. 연관 정보가 변경되어도 수동 갱신 대상이 된다.';

COMMIT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- text-embedding-3-large의 기본 3072차원을 사용합니다.
-- 기존 1536차원 벡터는 변환할 수 없으므로 초기화한 뒤 관리자 화면에서 재생성합니다.
-- 실행 중인 임베딩 갱신을 마친 후 적용하고, 새 앱 배포 후 갱신을 다시 실행합니다.
BEGIN;

ALTER TABLE public.club
ALTER COLUMN embedding TYPE vector(3072) USING NULL::vector(3072);

UPDATE public.club
SET embedding_model = NULL,
embedding_source_hash = NULL,
embedding_updated_at = NULL
WHERE embedding_model IS NOT NULL
OR embedding_source_hash IS NOT NULL
OR embedding_updated_at IS NOT NULL;

COMMENT ON COLUMN public.club.embedding IS
'공개 동아리 정보와 연관 정보로 만든 검색 문서의 3072차원 임베딩. 미생성 시 NULL.';

COMMIT;
Loading
Loading