Skip to content
Merged
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
259 changes: 71 additions & 188 deletions TASK.md

Large diffs are not rendered by default.

171 changes: 171 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Bubblog AI API λ¬Έμ„œ (v1 ~ v2)

λ³Έ λ¬Έμ„œλŠ” `/ai` (v1)와 `/ai/v2` (v2) μ—”λ“œν¬μΈνŠΈλ₯Ό μ •λ¦¬ν•©λ‹ˆλ‹€. μ„œλ²„λŠ” Express 기반이며, `POST /ask` λ₯˜λŠ” Server‑Sent Events(SSE)둜 닡변을 μŠ€νŠΈλ¦¬λ°ν•©λ‹ˆλ‹€.

## κΈ°λ³Έ 정보
- Base Path
- v1: `/ai`
- v2: `/ai/v2`
- 인증
- `POST /ask` μ—”λ“œν¬μΈνŠΈλŠ” `Authorization: Bearer <JWT>` ν•„μš”
- μž„λ² λ”© 생성 μ—”λ“œν¬μΈνŠΈλŠ” 인증 없이 μ‚¬μš© κ°€λŠ₯
- λ³Έλ¬Έ ν˜•μ‹: `application/json`
- SSE μˆ˜μ‹ : `Content-Type: text/event-stream`
- 이벀트λͺ…은 `event:` 라인으둜, λ°μ΄ν„°λŠ” `data:` 라인으둜 μ „μ†‘λ©λ‹ˆλ‹€.
- 일반 ν…μŠ€νŠΈ μ½˜ν…μΈ λŠ” `event: answer`둜 λΆ„ν•  μ „μ†‘λ˜λ©°, μ’…λ£Œ μ‹œ `event: end` + `data: [DONE]`κ°€ μ†‘μ‹ λ©λ‹ˆλ‹€.

## v1 μ—”λ“œν¬μΈνŠΈ (`/ai`)

### GET `/ai/health`
- 인증: λΆˆν•„μš”
- 응닡(200): `{ "status": "ok" }`

### POST `/ai/embeddings/title`
- 인증: λΆˆν•„μš”
- μš”μ²­ Body
- `post_id`(number, required)
- `title`(string, required)
- λ™μž‘: 제λͺ© μž„λ² λ”© 생성 ν›„ μ €μž₯
- 응닡(200): `{ "ok": true }`

### POST `/ai/embeddings/content`
- 인증: λΆˆν•„μš”
- μš”μ²­ Body
- `post_id`(number, required)
- `content`(string, required)
- λ™μž‘: 본문을 μ•½ 512 토큰 λ‹¨μœ„λ‘œ 쀑첩(50) μ²­ν‚Ή β†’ μž„λ² λ”© 생성/μ €μž₯
- 응닡(200): `{ "post_id": number, "chunk_count": number, "success": true }`

### POST `/ai/ask` (SSE)
- 인증: ν•„μš” (`Authorization: Bearer <JWT>`)
- μš”μ²­ Body
- `question`(string, required)
- `user_id`(string, required)
- `category_id`(number, optional)
- `post_id`(number, optional) β€” μ§€μ • μ‹œ ν•΄λ‹Ή κΈ€ μ»¨ν…μŠ€νŠΈμ— κ΅­ν•œν•˜μ—¬ λ‹΅λ³€
- `speech_tone`(number, optional)
- `-1`: κ°„κ²°ν•˜κ³  λͺ…ν™•ν•œ 말투(κΈ°λ³Έ)
- `-2`: ν•΄λ‹Ή κΈ€μ˜ 말투λ₯Ό μ΅œλŒ€ν•œ λͺ¨μ‚¬
- μ–‘μ˜ μ •μˆ˜: 페λ₯΄μ†Œλ‚˜ ID(ν•΄λ‹Ή μœ μ €μ˜ λ“±λ‘λœ 페λ₯΄μ†Œλ‚˜ μ°Έμ‘°)
- `llm`(object, optional)
- `provider`: `openai` | `gemini`
- `model`: string (λ―Έμ§€μ • μ‹œ μ„œλ²„ κΈ°λ³Έκ°’ μ‚¬μš©)
- `options`: `{ temperature?, top_p?, max_output_tokens? }`
- SSE 이벀트(μ£Όμš”)
- `exist_in_post_status`: `true|false` β€” κ΄€λ ¨ μ»¨ν…μŠ€νŠΈ 쑴재 μ—¬λΆ€
- `context`: `[ { postId, postTitle }, ... ]` β€” 검색/μ„ νƒλœ μ»¨ν…μŠ€νŠΈ μš”μ•½
- `answer`: λͺ¨λΈμ˜ λΆ€λΆ„ 응닡 ν…μŠ€νŠΈ(μ—¬λŸ¬ 번 전솑)
- `end`: μ’…λ£Œ μ‹œ `data: [DONE]`
- `error`: `{ code?, message }` β€” 예: `post_id`κ°€ μ—†κ±°λ‚˜ κΆŒν•œ μ—†μŒ(403), μ—†μŒ(404)
- μ˜ˆμ‹œ(curl)
```bash
curl -N \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-X POST http://localhost:3000/ai/ask \
-d '{
"question": "μΉ΄ν…Œκ³ λ¦¬ A κ΄€λ ¨ μš”μ•½ ν•΄μ€˜",
"user_id": "u_123",
"category_id": 1,
"speech_tone": -1
}'
```

