Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9a6d00059 | |||
| 2e9e8a33fe | |||
| 432cc9565c | |||
| e4c56a9b6c | |||
| 86370f6c1e |
@@ -15,3 +15,13 @@ DB_PASSWORD=
|
||||
LANGCHAIN_TRACING_V2=false
|
||||
LANGCHAIN_API_KEY=
|
||||
LANGCHAIN_PROJECT=youlbot
|
||||
|
||||
# Hybrid Search (Phase 18) — BM25 + Vector (활성화 후 기존 문서 재수집 필요)
|
||||
HYBRID_SEARCH_ENABLED=false
|
||||
SPARSE_MODEL_ID=Qdrant/bm25
|
||||
|
||||
# Query Rewriting (Phase 19) — search_documents 호출 시 구어체 쿼리를 검색 최적화 쿼리로 변환
|
||||
QUERY_REWRITE_ENABLED=false
|
||||
|
||||
# REST API (Phase 22) — Bearer 토큰 인증. 빈 값이면 인증 없음(개발 모드)
|
||||
API_TOKEN=
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""율봇 REST API — Phase 22.
|
||||
|
||||
실행:
|
||||
uvicorn api:app --host 0.0.0.0 --port 8000
|
||||
|
||||
클라이언트 예시:
|
||||
import httpx, json
|
||||
headers = {"Authorization": "Bearer YOUR_TOKEN"}
|
||||
with httpx.Client() as c:
|
||||
with c.stream("POST", "http://localhost:8000/chat",
|
||||
json={"message": "안녕", "user_id": "홍길동"},
|
||||
headers=headers, timeout=120) as r:
|
||||
for line in r.iter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = json.loads(line[6:])
|
||||
if isinstance(payload, dict) and payload.get("__done"):
|
||||
break # run_id = payload["run_id"]
|
||||
print(payload, end="", flush=True)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from container import Container
|
||||
from services.agent.agent_service import AgentService
|
||||
|
||||
app = FastAPI(title="율봇 API", version="1.0")
|
||||
|
||||
_container = Container()
|
||||
_container.db_service().connect()
|
||||
_container.db_service().init_schema()
|
||||
|
||||
_cfg = _container.config()
|
||||
_agent_cache: dict[str, AgentService] = {}
|
||||
|
||||
|
||||
def _get_agent(user_id: str) -> AgentService:
|
||||
if user_id not in _agent_cache:
|
||||
_agent_cache[user_id] = AgentService(
|
||||
chat_model=_container.chat_model(),
|
||||
retriever_service=_container.retriever_service(),
|
||||
system_prompt=_cfg.system_prompt,
|
||||
rag_verbose=_cfg.rag_verbose,
|
||||
rag_show_sources=_cfg.rag_show_sources,
|
||||
langgraph_verbose=_cfg.langgraph_verbose,
|
||||
think_verbose=_cfg.think_verbose,
|
||||
query_rewrite_enabled=_cfg.query_rewrite_enabled,
|
||||
user_profile_repository=_container.user_profile_repository(),
|
||||
conversation_repository=_container.conversation_repository(),
|
||||
user_id=user_id,
|
||||
)
|
||||
return _agent_cache[user_id]
|
||||
|
||||
|
||||
def _auth(authorization: str = Header(default="")):
|
||||
"""API_TOKEN 설정 시 Bearer 토큰 검증. 미설정 시 인증 스킵(개발 모드)."""
|
||||
token = _cfg.api_token
|
||||
if token and authorization != f"Bearer {token}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
# ── 요청/응답 모델 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
user_id: str = "default"
|
||||
show_thinking: bool = False
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
user_id: str = "default"
|
||||
user_msg: str
|
||||
asst_msg: str
|
||||
rating: int
|
||||
run_id: str | None = None
|
||||
|
||||
|
||||
# ── 엔드포인트 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/chat")
|
||||
async def chat(req: ChatRequest, _=Depends(_auth)):
|
||||
"""SSE 스트리밍 응답. 각 라인: `data: <JSON 토큰>\n\n`, 종료: `data: [DONE]\n\n`"""
|
||||
agent = _get_agent(req.user_id)
|
||||
|
||||
async def generate():
|
||||
async for token in agent.stream_response(req.message, show_thinking=req.show_thinking):
|
||||
yield f"data: {json.dumps(token, ensure_ascii=False)}\n\n"
|
||||
yield f"data: {json.dumps({'__done': True, 'run_id': agent.last_run_id}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@app.post("/feedback")
|
||||
async def save_feedback(req: FeedbackRequest, _=Depends(_auth)):
|
||||
"""👍/👎 피드백 저장. LangSmith 트레이싱 활성화 시 자동 연동."""
|
||||
_container.feedback_repository().save_feedback(
|
||||
req.user_id, req.user_msg, req.asst_msg, req.rating, req.run_id
|
||||
)
|
||||
if req.run_id and os.getenv("LANGCHAIN_TRACING_V2") == "true":
|
||||
try:
|
||||
from langsmith import Client
|
||||
Client().create_feedback(run_id=req.run_id, key="user_feedback", score=req.rating)
|
||||
except Exception:
|
||||
pass
|
||||
return {"saved": True}
|
||||
|
||||
|
||||
@app.post("/reset")
|
||||
async def reset(user_id: str = "default", _=Depends(_auth)):
|
||||
"""대화 이력 초기화."""
|
||||
if user_id in _agent_cache:
|
||||
_agent_cache[user_id].reset()
|
||||
return {"reset": True, "user_id": user_id}
|
||||
|
||||
|
||||
@app.post("/ingest")
|
||||
async def ingest(file: UploadFile = File(...), _=Depends(_auth)):
|
||||
"""PDF 또는 TXT 파일을 업로드해 벡터DB에 수집."""
|
||||
suffix = os.path.splitext(file.filename or "")[1] or ".bin"
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
|
||||
f.write(await file.read())
|
||||
tmp_path = f.name
|
||||
try:
|
||||
count = _container.ingestion_service().ingest([tmp_path])
|
||||
return {"chunks": count, "filename": file.filename}
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
@app.get("/documents")
|
||||
async def list_documents(_=Depends(_auth)):
|
||||
"""등록된 문서 경로 목록 반환."""
|
||||
return {"documents": _container.retriever_service().list_documents()}
|
||||
|
||||
|
||||
@app.delete("/documents/{source:path}")
|
||||
async def delete_document(source: str, _=Depends(_auth)):
|
||||
"""source 경로에 해당하는 모든 청크 삭제."""
|
||||
_container.retriever_service().delete_document(source)
|
||||
return {"deleted": source}
|
||||
@@ -71,6 +71,7 @@ def _get_agent(user_id: str) -> AgentService:
|
||||
rag_show_sources=_cfg.rag_show_sources,
|
||||
langgraph_verbose=_cfg.langgraph_verbose,
|
||||
think_verbose=_cfg.think_verbose,
|
||||
query_rewrite_enabled=_cfg.query_rewrite_enabled,
|
||||
user_profile_repository=container.user_profile_repository(),
|
||||
conversation_repository=container.conversation_repository(),
|
||||
user_id=user_id,
|
||||
|
||||
@@ -41,6 +41,16 @@ class Config(BaseSettings):
|
||||
reranker_enabled: bool = False
|
||||
reranker_model_id: str = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1" # 한국어 지원 다국어 모델
|
||||
reranker_fetch_k: int = 10 # rerank 전 벡터 검색 후보 수 (rag_top_k보다 커야 함)
|
||||
|
||||
# Hybrid Search (Phase 18) — BM25 + Vector
|
||||
hybrid_search_enabled: bool = False
|
||||
sparse_model_id: str = "Qdrant/bm25" # fastembed sparse 모델 (언어 무관 BM25)
|
||||
|
||||
# Query Rewriting (Phase 19) — 구어체 질문을 검색 최적화 쿼리로 변환
|
||||
query_rewrite_enabled: bool = False
|
||||
|
||||
# REST API (Phase 22) — 빈 문자열이면 인증 스킵 (개발 모드)
|
||||
api_token: str = ""
|
||||
rag_verbose: bool = False
|
||||
rag_show_sources: bool = False
|
||||
langgraph_verbose: bool = False
|
||||
|
||||
+14
-5
@@ -14,6 +14,7 @@ from services.ui.cli_service import CliUiService
|
||||
from services.events.event_bus import EventBus
|
||||
from services.events.handlers import StreamTokenHandler, StreamEndHandler
|
||||
from langchain_huggingface import HuggingFaceEmbeddings
|
||||
from langchain_qdrant import FastEmbedSparse
|
||||
from services.rag.ingestion_service import IngestionService
|
||||
from services.rag.rerank_service import RerankService
|
||||
from services.rag.retriever_service import RetrieverService
|
||||
@@ -96,6 +97,16 @@ class Container(containers.DeclarativeContainer):
|
||||
model_kwargs=providers.Callable(lambda c: {"device": c.embedding_device}, config),
|
||||
)
|
||||
|
||||
reranker = providers.Callable(
|
||||
lambda c: RerankService(c.reranker_model_id) if c.reranker_enabled else None,
|
||||
config,
|
||||
)
|
||||
|
||||
sparse_embeddings = providers.Singleton(
|
||||
lambda c: FastEmbedSparse(model_name=c.sparse_model_id) if c.hybrid_search_enabled else None,
|
||||
config,
|
||||
)
|
||||
|
||||
ingestion_service = providers.Singleton(
|
||||
IngestionService,
|
||||
embeddings=embeddings,
|
||||
@@ -105,11 +116,7 @@ class Container(containers.DeclarativeContainer):
|
||||
lambda c: c.semantic_breakpoint_threshold_type, config
|
||||
),
|
||||
buffer_size=providers.Callable(lambda c: c.semantic_buffer_size, config),
|
||||
)
|
||||
|
||||
reranker = providers.Callable(
|
||||
lambda c: RerankService(c.reranker_model_id) if c.reranker_enabled else None,
|
||||
config,
|
||||
sparse_embeddings=sparse_embeddings,
|
||||
)
|
||||
|
||||
retriever_service = providers.Singleton(
|
||||
@@ -120,6 +127,7 @@ class Container(containers.DeclarativeContainer):
|
||||
top_k=providers.Callable(lambda c: c.rag_top_k, config),
|
||||
reranker=reranker,
|
||||
rerank_fetch_k=providers.Callable(lambda c: c.reranker_fetch_k, config),
|
||||
sparse_embeddings=sparse_embeddings,
|
||||
)
|
||||
|
||||
# Phase 3 — LangGraph Agent
|
||||
@@ -132,6 +140,7 @@ class Container(containers.DeclarativeContainer):
|
||||
rag_show_sources=providers.Callable(lambda c: c.rag_show_sources, config),
|
||||
langgraph_verbose=providers.Callable(lambda c: c.langgraph_verbose, config),
|
||||
think_verbose=providers.Callable(lambda c: c.think_verbose, config),
|
||||
query_rewrite_enabled=providers.Callable(lambda c: c.query_rewrite_enabled, config),
|
||||
user_profile_repository=user_profile_repository,
|
||||
conversation_repository=conversation_repository,
|
||||
)
|
||||
|
||||
+150
-18
@@ -184,33 +184,163 @@ turns = conversation_repository.load_turns_after(self._conv_id, None, limit=10)
|
||||
|
||||
---
|
||||
|
||||
## Phase 18 — Hybrid Search (BM25 + Vector) ★★☆
|
||||
## ✅ Phase 18 — Hybrid Search (BM25 + Vector) ★★☆
|
||||
|
||||
**배경**: 한국어 질문에서 고유명사·전문용어가 포함된 경우 의미 검색(Dense)만으로는 recall이 떨어진다. BM25 키워드 검색과 결합(Hybrid)하면 보완이 가능하다.
|
||||
|
||||
**구현 방식**:
|
||||
- Qdrant의 Sparse Vector 지원 활용 (`FastEmbedSparseEmbeddings` 또는 BM42)
|
||||
- 인덱싱 시 dense + sparse 두 벡터 동시 저장
|
||||
- 검색 시 `RRF(Reciprocal Rank Fusion)`로 결과 통합
|
||||
- `IngestionService`, `RetrieverService` 양쪽 수정 필요
|
||||
**구현 내용**:
|
||||
- `FastEmbedSparse(model_name="Qdrant/bm25")` — 언어 무관 BM25 sparse 임베딩 (`fastembed` 패키지)
|
||||
- `IngestionService`: `HYBRID_SEARCH_ENABLED=true` 시 dense + sparse 동시 저장 (`RetrievalMode.HYBRID`)
|
||||
- `RetrieverService`: hybrid 스토어로 검색 → Qdrant 내장 RRF로 결과 통합; sparse vector 미설정 컬렉션은 dense로 자동 폴백
|
||||
- `_ensure_collection_schema()`: hybrid 전환 시 스키마 불일치 컬렉션 자동 재생성 (기존 문서 재수집 필요)
|
||||
- `.env` `HYBRID_SEARCH_ENABLED=true`로 활성화, 활성화 후 기존 문서 재수집 필요
|
||||
|
||||
| 설정 | 기본값 | 설명 |
|
||||
|------|--------|------|
|
||||
| `HYBRID_SEARCH_ENABLED` | `false` | `true`로 설정 시 활성화 |
|
||||
| `SPARSE_MODEL_ID` | `Qdrant/bm25` | fastembed sparse 모델 (첫 실행 시 자동 다운로드) |
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 높음 (키워드 포함 질문 recall 대폭 향상)
|
||||
|
||||
---
|
||||
|
||||
## Phase 19 — Query Rewriting ★☆☆
|
||||
## ✅ Phase 19 — Query Rewriting ★☆☆
|
||||
|
||||
**배경**: 사용자 구어체 질문("아이가 밥을 안 먹어요")은 벡터 검색에 최적화되어 있지 않다. LLM이 검색 전에 질문을 재작성하면 관련 문서 검색 확률이 높아진다.
|
||||
|
||||
**구현 방식**:
|
||||
- LangGraph에 `query_rewrite` 노드 추가 (agent → query_rewrite → tools 순서)
|
||||
- 또는 `search_documents` 도구 내부에서 rewrite 후 검색
|
||||
- 프롬프트: "다음 질문을 문서 검색에 최적화된 키워드 중심 문장으로 변환하세요"
|
||||
**구현 내용**:
|
||||
- LangGraph 그래프에 `query_rewrite` 노드 추가 — `agent → query_rewrite → tools` 순서
|
||||
- `search_documents` 호출 시에만 작동하는 조건부 라우팅 (`route_after_agent`): 다른 도구 호출이나 tool 없음 케이스는 그대로 통과
|
||||
- 구어체 → 키워드 중심 쿼리로 변환 + 대명사·지시어를 구체적 명칭으로 해소 (이전 대화 2턴 컨텍스트 활용)
|
||||
- `tools_condition` 제거 → 커스텀 `route_after_agent` 함수로 대체
|
||||
- 변환 결과를 custom stream 이벤트로 emit → `RAG_VERBOSE=true` 시 `쿼리 최적화: "원본" → "최적화"` 출력
|
||||
- `.env` `QUERY_REWRITE_ENABLED=true`로 활성화
|
||||
|
||||
**난이도**: 하 | **임팩트**: 중간 (구어체 질문 검색 품질 향상)
|
||||
|
||||
---
|
||||
|
||||
## Phase 21 — Telegram Bot ★★☆
|
||||
|
||||
**배경**: Gradio Web UI는 브라우저에서만 사용 가능. 텔레그램으로 이동 중에도 율봇과 대화하고 싶음.
|
||||
|
||||
**구현 방식**: `AgentService`를 직접 임포트 — 별도 API 서버 없이 동일 머신에서 실행.
|
||||
|
||||
```
|
||||
telegram_bot.py
|
||||
├── Application (python-telegram-bot >= 20.0, async)
|
||||
├── /start, /reset CommandHandler
|
||||
├── MessageHandler → agent.stream_response() → message.edit_text() (타이핑 효과)
|
||||
└── Telegram user_id → youlbot user_id 매핑 (멀티유저 그대로 활용)
|
||||
```
|
||||
|
||||
**구현 내용**:
|
||||
- `python-telegram-bot>=20.0` (asyncio 기반)
|
||||
- `telegram_bot.py` — 새 진입점 (`python telegram_bot.py`로 실행)
|
||||
- `/start` — 환영 메시지 + 사용법 안내
|
||||
- `/reset` — 대화 이력 초기화 (`agent.reset()`)
|
||||
- 일반 메시지 → `agent.stream_response()` → 500자 단위 실시간 편집 (Telegram `edit_message_text`)
|
||||
- `telegram_user_id`를 `user_id`로 사용 → 기존 멀티유저·메모리·DB 구조 그대로 재사용
|
||||
- `.env` `TELEGRAM_BOT_TOKEN` 추가
|
||||
|
||||
**제약**: 동일 머신에서만 실행 가능 (원격 실행은 Phase 22 REST API 필요)
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 높음 (모바일·이동 중 접근)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 22 — REST API (FastAPI) ★★☆
|
||||
|
||||
**배경**: 다른 Python 스크립트나 원격 서버에서 율봇을 호출하려면 HTTP API가 필요하다.
|
||||
Telegram Bot을 별도 프로젝트로 분리해 이 API를 호출하는 구조로 사용 가능.
|
||||
|
||||
**구현 내용**:
|
||||
- `api.py` — FastAPI 앱, `uvicorn api:app --host 0.0.0.0 --port 8000`으로 실행
|
||||
- SSE(`text/event-stream`) 스트리밍: 각 라인 `data: <JSON 토큰>\n\n`, 종료 `data: [DONE]\n\n`
|
||||
- Bearer Token 인증 (`.env` `API_TOKEN` 설정; 빈 값이면 개발 모드 무인증)
|
||||
- `user_id` 파라미터로 멀티유저 지원 (기존 DB·메모리 구조 그대로 재사용)
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `GET /health` | 헬스체크 |
|
||||
| `POST /chat` | SSE 스트리밍 대화 (`message`, `user_id`, `show_thinking`) |
|
||||
| `POST /reset` | 대화 이력 초기화 (`user_id`) |
|
||||
| `POST /ingest` | PDF/TXT 파일 업로드 → 벡터DB 수집 |
|
||||
| `GET /documents` | 등록 문서 목록 |
|
||||
| `DELETE /documents/{source}` | 문서 삭제 |
|
||||
|
||||
**클라이언트 예시 (별도 Telegram 봇 프로젝트)**:
|
||||
```python
|
||||
import httpx, json
|
||||
|
||||
API_URL = "http://192.168.10.x:8000"
|
||||
HEADERS = {"Authorization": "Bearer YOUR_TOKEN"}
|
||||
|
||||
async def ask_youlbot(message: str, user_id: str) -> str:
|
||||
full = ""
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
async with client.stream("POST", f"{API_URL}/chat",
|
||||
json={"message": message, "user_id": user_id},
|
||||
headers=HEADERS) as r:
|
||||
async for line in r.aiter_lines():
|
||||
if line.startswith("data: ") and line != "data: [DONE]":
|
||||
full += json.loads(line[6:])
|
||||
return full
|
||||
```
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 높음 (확장성·외부 연동)
|
||||
|
||||
---
|
||||
|
||||
## Phase 23 — WebUI 분리 (youlbot-webui 별도 프로젝트) ★★☆
|
||||
|
||||
**배경**: 현재 `app.py`(Gradio)는 `container.py`를 직접 import해 서비스를 사용한다.
|
||||
REST API(Phase 22)를 완성했으므로, WebUI를 독립 프로젝트로 분리해 API만 호출하도록 변경한다.
|
||||
분리 후 youlbot은 순수 백엔드(API 서버)로만 동작하며, Telegram Bot과 WebUI가 모두 같은 API를 공유한다.
|
||||
|
||||
**구현 내용**:
|
||||
|
||||
**① youlbot/api.py 보완**
|
||||
- `POST /feedback` 엔드포인트 추가 (FeedbackRepository 노출 + LangSmith 연동)
|
||||
- `/chat` SSE 마지막 이벤트에 `run_id` 포함 → 피드백 연결 가능
|
||||
```
|
||||
data: {"__done": true, "run_id": "uuid"}
|
||||
```
|
||||
|
||||
**② 신규 프로젝트 youlbot-webui/**
|
||||
```
|
||||
youlbot-webui/
|
||||
├── app.py ← Gradio UI (REST API 호출 방식으로 재작성)
|
||||
├── api_client.py ← httpx 기반 API 클라이언트 (chat/reset/ingest/documents/feedback)
|
||||
├── .env ← YOULBOT_API_URL, YOULBOT_API_TOKEN
|
||||
├── .env.example
|
||||
└── requirements.txt ← gradio, httpx, python-dotenv, openai-whisper
|
||||
```
|
||||
|
||||
| 기존 app.py (container 직접 사용) | 변경 후 (API 클라이언트) |
|
||||
|---|---|
|
||||
| `container.ingestion_service()` | `api_client.ingest(path)` |
|
||||
| `agent.stream_response()` | `api_client.chat(msg, user_id)` |
|
||||
| `retriever.list_documents()` | `api_client.list_documents()` |
|
||||
| `feedback_repo.save_feedback()` | `api_client.save_feedback(...)` |
|
||||
| STT (Whisper) | 변경 없음 — WebUI 로컬 실행 유지 |
|
||||
| TTS (macOS say) | 변경 없음 — WebUI 로컬 실행 유지 |
|
||||
|
||||
**실행 방법**:
|
||||
```bash
|
||||
# 백엔드
|
||||
cd youlbot && uvicorn api:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# WebUI (별도 터미널, 별도 프로젝트)
|
||||
cd youlbot-webui && python app.py
|
||||
```
|
||||
|
||||
기존 `youlbot/app.py`는 레거시 직접 실행 옵션으로 보존.
|
||||
|
||||
**난이도**: 중간 | **임팩트**: 높음 (백엔드/프론트엔드 완전 분리, 다중 클라이언트 지원)
|
||||
|
||||
---
|
||||
|
||||
## Phase 20 — RAG 품질 자동 평가 (RAGAS) ★☆☆
|
||||
|
||||
**배경**: 청킹 전략·검색 파라미터·Reranker 변경 시 답변 품질이 실제로 나아졌는지 수치로 확인할 방법이 없다.
|
||||
@@ -271,8 +401,8 @@ docker-compose.yml
|
||||
```
|
||||
단기 (1~2주) 중기 (1개월) 장기
|
||||
──────────────────────── ────────────────────── ──────────────────
|
||||
Phase 18 Hybrid Search → Phase 15 (모델선택) → Phase 16 (Docker)
|
||||
Phase 19 Query Rewriting → Phase 20 (RAGAS 평가) → Phase 17 (멀티모달)
|
||||
Phase 21 Telegram Bot → Phase 20 RAGAS 평가 → Phase 16 (Docker)
|
||||
→ Phase 15 (모델선택) → Phase 17 (멀티모달)
|
||||
```
|
||||
|
||||
### 우선순위 매트릭스
|
||||
@@ -295,9 +425,11 @@ Phase 19 Query Rewriting → Phase 20 (RAGAS 평가) → Phase 17 (멀티모
|
||||
| Phase 13 Semantic Chunker | ✅ 완료 | — | — | — |
|
||||
| Phase 14 음성 인터페이스 | ✅ 완료 | — | — | — |
|
||||
| Phase 13-B Reranker | ✅ 완료 | — | — | — |
|
||||
| Phase 18 Hybrid Search | 🔲 신규 | 중간 | 높음 | ⭐ 1순위 |
|
||||
| Phase 19 Query Rewriting | 🔲 신규 | 하 | 중간 | 3순위 |
|
||||
| Phase 18 Hybrid Search | ✅ 완료 | — | — | — |
|
||||
| Phase 19 Query Rewriting | ✅ 완료 | — | — | — |
|
||||
| Phase 21 Telegram Bot | 🔲 신규 | 중간 | 높음 | ⭐ 1순위 (REST API 활용) |
|
||||
| Phase 22 REST API | ✅ 완료 | — | — | — |
|
||||
| Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 3순위 |
|
||||
| Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 |
|
||||
| Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 5순위 |
|
||||
| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 6순위 |
|
||||
| Phase 17 멀티모달 | 🔲 미완 | 높음 | 높음 | 7순위 |
|
||||
| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 5순위 |
|
||||
| Phase 17 멀티모달 | 🔲 미완 | 높음 | 높음 | 6순위 |
|
||||
|
||||
@@ -12,6 +12,12 @@ langchain-qdrant>=0.2.0
|
||||
sentence-transformers>=3.0.0
|
||||
qdrant-client>=1.9.0
|
||||
pdfplumber>=0.11.0
|
||||
# Phase 18 — Hybrid Search (BM25 sparse vectors)
|
||||
fastembed>=0.3.0
|
||||
# Phase 22 — REST API
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.23.0
|
||||
python-multipart>=0.0.7
|
||||
# Phase 3 — Agent orchestration
|
||||
langgraph>=1.0.0
|
||||
# Phase 4 — Web UI
|
||||
|
||||
@@ -7,8 +7,8 @@ from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, Sys
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.config import get_stream_writer
|
||||
from langgraph.graph import START, MessagesState, StateGraph
|
||||
from langgraph.prebuilt import ToolNode, tools_condition
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
from langgraph.prebuilt import ToolNode
|
||||
|
||||
from services.agent.tools import get_current_date, make_memory_tools, make_retriever_tool, make_search_tool, web_search
|
||||
|
||||
@@ -28,6 +28,7 @@ class AgentService:
|
||||
rag_show_sources: bool = False,
|
||||
langgraph_verbose: bool = False,
|
||||
think_verbose: bool = False,
|
||||
query_rewrite_enabled: bool = False,
|
||||
user_profile_repository=None,
|
||||
conversation_repository=None,
|
||||
user_id: str = "default",
|
||||
@@ -37,6 +38,7 @@ class AgentService:
|
||||
self._rag_show_sources = rag_show_sources
|
||||
self._langgraph_verbose = langgraph_verbose
|
||||
self._think_verbose = think_verbose
|
||||
self._query_rewrite_enabled = query_rewrite_enabled
|
||||
self._source_buffer: list[dict] = []
|
||||
self._thread_id = "default"
|
||||
self._profile_repo = user_profile_repository
|
||||
@@ -133,11 +135,76 @@ class AgentService:
|
||||
additional_kwargs=extra,
|
||||
)]}
|
||||
|
||||
async def query_rewrite_node(state: MessagesState, config: RunnableConfig) -> dict:
|
||||
last_msg = state["messages"][-1]
|
||||
if not (hasattr(last_msg, "tool_calls") and last_msg.tool_calls):
|
||||
return {}
|
||||
|
||||
# 최근 사용자 메시지 2개를 컨텍스트로 활용 (대명사·지시어 해소)
|
||||
recent_human = [m.content for m in state["messages"][:-1]
|
||||
if isinstance(m, HumanMessage)][-2:]
|
||||
ctx = ("\n\n이전 대화 컨텍스트:\n" + "\n".join(f"- {m}" for m in recent_human)
|
||||
if recent_human else "")
|
||||
|
||||
try:
|
||||
writer = get_stream_writer()
|
||||
except Exception:
|
||||
writer = None
|
||||
|
||||
_rewrite_llm = chat_model.bind(enable_thinking=False)
|
||||
new_tool_calls = []
|
||||
for tc in last_msg.tool_calls:
|
||||
if tc["name"] == "search_documents":
|
||||
original = tc["args"].get("query", "")
|
||||
prompt = (
|
||||
f"다음 구어체 질문을 문서 검색에 최적화된 키워드 중심 문장으로 변환하세요.{ctx}\n\n"
|
||||
f"규칙:\n"
|
||||
f"- 핵심 개념과 전문용어를 포함하세요\n"
|
||||
f"- 대명사(이것, 그것, 그 논문 등)는 구체적인 명칭으로 교체하세요\n"
|
||||
f"- 변환된 질문만 한 문장으로 출력하세요. 부가 설명 없이 질문만 출력하세요\n\n"
|
||||
f"원본 질문: {original}\n최적화된 질문:"
|
||||
)
|
||||
try:
|
||||
result = await _rewrite_llm.ainvoke([HumanMessage(content=prompt)])
|
||||
rewritten = result.content.strip()
|
||||
except Exception as e:
|
||||
print(f"[QueryRewrite] 실패: {e}")
|
||||
rewritten = original
|
||||
if rewritten and rewritten != original:
|
||||
new_tool_calls.append({**tc, "args": {**tc["args"], "query": rewritten}})
|
||||
if writer:
|
||||
writer({"__query_rewrite": {"original": original, "rewritten": rewritten}})
|
||||
else:
|
||||
new_tool_calls.append(tc)
|
||||
else:
|
||||
new_tool_calls.append(tc)
|
||||
|
||||
if not last_msg.id:
|
||||
return {}
|
||||
new_msg = AIMessage(
|
||||
id=last_msg.id,
|
||||
content=last_msg.content,
|
||||
tool_calls=new_tool_calls,
|
||||
additional_kwargs=last_msg.additional_kwargs,
|
||||
)
|
||||
return {"messages": [new_msg]}
|
||||
|
||||
def route_after_agent(state: MessagesState) -> str:
|
||||
last_msg = state["messages"][-1]
|
||||
if not (hasattr(last_msg, "tool_calls") and last_msg.tool_calls):
|
||||
return END
|
||||
if self._query_rewrite_enabled:
|
||||
if any(tc["name"] == "search_documents" for tc in last_msg.tool_calls):
|
||||
return "query_rewrite"
|
||||
return "tools"
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("agent", call_model)
|
||||
builder.add_node("query_rewrite", query_rewrite_node)
|
||||
builder.add_node("tools", ToolNode(tools))
|
||||
builder.add_edge(START, "agent")
|
||||
builder.add_conditional_edges("agent", tools_condition)
|
||||
builder.add_conditional_edges("agent", route_after_agent)
|
||||
builder.add_edge("query_rewrite", "tools")
|
||||
builder.add_edge("tools", "agent")
|
||||
|
||||
self._agent = builder.compile(checkpointer=MemorySaver())
|
||||
@@ -176,8 +243,13 @@ class AgentService:
|
||||
):
|
||||
mode, data = stream_event
|
||||
|
||||
# ── custom 이벤트 — call_model writer가 emit한 thinking 토큰 ──
|
||||
# ── custom 이벤트 ────────────────────────────────────────────
|
||||
if mode == "custom":
|
||||
if isinstance(data, dict) and "__query_rewrite" in data:
|
||||
info = data["__query_rewrite"]
|
||||
if lg or self._rag_verbose:
|
||||
yield f'\n쿼리 최적화: "{info["original"]}" → "{info["rewritten"]}"\n'
|
||||
continue
|
||||
if isinstance(data, dict) and "__thinking" in data:
|
||||
# thinking 첫 토큰 도착 시 agent 레이블 + prev_node 갱신
|
||||
if "agent" != prev_node:
|
||||
@@ -209,12 +281,13 @@ class AgentService:
|
||||
thinking_open = False
|
||||
content_started = False
|
||||
if lg:
|
||||
if node == "agent":
|
||||
elapsed = time.perf_counter() - start_time
|
||||
if node == "agent":
|
||||
label = "agent: 검색 결과 반영 중" if prev_node == "tools" else "agent: 질문 분석 중"
|
||||
yield f"\n[LangGraph → {label}] ({elapsed:.2f}s)\n"
|
||||
elif node == "query_rewrite":
|
||||
yield f"\n[LangGraph → query_rewrite: 쿼리 최적화 중] ({elapsed:.2f}s)\n"
|
||||
elif node == "tools":
|
||||
elapsed = time.perf_counter() - start_time
|
||||
yield f"\n[LangGraph → tools: 도구 실행 중] ({elapsed:.2f}s)\n"
|
||||
prev_node = node
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from langchain_community.document_loaders import PDFPlumberLoader, TextLoader
|
||||
from langchain_experimental.text_splitter import SemanticChunker
|
||||
from langchain_qdrant import QdrantVectorStore
|
||||
from langchain_qdrant import QdrantVectorStore, RetrievalMode
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import Filter, FieldCondition, MatchValue, FilterSelector
|
||||
|
||||
@@ -15,10 +15,12 @@ class IngestionService:
|
||||
collection_name: str,
|
||||
breakpoint_threshold_type: str = "percentile",
|
||||
buffer_size: int = 1,
|
||||
sparse_embeddings=None,
|
||||
):
|
||||
self._embeddings = embeddings
|
||||
self._qdrant_url = qdrant_url
|
||||
self._collection_name = collection_name
|
||||
self._sparse_embeddings = sparse_embeddings
|
||||
self._splitter = SemanticChunker(
|
||||
embeddings=embeddings,
|
||||
breakpoint_threshold_type=breakpoint_threshold_type,
|
||||
@@ -26,6 +28,18 @@ class IngestionService:
|
||||
)
|
||||
self._client = QdrantClient(url=qdrant_url)
|
||||
|
||||
def _ensure_collection_schema(self) -> None:
|
||||
"""Hybrid 모드 전환 시 컬렉션에 sparse vector 설정이 없으면 삭제해 재생성을 유도한다."""
|
||||
if not self._sparse_embeddings:
|
||||
return
|
||||
try:
|
||||
info = self._client.get_collection(self._collection_name)
|
||||
if not info.config.params.sparse_vectors:
|
||||
print(f"[Hybrid] '{self._collection_name}' 컬렉션에 sparse vector 설정이 없어 재생성합니다.")
|
||||
self._client.delete_collection(self._collection_name)
|
||||
except Exception:
|
||||
pass # 컬렉션 미존재 시 무시
|
||||
|
||||
def _delete_by_source(self, source_path: str) -> None:
|
||||
"""같은 파일 경로로 저장된 기존 청크를 모두 삭제한다."""
|
||||
try:
|
||||
@@ -46,6 +60,7 @@ class IngestionService:
|
||||
pass # 컬렉션이 없을 때(최초 수집) 무시
|
||||
|
||||
def ingest(self, file_paths: list[str]) -> int:
|
||||
self._ensure_collection_schema()
|
||||
docs = []
|
||||
for path in file_paths:
|
||||
self._delete_by_source(path)
|
||||
@@ -53,10 +68,14 @@ class IngestionService:
|
||||
docs.extend(loader.load())
|
||||
|
||||
chunks = self._splitter.split_documents(docs)
|
||||
QdrantVectorStore.from_documents(
|
||||
kwargs = dict(
|
||||
documents=chunks,
|
||||
embedding=self._embeddings,
|
||||
url=self._qdrant_url,
|
||||
collection_name=self._collection_name,
|
||||
)
|
||||
if self._sparse_embeddings:
|
||||
kwargs["sparse_embedding"] = self._sparse_embeddings
|
||||
kwargs["retrieval_mode"] = RetrievalMode.HYBRID
|
||||
QdrantVectorStore.from_documents(**kwargs)
|
||||
return len(chunks)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from langchain_core.documents import Document
|
||||
from langchain_qdrant import QdrantVectorStore
|
||||
from langchain_qdrant import QdrantVectorStore, RetrievalMode
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import Filter, FieldCondition, MatchValue, FilterSelector
|
||||
|
||||
@@ -15,24 +15,47 @@ class RetrieverService:
|
||||
top_k: int,
|
||||
reranker=None,
|
||||
rerank_fetch_k: int = 10,
|
||||
sparse_embeddings=None,
|
||||
):
|
||||
self._client = QdrantClient(url=qdrant_url)
|
||||
self._collection_name = collection_name
|
||||
self._store = QdrantVectorStore(
|
||||
self._top_k = top_k
|
||||
self._reranker = reranker
|
||||
self._rerank_fetch_k = rerank_fetch_k
|
||||
self._sparse_embeddings = sparse_embeddings
|
||||
|
||||
# Dense-only store — hybrid 실패 시 폴백으로도 사용
|
||||
self._dense_store = QdrantVectorStore(
|
||||
client=self._client,
|
||||
collection_name=collection_name,
|
||||
embedding=embeddings,
|
||||
)
|
||||
self._top_k = top_k
|
||||
self._reranker = reranker
|
||||
self._rerank_fetch_k = rerank_fetch_k
|
||||
|
||||
if sparse_embeddings:
|
||||
self._store = QdrantVectorStore(
|
||||
client=self._client,
|
||||
collection_name=collection_name,
|
||||
embedding=embeddings,
|
||||
sparse_embedding=sparse_embeddings,
|
||||
retrieval_mode=RetrievalMode.HYBRID,
|
||||
)
|
||||
else:
|
||||
self._store = self._dense_store
|
||||
|
||||
def as_retriever(self):
|
||||
return self._store.as_retriever(search_kwargs={"k": self._top_k})
|
||||
|
||||
def search(self, query: str) -> list[Document]:
|
||||
fetch_k = self._rerank_fetch_k if self._reranker else self._top_k
|
||||
try:
|
||||
docs = self._store.similarity_search(query, k=fetch_k)
|
||||
except Exception as e:
|
||||
if self._sparse_embeddings:
|
||||
# 컬렉션에 sparse vector 없음 → dense 폴백 (재수집 필요)
|
||||
print(f"[Hybrid] 검색 실패, dense 폴백 (문서 재수집 필요): {e}")
|
||||
docs = self._dense_store.similarity_search(query, k=fetch_k)
|
||||
else:
|
||||
raise
|
||||
if self._reranker:
|
||||
docs = self._reranker.rerank(query, docs, top_k=self._top_k)
|
||||
return docs
|
||||
|
||||
Reference in New Issue
Block a user