diff --git a/ai/src/ai_server/analyzer/_embedding_step.py b/ai/src/ai_server/analyzer/_embedding_step.py index 757585d9..35846f47 100644 --- a/ai/src/ai_server/analyzer/_embedding_step.py +++ b/ai/src/ai_server/analyzer/_embedding_step.py @@ -1,4 +1,4 @@ -# 공통 임베딩 모듈 +# 공통 임베딩 모듈 from __future__ import annotations import structlog @@ -22,6 +22,27 @@ def __init__(self, *, code: str, message: str, retriable: bool) -> None: self.retriable = retriable +# 문맥 프리픽스에 넣을 요약 최대 길이 (청크 비대화 방지). +_MAX_SUMMARY_CHARS = 200 + + +def _contextualize(chunk_text: str, heading_path: str, summary: str) -> str: + """Contextual Retrieval: 고립된 청크가 문서 맥락을 잃지 않도록 + [문서요약 > 헤딩경로] 프리픽스를 붙인다 (Anthropic 기법의 결정적 변형 — LLM 호출 없음). + """ + parts: list[str] = [] + s = " ".join((summary or "").split()) + if s: + if len(s) > _MAX_SUMMARY_CHARS: + s = s[:_MAX_SUMMARY_CHARS].rstrip() + "…" + parts.append(s) + if heading_path: + parts.append(heading_path) + if not parts: + return chunk_text + return f"[{' > '.join(parts)}]\n\n{chunk_text}" + + async def chunk_embed_and_upsert( *, document_id: int, @@ -29,6 +50,7 @@ async def chunk_embed_and_upsert( chunker: MarkdownChunker, embedder: EmbeddingProvider, core_client: CoreClient, + summary: str = "", log_prefix: str = "analyze", ) -> int: chunks = chunker.split(markdown) @@ -41,8 +63,11 @@ async def chunk_embed_and_upsert( if not chunks: return 0 + # 임베딩·저장 대상은 문맥이 보강된 텍스트 (검색 정합도↑ + grounding↑). + contextualized = [_contextualize(c.text, c.heading_path, summary) for c in chunks] + try: - vectors = await embedder.embed([c.text for c in chunks]) + vectors = await embedder.embed(contextualized) except EmbeddingError as err: raise EmbeddingStepError( code=err.code, message=err.message, retriable=err.retriable @@ -58,7 +83,7 @@ async def chunk_embed_and_upsert( payloads = [ EmbeddingChunkPayload( chunk_index=chunks[i].index, - chunk_text=chunks[i].text, + chunk_text=contextualized[i], embedding=vectors[i], ) for i in range(len(chunks)) diff --git a/ai/src/ai_server/analyzer/repository_analyzer.py b/ai/src/ai_server/analyzer/repository_analyzer.py index ca0d867f..b633175c 100644 --- a/ai/src/ai_server/analyzer/repository_analyzer.py +++ b/ai/src/ai_server/analyzer/repository_analyzer.py @@ -144,6 +144,7 @@ async def emit(phase: str, message: str) -> None: chunker=self._chunker, embedder=self._embedder, core_client=self._core_client, + summary=analysis.summary, log_prefix="repository", ) except EmbeddingStepError as err: diff --git a/ai/src/ai_server/analyzer/resume_analyzer.py b/ai/src/ai_server/analyzer/resume_analyzer.py index cc46ed52..18b31b2d 100644 --- a/ai/src/ai_server/analyzer/resume_analyzer.py +++ b/ai/src/ai_server/analyzer/resume_analyzer.py @@ -112,6 +112,7 @@ async def emit(phase: str, message: str) -> None: chunker=self._chunker, embedder=self._embedder, core_client=self._core_client, + summary=analysis.summary, log_prefix="resume", ) except EmbeddingStepError as err: diff --git a/ai/src/ai_server/analyzer/web_resume_analyzer.py b/ai/src/ai_server/analyzer/web_resume_analyzer.py index b5da258d..204ea708 100644 --- a/ai/src/ai_server/analyzer/web_resume_analyzer.py +++ b/ai/src/ai_server/analyzer/web_resume_analyzer.py @@ -96,6 +96,7 @@ async def analyze( chunker=self._chunker, embedder=self._embedder, core_client=self._core_client, + summary=analysis.summary, log_prefix="web_resume", ) except EmbeddingStepError as err: diff --git a/ai/src/ai_server/chain/prompts/rerank.py b/ai/src/ai_server/chain/prompts/rerank.py new file mode 100644 index 00000000..b5df409b --- /dev/null +++ b/ai/src/ai_server/chain/prompts/rerank.py @@ -0,0 +1,22 @@ +# 검색 후보 리랭킹 프롬프트. +# 하이브리드 검색이 가져온 후보 청크들을 쿼리와 함께 한 번에 모델에 넣어, +# 관련도 높은 순으로 인덱스를 재정렬한다 (cross-encoder 대용, 호출 1회). + +SYSTEM_PROMPT = ( + "당신은 검색 결과 재정렬기(reranker)입니다. " + "주어진 쿼리에 대해 후보 청크들의 관련도를 평가하고, " + "가장 관련 높은 순서로 인덱스를 정렬하세요.\n" + "- 관련도는 쿼리의 의도에 대한 답이 청크에 실제로 담겨 있는 정도로 판단합니다.\n" + "- 관련 없는 청크는 결과에서 제외해도 됩니다.\n" + "- 응답은 반드시 지정된 JSON 스키마(ranked_indices)를 따릅니다." +) + +HUMAN_PROMPT = ( + "쿼리:\n{query}\n\n" + "후보 청크 (각 [i] 인덱스):\n" + "---\n" + "{candidates}\n" + "---\n\n" + "관련도가 높은 순으로 최대 {top_k}개의 인덱스를 ranked_indices 에 담아 반환하세요.\n" + "{format_instructions}" +) diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 4601b3f1..42fa0fa5 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -38,6 +38,9 @@ class Settings(BaseSettings): ai_realtime_exchange: str = "stackup.realtime" ai_realtime_routing_user: str = "realtime.user.notify" feedback_rag_top_k: int = 5 + # 리랭킹: 하이브리드 검색으로 후보 N개(candidate_k)를 가져와 LLM 으로 재정렬 후 top_k 주입. + rerank_enabled: bool = True + rerank_candidate_k: int = 20 # 질문 풀 초기 크기. Core 의 applyPool 이 questions[0] 만 INSERT 하므로 1 로 고정해 토큰 낭비 차단. # 후속 작업에서 풀 저장 도입 시 늘리기 (예: 5). questions_initial_pool_size: int = 1 @@ -51,16 +54,16 @@ class Settings(BaseSettings): whisper_timeout_sec: float = 60.0 deepgram_api_key: str = "" deepgram_base_url: str = "https://api.deepgram.com/v1" - deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2. + deepgram_model: str = "whisper-large" # 한국어 정확도 우선; 저비용 우선 시 nova-2. deepgram_language: str = "ko" deepgram_timeout_sec: float = 60.0 # 스트리밍 STT (실시간 음성 답변). "auto" 면 DEEPGRAM_API_KEY 보유 시 deepgram_live, 없으면 mock. live_stt_provider: Literal["auto", "mock", "deepgram_live"] = "auto" deepgram_live_url: str = "wss://api.deepgram.com/v1/listen" - deepgram_live_model: str = "nova-2" # 스트리밍은 nova-2(저지연). 한국어 지원. + deepgram_live_model: str = "nova-2" # 스트리밍은 nova-2(저지연). 한국어 지원. deepgram_live_language: str = "ko" - deepgram_live_endpointing_ms: int = 800 # 무음 800ms → utterance end + deepgram_live_endpointing_ms: int = 800 # 무음 800ms → utterance end voice_stream_internal_path: str = "/internal/voice/stream" # TTS (질문 음성화). "auto" 면 openai 키 보유 시 openai, 없으면 mock. @@ -125,7 +128,7 @@ class Settings(BaseSettings): embedding_dim: int = 1536 embedding_chunk_size: int = 1000 embedding_chunk_overlap: int = 200 - embedding_batch_size: int = 32 + embedding_batch_size: int = 32 gemini_api_key: str = "" diff --git a/ai/src/ai_server/core/client.py b/ai/src/ai_server/core/client.py index 98cacab1..b97f8dab 100644 --- a/ai/src/ai_server/core/client.py +++ b/ai/src/ai_server/core/client.py @@ -57,6 +57,7 @@ async def search_embeddings( self, *, query_embedding: list[float], + query_text: str | None = None, document_ids: list[int] | None = None, top_k: int = 5, ) -> list[EmbeddingSearchHit]: ... @@ -254,15 +255,19 @@ async def search_embeddings( self, *, query_embedding: list[float], + query_text: str | None = None, document_ids: list[int] | None = None, top_k: int = 5, ) -> list[EmbeddingSearchHit]: - """pgvector cosine topK 검색. 실패 시 빈 리스트 반환 (RAG 보강용이므로 fatal 아님).""" - body = { + """임베딩 검색. query_text 가 주어지면 Core 가 벡터+BM25 RRF 하이브리드로, + 없으면 pgvector cosine 단독으로 topK 반환. 실패 시 빈 리스트 (RAG 보강용이므로 fatal 아님).""" + body: dict = { "queryEmbedding": query_embedding, "documentIds": list(document_ids or []), "topK": top_k, } + if query_text: + body["queryText"] = query_text path = "/api/internal/embeddings/search" try: if self._client is not None: diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index 371522b8..01644b27 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Protocol - import structlog from aio_pika.abc import AbstractIncomingMessage @@ -17,6 +15,7 @@ VoiceAnalysisSummary, ) from ai_server.rag.embedder import EmbeddingProvider +from ai_server.rag.reranker import NoopReranker, Reranker, rerank_hits log = structlog.get_logger(__name__) @@ -42,6 +41,8 @@ def __init__( core_client: CoreClient, embedder: EmbeddingProvider | None = None, rag_top_k: int = 5, + reranker: Reranker | None = None, + candidate_k: int = 20, ) -> None: self._generator = generator self._publisher = publisher @@ -50,6 +51,8 @@ def __init__( self._core = core_client self._embedder = embedder self._rag_top_k = rag_top_k + self._reranker = reranker or NoopReranker() + self._candidate_k = max(candidate_k, rag_top_k) async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -136,17 +139,23 @@ async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str: if not last_answer: return "(none)" try: - query_vec = (await self._embedder.embed([last_answer]))[0] + query_vec = ( + await self._embedder.embed([last_answer], task_type="RETRIEVAL_QUERY") + )[0] hits = await self._core.search_embeddings( query_embedding=query_vec, + query_text=last_answer, document_ids=req.context_document_ids, - top_k=self._rag_top_k, + top_k=self._candidate_k, ) except Exception as exc: log.warn("feedback.rag.failed", error=str(exc), session_id=req.session_id) return "(none)" if not hits: return "(none)" + hits = await rerank_hits( + self._reranker, query=last_answer, hits=hits, top_k=self._rag_top_k + ) return "\n---\n".join( f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits ) diff --git a/ai/src/ai_server/messaging/consumers/followup_consumer.py b/ai/src/ai_server/messaging/consumers/followup_consumer.py index 4334cdfa..c6c03c80 100644 --- a/ai/src/ai_server/messaging/consumers/followup_consumer.py +++ b/ai/src/ai_server/messaging/consumers/followup_consumer.py @@ -13,6 +13,7 @@ GenerateFollowupRequest, ) from ai_server.rag.embedder import EmbeddingProvider +from ai_server.rag.reranker import NoopReranker, Reranker, rerank_hits log = structlog.get_logger(__name__) @@ -28,6 +29,8 @@ def __init__( core_client: CoreClient | None = None, embedder: EmbeddingProvider | None = None, rag_top_k: int = 5, + reranker: Reranker | None = None, + candidate_k: int = 20, ) -> None: self._generator = generator self._publisher = publisher @@ -36,6 +39,8 @@ def __init__( self._core = core_client self._embedder = embedder self._rag_top_k = rag_top_k + self._reranker = reranker or NoopReranker() + self._candidate_k = max(candidate_k, rag_top_k) async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -105,11 +110,14 @@ async def _build_rag_context(self, req: GenerateFollowupRequest) -> str: query = f"{req.previous_question}\n\n{req.answer_text}" try: - query_vec = (await self._embedder.embed([query]))[0] + query_vec = ( + await self._embedder.embed([query], task_type="RETRIEVAL_QUERY") + )[0] hits = await self._core.search_embeddings( query_embedding=query_vec, + query_text=query, document_ids=req.context_document_ids, - top_k=self._rag_top_k, + top_k=self._candidate_k, ) except Exception as exc: log.warn("followup.rag.failed", error=str(exc), session_id=req.session_id) @@ -117,6 +125,9 @@ async def _build_rag_context(self, req: GenerateFollowupRequest) -> str: if not hits: return "(none)" + hits = await rerank_hits( + self._reranker, query=query, hits=hits, top_k=self._rag_top_k + ) return "\n---\n".join( f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits ) diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index 08081684..bc5e8007 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -14,6 +14,7 @@ QuestionPoolCallbackPayload, ) from ai_server.rag.embedder import EmbeddingProvider +from ai_server.rag.reranker import NoopReranker, Reranker, rerank_hits log = structlog.get_logger(__name__) @@ -30,6 +31,8 @@ def __init__( core_client: CoreClient | None = None, embedder: EmbeddingProvider | None = None, rag_top_k: int = 5, + reranker: Reranker | None = None, + candidate_k: int = 20, ) -> None: self._generator = generator self._publisher = publisher @@ -41,6 +44,8 @@ def __init__( self._core = core_client self._embedder = embedder self._rag_top_k = rag_top_k + self._reranker = reranker or NoopReranker() + self._candidate_k = max(candidate_k, rag_top_k) async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -120,11 +125,14 @@ async def _build_context(self, req: GenerateQuestionsRequest) -> str: query = _build_initial_rag_query(req) try: - query_vec = (await self._embedder.embed([query]))[0] + query_vec = ( + await self._embedder.embed([query], task_type="RETRIEVAL_QUERY") + )[0] hits = await self._core.search_embeddings( query_embedding=query_vec, + query_text=query, document_ids=document_ids, - top_k=self._rag_top_k, + top_k=self._candidate_k, ) except Exception as exc: log.warn("questions.rag.failed", error=str(exc), session_id=req.session_id) @@ -132,6 +140,9 @@ async def _build_context(self, req: GenerateQuestionsRequest) -> str: if not hits: return base_context + hits = await rerank_hits( + self._reranker, query=query, hits=hits, top_k=self._rag_top_k + ) rag_context = "\n---\n".join( f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 36cd7186..a270b04d 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -30,6 +30,7 @@ from ai_server.messaging.connection import RabbitConnection from ai_server.rag.chunker import MarkdownChunker from ai_server.rag.embedder import build_embedding_provider +from ai_server.rag.reranker import build_reranker from ai_server.messaging.consumers.feedback_consumer import FeedbackConsumer from ai_server.messaging.consumers.followup_consumer import FollowupConsumer from ai_server.messaging.consumers.questions_consumer import QuestionsConsumer @@ -95,6 +96,7 @@ def __init__(self, settings: Settings) -> None: model=settings.embedding_model, gemini_api_key=settings.gemini_api_key, ) + reranker = build_reranker(settings, core_client=core_client) # 이력서 PDF resume_analyzer = ResumeAnalyzer( @@ -171,6 +173,8 @@ def __init__(self, settings: Settings) -> None: initial_pool_size=settings.questions_initial_pool_size, core_client=core_client, embedder=embedder, + reranker=reranker, + candidate_k=settings.rerank_candidate_k, ) # 꼬리질문 생성 (US-19) @@ -184,6 +188,8 @@ def __init__(self, settings: Settings) -> None: callback_routing_key=settings.ai_callback_routing_questions, core_client=core_client, embedder=embedder, + reranker=reranker, + candidate_k=settings.rerank_candidate_k, ) # 종합 피드백 생성 (US-24) @@ -197,6 +203,8 @@ def __init__(self, settings: Settings) -> None: callback_routing_key=settings.ai_callback_routing_feedback, core_client=core_client, embedder=embedder, + reranker=reranker, + candidate_k=settings.rerank_candidate_k, rag_top_k=settings.feedback_rag_top_k, ) diff --git a/ai/src/ai_server/rag/chunker.py b/ai/src/ai_server/rag/chunker.py index 8c6a6ed4..ba4874b6 100644 --- a/ai/src/ai_server/rag/chunker.py +++ b/ai/src/ai_server/rag/chunker.py @@ -2,16 +2,31 @@ from dataclasses import dataclass -from langchain_text_splitters import RecursiveCharacterTextSplitter +from langchain_text_splitters import ( + MarkdownHeaderTextSplitter, + RecursiveCharacterTextSplitter, +) @dataclass(frozen=True) class Chunk: index: int text: str + # 이 청크가 속한 마크다운 헤딩 경로 (예: "주요 경험 > 결제 시스템"). + # Contextual Retrieval(문맥 프리픽스)에서 사용. 없으면 빈 문자열. + heading_path: str = "" -# md를 잘라냄. size와 overlap은 설정에서 가져다 씀 +# 마크다운 헤딩 기준 1차 분할 (## 개요 / ## 주요 경험 / ## 기술 ...). +_HEADERS_TO_SPLIT_ON = [("#", "h1"), ("##", "h2"), ("###", "h3")] + +# 2차(크기) 분할의 separator 우선순위. +# 코드 펜스(```)·문단을 먼저 경계로 삼아 함수/코드블록 중간 절단을 최소화한다. +_SEPARATORS = ["\n```", "\n\n", "\n", " ", ""] + + +# md를 "헤딩 단위 → 크기 단위" 2단계로 잘라낸다. +# 한 청크 = 한 의미 단위(섹션)에 가깝고, 코드 블록은 가급적 통째로 유지. class MarkdownChunker: def __init__(self, *, chunk_size: int, chunk_overlap: int) -> None: if chunk_size <= 0: @@ -20,14 +35,34 @@ def __init__(self, *, chunk_size: int, chunk_overlap: int) -> None: raise ValueError( f"chunk_overlap must be in [0, chunk_size), got {chunk_overlap}" ) - self._splitter = RecursiveCharacterTextSplitter( + # strip_headers=False: 헤딩 라인을 본문에 남겨 LLM 가독성/맥락 유지. + self._header_splitter = MarkdownHeaderTextSplitter( + headers_to_split_on=_HEADERS_TO_SPLIT_ON, + strip_headers=False, + ) + self._body_splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, length_function=len, + separators=_SEPARATORS, ) def split(self, markdown: str) -> list[Chunk]: if not markdown or not markdown.strip(): return [] - parts = self._splitter.split_text(markdown) - return [Chunk(index=i, text=text) for i, text in enumerate(parts)] + + sections = self._header_splitter.split_text(markdown) + chunks: list[Chunk] = [] + index = 0 + for section in sections: + heading_path = _heading_path(section.metadata) + for part in self._body_splitter.split_text(section.page_content): + if not part.strip(): + continue + chunks.append(Chunk(index=index, text=part, heading_path=heading_path)) + index += 1 + return chunks + + +def _heading_path(metadata: dict[str, str]) -> str: + return " > ".join(metadata[key] for key in ("h1", "h2", "h3") if metadata.get(key)) diff --git a/ai/src/ai_server/rag/embedder.py b/ai/src/ai_server/rag/embedder.py index d6239758..0c79adb3 100644 --- a/ai/src/ai_server/rag/embedder.py +++ b/ai/src/ai_server/rag/embedder.py @@ -21,7 +21,9 @@ def dim(self) -> int: ... @property def model(self) -> str: ... - async def embed(self, texts: list[str]) -> list[list[float]]: ... + async def embed( + self, texts: list[str], *, task_type: str = "RETRIEVAL_DOCUMENT" + ) -> list[list[float]]: ... # 우선 mock 구현체 @@ -40,7 +42,10 @@ def dim(self) -> int: def model(self) -> str: return self._model - async def embed(self, texts: list[str]) -> list[list[float]]: + async def embed( + self, texts: list[str], *, task_type: str = "RETRIEVAL_DOCUMENT" + ) -> list[list[float]]: + # mock 은 task_type 을 무시한다 (시그니처 호환만 유지). return [self._embed_one(t) for t in texts] def _embed_one(self, text: str) -> list[float]: @@ -53,8 +58,8 @@ def _embed_one(self, text: str) -> list[float]: return [v * scale - 1.0 for v in ints] -# Gemini Embedding 을 사용합니다. -# 이건 충대키로 안되니 키 발급 필요함 +# Gemini Embedding 을 사용합니다. +# 이건 충대키로 안되니 키 발급 필요함 class GeminiEmbeddingProvider: def __init__(self, *, api_key: str, model: str, dim: int) -> None: if not api_key: @@ -75,7 +80,9 @@ def dim(self) -> int: def model(self) -> str: return self._model - async def embed(self, texts: list[str]) -> list[list[float]]: + async def embed( + self, texts: list[str], *, task_type: str = "RETRIEVAL_DOCUMENT" + ) -> list[list[float]]: if not texts: return [] from google.genai import types as genai_types @@ -85,7 +92,9 @@ async def embed(self, texts: list[str]) -> list[list[float]]: model=self._model, contents=texts, config=genai_types.EmbedContentConfig( - task_type="RETRIEVAL_DOCUMENT", + # 인덱싱은 RETRIEVAL_DOCUMENT, 검색 쿼리는 RETRIEVAL_QUERY 로 + # 분리해야 Gemini embedding 의 코사인 정합도가 최적화된다. + task_type=task_type, output_dimensionality=self._dim, ), ) diff --git a/ai/src/ai_server/rag/reranker.py b/ai/src/ai_server/rag/reranker.py new file mode 100644 index 00000000..2668705e --- /dev/null +++ b/ai/src/ai_server/rag/reranker.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from typing import Protocol + +import structlog +from langchain_core.output_parsers import PydanticOutputParser +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import Runnable +from pydantic import BaseModel, Field + +from ai_server.chain.prompts.rerank import HUMAN_PROMPT, SYSTEM_PROMPT +from ai_server.config.settings import Settings +from ai_server.core.client import CoreClient +from ai_server.observability.llm_logging_callback import CoreAiLogCallback + +log = structlog.get_logger(__name__) + + +class RerankResult(BaseModel): + ranked_indices: list[int] = Field( + default_factory=list, + description="relevant candidate indices, most relevant first", + ) + + +class Reranker(Protocol): + async def rerank( + self, *, query: str, candidates: list[str], top_k: int + ) -> list[int]: ... + + +async def rerank_hits( + reranker: Reranker, *, query: str, hits: list, top_k: int +) -> list: + """검색 hit 리스트(.chunk_text 보유)를 query 기준으로 재정렬해 top_k 반환.""" + if not hits: + return list(hits) + indices = await reranker.rerank( + query=query, candidates=[h.chunk_text for h in hits], top_k=top_k + ) + return [hits[i] for i in indices] + + +def _identity(n: int, top_k: int) -> list[int]: + return list(range(min(top_k, n))) + + +# 리랭킹 비활성/미주입 시 검색 순서를 그대로 사용하는 passthrough. +class NoopReranker: + async def rerank( + self, *, query: str, candidates: list[str], top_k: int + ) -> list[int]: + return _identity(len(candidates), top_k) + + +# LLM 으로 후보를 한 번에 재정렬한다. 실패 시 검색 순서로 graceful degrade. +class LlmReranker: + def __init__(self, chain: Runnable) -> None: + self._chain = chain + + async def rerank( + self, *, query: str, candidates: list[str], top_k: int + ) -> list[int]: + if not candidates: + return [] + numbered = "\n\n".join(f"[{i}] {c}" for i, c in enumerate(candidates)) + try: + result = await self._chain.ainvoke( + {"query": query, "candidates": numbered, "top_k": top_k} + ) + seen: set[int] = set() + ordered: list[int] = [] + for i in result.ranked_indices: + if 0 <= i < len(candidates) and i not in seen: + seen.add(i) + ordered.append(i) + if not ordered: + raise ValueError("리랭커가 유효한 인덱스를 반환하지 않음") + return ordered[:top_k] + except Exception as exc: # noqa: BLE001 + log.warning("rerank.failed_fallback_to_search_order", error=str(exc)) + return _identity(len(candidates), top_k) + + +def build_reranker( + settings: Settings, core_client: CoreClient | None = None +) -> Reranker: + if not settings.rerank_enabled: + return NoopReranker() + + from langchain_openai import ChatOpenAI + + parser = PydanticOutputParser(pydantic_object=RerankResult) + prompt = ChatPromptTemplate.from_messages( + [ + ("system", SYSTEM_PROMPT), + ("human", HUMAN_PROMPT), + ] + ).partial(format_instructions=parser.get_format_instructions()) + + callbacks = [] + if core_client is not None: + callbacks.append( + CoreAiLogCallback( + core_client=core_client, + request_type="rerank", + default_model=settings.llm_flash_model, + ) + ) + + llm = ChatOpenAI( + model=settings.llm_flash_model, + temperature=0.0, + api_key=settings.llm_api_key or None, + base_url=settings.llm_base_url, + max_tokens=settings.llm_flash_max_tokens, + callbacks=callbacks, + ) + return LlmReranker(prompt | llm | parser) diff --git a/ai/tests/test_chunker.py b/ai/tests/test_chunker.py index de6328c1..5b52eb26 100644 --- a/ai/tests/test_chunker.py +++ b/ai/tests/test_chunker.py @@ -46,3 +46,38 @@ def test_chunk_indices_are_sequential() -> None: chunks = chunker.split("abc " * 200) indices = [c.index for c in chunks] assert indices == list(range(len(chunks))) + + +def test_heading_path_is_captured_from_markdown_headers() -> None: + md = ( + "# 이력서\n" + "## 주요 경험\n" + "### 결제 시스템\n" + "분산 락으로 동시성을 해결했다.\n" + "## 기술\n" + "Kafka 를 사용했다.\n" + ) + chunker = MarkdownChunker(chunk_size=500, chunk_overlap=50) + chunks = chunker.split(md) + paths = {c.heading_path for c in chunks} + # 헤딩 경로가 섹션별로 부여됨 + assert any("결제 시스템" in p for p in paths) + assert any("기술" in p for p in paths) + # 헤딩 경로는 상위>하위 형태 + deep = next(c for c in chunks if "결제 시스템" in c.heading_path) + assert deep.heading_path.startswith("이력서 > 주요 경험") + + +def test_no_headers_yields_empty_heading_path() -> None: + chunker = MarkdownChunker(chunk_size=200, chunk_overlap=20) + chunks = chunker.split("헤딩 없는 평범한 본문 문장입니다.") + assert len(chunks) == 1 + assert chunks[0].heading_path == "" + + +def test_chunk_indices_sequential_across_multiple_sections() -> None: + md = "## A\n" + ("문장. " * 100) + "\n## B\n" + ("다른. " * 100) + chunker = MarkdownChunker(chunk_size=150, chunk_overlap=30) + chunks = chunker.split(md) + assert len(chunks) >= 2 + assert [c.index for c in chunks] == list(range(len(chunks))) diff --git a/ai/tests/test_core_client.py b/ai/tests/test_core_client.py index d50842bf..02977b2e 100644 --- a/ai/tests/test_core_client.py +++ b/ai/tests/test_core_client.py @@ -310,6 +310,23 @@ async def test_search_embeddings_uses_latest_core_contract() -> None: ) +@pytest.mark.asyncio +async def test_search_embeddings_includes_query_text_for_hybrid() -> None: + client = _make_post_client(json_body={"hits": []}) + core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) + + await core.search_embeddings( + query_embedding=[0.1], + query_text="gRPC 동시성 처리", + document_ids=[7], + top_k=20, + ) + + body = client.post.await_args.kwargs["json"] + assert body["queryText"] == "gRPC 동시성 처리" + assert body["topK"] == 20 + + @pytest.mark.parametrize("status", [400, 401, 403, 404, 500]) @pytest.mark.asyncio async def test_search_embeddings_non_2xx_returns_empty(status: int) -> None: diff --git a/ai/tests/test_embedder.py b/ai/tests/test_embedder.py index bdb67160..afc53c61 100644 --- a/ai/tests/test_embedder.py +++ b/ai/tests/test_embedder.py @@ -119,6 +119,37 @@ async def test_gemini_embed_returns_vector_list_from_sdk_response() -> None: assert kwargs["contents"] == ["chunk A", "chunk B"] +@pytest.mark.asyncio +async def test_gemini_embed_task_type_defaults_to_document_and_overrides_to_query() -> None: + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from ai_server.rag.embedder import GeminiEmbeddingProvider + + fake_resp = SimpleNamespace(embeddings=[SimpleNamespace(values=[0.1, 0.2])]) + fake_aio = MagicMock() + fake_aio.models.embed_content = AsyncMock(return_value=fake_resp) + fake_client = MagicMock() + fake_client.aio = fake_aio + + with patch("google.genai.Client", return_value=fake_client): + emb = GeminiEmbeddingProvider(api_key="fake", model="m", dim=2) + + # 기본: 인덱싱 → RETRIEVAL_DOCUMENT + await emb.embed(["doc"]) + assert ( + fake_aio.models.embed_content.await_args.kwargs["config"].task_type + == "RETRIEVAL_DOCUMENT" + ) + + # 검색: RETRIEVAL_QUERY 로 전달됨 + await emb.embed(["query"], task_type="RETRIEVAL_QUERY") + assert ( + fake_aio.models.embed_content.await_args.kwargs["config"].task_type + == "RETRIEVAL_QUERY" + ) + + @pytest.mark.asyncio async def test_gemini_embed_empty_input_returns_empty_without_sdk_call() -> None: from unittest.mock import MagicMock, patch diff --git a/ai/tests/test_embedding_step.py b/ai/tests/test_embedding_step.py new file mode 100644 index 00000000..b52acac3 --- /dev/null +++ b/ai/tests/test_embedding_step.py @@ -0,0 +1,62 @@ +from unittest.mock import AsyncMock + +import pytest + +from ai_server.analyzer._embedding_step import _contextualize, chunk_embed_and_upsert +from ai_server.rag.chunker import MarkdownChunker +from ai_server.rag.embedder import MockEmbeddingProvider + + +def test_contextualize_prefixes_summary_and_heading() -> None: + out = _contextualize("본문 내용", heading_path="주요 경험 > 결제", summary="백엔드 지원자") + assert out.startswith("[백엔드 지원자 > 주요 경험 > 결제]") + assert out.endswith("본문 내용") + + +def test_contextualize_without_context_returns_raw() -> None: + assert _contextualize("본문", heading_path="", summary="") == "본문" + + +def test_contextualize_truncates_long_summary() -> None: + out = _contextualize("c", heading_path="", summary="가" * 500) + assert "…" in out + # 프리픽스 길이가 요약 상한 + 여유 이내 + assert len(out) < 500 + + +@pytest.mark.asyncio +async def test_chunk_embed_and_upsert_stores_contextualized_text() -> None: + core = AsyncMock() + core.upsert_embeddings = AsyncMock(return_value=2) + md = "## 주요 경험\n" + ("결제 시스템 동시성 분산락. " * 40) + + await chunk_embed_and_upsert( + document_id=7, + markdown=md, + chunker=MarkdownChunker(chunk_size=200, chunk_overlap=40), + embedder=MockEmbeddingProvider(dim=8), + core_client=core, + summary="백엔드 면접 지원자", + ) + + core.upsert_embeddings.assert_awaited_once() + chunks = core.upsert_embeddings.await_args.kwargs["chunks"] + assert len(chunks) > 0 + # 저장되는 chunk_text 가 문맥 프리픽스를 포함 + assert chunks[0].chunk_text.startswith("[백엔드 면접 지원자 > 주요 경험]") + + +@pytest.mark.asyncio +async def test_chunk_embed_and_upsert_empty_markdown_skips() -> None: + core = AsyncMock() + core.upsert_embeddings = AsyncMock() + n = await chunk_embed_and_upsert( + document_id=1, + markdown=" \n ", + chunker=MarkdownChunker(chunk_size=100, chunk_overlap=10), + embedder=MockEmbeddingProvider(dim=8), + core_client=core, + summary="x", + ) + assert n == 0 + core.upsert_embeddings.assert_not_called() diff --git a/ai/tests/test_followup_consumer.py b/ai/tests/test_followup_consumer.py index be80ce01..72ab45a0 100644 --- a/ai/tests/test_followup_consumer.py +++ b/ai/tests/test_followup_consumer.py @@ -127,11 +127,11 @@ async def test_consumer_injects_followup_rag_context_when_available(): await consumer.handle(_StubMessage(_envelope())) embedder.embed.assert_awaited_once() - core.search_embeddings.assert_awaited_once_with( - query_embedding=[0.1, 0.2, 0.3], - document_ids=[7], - top_k=3, - ) + call = core.search_embeddings.await_args + assert call.kwargs["query_embedding"] == [0.1, 0.2, 0.3] + assert call.kwargs["document_ids"] == [7] + assert call.kwargs["top_k"] == 20 # candidate_k (리랭크 후보 수) + assert call.kwargs["query_text"] # 하이브리드 검색: 쿼리 텍스트 동봉 context = generator.generate.await_args.kwargs["context"] assert "Outbox rows are inserted in the same transaction" in context diff --git a/ai/tests/test_questions_consumer.py b/ai/tests/test_questions_consumer.py index aeb63b5c..fcfa535b 100644 --- a/ai/tests/test_questions_consumer.py +++ b/ai/tests/test_questions_consumer.py @@ -246,11 +246,11 @@ async def test_consumer_injects_initial_rag_chunks_when_available(): await consumer.handle(_StubMessage(body)) embedder.embed.assert_awaited_once() - core.search_embeddings.assert_awaited_once_with( - query_embedding=[0.1, 0.2], - document_ids=[1], - top_k=2, - ) + call = core.search_embeddings.await_args + assert call.kwargs["query_embedding"] == [0.1, 0.2] + assert call.kwargs["document_ids"] == [1] + assert call.kwargs["top_k"] == 20 # candidate_k (리랭크 후보 수) + assert call.kwargs["query_text"] # 하이브리드 검색: 쿼리 텍스트 동봉 context = generator.generate.await_args.kwargs["context"] assert "Outbox table uses status and retry count" in context assert "outbox 구현" in context diff --git a/ai/tests/test_reranker.py b/ai/tests/test_reranker.py new file mode 100644 index 00000000..f5094417 --- /dev/null +++ b/ai/tests/test_reranker.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass + +import pytest + +from ai_server.rag.reranker import ( + LlmReranker, + NoopReranker, + RerankResult, + rerank_hits, +) + + +@dataclass +class _Hit: + chunk_text: str + + +class _FakeChain: + def __init__(self, result=None, *, raises: Exception | None = None) -> None: + self._result = result + self._raises = raises + + async def ainvoke(self, _inputs): + if self._raises is not None: + raise self._raises + return self._result + + +@pytest.mark.asyncio +async def test_noop_reranker_returns_identity_truncated() -> None: + r = NoopReranker() + assert await r.rerank(query="q", candidates=["a", "b", "c"], top_k=2) == [0, 1] + + +@pytest.mark.asyncio +async def test_llm_reranker_reorders_by_returned_indices() -> None: + chain = _FakeChain(RerankResult(ranked_indices=[2, 0])) + r = LlmReranker(chain) + assert await r.rerank(query="q", candidates=["a", "b", "c"], top_k=2) == [2, 0] + + +@pytest.mark.asyncio +async def test_llm_reranker_filters_out_of_range_and_dupes() -> None: + chain = _FakeChain(RerankResult(ranked_indices=[1, 1, 9, -1, 0])) + r = LlmReranker(chain) + # 중복/범위밖 제거 후 순서 유지 + assert await r.rerank(query="q", candidates=["a", "b", "c"], top_k=5) == [1, 0] + + +@pytest.mark.asyncio +async def test_llm_reranker_falls_back_to_search_order_on_failure() -> None: + chain = _FakeChain(raises=RuntimeError("llm down")) + r = LlmReranker(chain) + assert await r.rerank(query="q", candidates=["a", "b", "c"], top_k=2) == [0, 1] + + +@pytest.mark.asyncio +async def test_llm_reranker_empty_indices_falls_back() -> None: + chain = _FakeChain(RerankResult(ranked_indices=[])) + r = LlmReranker(chain) + assert await r.rerank(query="q", candidates=["a", "b"], top_k=2) == [0, 1] + + +@pytest.mark.asyncio +async def test_rerank_hits_maps_indices_back_to_hits() -> None: + chain = _FakeChain(RerankResult(ranked_indices=[2, 0])) + r = LlmReranker(chain) + hits = [_Hit("zero"), _Hit("one"), _Hit("two")] + out = await rerank_hits(r, query="q", hits=hits, top_k=2) + assert [h.chunk_text for h in out] == ["two", "zero"] + + +@pytest.mark.asyncio +async def test_rerank_hits_empty_returns_empty() -> None: + out = await rerank_hits(NoopReranker(), query="q", hits=[], top_k=5) + assert out == [] diff --git a/backend/openapi.json b/backend/openapi.json index 5d9bccbd..88d6445e 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2654,6 +2654,9 @@ "format" : "float" } }, + "queryText" : { + "type" : "string" + }, "documentIds" : { "type" : "array", "items" : { diff --git a/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java b/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java index c063c9da..606c1a8c 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java @@ -31,8 +31,8 @@ public int upsert(EmbeddingUpsertCommand command) { } public List search( - float[] queryEmbedding, List documentIds, int topK + float[] queryEmbedding, String queryText, List documentIds, int topK ) { - return embeddingRepository.search(queryEmbedding, documentIds, topK); + return embeddingRepository.search(queryEmbedding, queryText, documentIds, topK); } } diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java b/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java index 1b41b5d2..46ff1a0b 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java @@ -8,8 +8,11 @@ public interface DocumentEmbeddingRepository { int countByDocumentId(long documentId); - // pgvector cosine distance topK 검색. documentIds 가 비어 있으면 전체 대상. - List search(float[] queryEmbedding, List documentIds, int topK); + // 임베딩 검색. queryText 가 주어지면 벡터 + full-text(BM25) 를 RRF 로 융합한 + // 하이브리드 검색, 없으면(null/blank) pgvector cosine 단독 검색. + // documentIds 가 비어 있으면 전체 대상. + List search( + float[] queryEmbedding, String queryText, List documentIds, int topK); record EmbeddingChunk(int chunkIndex, String chunkText, float[] embedding) { } diff --git a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java index fae61cb6..4766f25c 100644 --- a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java @@ -65,12 +65,22 @@ public int countByDocumentId(long documentId) { } @Override - public List search(float[] queryEmbedding, List documentIds, int topK) { + public List search( + float[] queryEmbedding, String queryText, List documentIds, int topK) { if (queryEmbedding == null || queryEmbedding.length == 0) { return List.of(); } int limit = topK <= 0 ? 5 : topK; boolean filterByDoc = documentIds != null && !documentIds.isEmpty(); + boolean hybrid = queryText != null && !queryText.isBlank(); + + return hybrid + ? searchHybrid(queryEmbedding, queryText, documentIds, filterByDoc, limit) + : searchVectorOnly(queryEmbedding, documentIds, filterByDoc, limit); + } + + private List searchVectorOnly( + float[] queryEmbedding, List documentIds, boolean filterByDoc, int limit) { StringBuilder sql = new StringBuilder( "SELECT document_id, chunk_index, chunk_text, (embedding <=> CAST(:qvec AS vector)) AS distance " + "FROM document_embeddings "); @@ -83,13 +93,72 @@ public List search(float[] queryEmbedding, List documentIds, in sql.append("ORDER BY embedding <=> CAST(:qvec AS vector) LIMIT :limit"); params.put("limit", limit); - return namedJdbc.query(sql.toString(), params, (rs, rowNum) -> new SearchHit( + return namedJdbc.query(sql.toString(), params, ROW_MAPPER); + } + + // 벡터(코사인) 랭킹과 full-text(ts_rank_cd) 랭킹을 각각 구한 뒤 + // RRF(Reciprocal Rank Fusion, k=60): score = 1/(k+rank) 합으로 융합한다. + // 점수 스케일이 다른 두 랭킹을 "순위"만으로 합치므로 가중치 튜닝이 불필요. + private List searchHybrid( + float[] queryEmbedding, + String queryText, + List documentIds, + boolean filterByDoc, + int limit) { + String docFilterVec = filterByDoc ? "WHERE document_id IN (:documentIds) " : ""; + String docFilterFts = filterByDoc ? "AND document_id IN (:documentIds) " : ""; + + String sql = """ + WITH v AS ( + SELECT document_id, chunk_index, chunk_text, + (embedding <=> CAST(:qvec AS vector)) AS distance, + ROW_NUMBER() OVER (ORDER BY embedding <=> CAST(:qvec AS vector)) AS rnk + FROM document_embeddings + %s + ORDER BY embedding <=> CAST(:qvec AS vector) + LIMIT :cand + ), + t AS ( + SELECT document_id, chunk_index, chunk_text, + ROW_NUMBER() OVER ( + ORDER BY ts_rank_cd(chunk_text_tsv, plainto_tsquery('simple', :qtext)) DESC + ) AS rnk + FROM document_embeddings + WHERE chunk_text_tsv @@ plainto_tsquery('simple', :qtext) + %s + ORDER BY ts_rank_cd(chunk_text_tsv, plainto_tsquery('simple', :qtext)) DESC + LIMIT :cand + ) + SELECT COALESCE(v.document_id, t.document_id) AS document_id, + COALESCE(v.chunk_index, t.chunk_index) AS chunk_index, + COALESCE(v.chunk_text, t.chunk_text) AS chunk_text, + COALESCE(v.distance, 1.0) AS distance, + COALESCE(1.0 / (60 + v.rnk), 0) + COALESCE(1.0 / (60 + t.rnk), 0) AS rrf + FROM v + FULL OUTER JOIN t + ON v.document_id = t.document_id AND v.chunk_index = t.chunk_index + ORDER BY rrf DESC + LIMIT :limit + """.formatted(docFilterVec, docFilterFts); + + Map params = new HashMap<>(); + params.put("qvec", toVectorLiteral(queryEmbedding)); + params.put("qtext", queryText); + params.put("cand", limit); + params.put("limit", limit); + if (filterByDoc) { + params.put("documentIds", documentIds); + } + return namedJdbc.query(sql, params, ROW_MAPPER); + } + + private static final org.springframework.jdbc.core.RowMapper ROW_MAPPER = + (rs, rowNum) -> new SearchHit( rs.getLong("document_id"), rs.getInt("chunk_index"), rs.getString("chunk_text"), rs.getDouble("distance") - )); - } + ); private static String toVectorLiteral(float[] embedding) { StringBuilder sb = new StringBuilder(embedding.length * 8 + 2); diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalEmbeddingSearchController.java b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalEmbeddingSearchController.java index 8f031832..6835b27c 100644 --- a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalEmbeddingSearchController.java +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalEmbeddingSearchController.java @@ -39,6 +39,7 @@ public class InternalEmbeddingSearchController { public SearchResponse search(@Valid @RequestBody SearchRequest request) { List hits = embeddingService.search( request.queryEmbedding(), + request.queryText(), request.documentIds() == null ? List.of() : request.documentIds(), request.topK() == null ? 5 : request.topK() ); @@ -47,6 +48,8 @@ public SearchResponse search(@Valid @RequestBody SearchRequest request) { public record SearchRequest( @NotNull float[] queryEmbedding, + // 선택. 주어지면 벡터 + full-text(BM25) RRF 하이브리드 검색. + String queryText, List documentIds, @Positive Integer topK ) { diff --git a/backend/src/main/resources/db/migration/V8__hybrid_search.sql b/backend/src/main/resources/db/migration/V8__hybrid_search.sql new file mode 100644 index 00000000..61c36897 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__hybrid_search.sql @@ -0,0 +1,26 @@ +-- ============================================================================= +-- 하이브리드 검색(벡터 + BM25 full-text + RRF) 지원 +-- ============================================================================= +-- 기술 용어(gRPC, Kafka 등)의 정확 매칭을 위해 full-text 검색을 병행한다. +-- 의미 유사(벡터)만으로는 "단어가 들어있는 청크"를 놓칠 수 있으므로, +-- tsvector 기반 키워드 검색 결과와 RRF(Reciprocal Rank Fusion)로 융합한다. + +-- chunk_text 의 full-text 색인용 generated 컬럼. +-- 'simple' config: 스테밍/불용어 없이 토큰화+소문자화만 → 영문 기술용어/한글 혼용에 적합. +-- GENERATED ALWAYS STORED 라 chunk_text 변경 시 자동 동기화 (upsert 코드 변경 불필요). +ALTER TABLE document_embeddings + ADD COLUMN chunk_text_tsv tsvector + GENERATED ALWAYS AS (to_tsvector('simple', chunk_text)) STORED; + +CREATE INDEX idx_document_embeddings_tsv + ON document_embeddings USING GIN (chunk_text_tsv); + +-- ============================================================================= +-- ANN 인덱스 ivfflat → HNSW 교체 +-- ============================================================================= +-- ivfflat 은 k-means 학습 기반이라 빈 테이블 생성 시 centroid 가 부정확하고 +-- 쿼리 시 probes 튜닝이 필요하다. HNSW 는 학습 불필요 + recall/latency 우수. +DROP INDEX IF EXISTS idx_document_embeddings_ann; + +CREATE INDEX idx_document_embeddings_hnsw + ON document_embeddings USING hnsw (embedding vector_cosine_ops); diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 42698132..b3683179 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -865,6 +865,7 @@ export interface components { }; SearchRequest: { queryEmbedding: number[]; + queryText?: string; documentIds?: number[]; /** Format: int32 */ topK?: number;