## v2 μ—”λ“œν¬μΈνŠΈ (`/ai/v2`)

### GET `/ai/v2/health`
- 인증: λΆˆν•„μš”
- 응닡(200): `{ "status": "ok", "v": "v2" }`

### POST `/ai/v2/ask` (SSE)
- 인증: ν•„μš” (`Authorization: Bearer <JWT>`)
- μš”μ²­ Body
- `question`(string, required)
- `user_id`(string, required)
- `category_id`(number, optional)
- `post_id`(number, optional)
- `speech_tone`(number, optional)
- `-1`: κΈ°λ³Έ 말투(κ°„κ²°/λͺ…ν™•)
- `-2`: ν•΄λ‹Ή κΈ€(post λͺ¨λ“œ) 말투 λͺ¨μ‚¬
- μ–‘μˆ˜: 페λ₯΄μ†Œλ‚˜ ID(ν•΄λ‹Ή μœ μ €μ˜ 등둝 페λ₯΄μ†Œλ‚˜)
- `llm`(object, optional)
- `provider`: `openai` | `gemini`
- `model`: string (λ―Έμ§€μ • μ‹œ μ„œλ²„ κΈ°λ³Έκ°’ μ‚¬μš©)
- `options`: `{ temperature?: number, top_p?: number, max_output_tokens?: number }`
- λ™μž‘ κ°œμš”
- μ„œλ²„κ°€ μ§ˆλ¬Έμ„ ν† λŒ€λ‘œ β€œκ²€μƒ‰ κ³„νš(JSON)”을 μƒμ„±Β·κ²€μ¦Β·μ •κ·œν™”ν•œ λ’€, κ³„νšμ— 따라 μ‹œλ§¨ν‹± λ˜λŠ” ν•˜μ΄λΈŒλ¦¬λ“œ 검색을 μˆ˜ν–‰ν•˜κ³  κ²°κ³Όλ₯Ό SSE둜 μŠ€νŠΈλ¦¬λ°ν•©λ‹ˆλ‹€.
- `post_id`κ°€ 있으면 post λͺ¨λ“œ(단일 κΈ€ μ»¨ν…μŠ€νŠΈ)둜 μ²˜λ¦¬ν•˜λ©°, κ°„λž΅ν•œ `search_plan`/`search_result` 이벀트 ν›„ λ³Έλ¬Έ 기반 닡변을 μŠ€νŠΈλ¦¬λ°ν•©λ‹ˆλ‹€.
- ν•˜μ΄λΈŒλ¦¬λ“œ 검색(벑터+ν…μŠ€νŠΈ)
- κ³„νšμ— `hybrid.enabled: true`인 경우 ν™œμ„±ν™”λ©λ‹ˆλ‹€.
- `rewrites`(μž¬μž‘μ„± 질의)와 `keywords`(핡심 ν‚€μ›Œλ“œ)λ₯Ό μƒμ„±ν•˜μ—¬ 벑터/ν…μŠ€νŠΈ 두 경둜둜 후보λ₯Ό μˆ˜μ§‘ν•˜κ³ , `hybrid.retrieval_bias` 라벨을 μ„œλ²„κ°€ `alpha` κ°’μœΌλ‘œ λ§€ν•‘ν•΄ 점수λ₯Ό μœ΅ν•©ν•˜μ—¬ μƒμœ„ `top_k`λ₯Ό μ„ νƒν•©λ‹ˆλ‹€.
- λ§€ν•‘(κΈ°λ³Έ): `lexical β†’ 0.3`, `balanced β†’ 0.5`, `semantic β†’ 0.75`
- 결합식: `score = alpha*vec + (1-alpha)*text` (각 경둜 점수 min-max μ •κ·œν™” ν›„)
- SSE둜 `rewrite`, `keywords`, `hybrid_result` μ΄λ²€νŠΈκ°€ ν•„μš”ν•œ κ²½μš°μ—λ§Œ μ†‘μ‹ λ©λ‹ˆλ‹€. ν•˜μ΄λΈŒλ¦¬λ“œ κ²°κ³Όκ°€ μ—†μœΌλ©΄ μ‹œλ§¨ν‹± κ²€μƒ‰μœΌλ‘œ ν΄λ°±ν•©λ‹ˆλ‹€.
- SSE 이벀트 μˆœμ„œ(일반적인 흐름)
1) `search_plan`: μ •κ·œν™”λœ 검색 κ³„νš(JSON)
- μ˜ˆμ‹œ 데이터(μ •κ·œν™”):
```json
{
"mode": "rag",
"top_k": 5,
"threshold": 0.2,
"weights": { "chunk": 0.7, "title": 0.3 },
"filters": {
"time": { "type": "absolute", "from": "2025-09-01T00:00:00.000Z", "to": "2025-09-30T23:59:59.999Z" }
},
"sort": "created_at_desc",
"limit": 5,
"hybrid": { "enabled": true, "retrieval_bias": "balanced", "alpha": 0.5, "max_rewrites": 3, "max_keywords": 6 },
"rewrites": ["ν”„λ‘œμ νŠΈ X μš”μ•½", "ν”„λ‘œμ νŠΈ X 핡심"],
"keywords": ["ν”„λ‘œμ νŠΈ X", "핡심", "μš”μ•½"]
}
```
- λΉ„κ³ :
- `filters.time`만 ν¬ν•¨λ©λ‹ˆλ‹€. `user_id`/`category_id`/`post_id` 등은 μ„œλ²„κ°€ 검색 μ‹œ λ‚΄λΆ€μ μœΌλ‘œ μ μš©ν•©λ‹ˆλ‹€.
- `hybrid.retrieval_bias`λŠ” LLM 라벨이며 μ„œλ²„κ°€ `alpha`둜 λ³€ν™˜ν•΄ μ‚¬μš©ν•©λ‹ˆλ‹€.
- post λͺ¨λ“œμ—μ„œλŠ” κ°„λž΅ν•œ ν˜•νƒœ 예: `{ "mode": "post", "filters": { "post_id": 123, "user_id": "u_123" } }`.
2) (ν•˜μ΄λΈŒλ¦¬λ“œ μ‚¬μš© μ‹œ) `rewrite`: `string[]`
3) (ν•˜μ΄λΈŒλ¦¬λ“œ μ‚¬μš© μ‹œ) `keywords`: `string[]`
4) (ν•˜μ΄λΈŒλ¦¬λ“œ μ‚¬μš© μ‹œ) `hybrid_result`: `[ { postId, postTitle }, ... ]`
5) `search_result`: `[ { postId, postTitle }, ... ]` β€” μ΅œμ’… μ»¨ν…μŠ€νŠΈ μš”μ•½(ν•˜μ΄λΈŒλ¦¬λ“œ λ˜λŠ” μ‹œλ§¨ν‹±)
6) `exist_in_post_status`: `true|false`
7) `context`: `[ { postId, postTitle }, ... ]`
8) `answer` β€” λͺ¨λΈ λΆ€λΆ„ 응닡(μ—¬λŸ¬ 번)
9) `end` β€” `data: [DONE]`
- 였λ₯˜ μ‹œ `error`: `{ code?: number, message: string }`
- 폴백 λ™μž‘
- ν”Œλž˜λ„ˆ μ‹€νŒ¨ μ‹œ `search_plan`으둜 `{ "mode": "rag", "fallback": true }`κ°€ μ†‘μ‹ λ˜λ©°, v1 μŠ€νƒ€μΌ RAG둜 μ»¨ν…μŠ€νŠΈλ₯Ό κ΅¬μ„±ν•©λ‹ˆλ‹€.

