-
Notifications
You must be signed in to change notification settings - Fork 0
[Phase 3] feat(ai): 법률 RAG 검색기 구현 #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| from typing import Any | ||
|
|
||
| import httpx | ||
|
|
||
| from app.core.config import get_settings | ||
|
|
||
|
|
||
| class EmbeddingClient: | ||
| """HTTP boundary for query embeddings.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| api_key: str | None = None, | ||
| base_url: str | None = None, | ||
| model: str | None = None, | ||
| timeout_seconds: float = 10.0, | ||
| ) -> None: | ||
| settings = get_settings() | ||
| self.api_key = api_key if api_key is not None else settings.embedding_api_key | ||
| self.base_url = (base_url or settings.embedding_base_url).rstrip("/") | ||
| self.model = model or settings.embedding_model | ||
| self.timeout_seconds = timeout_seconds | ||
|
|
||
| def embed_query(self, query: str) -> list[float]: | ||
| if not query.strip(): | ||
| raise ValueError("query must not be blank.") | ||
| if not self.api_key or not self.model: | ||
| raise RuntimeError("Embedding client is not configured.") | ||
|
|
||
| response = httpx.post( | ||
| f"{self.base_url}/embeddings", | ||
| headers={"Authorization": f"Bearer {self.api_key}"}, | ||
| json={"model": self.model, "input": query}, | ||
| timeout=self.timeout_seconds, | ||
| ) | ||
| response.raise_for_status() | ||
| payload: dict[str, Any] = response.json() | ||
| data = payload.get("data") | ||
| if not isinstance(data, list) or not data or not isinstance(data[0], dict): | ||
| raise RuntimeError("Embedding response did not include data.") | ||
|
|
||
| embedding = data[0].get("embedding") | ||
| if not isinstance(embedding, list): | ||
| raise RuntimeError("Embedding response did not include an embedding vector.") | ||
| return [float(value) for value in embedding] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,53 @@ | ||
| from typing import Any | ||
| from typing import Any, Protocol | ||
|
|
||
| from app.clients.embedding_client import EmbeddingClient | ||
| from app.clients.supabase_client import SupabaseVectorClient | ||
|
|
||
|
|
||
| class EmbeddingClientProtocol(Protocol): | ||
| def embed_query(self, query: str) -> list[float]: | ||
| ... | ||
|
|
||
|
|
||
| class VectorClientProtocol(Protocol): | ||
| def similarity_search_legal_documents( | ||
| self, | ||
| query_embedding: list[float], | ||
| top_k: int, | ||
| ) -> list[dict[str, Any]]: | ||
| ... | ||
|
|
||
|
|
||
| class LegalRetriever: | ||
| def __init__(self) -> None: | ||
| self.vector_client = SupabaseVectorClient() | ||
| def __init__( | ||
| self, | ||
| embedding_client: EmbeddingClientProtocol | None = None, | ||
| vector_client: VectorClientProtocol | None = None, | ||
| max_top_k: int = 5, | ||
| ) -> None: | ||
| self.embedding_client = embedding_client or EmbeddingClient() | ||
| self.vector_client = vector_client or SupabaseVectorClient() | ||
| self.max_top_k = max_top_k | ||
|
|
||
| def retrieve(self, query: str, top_k: int = 3) -> list[dict[str, Any]]: | ||
| return self.vector_client.similarity_search_legal_documents(query=query, top_k=top_k) | ||
| normalized_query = query.strip() | ||
| if not normalized_query: | ||
| return [] | ||
|
|
||
| safe_top_k = min(max(top_k, 1), self.max_top_k) | ||
| query_embedding = self.embedding_client.embed_query(normalized_query) | ||
| rows = self.vector_client.similarity_search_legal_documents( | ||
| query_embedding=query_embedding, | ||
| top_k=safe_top_k, | ||
| ) | ||
| return [normalize_legal_card(row) for row in rows] | ||
|
|
||
|
|
||
| def normalize_legal_card(row: dict[str, Any]) -> dict[str, Any]: | ||
| return { | ||
| "lawName": row.get("lawName") or row["law_name"], | ||
| "articleNo": row.get("articleNo") or row["article_no"], | ||
| "title": row.get("title") or row["article_title"], | ||
| "content": row["content"], | ||
| "score": float(row["score"]), | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import pytest | ||
|
|
||
| from app.graph.nodes import legal_rag as legal_rag_module | ||
| from app.graph.state import Intent | ||
| from app.rag.retriever import LegalRetriever | ||
|
|
||
|
|
||
| class FakeEmbeddingClient: | ||
| def __init__(self) -> None: | ||
| self.queries: list[str] = [] | ||
|
|
||
| def embed_query(self, query: str) -> list[float]: | ||
| self.queries.append(query) | ||
| return [0.1, 0.2, 0.3] | ||
|
|
||
|
|
||
| class FakeVectorClient: | ||
| def __init__(self, rows): | ||
| self.rows = rows | ||
| self.calls: list[dict] = [] | ||
|
|
||
| def similarity_search_legal_documents( | ||
| self, | ||
| query_embedding: list[float], | ||
| top_k: int, | ||
| ) -> list[dict]: | ||
| self.calls.append({"query_embedding": query_embedding, "top_k": top_k}) | ||
| return self.rows[:top_k] | ||
|
|
||
|
|
||
| def test_legal_retriever_normalizes_pgvector_rows() -> None: | ||
| embedding_client = FakeEmbeddingClient() | ||
| vector_client = FakeVectorClient( | ||
| [ | ||
| { | ||
| "law_name": "주택임대차보호법", | ||
| "article_no": "제3조의2", | ||
| "article_title": "보증금의 회수", | ||
| "content": "임차인은 보증금을 우선변제받을 권리가 있다.", | ||
| "score": 0.91, | ||
| } | ||
| ] | ||
| ) | ||
| retriever = LegalRetriever( | ||
| embedding_client=embedding_client, | ||
| vector_client=vector_client, | ||
| ) | ||
|
|
||
| cards = retriever.retrieve("보증금은 어떻게 돌려받나요?", top_k=3) | ||
|
|
||
| assert embedding_client.queries == ["보증금은 어떻게 돌려받나요?"] | ||
| assert vector_client.calls == [{"query_embedding": [0.1, 0.2, 0.3], "top_k": 3}] | ||
| assert cards == [ | ||
| { | ||
| "lawName": "주택임대차보호법", | ||
| "articleNo": "제3조의2", | ||
| "title": "보증금의 회수", | ||
| "content": "임차인은 보증금을 우선변제받을 권리가 있다.", | ||
| "score": 0.91, | ||
| } | ||
| ] | ||
|
|
||
|
|
||
| def test_legal_retriever_returns_empty_for_blank_query_without_external_calls() -> None: | ||
| embedding_client = FakeEmbeddingClient() | ||
| vector_client = FakeVectorClient([]) | ||
| retriever = LegalRetriever( | ||
| embedding_client=embedding_client, | ||
| vector_client=vector_client, | ||
| ) | ||
|
|
||
| assert retriever.retrieve(" ") == [] | ||
| assert embedding_client.queries == [] | ||
| assert vector_client.calls == [] | ||
|
|
||
|
|
||
| def test_legal_retriever_clamps_top_k() -> None: | ||
| embedding_client = FakeEmbeddingClient() | ||
| vector_client = FakeVectorClient([]) | ||
| retriever = LegalRetriever( | ||
| embedding_client=embedding_client, | ||
| vector_client=vector_client, | ||
| max_top_k=5, | ||
| ) | ||
|
|
||
| retriever.retrieve("대항력", top_k=99) | ||
|
|
||
| assert vector_client.calls[0]["top_k"] == 5 | ||
|
|
||
|
|
||
| def test_legal_rag_node_records_tool_metadata(monkeypatch: pytest.MonkeyPatch) -> None: | ||
| class FakeRetriever: | ||
| def retrieve(self, query: str, top_k: int = 3) -> list[dict]: | ||
| return [ | ||
| { | ||
| "lawName": "주택임대차보호법", | ||
| "articleNo": "제3조", | ||
| "title": "대항력", | ||
| "content": "임차인은 대항력을 취득한다.", | ||
| "score": 0.88, | ||
| } | ||
| ] | ||
|
|
||
| monkeypatch.setattr(legal_rag_module, "LegalRetriever", FakeRetriever) | ||
|
|
||
| result = legal_rag_module.legal_rag( | ||
| { | ||
| "user_id": "user-1", | ||
| "session_id": None, | ||
| "message": "대항력이 뭐예요?", | ||
| "context": {}, | ||
| "intent": Intent.LEGAL_CONSULT, | ||
| } | ||
| ) | ||
|
|
||
| assert len(result["legal_cards"]) == 1 | ||
| assert result["tool_results"]["legalRag"] == { | ||
| "topK": 1, | ||
| "source": "supabase-pgvector", | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| # Phase 3: Legal RAG Retriever | ||
|
|
||
| ## Goal | ||
| Replace the legal RAG stub with a testable backend-ai retriever boundary that prepares query embeddings, performs pgvector similarity search, and returns normalized legal cards. This phase keeps the implementation offline-testable by injecting fake embedding and vector clients in tests. | ||
|
|
||
| ## Files | ||
| - `backend-ai/tests/test_legal_retriever.py` - tests for legal retriever normalization, empty queries, and pgvector client delegation | ||
| - `backend-ai/app/rag/retriever.py` - legal RAG retrieval service with dependency injection | ||
| - `backend-ai/app/clients/supabase_client.py` - pgvector SQL client boundary for legal document chunks | ||
| - `backend-ai/app/clients/embedding_client.py` - embedding client boundary driven by environment configuration | ||
| - `backend-ai/app/graph/nodes/legal_rag.py` - legal_rag node using the retriever boundary | ||
|
|
||
| ## Done When | ||
| - [ ] Legal retriever tests pass without network or database access | ||
| - [ ] Stubbed legal cards are removed from production client code | ||
| - [ ] Retriever validates blank queries and clamps top_k to a safe range | ||
| - [ ] Supabase pgvector search maps rows to legal card fields expected by the chat contract | ||
| - [ ] `legal_rag` node records tool metadata without Spring or frontend direct pgvector access | ||
|
|
||
| ## Architecture Rules | ||
| - pgvector similarity search belongs only in FastAPI `backend-ai`. | ||
| - Spring Boot must not call LLMs or pgvector directly. | ||
| - API keys and database URLs must be read from environment settings only. | ||
| - F-3 MVP legal RAG is limited to official legal document chunks; news RAG and registry AI are out of scope. | ||
|
|
||
| ## Implementation Instructions | ||
| Write tests first. Keep the retriever injectable so tests can use fake embedding and vector clients. Do not require live Supabase or OpenAI calls in unit tests. Production code may define the SQL and client boundary, but it must not hardcode secrets or fallback to fake legal content. |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.