- μ˜ˆμ‹œ(curl)
```bash
curl -N \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-X POST http://localhost:3000/ai/v2/ask \
-d '{
"question": "졜근 ν•œ 달 λΈ”λ‘œκ·Έμ—μ„œ ν”„λ‘œμ νŠΈ X κ΄€λ ¨ λ‚΄μš© μš”μ•½",
"user_id": "u_123",
"category_id": 3,
"llm": { "provider": "openai", "model": "gpt-5-mini", "options": { "temperature": 0.2, "top_p": 0.9, "max_output_tokens": 800 } }
}'
```

## μ°Έκ³  사항
- `post_id`κ°€ μ§€μ •λœ μš”μ²­μ—μ„œ ν•΄λ‹Ή 글이 μ‘΄μž¬ν•˜μ§€ μ•ŠμœΌλ©΄ SSE둜 `error` 이벀트(404)κ°€ μ†‘μ‹ λ˜κ³  슀트림이 μ’…λ£Œλ©λ‹ˆλ‹€.
- `post.is_public`이 `false`인 글은 μš”μ²­ `user_id`κ°€ κΈ€ μ†Œμœ μžμ™€ λ‹€λ₯΄λ©΄ `error` 이벀트(403)둜 μ°¨λ‹¨λ©λ‹ˆλ‹€. `post.is_public`이 `true`λ©΄ λˆ„κ΅¬λ‚˜ μ ‘κ·Ό κ°€λŠ₯ν•©λ‹ˆλ‹€.
- v1/v2 λͺ¨λ‘ λͺ¨λΈ 응닡 ν…μŠ€νŠΈλŠ” `answer` 이벀트둜 λΆ„ν•  μ „μ†‘λ©λ‹ˆλ‹€. ν΄λΌμ΄μ–ΈνŠΈλŠ” λˆ„μ ν•˜μ—¬ μ΅œμ’… 닡변을 ꡬ성해야 ν•©λ‹ˆλ‹€.
- EventSource(λΈŒλΌμš°μ €) μ‚¬μš© μ˜ˆμ‹œ
```js
const es = new EventSource('/ai/v2/ask', { withCredentials: true }); // 헀더 인증이 ν•„μš”ν•œ 경우 fetch/XHR ꢌμž₯
es.addEventListener('search_plan', (e) => console.log('plan', e.data));
es.addEventListener('search_result', (e) => console.log('result', e.data));
es.addEventListener('context', (e) => console.log('ctx', e.data));
es.addEventListener('answer', (e) => renderAppend(JSON.parse(e.data)));
es.addEventListener('end', () => es.close());
es.addEventListener('error', (e) => es.close());
```

## μš”μ•½
- v1 `/ai/ask`: μ»¨ν…μŠ€νŠΈ 쑴재 여뢀와 μš”μ•½(`exist_in_post_status`, `context`) ν›„ λ‹΅λ³€ 슀트리밍
- v2 `/ai/v2/ask`: μœ„ 흐름에 더해 검색 κ³„νš(`search_plan`)κ³Ό 검색 κ²°κ³Ό μš”μ•½(`search_result`)을 μΆ”κ°€λ‘œ 제곡
- μž„λ² λ”© API(v1): κ²Œμ‹œλ¬Ό 제λͺ©/λ³Έλ¬Έ μž„λ² λ”© 생성 및 μ €μž₯
9 changes: 9 additions & 0 deletions docs/migrations/2025-01-pgtrgm.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Enable pg_trgm and add GIN indexes for text search on content and title
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX IF NOT EXISTS idx_pc_content_trgm
ON post_chunks USING gin (content gin_trgm_ops);

CREATE INDEX IF NOT EXISTS idx_bp_title_trgm
ON blog_post USING gin (title gin_trgm_ops);

27 changes: 27 additions & 0 deletions docs/migrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Migrations Guide

This folder contains SQL scripts for optional indexes/extensions used by the AI services.

## Apply pg_trgm for text search

File: `2025-01-pgtrgm.sql`

Purpose:
- Enable `pg_trgm` extension and add GIN indexes on `post_chunks.content` and `blog_post.title` to accelerate partial/fuzzy text search used in hybrid search.

Run (with `DATABASE_URL`):

```bash
psql "$DATABASE_URL" -f docs/migrations/2025-01-pgtrgm.sql
```

Or inside `psql`:

```sql
\i docs/migrations/2025-01-pgtrgm.sql
```

Notes:
- Indexes increase disk usage and write overhead; create only on columns used for text search.
- The extension must be installed once per database.

134 changes: 134 additions & 0 deletions docs/reports/REPORT-askv2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# λ³΄κ³ μ„œ: /ai/v2/ask β€” ꡬ쑰, λ™μž‘, ν•˜μ΄λΈŒλ¦¬λ“œ 검색 κ³„νš

## 1) κ°œμš”
- λͺ©ν‘œ: v1의 κ³ μ •ν˜• RAG ν•œκ³„λ₯Ό 보완. β€œκ²€μƒ‰ κ³„νš(JSON)”을 LLM이 생성 β†’ μ„œλ²„κ°€ μ•ˆμ „ν•˜κ²Œ ν‘œμ€€ν™”/검증 β†’ μ‹œλ§¨ν‹±/ν•˜μ΄λΈŒλ¦¬λ“œ 검색 μˆ˜ν–‰ β†’ μ΅œμ’… 닡변을 SSE둜 슀트리밍.
- μƒνƒœ: `POST /ai/v2/ask`(SSE) 운영 쀑. v1은 μœ μ§€ν•˜λ©°, v2μ—μ„œ κ³„νš 기반 검색과 관츑성을 κ°•ν™”.

## 2) v1의 ν•œκ³„μ™€ v2 λ„μž… 효과
- κ³ μ • νŒŒλΌλ―Έν„° β†’ 동적 κ³„νš
- v1: μž„κ³„μΉ˜(0.2), LIMIT(5), κ°€μ€‘μΉ˜(0.7/0.3) λ“± κ³ μ •.
- v2: `top_k`, `threshold`, `weights`, `sort`, `limit`을 질문/λ§₯락에 맞게 동적 μ œμ–΄(μ„œλ²„κ°€ λ²”μœ„ κ°•μ œ).
- μ‹œκ°„/μ •λ ¬ μ˜λ„ 미반영 β†’ μžμ—°μ–΄ μ‹œκ°„ 해석
- v1: β€œμ΅œκ·Ό/μ§€λ‚œμ£Ό/9μ›”/μž‘λ…„β€ 같은 μ‹œκ°„ μ˜λ„ 반영 λΆˆκ°€.
- v2: `filters.time`(μƒλŒ€/μ›”/λΆ„κΈ°/연도)을 λ°›μ•„ `KST μ ˆλŒ€λ²”μœ„(from/to)`둜 λ³€ν™˜ν•΄ 쿼리에 반영.
- 리콜/정밀도 κ· ν˜• ν•œκ³„ β†’ ν•˜μ΄λΈŒλ¦¬λ“œ
- v1: μž„λ² λ”© 기반 RAG만.
- v2: 벑터+ν…μŠ€νŠΈ ν•˜μ΄λΈŒλ¦¬λ“œ μœ΅ν•©μœΌλ‘œ μž¬ν˜„μœ¨ ν–₯상 및 ν‚€μ›Œλ“œ 민감 질의 λŒ€μ‘.
- κ΄€μΈ‘μ„± λΆ€μ‘± β†’ SSE 메타 이벀트
- v1: 검색/선택 κ·Όκ±°κ°€ 뢈투λͺ….
- v2: `search_plan`, `rewrite`, `keywords`, `hybrid_result`, `search_result` λ“±μœΌλ‘œ μ˜μ‚¬κ²°μ • κ°€μ‹œν™”.
- μ•ˆμ „μ„±/μ •ν•©μ„±
- v1: ν΄λΌμ΄μ–ΈνŠΈ μž…λ ₯ μ΄νƒˆ 감지 어렀움.
- v2: JSON Schema(strict) + Zod 검증 + ν•„ν„° κ°•μ œ μ£Όμž…μœΌλ‘œ μ•ˆμ „ν•œ μ‹€ν–‰κ³„νšλ§Œ μˆ˜ν–‰.
- λΉ„μš©/토큰 관리
- v1: κ³ μ • μƒμˆ˜λ‘œ μ„Έλ°€ν•œ μ œμ–΄ 어렀움.
- v2: `top_k`/ν–₯ν›„ `limit`/dedupe둜 토큰 μ˜ˆμ‚°μ„ 상황별 μ΅œμ ν™” κ°€λŠ₯.

![v1 ꡬ쑰](../structureDiagram/askV1-structure.png)

## 3) 검색 κ³„νš(Planner)κ³Ό μ•ˆμ „ ν‘œμ€€ν™”
- νƒ€μž…/μŠ€ν‚€λ§ˆ(`src/types/ai.v2.types.ts`)
- `mode`: `rag | post`
- `top_k`(1..10), `threshold`(0..1), `weights`(`chunk`,`title`; ν•© 1둜 μ •κ·œν™”)
- `filters`: `{ user_id, category_ids?, post_id?, time? }`
- `sort`: `created_at_desc | created_at_asc`, `limit`(1..20)
- `hybrid`: `{ enabled, retrieval_bias, alpha?, max_rewrites, max_keywords }`
- `rewrites`: string[], `keywords`: string[]
- ν”„λ‘¬ν”„νŠΈ κ·œμΉ™(`src/prompts/qa.v2.prompts.ts`)
- μ„œλ²„ 제곡 ν•„ν„°(`user_id`, `category_id`, `post_id`)λŠ” κ³ μ •(FIXED)이며 λ³€κ²½ κΈˆμ§€.
- ν”Œλž˜λ„ˆλŠ” `top_k`, `threshold`, `filters.time`, `sort`, `limit`, `hybrid`, `rewrites`, `keywords`만 κ²°μ •.
- ν•˜μ΄λΈŒλ¦¬λ“œ μ‹œ `hybrid.retrieval_bias ∈ {lexical, balanced, semantic}` 라벨을 κ²°μ •.
- 생성/μ •κ·œν™”(`src/services/search-plan.service.ts`)
- OpenAI Responses(JSON Schema strict) β†’ Zod νŒŒμ‹±/검증.
- κ°’ λ²”μœ„ κ°•μ œ, `weights` ν•© 1둜 μ •κ·œν™”.
- λΆˆμš©μ–΄/쀑볡 제거둜 `rewrites`/`keywords` μ •μ œ.
- μ‹œκ°„ ν•„ν„°λ₯Ό `KST μ ˆλŒ€λ²”μœ„`둜 λ³€ν™˜(`src/utils/time.ts`).
- `retrieval_bias β†’ alpha` λ§€ν•‘(μ„œλ²„): `lexicalβ†’0.3`, `balancedβ†’0.5`, `semanticβ†’0.75`(ν•„μš” μ‹œ μ„œλ²„κ°€ `alpha`λ₯Ό 직접 ν΄λž¨ν”„/μ£Όμž…).
- `user_id/category_id/post_id`λŠ” μ„œλ²„κ°€ μ΅œμ’… μ£Όμž…(μ •ν•©μ„± 보μž₯). μ‹€νŒ¨ μ‹œ v1 RAG 폴백.

## 4) 검색 μ—”μ§„: μ‹œλ§¨ν‹± + ν•˜μ΄λΈŒλ¦¬λ“œ
- μ‹œλ§¨ν‹±(`src/services/semantic-search.service.ts` β†’ `findSimilarChunksV2`)
- μž…λ ₯: 질문 μž„λ² λ”©, `threshold/top_k/weights/sort`, μΉ΄ν…Œκ³ λ¦¬/μ‹œκ°„ ν•„ν„°.
- 점수: `w_chunk*(1 - chunk_dist) + w_title*(1 - title_dist)`.
- μ €μž₯μ†Œ: `postRepository.findSimilarChunksV2(...)` 호좜, νŒŒλΌλ―Έν„° 바인딩 기반 μ•ˆμ „ 쿼리.
- ν•˜μ΄λΈŒλ¦¬λ“œ(`src/services/hybrid-search.service.ts`)
- μž…λ ₯: 원 질문 + `rewrites`(μž¬μž‘μ„±), `keywords`(ν‚€μ›Œλ“œ), `alpha`(μ„œλ²„ λ§€ν•‘).
- 벑터 경둜: 각 query μž„λ² λ”© β†’ μ‹œλ§¨ν‹± 후보 μˆ˜μ§‘ β†’ 동일 μ²­ν¬λŠ” μ΅œλŒ€ vecScore둜 병합.
- ν…μŠ€νŠΈ 경둜: `textSearchChunksV2`(ν‚€μ›Œλ“œ/질의 기반 ν…μŠ€νŠΈ 검색) β†’ 동일 μ²­ν¬λŠ” μ΅œλŒ€ textScore둜 병합.
- μ •κ·œν™”/μœ΅ν•©: min-max μ •κ·œν™” ν›„ `score = alpha*vec + (1-alpha)*text`둜 κ²°ν•© β†’ μƒμœ„ `top_k`λ₯Ό λ°˜ν™˜.
- 폴백: ν•˜μ΄λΈŒλ¦¬λ“œ κ²°κ³Ό λΉ„μ—ˆμ„ λ•Œ μ‹œλ§¨ν‹± 경둜둜 μž¬μ‹œλ„.
- SSE 메타: `rewrite`, `keywords`, `hybrid_result`둜 쀑간 μ‚°μΆœλ¬Ό/μš”μ•½μ„ 별도 솑신.

![v2 ꡬ쑰](../structureDiagram/askV2-structure.png)

## 5) νŒŒμ΄ν”„λΌμΈ(SSE 이벀트)κ³Ό λͺ¨λ“œ
- 컨트둀/μ˜€μΌ€μŠ€νŠΈλ ˆμ΄μ…˜
- `src/controllers/ai.v2.controller.ts`: SSE 헀더, 슀트림 λΌμš°νŒ….
- `src/services/qa.v2.service.ts`: κ³„νš 생성 β†’ 검색 μ‹€ν–‰(ν•˜μ΄λΈŒλ¦¬λ“œ/μ‹œλ§¨ν‹±) β†’ LLM ν˜ΈμΆœκΉŒμ§€ μ˜€μΌ€μŠ€νŠΈλ ˆμ΄μ…˜.
- post λͺ¨λ“œ(`post_id` 쑴재)
- μ ‘κ·Ό μ •μ±…: `post.is_public=false`λ©΄ μ†Œμœ μžλ§Œ(403), 미쑴재 μ‹œ 404.
- 이벀트: `search_plan`(κ°„λž΅) β†’ `search_result` β†’ `exist_in_post_status:true` β†’ `context` β†’ `answer`* β†’ `end`.
- μ»¨ν…μŠ€νŠΈ: ν•΄λ‹Ή κΈ€ 본문만 μ‚¬μš©(μ „μ²˜λ¦¬ ν›„). ν•˜μ΄λΈŒλ¦¬λ“œ λ―Έμ‚¬μš©.
- rag/ν•˜μ΄λΈŒλ¦¬λ“œ λͺ¨λ“œ
- 이벀트 μˆœμ„œ(일반):
1) `search_plan`: μ •κ·œν™”λœ κ³„νš(JSON). `hybrid.enabled`κ°€ trueλ©΄ `retrieval_bias`와 μ„œλ²„ 계산 `alpha`κ°€ ν•¨κ»˜ 포함.
2) `rewrite`(선택): κ³„νšμ˜ μž¬μž‘μ„± 질의 λͺ©λ‘.
3) `keywords`(선택): κ³„νšμ˜ 핡심 ν‚€μ›Œλ“œ λͺ©λ‘.
4) `hybrid_result`(선택): ν•˜μ΄λΈŒλ¦¬λ“œ 후보 μš”μ•½.
5) `search_result`: μ΅œμ’… μ»¨ν…μŠ€νŠΈ μš”μ•½.
6) `exist_in_post_status`: `true | false`.
7) `context`: `[ { postId, postTitle }, ... ]`.
8) `answer`*: λͺ¨λΈ λΆ€λΆ„ 응닡.
9) `end`: μ’…λ£Œ μ‹œκ·Έλ„(`[DONE]`).
- 였λ₯˜ μ‹œ: `error` 솑신.

## 6) LLM/λΉ„μš©/ν”„λ‘œλ°”μ΄λ”
- κΈ°λ³Έ λͺ¨λΈ: `openai/gpt-5-mini`(ν™˜κ²½λ³€μˆ˜λ‘œ λ³€κ²½ κ°€λŠ₯), μž„λ² λ”©: `text-embedding-3-small`.
- OpenAI: Responses API 슀트리밍 μš°μ„ , μ‹€νŒ¨ μ‹œ λ…ΌμŠ€νŠΈλ¦Ό/Chat Completions 폴백.
- Gemini: ν˜„μž¬ λ…ΌμŠ€νŠΈλ¦Ό κ²°κ³Όλ₯Ό SSE 청크둜 λΆ„ν•  전솑.
- λΉ„μš© λ‘œκΉ…: ν”„λ‘¬ν”„νŠΈ/μ™„λ£Œ 토큰 및 μΆ”μ • λΉ„μš© λ‘œκΉ…(`src/llm/*`).
- 툴콜: μ»¨ν…μŠ€νŠΈ λΆ€μ‘± μ‹œ `report_content_insufficient` νˆ΄μ„ 톡해 μ•ˆλ‚΄ 문ꡬ μœ λ„.

## 7) λ³΄μ•ˆΒ·μ •ν•©μ„±
- 생성 κ³„νš λ°©μ–΄μ„ : JSON Schema(strict) + Zod 검증 + μ„œλ²„ μΈ‘ λ²”μœ„ κ°•μ œ.
- ν•„ν„° μ£Όμž…: `user_id/category_id/post_id`λŠ” μ„œλ²„κ°€ μ΅œμ’… μ£Όμž…ν•˜μ—¬ μΌνƒˆ λ°©μ§€.
- SQL μ•ˆμ „μ„±: νŒŒλΌλ―Έν„° 바인딩/ν™”μ΄νŠΈλ¦¬μŠ€νŠΈ ν…œν”Œλ¦Ώ.
- 데이터 μ΅œμ†Œν™”: SSEμ—λŠ” ID/제λͺ© μœ„μ£Ό μš”μ•½λ§Œ 전솑.
- μ ‘κ·Ό μ œμ–΄: post λͺ¨λ“œμ—μ„œ `is_public`/μ†Œμœ μž 검사.

## 8) ν˜„μž¬ λ™μž‘κ³Ό ν•œκ³„(ν–₯ν›„ 과제)
- ν˜„μž¬
- `top_k`둜 리콜 폭 μ œμ–΄, μ‹œλ§¨ν‹±/ν•˜μ΄λΈŒλ¦¬λ“œ λͺ¨λ‘ 적용.
- `limit`은 κ³„νšμ—λŠ” μ‘΄μž¬ν•˜λ‚˜ μ»¨ν…μŠ€νŠΈ μΆ•μ•½μ—λŠ” 미적용(ν–₯ν›„ 포슀트 λ‹¨μœ„ dedupe와 ν•¨κ»˜ 적용 μ˜ˆμ •).
- 청크 λ‹¨μœ„ λž­ν‚Ή β†’ 동일 포슀트 λ‹€μˆ˜ λ…ΈμΆœ κ°€λŠ₯.
- μΉ΄ν…Œκ³ λ¦¬ 배열은 첫 ν•­λͺ©λ§Œ 반영.
- ν–₯ν›„
- 포슀트 λ‹¨μœ„ dedupe + `limit` 적용으둜 λ‹€μ–‘μ„±/λΉ„μš© κ· ν˜•.
- `retrieval_bias β†’ alpha` λ§€ν•‘μ˜ AB ν…ŒμŠ€νŠΈ/ν…”λ ˆλ©”νŠΈλ¦¬ νŠœλ‹(예: {0.25,0.5,0.8}).
- 점수 μ •κ·œν™” 고도화(z-score/quantile) 및 RRF μ˜΅μ…˜ λ„μž… κ²€ν† .
- ν…μŠ€νŠΈ 경둜 ν–₯상(μ „μ²˜λ¦¬/μˆœμœ„ ν•¨μˆ˜/ν‚€μ›Œλ“œ ν™•μž₯) 및 ν”„λ‘œλ°”μ΄λ” λ‹€λ³€ν™”.
- SSE `search_sql`/`search_debug`둜 투λͺ…μ„± κ°•ν™”.

## 9) 파일 λ§΅(핡심)
- 라우트/컨트둀러: `src/routes/ai.v2.routes.ts`, `src/controllers/ai.v2.controller.ts`, `src/app.ts`
- ν”Œλž˜λ„ˆ/νƒ€μž…/ν”„λ‘¬ν”„νŠΈ: `src/services/search-plan.service.ts`, `src/types/ai.v2.types.ts`, `src/prompts/qa.v2.prompts.ts`
- μ‹œκ°„ μœ ν‹Έ: `src/utils/time.ts`
- 검색: `src/services/semantic-search.service.ts`, `src/services/hybrid-search.service.ts`, `src/repositories/post.repository.ts`
- μ˜€μΌ€μŠ€νŠΈλ ˆμ΄μ…˜: `src/services/qa.v2.service.ts`
- LLM: `src/llm/*`

## 10) μ˜ˆμ‹œ
- μš”μ²­
```json
{
"question": "μ§€λ‚œλ‹¬ ν”„λ‘œμ νŠΈ X κ΄€λ ¨ ν•΅μ‹¬λ§Œ 3개 λ³΄μ—¬μ€˜",
"user_id": "u_123",
"category_id": 3,
"llm": { "provider": "openai", "options": { "temperature": 0.2 } }
}
```
- 이벀트 μ˜ˆμ‹œ
- `search_plan`(rag, time=μ§€λ‚œλ‹¬, top_k/threshold/weights/sort/limit/hybrid 포함; hybrid.retrieval_bias와 μ„œλ²„ μ£Όμž… alpha ν•¨κ»˜ ν‘œκΈ°)
- `rewrite`/`keywords`(선택)
- `hybrid_result`(선택)
- `search_result` β†’ `exist_in_post_status` β†’ `context` β†’ `answer`* β†’ `end`
Binary file added docs/structureDiagram/askV1-structure.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/structureDiagram/askV2-structure.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"db:migrate:pgtrgm": "psql \"$DATABASE_URL\" -f docs/migrations/2025-01-pgtrgm.sql"
},
"keywords": [],
"author": "",
Expand Down
Loading