diff --git a/README.md b/README.md index be7326e..dc53629 100644 --- a/README.md +++ b/README.md @@ -137,13 +137,19 @@ lang2sql query "질문" --vectordb-type faiss --vectordb-location ./dev/table_in lang2sql query "질문" --vectordb-type pgvector --vectordb-location "postgresql://user:pass@host:5432/db" ``` -참고: DataHub 없이도 미리 준비된 VectorDB(FAISS 디렉토리 혹은 pgvector 컬렉션)를 바로 사용할 수 있습니다. 자세한 준비 방법은 [DataHub 없이 시작하기](docs/tutorials/getting-started-without-datahub.md)를 참고하세요. +참고: DataHub 없이도 미리 준비된 VectorDB(FAISS 파일 혹은 pgvector 컬렉션)를 바로 사용할 수 있습니다. 자세한 준비 방법은 [벡터 검색 튜토리얼](docs/tutorials/03-vector-search.md)을 참고하세요. -### 처음 시작하기 (DataHub 없이) +### 처음 시작하기 -튜토리얼 본문이 길어져 별도 문서로 분리되었습니다. 아래 문서를 참고하세요. +튜토리얼은 난이도 순서로 구성되어 있습니다. -- [DataHub 없이 시작하기 튜토리얼](docs/tutorials/getting-started-without-datahub.md) +| 번호 | 문서 | 내용 | +|------|------|------| +| 01 | [빠른 시작](docs/tutorials/01-quickstart.md) | 5분 안에 NL2SQL 실행 | +| 02 | [Baseline 파이프라인](docs/tutorials/02-baseline.md) | 실제 DB 연결, DB Explorer | +| 03 | [벡터 검색](docs/tutorials/03-vector-search.md) | FAISS/pgvector 인덱싱 | +| 04 | [하이브리드 검색](docs/tutorials/04-hybrid.md) | BM25 + Vector, EnrichedNL2SQL | +| 05 | [고급](docs/tutorials/05-advanced.md) | 수동 조합, 커스텀 어댑터, Hook | ### 자연어 쿼리 실행 diff --git a/docs/BaseComponent_ko.md b/docs/BaseComponent_ko.md index 657a79c..534be30 100644 --- a/docs/BaseComponent_ko.md +++ b/docs/BaseComponent_ko.md @@ -2,25 +2,25 @@ `BaseComponent`는 **define-by-run(순수 파이썬 제어)** 철학을 유지하면서도, 컴포넌트 실행을 **관측 가능(observable)** 하게 만들기 위한 **선택적(opt-in) 표준 레이어**입니다. -* 파이프라인은 `step(run: RunContext) -> RunContext` 형태의 **그냥 함수/콜러블**만으로도 충분히 동작합니다. +* 파이프라인은 그냥 함수/콜러블만으로도 충분히 동작합니다. * `BaseComponent`는 그 위에 **추적(hooks), 에러 표준화, 이름/형식 통일**을 얹어주는 역할을 합니다. -즉, **필수는 아니지만**, 라이브러리/팀 단위 개발에서 “운영 가능한 형태”로 만들고 싶을 때 유용합니다. +즉, **필수는 아니지만**, 라이브러리/팀 단위 개발에서 "운영 가능한 형태"로 만들고 싶을 때 유용합니다. --- ## 왜 필요한가? -### 1) 관측성(Tracing)을 “그래프 엔진 없이” 얻기 위해 +### 1) 관측성(Tracing)을 "그래프 엔진 없이" 얻기 위해 -Lang2SQL은 LangGraph 같은 그래프 엔진을 강제하지 않습니다. 대신: +Lang2SQL은 그래프 엔진을 강제하지 않습니다. 대신: * 사용자는 Python `if/for/while`로 제어한다. * 라이브러리는 관측성은 **hook 이벤트**로 제공한다. `BaseComponent`는 각 컴포넌트 실행의 `start/end/error`를 이벤트로 남깁니다. -### 2) 에러를 “도메인 친화적으로” 정리하기 위해 +### 2) 에러를 "도메인 친화적으로" 정리하기 위해 현실에서는 `ValueError`, `KeyError`, 외부 라이브러리 예외 등이 섞여서 올라옵니다. @@ -29,9 +29,9 @@ Lang2SQL은 LangGraph 같은 그래프 엔진을 강제하지 않습니다. 대 * `Lang2SQLError`(ValidationError, IntegrationMissingError 등)는 **그대로 유지** * 그 외 예외는 `ComponentError`로 **표준 래핑**(+ 원인 예외를 `cause`로 보존) -→ 사용자/운영자 관점에서 “어디서 터졌는지”가 분명해집니다. +→ 사용자/운영자 관점에서 "어디서 터졌는지"가 분명해집니다. -### 3) “컴포넌트 단위 표준”을 만들기 위해 +### 3) "컴포넌트 단위 표준"을 만들기 위해 라이브러리 제공 컴포넌트를 모두 BaseComponent 기반으로 만들면: @@ -41,21 +41,6 @@ Lang2SQL은 LangGraph 같은 그래프 엔진을 강제하지 않습니다. 대 --- -## 철학: Define-by-run + Minimal core - -Lang2SQL의 기본 철학은 아래 2개입니다. - -1. **제어는 파이썬으로** - 루프/분기/재시도/서브플로우 호출은 “프레임워크 DSL”이 아니라 Python으로 표현합니다. - -2. **상태는 RunContext 하나로** - 파이프라인이 커져도, step 간 연결이 깨지지 않도록 `RunContext`를 I/O로 둡니다. - -`BaseComponent`는 이 철학을 해치지 않습니다. -컴포넌트의 실행을 감싸서 이벤트만 남길 뿐, 그래프/스키마/실행 모델을 강제하지 않습니다. - ---- - ## BaseComponent가 제공하는 API ### 생성자 @@ -67,21 +52,27 @@ BaseComponent(name: str | None = None, hook: TraceHook | None = None) * `name`: 이벤트에 찍힐 컴포넌트 이름 (기본값: 클래스명) * `hook`: 이벤트 수신자. 기본값은 `NullHook()` (아무것도 하지 않음) -### 구현해야 하는 것: `run()` +### 구현해야 하는 것: `_run()` + +서브클래스는 `_run()`을 구현합니다. 인자 타입과 반환 타입은 각 컴포넌트에 맞게 자유롭게 정의합니다. ```python -class MyComp(BaseComponent): - def run(self, run: RunContext) -> RunContext: - ... - return run +class MyRetriever(BaseComponent): + def __init__(self, catalog: list, **kwargs): + super().__init__(**kwargs) + self._catalog = catalog + + def _run(self, query: str) -> list[dict]: + # 비즈니스 로직 + return [t for t in self._catalog if query in t["description"]] ``` -### 실행: `__call__` +### 호출: `run()` / `__call__` -`comp(run)`을 호출하면 내부적으로 아래를 자동 수행합니다. +`comp.run(query)` 또는 `comp(query)`를 호출하면 내부적으로 아래를 자동 수행합니다. * `component.run start 이벤트 발행` -* `self.run(...)` 실행 +* `self._run(...)` 실행 * 성공 시 `end 이벤트` + `duration_ms` * 실패 시 `error 이벤트` @@ -90,34 +81,45 @@ class MyComp(BaseComponent): --- -## 권장 규약: RunContext in → RunContext out +## 타입 인자 패턴 -Lang2SQL의 기본 step 규약은 단순합니다. +Lang2SQL의 컴포넌트는 **명시적 타입 인자**를 받고, **명시적 타입 결과**를 반환합니다. -> **RunContext를 받으면 RunContext를 반환한다.** -> (`return run`을 습관처럼) - -왜냐하면 “None 반환”은 인간이 보기엔 자연스럽지만, 팀/사용자 관점에서는 실수를 만들기 쉽습니다. +```python +# 라이브러리 내장 컴포넌트 시그니처 예시 +KeywordRetriever._run(query: str) -> list[CatalogEntry] +SQLGenerator._run(query: str, schemas: list[CatalogEntry], context: str = "") -> str +SQLExecutor._run(sql: str) -> list[dict] +``` -* `return None`은 “의도적”인지 “실수(반환 누락)”인지 구분이 안 됨 -* Flow/컴포넌트 조합에서 결과가 조용히 깨지기 쉬움 +### 구성(config)은 `__init__`에, 요청별 데이터는 `_run()` 인자에 -그래서 Lang2SQL은 **fail-fast** 스타일을 권장합니다. +```python +class SQLGenerator(BaseComponent): + def __init__(self, llm: LLMPort, db_dialect: str = "default", **kwargs): + super().__init__(**kwargs) + self._llm = llm # 고정 설정 + self._dialect = db_dialect + + def _run(self, query: str, schemas: list[CatalogEntry], context: str = "") -> str: + # 요청마다 달라지는 값은 _run() 인자로 받는다 + ... +``` --- ## 언제 BaseComponent를 쓰는가? -### ✅ BaseComponent를 쓰는 게 좋은 경우 +### BaseComponent를 쓰는 게 좋은 경우 -* 라이브러리 기본 제공 컴포넌트( retriever/builder/generator/validator ) +* 라이브러리 기본 제공 컴포넌트(retriever/generator/executor) * 팀/제품 환경에서 **관측성(트레이싱)이 필요한 경우** * 예외 표준화가 중요한 경우(운영/테스트/디버깅) -### ✅ BaseComponent 없이 함수로 두는 게 좋은 경우 +### BaseComponent 없이 함수로 두는 게 좋은 경우 * `policy`, `eval`, metric 계산처럼 **순수 함수 성격**이 강한 로직 -* “유저가 빠르게 붙여 넣어 쓰는” 초경량 커스텀 로직 +* "유저가 빠르게 붙여 넣어 쓰는" 초경량 커스텀 로직 * 실행 단위가 너무 작아 이벤트가 과도해지는 경우 즉, **핵심 파이프라인 축**은 BaseComponent로 잡고, @@ -125,55 +127,38 @@ Lang2SQL의 기본 step 규약은 단순합니다. --- -## FunctionalComponent: “함수도 트레이싱하고 싶다” +## 커스텀 컴포넌트 예시 -유저에게 “클래스 상속 + run 메서드 작성”이 부담인 경우가 많습니다. -그래서 **함수/콜러블을 그대로 유지하면서**도 트레이싱을 얻고 싶다면 래퍼를 제공합니다. +```python +from lang2sql.core.base import BaseComponent -### 예시: FunctionalComponent +class UpperCaseSQL(BaseComponent): + """SQL을 대문자로 변환하는 후처리 컴포넌트.""" + def _run(self, sql: str) -> str: + return sql.upper() -```python -from __future__ import annotations -from typing import Callable, Any, Optional - -from .base import BaseComponent -from .context import RunContext - -class FunctionalComponent(BaseComponent): - """ - Wrap a callable(run: RunContext) -> RunContext into a BaseComponent, - so it becomes traceable and error-normalized. - """ - - def __init__( - self, - fn: Callable[[RunContext], RunContext], - *, - name: str | None = None, - hook=None, - ) -> None: - super().__init__(name=name or getattr(fn, "__name__", "FunctionalComponent"), hook=hook) - self._fn = fn - - def run(self, run: RunContext) -> RunContext: - return self._fn(run) +upper = UpperCaseSQL() +print(upper.run("select 1")) # SELECT 1 ``` -### 사용 예 +hook을 주입하면 실행 추적도 자동으로 됩니다: ```python -def my_retriever(run: RunContext) -> RunContext: - run.schema_selected = ... - return run +from lang2sql import MemoryHook -retriever = FunctionalComponent(my_retriever, name="MyRetriever", hook=hook) -``` +hook = MemoryHook() +upper = UpperCaseSQL(hook=hook) +upper.run("select 1") -> 이 방식의 장점: 유저는 “함수 스타일” 그대로 유지하면서, 운영/디버깅을 위한 트레이싱을 얻게 됩니다. +for e in hook.snapshot(): + print(e.component, e.phase, e.duration_ms) +# UpperCaseSQL start 0.0 +# UpperCaseSQL end 0.1 +``` --- -## 훅(Tracing) 시스템이 뭐고, 왜 필요한가? +## 훅(Tracing) 시스템 ### Hook이란? @@ -182,22 +167,18 @@ retriever = FunctionalComponent(my_retriever, name="MyRetriever", hook=hook) * `start/end/error` 시점 기록 * 소요 시간(duration_ms) * 입력/출력 요약(input_summary/output_summary) -* 필요하면 `data`에 구조화된 값을 추가 ### 어디서 확인하나? 가장 쉬운 건 `MemoryHook`입니다. ```python -from lang2sql.core.hooks import MemoryHook -from lang2sql.flows.baseline import SequentialFlow +from lang2sql import MemoryHook, HybridNL2SQL hook = MemoryHook() +pipeline = HybridNL2SQL(catalog=catalog, llm=llm, db=db, embedding=embedding, hook=hook) +pipeline.run("지난달 매출") -flow = SequentialFlow(steps=[...], hook=hook) # 또는 컴포넌트마다 hook 주입 -out = flow.run("지난달 매출") - -# 이벤트 확인 for e in hook.snapshot(): print(e.phase, e.component, e.duration_ms, e.error) ``` @@ -210,13 +191,13 @@ for e in hook.snapshot(): * APM/Tracing으로 보내는 Hook (OpenTelemetry span 등) * 필터링 Hook (특정 컴포넌트만 샘플링) -핵심은: **관측성은 hook 구현체에서 제어**하고, 파이프라인/컴포넌트 코드는 최대한 “비즈니스 로직”만 갖도록 분리합니다. +핵심은: **관측성은 hook 구현체에서 제어**하고, 파이프라인/컴포넌트 코드는 최대한 "비즈니스 로직"만 갖도록 분리합니다. --- ## 중첩(서브플로우/래핑)하면 트레이싱이 깨지나? -“깨진다”기보다는 **이벤트가 더 많이 찍힙니다.** +"깨진다"기보다는 **이벤트가 더 많이 찍힙니다.** * `flow_b` 안에 `flow_a`를 step으로 넣으면 @@ -235,49 +216,33 @@ for e in hook.snapshot(): ## 베스트 프랙티스 -### 1) 구성(config)은 `__init__`에, 요청별 상태는 `RunContext`에 +### 1) 구성(config)은 `__init__`에, 요청별 데이터는 `_run()` 인자에 -```python -class Retriever(BaseComponent): - def __init__(self, catalog, top_k=8, ...): - self.catalog = catalog # 고정 설정 - self.top_k = top_k +고정 설정(모델, 카탈로그, DB 연결 등)은 생성자에서 받고, +요청마다 달라지는 값(쿼리, 스키마 목록 등)은 `_run()` 인자로 전달합니다. - def run(self, run: RunContext) -> RunContext: - # 요청마다 달라지는 값은 run에서 읽고 run에 쓴다 - ... - return run -``` +### 2) `_run()`의 반환값은 명시적으로 -### 2) RunContext가 들어오면 무조건 `return run` +반환 타입을 명확히 정의하면 Flow에서 컴포넌트를 조합할 때 안전합니다. -* 가독성(계약이 분명) -* 실수 방지(fail-fast) -* flow 합성 시 안정 - -### 3) “작은 로직(policy/eval)은 그냥 함수” +### 3) "작은 로직(policy/eval)은 그냥 함수" * BaseComponent로 감싸는 건 선택 -* 운영에서 꼭 추적이 필요할 때만 FunctionalComponent로 감싼다 +* 운영에서 꼭 추적이 필요할 때만 감싼다 --- ## FAQ -### Q. “그냥 함수만 써도 되는데 왜 굳이 BaseComponent?” +### Q. "그냥 함수만 써도 되는데 왜 굳이 BaseComponent?" A. **운영/디버깅/협업에서** 차이가 큽니다. -문제 났을 때 “어디서, 어떤 입력으로, 얼마나 걸리다, 어떤 에러로” 터졌는지 자동으로 남는 게 핵심 가치입니다. +문제 났을 때 "어디서, 어떤 입력으로, 얼마나 걸리다, 어떤 에러로" 터졌는지 자동으로 남는 게 핵심 가치입니다. -### Q. “BaseComponent를 유저가 직접 써야 하나?” +### Q. "BaseComponent를 유저가 직접 써야 하나?" A. 필수 아닙니다. -초급 유저는 **SequentialFlow + 프리셋 컴포넌트**만으로 충분히 쓰게 하고, +초급 유저는 **프리셋 Flow + 프리셋 컴포넌트**만으로 충분히 쓰게 하고, 고급/운영 유저에게 BaseComponent/Hook을 제공하는 구성이 가장 자연스럽습니다. -### Q. “policy는 RunContext를 몰라도 되는데?” - -A. 맞습니다. `policy(metrics) -> action` 같은 건 순수 함수로 두는 걸 권장합니다. -필요하면 `FunctionalComponent(policy_fn)`처럼 감싸서 추적만 추가할 수 있습니다. - --- diff --git a/docs/BaseFlow_ko.md b/docs/BaseFlow_ko.md index 5e2f0e0..9ed9120 100644 --- a/docs/BaseFlow_ko.md +++ b/docs/BaseFlow_ko.md @@ -3,7 +3,7 @@ `BaseFlow`는 Lang2SQL에서 **define-by-run(순수 파이썬 제어)** 철학을 구현하기 위한 "플로우의 최소 추상화(minimal abstraction)"입니다. * 파이프라인의 **제어권(control-flow)** 을 프레임워크 DSL이 아니라 **사용자 코드(Python)** 가 갖습니다. -* LangGraph 같은 그래프 엔진을 강제하지 않습니다. +* 그래프 엔진을 강제하지 않습니다. * 대신, 실행 단위를 `Flow`로 묶고 **관측성(hooks)** 과 **에러 규약**을 통일합니다. --- @@ -36,50 +36,50 @@ Text2SQL은 현실적으로 다음 제어가 자주 필요합니다. ## BaseFlow가 제공하는 API -### 1) 구현해야 하는 것: `run()` +### 1) 구현해야 하는 것: `_run()` ```python class MyFlow(BaseFlow): - def run(self, value): + def _run(self, query: str) -> list[dict]: ... return result ``` * Flow의 본체 로직은 여기에 작성합니다. * 제어는 Python으로 직접 작성합니다. (`if/for/while`) -* 입출력 타입은 자유롭게 정의합니다. 공유 상태 백(RunContext)을 강제하지 않습니다. +* 입출력 타입은 자유롭게 정의합니다. -### 2) 호출: `__call__` +### 2) 호출: `run()` / `__call__` ```python -out = flow(value) +out = flow.run("지난달 매출") +# 또는 +out = flow("지난달 매출") ``` -* 내부적으로 `flow.run(...)`을 호출합니다. +* 내부적으로 `_run(...)`을 호출합니다. * hook 이벤트를 `start/end/error`로 기록합니다. --- ## 사용 패턴 -### 1) 초급: SequentialFlow로 구성하고 run으로 실행 +### 1) 초급: 프리셋 Flow로 바로 실행 초급 사용자는 보통 "구성만 하고 실행"하면 됩니다. ```python -flow = SequentialFlow(steps=[retriever, builder, generator, validator]) -result = flow.run("지난달 매출") +pipeline = BaselineNL2SQL(catalog=catalog, llm=llm, db=db) +rows = pipeline.run("지난달 매출") ``` -각 step은 이전 step의 출력을 입력으로 받습니다. - ### 2) 고급: CustomFlow로 제어(while/if/policy) 정책/루프/재시도 같은 제어가 들어오면 `BaseFlow`를 직접 상속해 작성하는 것이 가장 깔끔합니다. ```python class RetryFlow(BaseFlow): - def run(self, query: str) -> str: + def _run(self, query: str) -> str: for _ in range(3): schemas = retriever(query) sql = generator(query, schemas) @@ -88,10 +88,6 @@ class RetryFlow(BaseFlow): return sql ``` -### 3) Sequential을 유지하면서 동적 파라미터가 필요하면 closure/partial - -이건 "필수"가 아니라, **steps 배열을 유지하고 싶은 사람을 위한 옵션**입니다. - --- ## Hook(Tracing)은 어디서 확인하나? @@ -99,14 +95,14 @@ class RetryFlow(BaseFlow): Flow도 hook을 받을 수 있습니다. ```python -from lang2sql.core.hooks import MemoryHook +from lang2sql import MemoryHook, BaselineNL2SQL hook = MemoryHook() -flow = SequentialFlow(steps=[...], hook=hook) +pipeline = BaselineNL2SQL(catalog=catalog, llm=llm, db=db, hook=hook) -result = flow.run("지난달 매출") +rows = pipeline.run("지난달 매출") -for e in hook.events: +for e in hook.snapshot(): print(e.name, e.phase, e.component, e.duration_ms, e.error) ``` @@ -122,8 +118,8 @@ for e in hook.events: 일반적으로: -* **Flow는 여러 Component를 호출**하거나, -* **SequentialFlow는 Component/함수를 steps로 받아 순차 실행**합니다. +* **Flow는 여러 Component를 호출**합니다. +* **전용 Flow(BaselineNL2SQL 등)는 Component 간 와이어링을 내부에서 처리**합니다. 즉, **Flow가 상위 레벨 오케스트레이션**, Component가 **재사용 가능한 부품**입니다. @@ -150,13 +146,13 @@ TypeError 또는 잘못된 결과 ### 문제 2: 다중 인자 컴포넌트와 호환 불가 -`SQLGenerator.run(query, schemas)`처럼 2개 이상의 인자를 받는 컴포넌트는 +`SQLGenerator._run(query, schemas)`처럼 2개 이상의 인자를 받는 컴포넌트는 `SequentialFlow`의 단일 값 전달로 연결할 수 없습니다. ```python # ❌ 동작하지 않음 — generator는 (query, schemas) 2개 인자가 필요 flow = SequentialFlow(steps=[retriever, generator, executor]) -flow.run("주문 내역") # TypeError: run() missing 1 required positional argument: 'schemas' +flow.run("주문 내역") # TypeError: _run() missing 1 required positional argument: 'schemas' ``` ### 해결 방법 @@ -165,13 +161,13 @@ NL2SQL 파이프라인은 `SequentialFlow` 대신 **전용 Flow**를 사용하 전용 Flow는 내부에서 다중 인자 와이어링을 올바르게 처리합니다. ```python -# ✅ KeywordRetriever 기반 +# KeywordRetriever 기반 pipeline = BaselineNL2SQL(catalog=catalog, llm=llm, db=db) -# ✅ Keyword + Vector 기반 +# Keyword + Vector 기반 pipeline = HybridNL2SQL(catalog=catalog, llm=llm, db=db, embedding=embedding) -# ✅ Gate + 프로파일링 + 보강 포함 풀 파이프라인 +# Gate + 프로파일링 + 보강 포함 풀 파이프라인 pipeline = EnrichedNL2SQL(catalog=catalog, llm=llm, db=db, embedding=embedding) rows = pipeline.run("주문 내역") @@ -187,10 +183,9 @@ rows = pipeline.run("주문 내역") A. Flow라는 개념은 사실상 필요하지만, **모든 사용자가 BaseFlow를 직접 상속할 필요는 없습니다.** -* 초급: `SequentialFlow`만 사용 +* 초급: 프리셋 Flow(`BaselineNL2SQL`, `HybridNL2SQL`, `EnrichedNL2SQL`)만 사용 * 고급: `BaseFlow`를 상속해서 제어를 직접 작성 ### Q. Flow의 반환 타입은? -A. `run()`의 입출력 타입은 자유롭습니다. 컴포넌트끼리 합의한 타입을 그대로 사용하면 됩니다. -`SequentialFlow`는 각 step의 출력을 다음 step의 입력으로 전달하는 파이프입니다. +A. `_run()`의 입출력 타입은 자유롭습니다. 컴포넌트끼리 합의한 타입을 그대로 사용하면 됩니다. diff --git a/docs/Core_concept_ko.md b/docs/Core_concept_ko.md index 676dba7..13b9f00 100644 --- a/docs/Core_concept_ko.md +++ b/docs/Core_concept_ko.md @@ -1,7 +1,7 @@ # Core Concepts -Lang2SQL은 “그래프 엔진/DSL”을 강제하지 않고, **순수 Python 코드로 파이프라인을 제어**하는 define-by-run 철학을 따릅니다. -대신, 파이프라인이 커져도 연결이 무너지지 않도록 **`RunContext`라는 최소 상태 컨테이너**를 중심으로 설계합니다. +Lang2SQL은 "그래프 엔진/DSL"을 강제하지 않고, **순수 Python 코드로 파이프라인을 제어**하는 define-by-run 철학을 따릅니다. +각 컴포넌트는 **명시적 타입 인자**를 받고, 명시적 타입 결과를 반환합니다. --- @@ -17,121 +17,83 @@ Lang2SQL에서 파이프라인 제어는 프레임워크가 아니라 **사용 예시: ```python -def ret(run): ... -def ret_val(run): ... -def policy(metrics): ... -def gen(run): ... - -run = RunContext("q") +retriever = KeywordRetriever(catalog=catalog) +generator = SQLGenerator(llm=llm, db_dialect="sqlite") while True: - run = ret(run) - metrics = ret_val(run) # ✅ run 몰라도 되는 순수 함수 가능 - action = policy(metrics) # ✅ run 몰라도 되는 순수 함수 가능 - if action == "retry": - continue - break - -run = gen(run) + schemas = retriever.run(query) + sql = generator.run(query, schemas) + if validator(sql): + break + +rows = executor.run(sql) ``` -**핵심:** Lang2SQL은 위 패턴을 “프레임워크 문법”으로 바꾸지 않습니다. -그냥 Python으로 쓰되, 파이프라인 간 상태 전달을 안정적으로 하기 위해 `RunContext`를 사용합니다. +**핵심:** Lang2SQL은 위 패턴을 "프레임워크 문법"으로 바꾸지 않습니다. +그냥 Python으로 쓰되, 각 컴포넌트의 입출력이 **타입으로 명확히 정의**되어 있어 안전하게 조합할 수 있습니다. --- -## 2) 왜 RunContext가 필요한가? +## 2) 타입 인자 패턴 Text2SQL 파이프라인은 현실적으로 단계가 늘어납니다. * retriever 1개가 아니라 10개, 100개가 될 수 있음 * 중간 산출물(선택된 테이블, 컨텍스트, 후보 SQL, 검증 결과, 점수/메트릭)이 늘어남 -* loop/branch가 들어가면서 “어떤 단계에서 무엇이 생성되었는지” 추적이 어려워짐 - -이 상황에서 단계마다 함수 시그니처를 계속 바꾸면: - -* `retriever(query, catalog) -> selected` -* `builder(query, selected) -> context` -* `generator(query, context) -> sql` -* `validator(sql) -> validation` - -처럼 보이지만, 실제로는 **중간에 필요한 값이 계속 추가**되어 시그니처가 폭발합니다. - -### RunContext는 “큰 그래프에서 연결 안정성”을 만든다 - -Lang2SQL은 각 step의 I/O를 **`RunContext -> RunContext`**로 고정합니다. - -* step이 늘어나도 “연결 방식”이 바뀌지 않음 -* 어떤 단계가 어떤 값을 추가해도, 다음 단계는 필요한 값을 `run`에서 읽으면 됨 -* loop/branch/서브플로우에서도 동일한 규약 유지 - -그래서 문서에서 아래처럼 “개념적 함수형”으로 설명하더라도: - -* retriever: (query, catalog) -> selected -* builder: (query, selected) -> context -* generator: (query, context) -> sql -* validator: (sql) -> validation - -실제 구현은 **RunContext 내부 필드의 Read/Write 규약**으로 통일됩니다. - -예: +* loop/branch가 들어가면서 "어떤 단계에서 무엇이 생성되었는지" 추적이 어려워짐 -* retriever: `run.query`, `run.schema_catalog` 읽고 → `run.schema_selected` 씀 -* builder: `run.query`, `run.schema_selected` 읽고 → `run.schema_context` 씀 -* generator: `run.query`, `run.schema_context` 읽고 → `run.sql` 씀 -* validator: `run.sql` 읽고 → `run.validation` 씀 +Lang2SQL은 각 컴포넌트의 `_run()` 메서드가 **명시적 타입 인자를 받고 타입 결과를 반환**하도록 설계합니다. -### “쿼리가 바뀌면?”도 제어 가능 - -`RunContext`는 mutable state carrier이므로, 루프 중간에 쿼리를 업데이트해도 됩니다. - -```python -run.query = rewritten_query -run = ret(run) # 업데이트된 query로 재검색 +``` +KeywordRetriever._run(query: str) -> list[CatalogEntry] +SQLGenerator._run(query: str, schemas: list[CatalogEntry], context: str) -> str +SQLExecutor._run(sql: str) -> list[dict] ``` ---- - -## 3) `run(runcontext)` vs `run_query(query)` +이 방식의 장점: -두 API의 관계는 단순합니다. +* 각 컴포넌트의 입출력이 코드에 명확히 드러남 +* IDE 자동완성과 타입 체크를 활용할 수 있음 +* 컴포넌트를 독립적으로 테스트하기 쉬움 -### `run(run: RunContext) -> RunContext` +### 컴포넌트 간 데이터 전달 -* **명시적 엔트리포인트** -* 고급 제어(루프/분기/정책)나 서브플로우 합성에서 자연스럽습니다. +컴포넌트 간 와이어링은 **전용 Flow가 내부에서 처리**합니다. ```python -run = RunContext(query="지난달 매출") -out = flow.run(run) +# BaselineNL2SQL._run() 내부 구현 +def _run(self, query: str) -> list[dict]: + schemas = self._retriever(query) # list[CatalogEntry] + sql = self._generator(query, schemas) # str + return self._executor(sql) # list[dict] ``` -### `run_query(query: str) -> RunContext` - -* **편의(sugar) 엔트리포인트** -* 초급/데모/퀵스타트에서 `RunContext`를 몰라도 실행 가능하게 합니다. -* 내부적으로는 보통 아래와 동치입니다: +사용자 관점에서는 Flow의 `run()` 하나만 호출하면 됩니다: ```python -out = flow.run(RunContext(query=query)) +rows = pipeline.run("지난달 매출") ``` -즉, +--- -```python -out1 = flow.run_query("지난달 매출") -out2 = flow.run(RunContext(query="지난달 매출")) -``` +## 3) 컴포넌트 vs 플로우 + +| | BaseComponent | BaseFlow | +|---|---|---| +| 역할 | 단일 작업 단위 (검색, 생성, 실행) | 여러 컴포넌트의 조합/제어 | +| 구현 | `_run()` 메서드 | `_run()` 메서드 | +| 관측성 | `component.run` 이벤트 | `flow.run` 이벤트 | +| 예시 | `KeywordRetriever`, `SQLGenerator` | `BaselineNL2SQL`, `HybridNL2SQL` | -은 **같은 기능**을 제공합니다. 차이는 **입력 형태(문자열 vs RunContext)** 뿐입니다. +둘 다 **`_run()`에 비즈니스 로직**을 작성하고, `run()` / `__call__()` 호출 시 자동으로 hook 이벤트를 발행합니다. --- ## 권장 규약 요약 * **제어는 Python으로 한다** (define-by-run) -* **상태 전달은 RunContext로 고정한다** (`RunContext -> RunContext`) -* `run_query()`는 **초급/데모용 편의 API**, `run()`은 **명시적/고급 제어용 API** -* policy/eval처럼 RunContext가 필요 없는 로직은 **순수 함수로 둬도 된다** (필요하면 run에서 읽거나 metadata로 남기는 건 선택) +* **컴포넌트의 입출력은 명시적 타입 인자로 정의한다** (`_run(query: str) -> list[CatalogEntry]`) +* **구성(config)은 `__init__`에, 요청별 데이터는 `_run()` 인자에** +* policy/eval처럼 관측성이 불필요한 로직은 **순수 함수로 둬도 된다** --- diff --git a/docs/Hook_and_exception_ko.md b/docs/Hook_and_exception_ko.md index b2c650a..ccc9c14 100644 --- a/docs/Hook_and_exception_ko.md +++ b/docs/Hook_and_exception_ko.md @@ -255,7 +255,7 @@ class ContractError(Lang2SQLError): ### 언제 발생? * Lang2SQL이 요구하는 호출/반환 계약을 위반했을 때 -* 예: `RunContext -> RunContext` 계약인데 `None` 또는 `int`를 반환 +* 예: `_run()`이 반드시 반환해야 하는 타입과 다른 값을 반환 이 에러는 “사용자 코드 버그를 빨리 발견(fail-fast)”하기 위한 타입입니다. diff --git a/docs/tutorials/01-quickstart.md b/docs/tutorials/01-quickstart.md new file mode 100644 index 0000000..d7cac98 --- /dev/null +++ b/docs/tutorials/01-quickstart.md @@ -0,0 +1,99 @@ +# 01. 빠른 시작 — 5분 안에 NL2SQL 실행하기 + +lang2sql v2의 기본 파이프라인을 실제 LLM과 DB로 바로 실행합니다. + +--- + +## 사전 준비 + +```bash +pip install lang2sql +export OPENAI_API_KEY="sk-..." +``` + +샘플 DB 생성: + +```bash +python scripts/setup_sample_db.py +``` + +완료되면 프로젝트 루트에 `sample.db`가 생성됩니다. + +--- + +## BaselineNL2SQL 실행 + +```python +from lang2sql import BaselineNL2SQL +from lang2sql.integrations.db import SQLAlchemyDB +from lang2sql.integrations.llm import OpenAILLM + +catalog = [ + { + "name": "orders", + "description": "고객 주문 정보", + "columns": { + "order_id": "주문 고유 ID", + "customer_id": "고객 ID", + "order_date": "주문 일시", + "amount": "주문 금액", + "status": "주문 상태", + }, + }, + { + "name": "customers", + "description": "고객 마스터", + "columns": { + "customer_id": "고객 ID", + "name": "고객 이름", + "grade": "고객 등급: bronze / silver / gold", + }, + }, +] + +pipeline = BaselineNL2SQL( + catalog=catalog, + llm=OpenAILLM(model="gpt-4o-mini"), + db=SQLAlchemyDB("sqlite:///sample.db"), + db_dialect="sqlite", +) + +rows = pipeline.run("지난달 주문 건수를 알려줘") +print(rows) +``` + +--- + +## 파이프라인 구조 + +``` +BaselineNL2SQL + ├── KeywordRetriever — catalog에서 관련 테이블 검색 (BM25) + ├── SQLGenerator — LLM으로 SQL 생성 + └── SQLExecutor — DB 실행 후 결과 반환 +``` + +`BaselineNL2SQL`은 키워드 기반 검색(`KeywordRetriever`)을 사용합니다. +벡터 검색이 필요하면 `HybridNL2SQL`을 사용하세요 (→ [04-hybrid.md](./04-hybrid.md)). + +--- + +## 지원 LLM + +`LLMPort`를 만족하는 구현체로 교체할 수 있습니다. + +```python +from lang2sql.integrations.llm import AnthropicLLM +llm = AnthropicLLM(model="claude-sonnet-4-6") + +from lang2sql.integrations.llm import OpenAILLM +llm = OpenAILLM(model="gpt-4o") +``` + +둘 다 `LLMPort.invoke(messages)` 계약을 따르므로 파이프라인 코드는 동일합니다. + +--- + +## 다음 단계 + +DB Explorer, 다중 테이블 카탈로그, 실제 운영 DB 연결 → [02-baseline.md](./02-baseline.md) diff --git a/docs/tutorials/02-baseline.md b/docs/tutorials/02-baseline.md new file mode 100644 index 0000000..9a85b9f --- /dev/null +++ b/docs/tutorials/02-baseline.md @@ -0,0 +1,300 @@ +# 02. Baseline 파이프라인 — 실제 DB 연결과 DB Explorer + +`BaselineNL2SQL` 상세 사용법과 DB를 탐색하는 `SQLAlchemyExplorer`를 다룹니다. + +--- + +## 사전 준비 + +```bash +export OPENAI_API_KEY="sk-..." +python scripts/setup_sample_db.py # sample.db 생성 +``` + +--- + +## 1) BaselineNL2SQL — 다중 테이블 + +```python +from lang2sql import BaselineNL2SQL +from lang2sql.integrations.db import SQLAlchemyDB +from lang2sql.integrations.llm import OpenAILLM + +catalog = [ + { + "name": "orders", + "description": "고객 주문 정보. 주문 건수·금액·날짜 조회에 사용.", + "columns": { + "order_id": "주문 고유 ID (PK)", + "customer_id": "주문한 고객 ID (FK → customers)", + "order_date": "주문 일시 (TIMESTAMP)", + "amount": "주문 금액 (DECIMAL)", + "status": "주문 상태: pending / confirmed / shipped / cancelled", + }, + }, + { + "name": "customers", + "description": "고객 마스터. 이름·등급·가입일 조회에 사용.", + "columns": { + "customer_id": "고객 고유 ID (PK)", + "name": "고객 이름", + "grade": "고객 등급: bronze / silver / gold", + "created_at": "가입 일시", + }, + }, +] + +pipeline = BaselineNL2SQL( + catalog=catalog, + llm=OpenAILLM(model="gpt-4o-mini"), + db=SQLAlchemyDB("sqlite:///sample.db"), + db_dialect="sqlite", +) + +rows = pipeline.run("지난달 gold 고객의 주문 건수") +print(rows) +``` + +### 지원 dialect + +`db_dialect` 에 전달 가능한 값: `"sqlite"`, `"postgresql"`, `"mysql"`, `"bigquery"`, `"duckdb"`, `"default"` + +--- + +## 2) DB Explorer — 처음 보는 DB 탐색 + +카탈로그를 미리 작성하지 않아도 DDL과 샘플 데이터를 바로 꺼낼 수 있습니다. +LLM에 넘길 스키마 정보를 빠르게 파악할 때 유용합니다. + +### 기본 사용 + +```python +from lang2sql import build_explorer_from_url + +exp = build_explorer_from_url("sqlite:///sample.db") + +# 1) 테이블 목록 +print(exp.list_tables()) +# ['customers', 'orders', ...] + +# 2) DDL — CREATE TABLE 원문 +print(exp.get_ddl("orders")) + +# 3) 샘플 데이터 (기본 5행) +print(exp.sample_data("orders")) + +# 4) 읽기 전용 커스텀 쿼리 +print(exp.execute_read_only("SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status")) +``` + +### 전체 테이블 루프 + +```python +from lang2sql import build_explorer_from_url + +exp = build_explorer_from_url("sqlite:///sample.db") + +for table in exp.list_tables(): + print(f"\n=== {table} ===") + print(exp.get_ddl(table)) + print("샘플:", exp.sample_data(table, limit=2)) +``` + +### PostgreSQL / MySQL + +URL만 바꾸면 됩니다. + +```python +from lang2sql import build_explorer_from_url + +# PostgreSQL (schema 범위 지정 가능) +exp = build_explorer_from_url( + "postgresql://user:password@localhost:5432/mydb", + schema="analytics", +) + +# MySQL +exp = build_explorer_from_url("mysql+pymysql://user:password@localhost:3306/mydb") +``` + +### 기존 SQLAlchemyDB engine 재사용 + +```python +from lang2sql.integrations.db import SQLAlchemyDB, SQLAlchemyExplorer + +db = SQLAlchemyDB("sqlite:///sample.db") +exp = SQLAlchemyExplorer.from_engine(db._engine) + +# 같은 연결 풀을 공유 +rows = db.execute("SELECT COUNT(*) AS cnt FROM orders") +ddl = exp.get_ddl("orders") +``` + +### 쓰기 구문 거부 + +```python +exp.execute_read_only("DROP TABLE orders") +# ValueError: Write operations not allowed: 'DROP TABLE orders' + +exp.execute_read_only("INSERT INTO orders VALUES (99, 1, 0, 'test')") +# ValueError: Write operations not allowed: ... +``` + +--- + +## 3) CSV 카탈로그 빠르게 구성하기 + +테이블이 많을 때 CSV로 카탈로그를 만드는 패턴입니다. + +```python +import csv +from collections import defaultdict +from lang2sql import BaselineNL2SQL +from lang2sql.integrations.db import SQLAlchemyDB +from lang2sql.integrations.llm import OpenAILLM + +# CSV 예시 (dev/table_catalog.csv) +# table_name,table_description,column_name,column_description +# orders,주문 정보 테이블,order_id,주문 ID +# orders,주문 정보 테이블,amount,결제 금액 + +tables: dict = defaultdict(lambda: {"desc": "", "columns": {}}) +with open("dev/table_catalog.csv", newline="", encoding="utf-8") as f: + for row in csv.DictReader(f): + t = row["table_name"].strip() + tables[t]["desc"] = row["table_description"].strip() + tables[t]["columns"][row["column_name"].strip()] = row["column_description"].strip() + +catalog = [ + {"name": t, "description": info["desc"], "columns": info["columns"]} + for t, info in tables.items() +] + +pipeline = BaselineNL2SQL( + catalog=catalog, + llm=OpenAILLM(model="gpt-4o-mini"), + db=SQLAlchemyDB("sqlite:///sample.db"), + db_dialect="sqlite", +) + +rows = pipeline.run("주문 건수를 알려줘") +print(rows) +``` + +--- + +## 4) CLI 사용법 + +Python 코드 대신 CLI로 실행할 수 있습니다. +CLI는 환경변수(`LLM_PROVIDER`, `DB_URL` 등)로 설정을 읽으므로 `.env`를 먼저 구성합니다. + +### .env 설정 (OpenAI 기준) + +```bash +LLM_PROVIDER=openai +OPENAI_API_KEY=sk-... +OPEN_AI_LLM_MODEL=gpt-4o +EMBEDDING_PROVIDER=openai +OPEN_AI_EMBEDDING_MODEL=text-embedding-3-large +DB_URL=sqlite:///sample.db +DB_TYPE=sqlite +``` + +> **주의**: 코드에서 OpenAI 키는 `OPEN_AI_KEY` 또는 `OPENAI_API_KEY` 둘 다 읽습니다. +> `.env.example`을 참고해 실제 사용 변수명을 확인하세요. + +### Streamlit UI 실행 + +```bash +lang2sql run-streamlit + +# 포트 지정 +lang2sql run-streamlit -p 8888 +``` + +### CLI 쿼리 실행 + +```bash +# baseline 플로우 (기본값) +lang2sql query "주문 건수를 집계해줘" --flow baseline --dialect sqlite + +# enriched 플로우 (BM25 + Vector + Gate) +lang2sql query "이번 달 순매출 합계" --flow enriched --dialect sqlite --top-n 5 + +# QuestionGate 비활성화 (enriched 전용) +lang2sql query "이번 달 순매출 합계" --flow enriched --no-gate +``` + +**지원 옵션:** + +| 옵션 | 기본값 | 설명 | +|------|--------|------| +| `--flow` | `baseline` | `baseline` 또는 `enriched` | +| `--dialect` | `None` | SQL 방언 (sqlite, postgresql, mysql 등) | +| `--top-n` | `5` | 검색할 최대 테이블 수 | +| `--no-gate` | `False` | QuestionGate 비활성화 (enriched 전용) | + +> **참고**: CLI의 `--flow hybrid`는 없습니다. 하이브리드 검색은 Python API(`HybridNL2SQL`)를 사용하세요. + +--- + +## 5) Factory 함수 — 환경변수 기반 인스턴스 생성 + +`.env`만 설정하면 코드 변경 없이 LLM·임베딩·DB를 교체할 수 있습니다. +CLI와 Streamlit UI가 내부적으로 사용하는 함수이며, 직접 호출해도 됩니다. + +```python +from lang2sql import build_llm_from_env, build_embedding_from_env, build_db_from_env + +llm = build_llm_from_env() # LLM_PROVIDER 환경변수 참조 +embedding = build_embedding_from_env() # EMBEDDING_PROVIDER 환경변수 참조 +db = build_db_from_env() # DB_TYPE, DB_URL 등 환경변수 참조 +``` + +### 지원 프로바이더 + +| 함수 | 환경변수 | 지원 값 | +|------|----------|---------| +| `build_llm_from_env()` | `LLM_PROVIDER` | `openai`, `anthropic`, `azure`, `gemini`, `bedrock`, `ollama`, `huggingface` | +| `build_embedding_from_env()` | `EMBEDDING_PROVIDER` | `openai`, `azure`, `ollama`, `bedrock`, `gemini`, `huggingface` | +| `build_db_from_env()` | `DB_TYPE` | `sqlite`, `postgresql`, `mysql`, `mariadb`, `duckdb`, `clickhouse`, `snowflake`, `oracle` | + +### .env 예시 (Anthropic + PostgreSQL) + +```bash +LLM_PROVIDER=anthropic +ANTHROPIC_API_KEY=sk-ant-... +ANTHROPIC_LLM_MODEL=claude-sonnet-4-6 + +EMBEDDING_PROVIDER=openai +OPEN_AI_KEY=sk-... +OPEN_AI_EMBEDDING_MODEL=text-embedding-3-large + +DB_TYPE=postgresql +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=mydb +DB_USER=user +DB_PASSWORD=pass +``` + +### 파이프라인에서 사용 + +```python +from lang2sql import BaselineNL2SQL, build_llm_from_env, build_db_from_env + +pipeline = BaselineNL2SQL( + catalog=catalog, + llm=build_llm_from_env(), + db=build_db_from_env(), + db_dialect="postgresql", +) +``` + +> **팁**: `build_explorer_from_url()`은 URL을 직접 받습니다 (환경변수 방식 아님). + +--- + +## 다음 단계 + +벡터 검색으로 검색 품질을 높이려면 → [03-vector-search.md](./03-vector-search.md) diff --git a/docs/tutorials/03-vector-search.md b/docs/tutorials/03-vector-search.md new file mode 100644 index 0000000..70df8be --- /dev/null +++ b/docs/tutorials/03-vector-search.md @@ -0,0 +1,299 @@ +# 03. 벡터 검색 — 인덱싱과 VectorStore + +카탈로그와 비즈니스 문서를 벡터로 인덱싱해 의미 기반 검색을 수행합니다. + +--- + +## 사전 준비 + +```bash +export OPENAI_API_KEY="sk-..." +python scripts/setup_sample_db.py +python scripts/setup_sample_docs.py # docs/business/ 샘플 문서 생성 +``` + +--- + +## 1) from_sources() — 원터치 인덱싱 + +청킹·임베딩·저장을 한 번에 처리합니다. + +```python +from lang2sql import VectorRetriever +from lang2sql.integrations.embedding import OpenAIEmbedding + +catalog = [ + { + "name": "orders", + "description": "고객 주문 정보. 주문 건수·금액·날짜 조회에 사용.", + "columns": { + "order_id": "주문 고유 ID", + "amount": "주문 금액", + "order_date": "주문 일시", + "status": "주문 상태", + }, + }, +] + +docs = [ + { + "id": "revenue_def", + "title": "매출 정의", + "content": "매출은 반품을 제외한 순매출이다. cancelled 상태 주문은 제외한다.", + "source": "docs/business/revenue.md", + }, +] + +retriever = VectorRetriever.from_sources( + catalog=catalog, + documents=docs, + embedding=OpenAIEmbedding(model="text-embedding-3-small"), + top_n=5, +) + +result = retriever.run("지난달 순매출 기준 집계") +print("schemas:", [s["name"] for s in result.schemas]) +print("context:", result.context) +``` + +`from_sources()` 내부 동작: +1. `CatalogChunker`로 catalog split +2. `RecursiveCharacterChunker`로 docs split +3. `from_chunks()`로 embed + store (기본: `InMemoryVectorStore`) + +--- + +## 2) from_chunks() — 명시적 파이프라인 + +split 단계를 직접 제어하거나 영속 store(FAISS, pgvector)를 쓸 때 사용합니다. + +```python +from lang2sql import CatalogChunker, DirectoryLoader, RecursiveCharacterChunker, VectorRetriever +from lang2sql.integrations.embedding import OpenAIEmbedding + +# 1) 문서 로딩 +docs = DirectoryLoader("docs/business").load() + +# 2) 명시적 split +catalog_chunks = CatalogChunker().split(catalog) +doc_chunks = RecursiveCharacterChunker(chunk_size=800, chunk_overlap=80).split(docs) + +# 3) embed + store +retriever = VectorRetriever.from_chunks( + catalog_chunks + doc_chunks, + embedding=OpenAIEmbedding(model="text-embedding-3-small"), + top_n=5, +) + +result = retriever.run("순매출 집계 기준") +print("schemas:", [s["name"] for s in result.schemas]) +print("context:", result.context) +``` + +증분 추가: + +```python +new_docs = DirectoryLoader("docs/new").load() +retriever.add(RecursiveCharacterChunker().split(new_docs)) # pre-split 필수 +``` + +--- + +## 3) 문서 로더 + +### MarkdownLoader / PlainTextLoader + +```python +from lang2sql import MarkdownLoader, PlainTextLoader + +md_docs = MarkdownLoader().load("docs/business/revenue.md") +txt_docs = PlainTextLoader().load("docs/business/rules.txt") +``` + +### DirectoryLoader (권장) + +```python +from lang2sql import DirectoryLoader + +# 기본: .md → MarkdownLoader, .txt → PlainTextLoader +docs = DirectoryLoader("docs/business").load() +print(f"로드된 문서 수: {len(docs)}") +``` + +### PDFLoader (opt-in) + +```bash +pip install pymupdf +``` + +```python +from lang2sql import DirectoryLoader, MarkdownLoader +from lang2sql.integrations.loaders import PDFLoader + +docs = DirectoryLoader( + "docs/", + loaders={ + ".md": MarkdownLoader(), + ".pdf": PDFLoader(), + }, +).load() +``` + +PDFLoader는 페이지 단위로 `TextDocument`를 생성합니다: +- `id`: `"{filename}__p{page_number}"` +- `title`: `"{filename} page {page_number}"` + +--- + +## 4) FAISSVectorStore — 로컬 파일 영속성 + +### 인덱싱 후 저장 + +```python +from lang2sql import CatalogChunker, VectorRetriever +from lang2sql.integrations.embedding import OpenAIEmbedding +from lang2sql.integrations.vectorstore import FAISSVectorStore + +store = FAISSVectorStore(index_path="./index/catalog.faiss") + +chunks = CatalogChunker().split(catalog) +retriever = VectorRetriever.from_chunks( + chunks, + embedding=OpenAIEmbedding(model="text-embedding-3-large"), + vectorstore=store, +) + +# 벡터 인덱스 + registry 파일로 저장 +retriever.save("./index/catalog") +# → ./index/catalog.faiss +# → ./index/catalog.faiss.meta +# → ./index/catalog.registry +``` + +### 재시작 시 로드 + +```python +from lang2sql import VectorRetriever +from lang2sql.integrations.embedding import OpenAIEmbedding +from lang2sql.integrations.vectorstore import FAISSVectorStore + +embedding = OpenAIEmbedding(model="text-embedding-3-large") +store = FAISSVectorStore.load("./index/catalog.faiss") + +retriever = VectorRetriever.load( + "./index/catalog", + vectorstore=store, + embedding=embedding, +) + +result = retriever.run("주문 건수 집계") +``` + +### CSV → FAISS 인덱스 일괄 생성 + +DataHub 없이 CSV로 카탈로그를 만들고 FAISS로 인덱싱합니다. + +```python +import csv, os +from collections import defaultdict +from lang2sql import CatalogChunker, VectorRetriever +from lang2sql.integrations.embedding import OpenAIEmbedding +from lang2sql.integrations.vectorstore import FAISSVectorStore + +CSV_PATH = "./dev/table_catalog.csv" +OUTPUT_DIR = "./index" + +tables: dict = defaultdict(lambda: {"desc": "", "columns": {}}) +with open(CSV_PATH, newline="", encoding="utf-8") as f: + for row in csv.DictReader(f): + t = row["table_name"].strip() + tables[t]["desc"] = row["table_description"].strip() + tables[t]["columns"][row["column_name"].strip()] = row["column_description"].strip() + +catalog = [ + {"name": t, "description": info["desc"], "columns": info["columns"]} + for t, info in tables.items() +] + +os.makedirs(OUTPUT_DIR, exist_ok=True) +store = FAISSVectorStore(index_path=f"{OUTPUT_DIR}/catalog.faiss") +chunks = CatalogChunker().split(catalog) + +retriever = VectorRetriever.from_chunks( + chunks, + embedding=OpenAIEmbedding(model="text-embedding-3-large"), + vectorstore=store, +) +retriever.save(f"{OUTPUT_DIR}/catalog") +print(f"저장 완료: {OUTPUT_DIR}/catalog") +``` + +--- + +## 5) PGVectorStore — PostgreSQL 영속성 + +### Docker로 pgvector 실행 + +```bash +docker run -d \ + --name pgvector \ + -e POSTGRES_PASSWORD=postgres \ + -p 5432:5432 \ + pgvector/pgvector:pg16 +``` + +### 사용법 + +```python +from lang2sql import CatalogChunker, VectorRetriever +from lang2sql.integrations.embedding import OpenAIEmbedding +from lang2sql.integrations.vectorstore import PGVectorStore + +store = PGVectorStore( + connection="postgresql://postgres:postgres@localhost:5432/postgres", + table_name="lang2sql_vectors", # 첫 upsert 시 자동 생성 +) + +chunks = CatalogChunker().split(catalog) +retriever = VectorRetriever.from_chunks( + chunks, + embedding=OpenAIEmbedding(model="text-embedding-3-large"), + vectorstore=store, +) +# save() 없음 — upsert()마다 DB에 즉시 반영 +# 동일 chunk_id 재실행 시 덮어씀 (ON CONFLICT DO UPDATE) +``` + +--- + +## 6) 백엔드 비교 + +| | `InMemoryVectorStore` | `FAISSVectorStore` | `PGVectorStore` | +|---|---|---|---| +| 영속성 | 없음 | 파일 | PostgreSQL | +| Upsert | true upsert | append-only | true upsert | +| 멀티 서버 | 불가 | 불가 | 가능 | +| 권장 규모 | < 50k chunks | < 500k chunks | 500k+ chunks | + +`vectorstore=` 파라미터만 교체하면 나머지 코드는 동일합니다. + +--- + +## 7) top_n / score_threshold 조정 + +```python +retriever = VectorRetriever.from_sources( + catalog=catalog, + embedding=OpenAIEmbedding(), + top_n=3, # 반환할 최대 스키마/문서 수 (기본값: 5) + score_threshold=0.3, # 이 점수 이하는 결과에서 제외 (기본값: 0.0) +) +``` + +관련 없는 테이블이 검색될 때 `score_threshold`를 0.3~0.5로 올려보세요. + +--- + +## 다음 단계 + +BM25 + 벡터 하이브리드 검색 → [04-hybrid.md](./04-hybrid.md) diff --git a/docs/tutorials/04-hybrid.md b/docs/tutorials/04-hybrid.md new file mode 100644 index 0000000..fc6eb29 --- /dev/null +++ b/docs/tutorials/04-hybrid.md @@ -0,0 +1,184 @@ +# 04. 하이브리드 검색 — BM25 + Vector + +`HybridRetriever`는 BM25 키워드 검색과 벡터 유사도 검색을 RRF(Reciprocal Rank Fusion)로 결합합니다. +키워드 검색의 정확성과 벡터 검색의 의미 일반화를 모두 얻을 수 있습니다. + +--- + +## 사전 준비 + +```bash +export OPENAI_API_KEY="sk-..." +python scripts/setup_sample_db.py +python scripts/setup_sample_docs.py +``` + +--- + +## 1) HybridNL2SQL — 가장 빠른 시작 + +`BaselineNL2SQL`에서 `embedding` 파라미터 하나만 추가합니다. + +```python +from lang2sql import HybridNL2SQL +from lang2sql.integrations.db import SQLAlchemyDB +from lang2sql.integrations.embedding import OpenAIEmbedding +from lang2sql.integrations.llm import OpenAILLM + +catalog = [ + { + "name": "orders", + "description": "고객 주문 정보. 주문 건수·금액·날짜 조회에 사용.", + "columns": { + "order_id": "주문 고유 ID (PK)", + "customer_id":"고객 ID (FK → customers)", + "order_date": "주문 일시", + "amount": "주문 금액", + "status": "주문 상태: pending / confirmed / shipped / cancelled", + }, + }, + { + "name": "customers", + "description": "고객 마스터. 이름·등급·가입일 조회에 사용.", + "columns": { + "customer_id": "고객 고유 ID (PK)", + "name": "고객 이름", + "grade": "고객 등급: bronze / silver / gold", + }, + }, +] + +docs = [ + { + "id": "revenue_def", + "title": "매출 정의", + "content": "매출은 반품을 제외한 순매출이다. cancelled 상태 주문은 제외한다.", + "source": "docs/business/revenue.md", + }, +] + +pipeline = HybridNL2SQL( + catalog=catalog, + llm=OpenAILLM(model="gpt-4o-mini"), + db=SQLAlchemyDB("sqlite:///sample.db"), + embedding=OpenAIEmbedding(model="text-embedding-3-small"), + documents=docs, + db_dialect="sqlite", + top_n=5, +) + +rows = pipeline.run("취소 제외한 이번 달 순매출 합계") +print(rows) +``` + +--- + +## 2) DirectoryLoader와 함께 + +```python +from lang2sql import DirectoryLoader, HybridNL2SQL +from lang2sql.integrations.db import SQLAlchemyDB +from lang2sql.integrations.embedding import OpenAIEmbedding +from lang2sql.integrations.llm import OpenAILLM + +docs = DirectoryLoader("docs/business").load() + +pipeline = HybridNL2SQL( + catalog=catalog, + llm=OpenAILLM(model="gpt-4o-mini"), + db=SQLAlchemyDB("sqlite:///sample.db"), + embedding=OpenAIEmbedding(model="text-embedding-3-small"), + documents=docs, + db_dialect="sqlite", +) + +rows = pipeline.run("gold 고객의 이번 달 주문 건수") +print(rows) +``` + +--- + +## 3) HybridRetriever 단독 사용 + +검색 결과만 먼저 확인하고 싶을 때: + +```python +from lang2sql import HybridRetriever +from lang2sql.integrations.embedding import OpenAIEmbedding + +retriever = HybridRetriever( + catalog=catalog, + embedding=OpenAIEmbedding(model="text-embedding-3-small"), + documents=docs, + top_n=5, + rrf_k=60, # RRF 상수. 높을수록 순위 간 점수 차이가 줄어듦 + score_threshold=0.0, +) + +result = retriever.run("지난달 할인 매출") +print("schemas:", [s["name"] for s in result.schemas]) +print("context:", result.context) +``` + +--- + +## 4) EnrichedNL2SQL — 풀 파이프라인 + +질문 적합성 검증, 테이블 필터링, 질문 프로파일링, 컨텍스트 보강까지 포함한 파이프라인입니다. + +``` +QuestionGate → HybridRetriever → TableSuitabilityEvaluator + → QuestionProfiler → ContextEnricher → SQLGenerator → SQLExecutor +``` + +```python +from lang2sql import EnrichedNL2SQL +from lang2sql.integrations.db import SQLAlchemyDB +from lang2sql.integrations.embedding import OpenAIEmbedding +from lang2sql.integrations.llm import OpenAILLM + +pipeline = EnrichedNL2SQL( + catalog=catalog, + llm=OpenAILLM(model="gpt-4o"), + db=SQLAlchemyDB("sqlite:///sample.db"), + embedding=OpenAIEmbedding(model="text-embedding-3-small"), + documents=docs, + db_dialect="sqlite", + gate_enabled=True, # QuestionGate 활성화 여부 (기본값: True) + top_n=5, +) + +rows = pipeline.run("이번 달 gold 고객의 순매출 합계") +print(rows) +``` + +### QuestionGate + +`gate_enabled=True`(기본값)이면 SQL로 답할 수 없는 질문은 `ContractError`를 발생시킵니다. + +```python +from lang2sql.core.exceptions import ContractError + +try: + rows = pipeline.run("오늘 날씨가 어때?") +except ContractError as e: + print(f"SQL 생성 불가: {e}") +``` + +Gate가 불필요하면 `gate_enabled=False`로 비활성화합니다. + +--- + +## 5) 파이프라인 선택 가이드 + +| 파이프라인 | 검색 방식 | 적합한 상황 | +|---|---|---| +| `BaselineNL2SQL` | BM25 키워드 | 빠른 시작, 카탈로그 규모 소~중간 | +| `HybridNL2SQL` | BM25 + Vector | 검색 품질 우선, 비즈니스 문서 활용 | +| `EnrichedNL2SQL` | BM25 + Vector + Gate | 운영 환경, 부적합 질문 필터링 필요 | + +--- + +## 다음 단계 + +수동 컴포넌트 조합, 커스텀 어댑터, 관측성 → [05-advanced.md](./05-advanced.md) diff --git a/docs/tutorials/05-advanced.md b/docs/tutorials/05-advanced.md new file mode 100644 index 0000000..7847da3 --- /dev/null +++ b/docs/tutorials/05-advanced.md @@ -0,0 +1,397 @@ +# 05. 고급 — 수동 조합, 커스텀 어댑터, 관측성 + +각 컴포넌트를 개별 제어하고, 임베딩·벡터스토어·청커를 교체하는 방법을 다룹니다. + +--- + +## 1) 완전 수동 Advanced Flow + +`Retriever → Generator → Executor`를 직접 조합합니다. +각 단계의 입출력이 코드에 보여 디버깅과 파라미터 튜닝이 쉽습니다. + +```python +from lang2sql import ( + CatalogChunker, + DirectoryLoader, + RecursiveCharacterChunker, + SQLExecutor, + SQLGenerator, + VectorRetriever, +) +from lang2sql.integrations.db import SQLAlchemyDB +from lang2sql.integrations.embedding import OpenAIEmbedding +from lang2sql.integrations.llm import OpenAILLM + +catalog = [ + { + "name": "orders", + "description": "주문 정보", + "columns": { + "order_id": "주문 고유 ID", + "order_date": "주문 일시", + "amount": "결제 금액", + "status": "주문 상태", + }, + } +] + +# 1) 문서 로드 + split +docs = DirectoryLoader("docs/business").load() +embedding = OpenAIEmbedding(model="text-embedding-3-small") +catalog_chunks = CatalogChunker().split(catalog) +doc_chunks = RecursiveCharacterChunker(chunk_size=800, chunk_overlap=80).split(docs) + +# 2) Retriever 구성 +retriever = VectorRetriever.from_chunks( + catalog_chunks + doc_chunks, + embedding=embedding, + top_n=5, + score_threshold=0.2, +) + +# 3) Generator / Executor 개별 구성 +generator = SQLGenerator( + llm=OpenAILLM(model="gpt-4o-mini"), + db_dialect="sqlite", +) +executor = SQLExecutor(db=SQLAlchemyDB("sqlite:///sample.db")) + +# 4) 수동 실행 — 각 단계 결과 직접 관측 +query = "지난달 순매출 합계" +retrieval = retriever.run(query) +sql = generator.run(query, retrieval.schemas, context=retrieval.context) +rows = executor.run(sql) + +print("SQL:", sql) +print("결과:", rows) +``` + +--- + +## 2) 임베딩 교체 + +`EmbeddingPort`를 만족하는 구현체라면 무엇이든 연결할 수 있습니다. + +### v2 내장 임베딩 + +```python +from lang2sql.integrations.embedding import ( + OpenAIEmbedding, + AzureOpenAIEmbedding, + GeminiEmbedding, + BedrockEmbedding, + OllamaEmbedding, + HuggingFaceEmbedding, +) + +embedding = OpenAIEmbedding(model="text-embedding-3-small") +``` + +### 커스텀 어댑터 예시 (SentenceTransformer) + +```python +class SentenceTransformerEmbedding: + def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2"): + from sentence_transformers import SentenceTransformer + self._model = SentenceTransformer(model_name) + + def embed_query(self, text: str) -> list[float]: + return self._model.encode([text], normalize_embeddings=True)[0].tolist() + + def embed_texts(self, texts: list[str]) -> list[list[float]]: + return self._model.encode(texts, normalize_embeddings=True).tolist() + +# 파이프라인 코드는 동일 +retriever = VectorRetriever.from_sources( + catalog=catalog, + embedding=SentenceTransformerEmbedding(), +) +``` + +--- + +## 3) 벡터스토어 교체 + +`VectorStorePort`의 `upsert()`와 `search()`만 구현하면 됩니다. + +### 커스텀 어댑터 예시 (Chroma) + +```python +class ChromaVectorStore: + def __init__(self, collection_name: str = "lang2sql"): + import chromadb + self._client = chromadb.Client() + self._col = self._client.get_or_create_collection(collection_name) + + def upsert(self, ids: list[str], vectors: list[list[float]]) -> None: + self._col.upsert(ids=ids, embeddings=vectors) + + def search(self, vector: list[float], k: int) -> list[tuple[str, float]]: + results = self._col.query(query_embeddings=[vector], n_results=k) + ids = results["ids"][0] + dists = results["distances"][0] + return [(id_, 1.0 - dist) for id_, dist in zip(ids, dists)] + +retriever = VectorRetriever.from_sources( + catalog=catalog, + embedding=OpenAIEmbedding(), + vectorstore=ChromaVectorStore("my_catalog"), +) +``` + +--- + +## 4) 청커 교체 + +### SemanticChunker (opt-in) + +```python +from lang2sql import CatalogChunker, VectorRetriever +from lang2sql.integrations.chunking import SemanticChunker +from lang2sql.integrations.embedding import OpenAIEmbedding + +embedding = OpenAIEmbedding(model="text-embedding-3-small") + +# from_chunks 패턴 +doc_chunks = SemanticChunker( + embedding=embedding, + breakpoint_threshold=0.3, + min_chunk_size=100, +).split(docs) + +retriever = VectorRetriever.from_chunks( + CatalogChunker().split(catalog) + doc_chunks, + embedding=embedding, +) + +# from_sources 패턴: splitter 파라미터로 전달 +retriever = VectorRetriever.from_sources( + catalog=catalog, + documents=docs, + embedding=embedding, + splitter=SemanticChunker(embedding=embedding), +) +``` + +### LangChain 청커 어댑터 + +```python +from langchain_text_splitters import RecursiveCharacterTextSplitter +from lang2sql import IndexedChunk, TextDocument + +class LangChainChunkerAdapter: + def __init__(self, splitter): + self._splitter = splitter + + def chunk(self, doc: TextDocument) -> list[IndexedChunk]: + texts = self._splitter.split_text(doc["content"]) + title = doc.get("title", "") + return [ + IndexedChunk( + chunk_id=f"{doc['id']}__{i}", + text=f"{title}: {text}" if title else text, + source_type="document", + source_id=doc["id"], + chunk_index=i, + metadata={"title": title, "source": doc.get("source", "")}, + ) + for i, text in enumerate(texts) + ] + +lc_chunker = LangChainChunkerAdapter( + RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50) +) + +retriever = VectorRetriever.from_sources( + catalog=catalog, + documents=docs, + embedding=OpenAIEmbedding(), + splitter=lc_chunker, +) +``` + +--- + +## 5) DataHub 카탈로그 로더 + +DataHub GMS 서버에서 테이블 메타데이터를 가져와 `CatalogEntry` 목록으로 변환합니다. +수동으로 카탈로그를 작성하지 않아도 DataHub에 등록된 스키마 정보를 바로 사용할 수 있습니다. + +```bash +pip install acryl-datahub +``` + +```python +from lang2sql.integrations.catalog import DataHubCatalogLoader + +loader = DataHubCatalogLoader( + gms_server="http://localhost:8080", + extra_headers={"Authorization": "Bearer "}, +) + +# 전체 URN 조회 +catalog = loader.load() + +# 특정 URN만 조회 +catalog = loader.load(urns=[ + "urn:li:dataset:(urn:li:dataPlatform:postgres,mydb.public.orders,PROD)", + "urn:li:dataset:(urn:li:dataPlatform:postgres,mydb.public.customers,PROD)", +]) + +# 바로 파이프라인에 연결 +from lang2sql import BaselineNL2SQL +from lang2sql.integrations.db import SQLAlchemyDB +from lang2sql.integrations.llm import OpenAILLM + +pipeline = BaselineNL2SQL( + catalog=catalog, + llm=OpenAILLM(model="gpt-4o-mini"), + db=SQLAlchemyDB("postgresql://user:pass@localhost:5432/mydb"), + db_dialect="postgresql", +) +``` + +> `DataHubCatalogLoader`는 `CatalogLoaderPort`를 구현합니다. +> DataHub 없이도 `SQLAlchemyExplorer`로 DDL을 직접 조회하거나 CSV/수동 카탈로그를 사용할 수 있습니다. + +--- + +## 6) Port 프로토콜 레퍼런스 + +커스텀 어댑터를 작성할 때 구현해야 하는 메서드 목록입니다. + +| Port | 메서드 | 시그니처 | 용도 | +|------|--------|----------|------| +| `LLMPort` | `invoke` | `(messages: list[dict]) -> str` | LLM 백엔드 교체 | +| `DBPort` | `execute` | `(sql: str) -> list[dict]` | DB 백엔드 교체 | +| `EmbeddingPort` | `embed_query` | `(text: str) -> list[float]` | 단일 텍스트 임베딩 | +| | `embed_texts` | `(texts: list[str]) -> list[list[float]]` | 배치 임베딩 | +| `VectorStorePort` | `upsert` | `(ids: list[str], vectors: list[list[float]]) -> None` | 벡터 저장 | +| | `search` | `(vector: list[float], k: int) -> list[tuple[str, float]]` | 유사도 검색 (id, score) | +| `DocumentLoaderPort` | `load` | `() -> list[TextDocument]` | 문서 로드 | +| `DocumentChunkerPort` | `chunk` | `(doc: TextDocument) -> list[IndexedChunk]` | 문서 분할 | +| `CatalogLoaderPort` | `load` | `(urns: list[str] \| None) -> list[CatalogEntry]` | 외부 카탈로그 로드 | +| `DBExplorerPort` | `list_tables` | `() -> list[str]` | 테이블 목록 | +| | `get_ddl` | `(table: str) -> str` | DDL 조회 | +| | `sample_data` | `(table: str, limit: int) -> list[dict]` | 샘플 데이터 | +| | `execute_read_only` | `(sql: str) -> list[dict]` | 읽기 전용 쿼리 | + +모든 Port는 `src/lang2sql/core/ports.py`에 `Protocol`로 정의되어 있습니다. +클래스 상속 없이 **메서드 시그니처만 맞추면** 어떤 객체든 연결할 수 있습니다 (structural subtyping). + +--- + +## 7) Hook — 관측성과 디버깅 + +`MemoryHook`으로 컴포넌트 단위 실행 이벤트를 수집합니다. + +```python +from lang2sql import HybridNL2SQL, MemoryHook +from lang2sql.integrations.db import SQLAlchemyDB +from lang2sql.integrations.embedding import OpenAIEmbedding +from lang2sql.integrations.llm import OpenAILLM + +hook = MemoryHook() + +pipeline = HybridNL2SQL( + catalog=catalog, + llm=OpenAILLM(model="gpt-4o-mini"), + db=SQLAlchemyDB("sqlite:///sample.db"), + embedding=OpenAIEmbedding(model="text-embedding-3-small"), + documents=docs, + db_dialect="sqlite", + hook=hook, +) + +pipeline.run("지난달 순매출 합계") + +for e in hook.snapshot(): + print(f"{e.component:30s} {e.phase:5s} {e.duration_ms:6.1f}ms error={e.error}") +``` + +출력 예시: +``` +HybridRetriever start 0.0ms error=None +HybridRetriever end 12.3ms error=None +SQLGenerator start 0.0ms error=None +SQLGenerator end 890.1ms error=None +SQLExecutor start 0.0ms error=None +SQLExecutor end 1.2ms error=None +``` + +운영 환경에서는 `duration_ms`로 병목을 파악하고 `error` 이벤트를 수집해 장애 패턴을 분석합니다. + +--- + +## 8) Best Practices 체크리스트 + +### 카탈로그 작성 + +- `description`은 한 문장으로 테이블 용도를 명확히 기술 +- `columns`는 비즈니스 용어와 컬럼명 매핑을 충실히 작성 +- 관련 테이블 간 FK 관계를 컬럼 설명에 명시 + +### 검색 파라미터 + +- `top_n`: 3~8로 시작해 실험 (너무 많으면 LLM 프롬프트 비용 증가) +- `score_threshold`: 0.0으로 시작 후 관련 없는 테이블이 검색될 때 0.3~0.5로 상향 +- `rrf_k` (HybridRetriever): 기본값 60, 검색 결과 순위 민감도 조정 + +### 청킹 + +- 기본은 `RecursiveCharacterChunker` +- 문서 품질이 중요하고 비용 허용 시 `SemanticChunker` 검토 +- `chunk_overlap`은 반드시 `chunk_size`보다 작게 설정 + +### 플로우 선택 + +| 우선 순위 | 파이프라인 | +|---|---| +| 빠른 시작 | `BaselineNL2SQL` | +| 검색 품질 | `HybridNL2SQL` | +| 운영 환경 | `EnrichedNL2SQL` | +| 세밀한 제어 | 수동 컴포넌트 조합 | + +--- + +## 9) 트러블슈팅 + +### `IntegrationMissingError: openai` + +```bash +pip install openai +``` + +### `chunk_overlap must be less than chunk_size` + +`RecursiveCharacterChunker`의 `chunk_overlap < chunk_size` 조건 위반. +파라미터를 수정하세요. + +### VectorRetriever 결과가 비어 있음 + +1. `from_chunks()` 또는 `from_sources()`가 실제로 호출됐는지 확인 +2. `len(retriever._registry) > 0` 확인 +3. `score_threshold`를 `0.0`으로 낮춰서 테스트 + +### `retriever.add()` 타입 에러 + +`add()`는 `list[IndexedChunk]`만 받습니다. `TextDocument`를 직접 전달하면 오류가 발생합니다. + +```python +# ❌ 동작 안 함 +retriever.add(docs) + +# ✅ 올바른 방법 +retriever.add(RecursiveCharacterChunker().split(docs)) +``` + +### `IntegrationMissingError: pymupdf` (PDFLoader) + +```bash +pip install pymupdf +``` + +### `ContractError` (EnrichedNL2SQL) + +`QuestionGate`가 SQL로 답할 수 없다고 판단한 경우입니다. +`gate_enabled=False`로 비활성화하거나 질문을 SQL 관련으로 구체화하세요. diff --git a/docs/tutorials/getting-started-without-datahub.md b/docs/tutorials/getting-started-without-datahub.md deleted file mode 100644 index d24d0d3..0000000 --- a/docs/tutorials/getting-started-without-datahub.md +++ /dev/null @@ -1,265 +0,0 @@ -## DataHub 없이 시작하기 (튜토리얼) - -이 문서는 DataHub 없이도 Lang2SQL을 바로 사용하기 위한 최소 절차를 설명합니다. -CSV로 테이블/컬럼 설명을 준비해 FAISS 또는 pgvector에 적재한 뒤 Lang2SQL을 실행합니다. - -### 0) 준비 - -```bash -# 소스 클론 -git clone https://github.com/CausalInferenceLab/lang2sql.git -cd lang2sql - -# (권장) uv 사용 -uv venv --python 3.11 -source .venv/bin/activate -uv pip install -e . - -# (대안) pip 사용 -python -m venv .venv -source .venv/bin/activate -pip install -e . -``` - -### 1) .env 최소 설정 (OpenAI 기준) - -```bash -# LLM/임베딩 -LLM_PROVIDER=openai -OPEN_AI_KEY=sk-... # OpenAI API Key (주의: OPENAI_API_KEY가 아니라 OPEN_AI_KEY) -OPEN_AI_LLM_MODEL=gpt-4o # 또는 gpt-4.1 등 -EMBEDDING_PROVIDER=openai -OPEN_AI_EMBEDDING_MODEL=text-embedding-3-large # 권장 - -# DB 타입 -DB_TYPE=clickhouse -``` - -중요: 코드상 OpenAI 키는 `OPEN_AI_KEY` 환경변수를 사용합니다. `.example.env`의 `OPENAI_API_KEY`는 사용되지 않으니 혼동에 주의하세요. - -### 2) 테이블/컬럼 메타데이터 준비 (CSV 예시) - -`dev/table_catalog.csv` 파일을 생성합니다. - -```csv -table_name,table_description,column_name,column_description -customers,고객 정보 테이블,customer_id,고객 고유 ID -customers,고객 정보 테이블,name,고객 이름 -customers,고객 정보 테이블,created_at,가입 일시 -orders,주문 정보 테이블,order_id,주문 ID -orders,주문 정보 테이블,customer_id,주문 고객 ID -orders,주문 정보 테이블,amount,결제 금액 -orders,주문 정보 테이블,status,주문 상태 -``` - -### 3) FAISS 인덱스 생성 (로컬) - -`dev/create_faiss.py` 파일을 실행합니다: `python dev/create_faiss.py` - -```python -""" -dev/create_faiss.py - -CSV 파일에서 테이블과 컬럼 정보를 불러와 OpenAI 임베딩으로 벡터화한 뒤, -FAISSVectorStore 인덱스를 생성하고 로컬 디렉토리에 저장한다. - -환경 변수: - OPEN_AI_KEY: OpenAI API 키 - OPEN_AI_EMBEDDING_MODEL: 사용할 임베딩 모델 이름 - -출력: - OUTPUT_DIR 경로에 FAISS 인덱스 저장 (catalog.faiss) -""" - -import csv -import os -from collections import defaultdict - -from dotenv import load_dotenv -from lang2sql import CatalogChunker, VectorRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.vectorstore import FAISSVectorStore - -load_dotenv() - -# CSV 파일 경로 -CSV_PATH = "./dev/table_catalog.csv" -# .env의 VECTORDB_LOCATION과 동일하게 맞추세요 -OUTPUT_DIR = "./dev/table_info_db" - -# CSV → CatalogEntry 변환 -tables: dict = defaultdict(lambda: {"desc": "", "columns": {}}) -with open(CSV_PATH, newline="", encoding="utf-8") as f: - reader = csv.DictReader(f) - for row in reader: - t = row["table_name"].strip() - tables[t]["desc"] = row["table_description"].strip() - col = row["column_name"].strip() - col_desc = row["column_description"].strip() - tables[t]["columns"][col] = col_desc - -catalog = [ - {"name": t, "description": info["desc"], "columns": info["columns"]} - for t, info in tables.items() -] - -# 청킹 → 임베딩 → 저장 -chunks = CatalogChunker().split(catalog) -store = FAISSVectorStore(index_path=f"{OUTPUT_DIR}/catalog.faiss") -os.makedirs(OUTPUT_DIR, exist_ok=True) - -VectorRetriever.from_chunks( - chunks, - embedding=OpenAIEmbedding( - model=os.getenv("OPEN_AI_EMBEDDING_MODEL", "text-embedding-3-large"), - api_key=os.getenv("OPEN_AI_KEY"), - ), - vectorstore=store, -) -store.save() -print(f"FAISS index saved to: {OUTPUT_DIR}/catalog.faiss") -``` - -### 4) 실행 - -v2 CLI는 외부 벡터 인덱스 경로를 인수로 받지 않습니다. -앞서 생성한 FAISS 인덱스를 활용하려면 Python API로 파이프라인을 직접 구성합니다. - -```python -# run_query.py -import os -from dotenv import load_dotenv -from lang2sql import CatalogChunker, VectorRetriever -from lang2sql.integrations.db import SQLAlchemyDB -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.llm import OpenAILLM -from lang2sql.integrations.vectorstore import FAISSVectorStore -from lang2sql.flows.hybrid import HybridNL2SQL - -load_dotenv() - -INDEX_DIR = "./dev/table_info_db" -embedding = OpenAIEmbedding( - model=os.getenv("OPEN_AI_EMBEDDING_MODEL", "text-embedding-3-large"), - api_key=os.getenv("OPEN_AI_KEY"), -) - -# FAISS 인덱스 로드 후 파이프라인 구성 -store = FAISSVectorStore.load(f"{INDEX_DIR}/catalog.faiss") - -pipeline = HybridNL2SQL( - catalog=[], # FAISS에 이미 인덱싱돼 있으므로 빈 리스트 - llm=OpenAILLM(model=os.getenv("OPEN_AI_LLM_MODEL", "gpt-4o"), api_key=os.getenv("OPEN_AI_KEY")), - db=SQLAlchemyDB(os.getenv("DB_URL", "sqlite:///sample.db")), - embedding=embedding, - db_dialect=os.getenv("DB_TYPE", "sqlite"), -) - -rows = pipeline.run("주문 수를 집계하는 SQL을 만들어줘") -print(rows) -``` - -Streamlit UI: - -```bash -lang2sql run-streamlit -``` - -CLI (카탈로그 없이 baseline만 가능): - -```bash -lang2sql query "주문 수를 집계해줘" --flow baseline --dialect sqlite -``` - -### 5) (선택) pgvector로 적재하기 - -`dev/create_pgvector.py` 파일을 실행합니다: `python dev/create_pgvector.py` - -pgvector를 사용하려면 PostgreSQL에 pgvector 확장이 설치되어 있어야 합니다. -아래 중 하나를 선택하세요: - -**방법 A — Docker (로컬 테스트용, 가장 빠름)** - -```bash -docker run -d \ - -e POSTGRES_USER=pgvector \ - -e POSTGRES_PASSWORD=pgvector \ - -e POSTGRES_DB=postgres \ - -p 5432:5432 \ - pgvector/pgvector:pg16 -``` - -**방법 B — 기존 PostgreSQL 서버에 확장 설치** - -```sql --- psql 또는 DBeaver 등에서 실행 -CREATE EXTENSION IF NOT EXISTS vector; -``` - -**방법 C — 클라우드 관리형 서비스 (별도 설치 불필요)** - -- [Supabase](https://supabase.com/) — 무료 플랜에서 pgvector 기본 지원 -- AWS RDS PostgreSQL 15+ — 파라미터 그룹에서 `pgvector` 활성화 -- Azure Database for PostgreSQL Flexible Server — 확장 목록에서 활성화 - -```python -""" -dev/create_pgvector.py - -CSV 파일에서 테이블과 컬럼 정보를 불러와 OpenAI 임베딩으로 벡터화한 뒤, -pgvector에 적재한다. ON CONFLICT upsert를 지원하므로 재실행 시 중복 없음. - -환경 변수: - OPEN_AI_KEY: OpenAI API 키 - OPEN_AI_EMBEDDING_MODEL: 사용할 임베딩 모델 이름 - VECTORDB_LOCATION: pgvector 연결 문자열 - PGVECTOR_COLLECTION: pgvector 테이블 이름 -""" - -import csv -import os -from collections import defaultdict - -from dotenv import load_dotenv -from lang2sql import CatalogChunker, VectorRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.vectorstore import PGVectorStore - -load_dotenv() - -# CSV 파일 경로 -CSV_PATH = "./dev/table_catalog.csv" -CONN = os.getenv("VECTORDB_LOCATION", "postgresql://pgvector:pgvector@localhost:5432/postgres") -TABLE = os.getenv("PGVECTOR_COLLECTION", "table_info_db") - -# CSV → CatalogEntry 변환 -tables: dict = defaultdict(lambda: {"desc": "", "columns": {}}) -with open(CSV_PATH, newline="", encoding="utf-8") as f: - reader = csv.DictReader(f) - for row in reader: - t = row["table_name"].strip() - tables[t]["desc"] = row["table_description"].strip() - col = row["column_name"].strip() - col_desc = row["column_description"].strip() - tables[t]["columns"][col] = col_desc - -catalog = [ - {"name": t, "description": info["desc"], "columns": info["columns"]} - for t, info in tables.items() -] - -# 청킹 → 임베딩 → pgvector 적재 -chunks = CatalogChunker().split(catalog) -store = PGVectorStore(connection=CONN, table_name=TABLE) - -VectorRetriever.from_chunks( - chunks, - embedding=OpenAIEmbedding( - model=os.getenv("OPEN_AI_EMBEDDING_MODEL", "text-embedding-3-large"), - api_key=os.getenv("OPEN_AI_KEY"), - ), - vectorstore=store, -) -print(f"pgvector collection populated: {TABLE}") -``` - diff --git a/docs/tutorials/v2-complete-tutorial.md b/docs/tutorials/v2-complete-tutorial.md deleted file mode 100644 index 1f2cd06..0000000 --- a/docs/tutorials/v2-complete-tutorial.md +++ /dev/null @@ -1,1087 +0,0 @@ -# lang2sql v2 Complete Tutorial - -이 문서는 `src/lang2sql` 기반 v2만 대상으로 합니다. -아래 순서대로 따라가면 초급에서 고급까지 모든 지원 경로를 직접 테스트할 수 있습니다. - -- 난이도 상승 순서: 스크롤할수록 어려워집니다. -- 코드 예제는 현재 레포 구현 기준으로 작성되었습니다. -- 범위 외 기능(예: v2 내장 FAISS/PGVector)은 "커스텀 어댑터" 방식으로만 설명합니다. - ---- - -## 목차 - -1. 목표와 범위 -1-1. Why lang2sql -2. 사전 준비 -3. 설치 -4. API 키 설정 -5. 샘플 DB 준비 -5-1. 샘플 문서 자동 생성 -6. 가장 쉬운 로컬 스모크 테스트 (API 키 없이) -7. BaselineNL2SQL 기본 사용 (KeywordRetriever) -7-1. DB 탐색: SQLAlchemyExplorer -8. 실제 LLM 연결 (OpenAI / Anthropic) -9. VectorRetriever 기초 (빠른 시작) -10. 문서 파싱: MarkdownLoader / PlainTextLoader / DirectoryLoader / PDFLoader -11. 명시적 파이프라인: from_chunks() 패턴 -12. 청킹 전략 교체: Recursive vs Semantic -13. HybridRetriever / HybridNL2SQL -14. 임베딩 교체 테스트 (v2 내장 + 사용자 구현) -15. 벡터 스토어 교체 테스트 (v2 내장 + 사용자 구현) -16. 완전 수동 Advanced Flow 조합 -17. 관측성(Tracing)과 디버깅 -18. Best Practices 체크리스트 -19. 트러블슈팅 - ---- - -## 1) 목표와 범위 - -이 튜토리얼의 목표: - -- v2 코어 사용법을 처음 설치부터 끝까지 실습 -- 기본 플로우, 벡터 인덱싱, 문서 로딩, 하이브리드 검색까지 검증 -- 고급 사용자용 확장 포인트(Embedding/VectorStore/Chunker)를 직접 갈아끼워 테스트 - -중요 범위: - -- 이 문서에서 "v2 공식 내장"은 아래만 의미합니다. - - Embedding: `OpenAIEmbedding` - - Vector store: `InMemoryVectorStore` -- 그 외는 Protocol 기반 "사용자 구현 어댑터" 방식으로 테스트합니다. - ---- - -## 1-1) Why lang2sql - -다른 라이브러리와 비교했을 때, v2에서 강조하는 포인트는 아래입니다. - -- **운영 친화 기본선**: `Retriever -> Generator -> Executor` 경로가 짧고 실패 지점이 명확합니다. -- **명시적 인덱싱 파이프라인**: `chunker.split(docs)` → `VectorRetriever.from_chunks(chunks)` 패턴으로 split/embed/store 각 단계가 코드에 보입니다. -- **확장 포인트 분리**: 코어는 Protocol 기반이라 임베딩/벡터스토어/청커를 교체해도 플로우 코드는 유지됩니다. -- **관측성 내장**: Hook 이벤트(`start/end/error`, duration)를 컴포넌트 단위로 수집할 수 있습니다. - -주의: -- v2는 "모든 기능을 직접 구현한 거대 프레임워크"가 목적이 아닙니다. -- 코어 오케스트레이션과 운영 안정성에 집중하고, 고급 백엔드는 교체 가능한 어댑터로 다룹니다. - ---- - -## 2) 사전 준비 - -권장 환경: - -- Python 3.11+ -- `uv` 또는 `pip` -- (선택) OpenAI API 키, Anthropic API 키 - ---- - -## 3) 설치 - -### 옵션 A: pip -```bash -pip install lang2sql -``` - -### 옵션 B: 소스 기준 개발 설치 -```bash -uv venv --python 3.11 -source .venv/bin/activate -uv pip install -e . -``` - ---- - -## 4) API 키 설정 - -OpenAI/Anthropic SDK는 환경변수를 기본으로 읽습니다. - -```bash -export OPENAI_API_KEY="sk-..." -export ANTHROPIC_API_KEY="sk-ant-..." -``` - ---- - -## 5) 샘플 DB 준비 - -튜토리얼 전체를 재현하려면 샘플 DB를 먼저 만듭니다. - -```bash -python scripts/setup_sample_db.py -``` - -완료되면 프로젝트 루트에 `sample.db`가 생성됩니다. - ---- - -## 5-1) 샘플 문서 자동 생성 - -문서 로더/청킹/벡터 인덱싱 실습용 파일을 자동으로 생성합니다. - -```bash -python scripts/setup_sample_docs.py -``` - -생성 위치(기본): -- `docs/business/revenue.md` -- `docs/business/order_status_policy.md` -- `docs/business/rules.txt` - -기존 파일이 있을 때 덮어쓰려면: - -```bash -python scripts/setup_sample_docs.py --force -``` - ---- - -## 6) 가장 쉬운 로컬 스모크 테스트 (API 키 없이) - -먼저 외부 의존 없이 파이프라인 구조가 동작하는지 확인합니다. - -```python -from lang2sql import BaselineNL2SQL - -# 1) LLM을 흉내 내는 테스트 더블 -class FakeLLM: - def invoke(self, messages): - # SQLGenerator는 ```sql ... ``` 블록을 기대합니다. - return "```sql\nSELECT 1 AS ok\n```" - -# 2) DB를 흉내 내는 테스트 더블 -class FakeDB: - def execute(self, sql): - # SQLExecutor가 실행한 SQL을 받아 고정 결과를 반환 - return [{"ok": 1, "sql_received": sql}] - -catalog = [ - { - "name": "orders", - "description": "주문 테이블", - "columns": {"order_id": "주문 ID", "amount": "주문 금액"}, - } -] - -pipeline = BaselineNL2SQL( - catalog=catalog, - llm=FakeLLM(), # 외부 API 없이 테스트 - db=FakeDB(), # 실제 DB 없이 테스트 - db_dialect="sqlite", -) - -rows = pipeline.run("주문 건수 알려줘") -print(rows) -``` - -이 단계의 목적: - -- 설치/임포트 문제 없는지 확인 -- `Retriever -> Generator -> Executor` 기본 경로 확인 - ---- - -## 7) BaselineNL2SQL 기본 사용 (KeywordRetriever) - -이제 실제 DB에 연결합니다. - -```python -from lang2sql import BaselineNL2SQL -from lang2sql.integrations.db import SQLAlchemyDB -from lang2sql.integrations.llm import OpenAILLM - -catalog = [ - { - "name": "orders", - "description": "고객 주문 정보", - "columns": { - "order_id": "주문 고유 ID", - "customer_id": "고객 ID", - "order_date": "주문 일시", - "amount": "주문 금액", - "status": "주문 상태", - }, - }, - { - "name": "customers", - "description": "고객 마스터", - "columns": { - "customer_id": "고객 ID", - "name": "고객명", - "grade": "고객 등급", - }, - }, -] - -pipeline = BaselineNL2SQL( - catalog=catalog, - llm=OpenAILLM(model="gpt-4o-mini"), - db=SQLAlchemyDB("sqlite:///sample.db"), - db_dialect="sqlite", -) - -rows = pipeline.run("지난달 주문 건수") -print(rows) -``` - -주의: - -- 현재 `BaselineNL2SQL`은 키워드 기반 리트리버를 내부에서 사용합니다. -- 벡터 검색 기반 플로우는 아래 `HybridNL2SQL` 또는 수동 조합을 사용하세요. - ---- - -## 7-1) DB 탐색: SQLAlchemyExplorer - -LLM에게 넘길 스키마 정보가 필요하거나, 처음 보는 DB를 손으로 살펴볼 때 사용합니다. -카탈로그를 미리 구축하지 않아도 DDL + 샘플 데이터를 바로 꺼내볼 수 있습니다. - -### 기본 사용 - -```python -from lang2sql import build_explorer_from_url - -exp = build_explorer_from_url("sqlite:///sample.db") - -# 1) 어떤 테이블이 있는지 -print(exp.list_tables()) -# ['customers', 'orders', ...] - -# 2) 테이블 DDL — CREATE TABLE 원문 -print(exp.get_ddl("orders")) -# CREATE TABLE orders ( -# id INTEGER PRIMARY KEY, -# customer_id INTEGER NOT NULL REFERENCES customers(id), -# amount REAL, -# status TEXT DEFAULT 'pending' -# ) - -# 3) 실제 샘플 데이터 (기본 5행) -print(exp.sample_data("orders")) -# [{'id': 1, 'customer_id': 1, 'amount': 99.9, 'status': 'shipped'}, ...] - -# 4) 커스텀 읽기 전용 질의 -print(exp.execute_read_only("SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status")) -# [{'status': 'pending', 'cnt': 3}, {'status': 'shipped', 'cnt': 2}] -``` - -### 전체 테이블 한 번에 둘러보기 - -```python -from lang2sql import build_explorer_from_url - -exp = build_explorer_from_url("sqlite:///sample.db") - -for table in exp.list_tables(): - print(f"\n=== {table} ===") - print(exp.get_ddl(table)) - rows = exp.sample_data(table, limit=2) - print("샘플:", rows) -``` - -### PostgreSQL / MySQL 연결 - -URL만 바꾸면 됩니다. - -```python -from lang2sql import build_explorer_from_url - -# PostgreSQL -exp = build_explorer_from_url("postgresql://user:password@localhost:5432/mydb") - -# MySQL -exp = build_explorer_from_url("mysql+pymysql://user:password@localhost:3306/mydb") - -# schema 지정 (schema 파라미터) -exp = build_explorer_from_url("postgresql://user:pass@host/db", schema="analytics") -print(exp.list_tables()) # analytics 스키마 테이블만 -``` - -### 기존 SQLAlchemyDB engine 재사용 - -연결 풀을 따로 만들지 않고 공유할 수 있습니다. - -```python -from lang2sql.integrations.db import SQLAlchemyDB, SQLAlchemyExplorer - -db = SQLAlchemyDB("sqlite:///sample.db") -exp = SQLAlchemyExplorer.from_engine(db._engine) - -# db는 SQL 실행, exp는 탐색 — 같은 연결 풀 공유 -rows = db.execute("SELECT COUNT(*) AS cnt FROM orders") -ddl = exp.get_ddl("orders") -``` - -### 쓰기 구문은 거부됩니다 - -```python -exp.execute_read_only("DROP TABLE orders") -# ValueError: Write operations not allowed: 'DROP TABLE orders' - -exp.execute_read_only("INSERT INTO orders VALUES (99, 1, 0, 'test')") -# ValueError: Write operations not allowed: 'INSERT INTO orders ...' -``` - ---- - -## 8) 실제 LLM 연결 (OpenAI / Anthropic) - -LLM 백엔드는 교체 가능합니다. - -### OpenAI LLM -```python -from lang2sql.integrations.llm import OpenAILLM -llm = OpenAILLM(model="gpt-4o-mini") -``` - -### Anthropic LLM -```python -from lang2sql.integrations.llm import AnthropicLLM -llm = AnthropicLLM(model="claude-sonnet-4-6") -``` - -둘 다 `LLMPort.invoke(messages)` 계약을 따르므로 플로우 코드는 동일합니다. - ---- - -## 9) VectorRetriever 기초 - -두 가지 생성 패턴을 제공합니다. 상황에 맞게 선택하세요. - -### 9-1. from_sources() — 원터치 (빠른 시작) - -`VectorRetriever.from_sources()`는 split/embed/store를 한 번에 처리합니다. - -```python -from lang2sql import VectorRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding - -catalog = [ - { - "name": "orders", - "description": "주문 정보 테이블", - "columns": { - "order_id": "주문 ID", - "amount": "주문 금액", - "discount_amount": "할인 금액", - "order_date": "주문 날짜", - }, - } -] - -docs = [ - { - "id": "biz_rules", - "title": "매출 정의", - "content": "매출은 반품 제외 순매출이다. 할인 금액은 discount_amount 컬럼을 사용한다.", - "source": "docs/biz_rules.md", - } -] - -retriever = VectorRetriever.from_sources( - catalog=catalog, - documents=docs, - embedding=OpenAIEmbedding(model="text-embedding-3-small"), - top_n=5, - score_threshold=0.0, -) - -result = retriever.run("지난달 할인 매출") -print("schemas:", [s["name"] for s in result.schemas]) -print("context:", result.context) -``` - -내부에서 일어나는 일: -1. catalog/docs를 각각 `CatalogChunker`, `RecursiveCharacterChunker`로 split -2. `from_chunks()`를 호출해 embed + store -3. 검색 가능한 `VectorRetriever` 반환 - -### 9-2. from_chunks() — 명시적 파이프라인 (LangChain 스타일) - -split 단계를 직접 제어하고 싶을 때 사용합니다. - -```python -from lang2sql import CatalogChunker, RecursiveCharacterChunker, VectorRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding - -# split 단계가 코드에 보임 -catalog_chunks = CatalogChunker().split(catalog) -doc_chunks = RecursiveCharacterChunker(chunk_size=800, chunk_overlap=80).split(docs) - -# chunks를 자유롭게 조합 -retriever = VectorRetriever.from_chunks( - catalog_chunks + doc_chunks, - embedding=OpenAIEmbedding(model="text-embedding-3-small"), - top_n=5, -) - -result = retriever.run("지난달 할인 매출") -print("schemas:", [s["name"] for s in result.schemas]) -print("context:", result.context) -``` - -`from_chunks()`의 장점: -- catalog/doc 외의 소스도 `IndexedChunk`를 직접 생성해 자유롭게 합칠 수 있음 -- 커스텀 chunker와 조합하기 쉬움 -- 증분 추가도 동일 패턴: `retriever.add(chunker.split(new_docs))` - ---- - -## 10) 문서 파싱: MarkdownLoader / PlainTextLoader / DirectoryLoader / PDFLoader - -문서를 수동으로 리스트 작성하지 않고 파일에서 읽어올 수 있습니다. - -### 10-1. MarkdownLoader -```python -from lang2sql import MarkdownLoader - -docs = MarkdownLoader().load("docs/business/revenue.md") -print(docs[0]["id"], docs[0]["title"], docs[0]["source"]) -``` - -### 10-2. PlainTextLoader -```python -from lang2sql import PlainTextLoader - -docs = PlainTextLoader().load("docs/business/rules.txt") -print(docs[0]["id"], docs[0]["title"], docs[0]["source"]) -``` - -### 10-3. DirectoryLoader (권장) -```python -from lang2sql import DirectoryLoader - -# 기본 매핑: -# .md -> MarkdownLoader -# .txt -> PlainTextLoader -docs = DirectoryLoader("docs/business").load() -print("loaded docs:", len(docs)) -for d in docs[:3]: - print(d["id"], d["source"]) -``` - -### 10-4. 로더 결과를 벡터 인덱싱에 연결 -```python -from lang2sql import VectorRetriever, DirectoryLoader -from lang2sql.integrations.embedding import OpenAIEmbedding - -docs = DirectoryLoader("docs/business").load() - -retriever = VectorRetriever.from_sources( - catalog=catalog, - documents=docs, - embedding=OpenAIEmbedding(), -) -``` - -### 10-5. Loader → split → from_chunks 플로우를 코드로 명시 - -```python -from lang2sql import ( - CatalogChunker, - DirectoryLoader, - RecursiveCharacterChunker, - VectorRetriever, -) -from lang2sql.integrations.embedding import OpenAIEmbedding - -catalog = [ - { - "name": "orders", - "description": "주문 정보", - "columns": { - "order_id": "주문 ID", - "order_date": "주문 일시", - "amount": "결제 금액", - "discount_amount": "할인 금액", - }, - } -] - -# 1) document loader -docs = DirectoryLoader("docs/business").load() - -# 2) 각 소스를 명시적으로 split -catalog_chunks = CatalogChunker().split(catalog) -doc_chunks = RecursiveCharacterChunker(chunk_size=800, chunk_overlap=80).split(docs) - -# 3) from_chunks: embed + store를 한 번에 -retriever = VectorRetriever.from_chunks( - catalog_chunks + doc_chunks, - embedding=OpenAIEmbedding(model="text-embedding-3-small"), - top_n=5, -) - -result = retriever.run("지난달 순매출 계산 규칙") -print("total chunks:", len(catalog_chunks) + len(doc_chunks)) -print("schemas:", [s["name"] for s in result.schemas]) -print("context sample:", result.context[:2]) -``` - -정리: -- `DirectoryLoader`가 `TextDocument`를 만든다. -- `chunker.split(docs)`가 `list[IndexedChunk]`를 반환한다. -- `from_chunks()`가 embed + upsert + registry를 처리한다. -- `VectorRetriever`는 쿼리 시 검색만 수행한다. - -### 10-6. 완전 수동 플로우 (내부 구조 직접 확인) - -`chunk → embed → vectorstore.upsert`를 눈으로 확인하려면 아래처럼 직접 실행하면 됩니다. - -```python -from lang2sql import CatalogChunker, RecursiveCharacterChunker, VectorRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.vectorstore import InMemoryVectorStore - -# 1) chunk — .split() 배치 호출 -catalog_chunks = CatalogChunker(max_columns_per_chunk=20).split(catalog) -doc_chunks = RecursiveCharacterChunker(chunk_size=800, chunk_overlap=80).split(docs) -chunks = catalog_chunks + doc_chunks - -# 2) embed -embedding = OpenAIEmbedding(model="text-embedding-3-small") -texts = [c["text"] for c in chunks] -vectors = embedding.embed_texts(texts) - -# 3) vector store 저장(upsert) -store = InMemoryVectorStore() -ids = [c["chunk_id"] for c in chunks] -store.upsert(ids, vectors) - -# 4) registry 구성 -registry = {c["chunk_id"]: c for c in chunks} - -# 5) retrieval 검증 -retriever = VectorRetriever( - vectorstore=store, - embedding=embedding, - registry=registry, - top_n=5, -) -result = retriever.run("지난달 순매출 계산 규칙") -print("schemas:", [s["name"] for s in result.schemas]) -print("context:", result.context[:2]) -``` - -### 10-7. PDFLoader — PDF 파일 인덱싱 - -PDF는 `integrations.loaders`에서 opt-in으로 제공합니다 (`pip install pymupdf` 필요). - -```python -from lang2sql import CatalogChunker, DirectoryLoader, MarkdownLoader, VectorRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.loaders import PDFLoader - -# PDFLoader를 DirectoryLoader에 추가 등록 -docs = DirectoryLoader( - "docs/", - loaders={ - ".md": MarkdownLoader(), - ".pdf": PDFLoader(), - }, -).load() - -# 이후 일반 from_chunks 패턴과 동일 -from lang2sql import RecursiveCharacterChunker - -chunks = ( - CatalogChunker().split(catalog) + - RecursiveCharacterChunker().split(docs) -) -retriever = VectorRetriever.from_chunks( - chunks, - embedding=OpenAIEmbedding(model="text-embedding-3-small"), -) -``` - -PDFLoader는 페이지 단위로 `TextDocument`를 생성합니다: -- `id`: `"{filename}__p{page_number}"` (1-indexed) -- `title`: `"{filename} page {page_number}"` -- `content`: 해당 페이지 추출 텍스트 - ---- - -## 11) 명시적 파이프라인: from_chunks() 패턴 - -고급 사용자는 split/embed/store 각 단계를 코드에서 명시적으로 제어합니다. - -### 11-1. 기본 from_chunks() 패턴 - -```python -from lang2sql import CatalogChunker, RecursiveCharacterChunker, VectorRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding - -# 1) 각 소스를 명시적으로 split -catalog_chunks = CatalogChunker().split(catalog) -doc_chunks = RecursiveCharacterChunker().split(docs) - -# 2) from_chunks: embed + store + registry 자동 처리 -retriever = VectorRetriever.from_chunks( - catalog_chunks + doc_chunks, - embedding=OpenAIEmbedding(model="text-embedding-3-small"), - top_n=5, -) - -result = retriever.run("할인 매출") -print(result.schemas) -print(result.context) -``` - -### 11-2. 커스텀 VectorStore와 함께 사용 - -```python -from lang2sql.integrations.vectorstore import InMemoryVectorStore - -store = InMemoryVectorStore() - -retriever = VectorRetriever.from_chunks( - catalog_chunks + doc_chunks, - embedding=OpenAIEmbedding(model="text-embedding-3-small"), - vectorstore=store, # 커스텀 store 주입 - top_n=5, - score_threshold=0.2, -) -``` - -### 11-3. 증분 추가 (add) - -`add()`는 pre-split된 `list[IndexedChunk]`만 받습니다. 추가 전 반드시 split이 필요합니다. - -```python -# 카탈로그/문서 초기 인덱싱 -retriever = VectorRetriever.from_chunks( - CatalogChunker().split(catalog), - embedding=OpenAIEmbedding(model="text-embedding-3-small"), -) - -# 나중에 문서 증분 추가 -new_docs = DirectoryLoader("docs/new").load() -retriever.add(RecursiveCharacterChunker().split(new_docs)) - -result = retriever.run("할인 매출") -print(result.schemas) -``` - -Best practice: - -- `from_chunks()`는 embed + upsert를 내부에서 처리 — store/registry 직접 관리 불필요 -- catalog와 doc chunks는 Python list `+` 로 자유롭게 합칠 수 있음 -- `add()`에는 반드시 `chunker.split(docs)` 결과를 전달 - ---- - -## 12) 청킹 전략 교체: Recursive vs Semantic - -### 12-1. 기본 청커 (RecursiveCharacterChunker) - -`from_sources()` — 원터치 패턴에서는 `splitter` 파라미터로 전달합니다. - -```python -from lang2sql import VectorRetriever, RecursiveCharacterChunker -from lang2sql.integrations.embedding import OpenAIEmbedding - -chunker = RecursiveCharacterChunker( - chunk_size=1000, - chunk_overlap=100, # 반드시 chunk_size보다 작아야 함 -) - -retriever = VectorRetriever.from_sources( - catalog=catalog, - documents=docs, - embedding=OpenAIEmbedding(), - splitter=chunker, # document_chunker 대신 splitter -) -``` - -`from_chunks()` — 명시적 패턴에서는 `.split()`을 직접 호출합니다. - -```python -doc_chunks = RecursiveCharacterChunker(chunk_size=1000, chunk_overlap=100).split(docs) -retriever = VectorRetriever.from_chunks( - CatalogChunker().split(catalog) + doc_chunks, - embedding=OpenAIEmbedding(), -) -``` - -### 12-2. 의미 기반 청커 (SemanticChunker, opt-in) - -```python -from lang2sql import CatalogChunker, VectorRetriever -from lang2sql.integrations.chunking import SemanticChunker -from lang2sql.integrations.embedding import OpenAIEmbedding - -embedding = OpenAIEmbedding(model="text-embedding-3-small") - -semantic_chunker = SemanticChunker( - embedding=embedding, # 청킹 단계에서도 임베딩 호출됨 - breakpoint_threshold=0.3, - min_chunk_size=100, -) - -# from_chunks 패턴: 청커를 직접 split에 사용 -doc_chunks = semantic_chunker.split(docs) -retriever = VectorRetriever.from_chunks( - CatalogChunker().split(catalog) + doc_chunks, - embedding=embedding, -) - -# 또는 from_sources 패턴: splitter 파라미터로 전달 -retriever = VectorRetriever.from_sources( - catalog=catalog, - documents=docs, - embedding=embedding, - splitter=semantic_chunker, -) -``` - -주의: - -- SemanticChunker는 인덱싱 비용/시간이 증가합니다. -- sentence split은 punctuation/newline 기반이라 문서 형식에 따라 튜닝이 필요합니다. - ---- - -## 13) HybridRetriever / HybridNL2SQL - -`HybridRetriever`는 BM25 + Vector를 RRF로 합쳐 안정적인 검색 결과를 제공합니다. - -### 13-1. Retriever 단독 사용 -```python -from lang2sql import HybridRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding - -retriever = HybridRetriever( - catalog=catalog, - embedding=OpenAIEmbedding(), - documents=docs, - top_n=5, - rrf_k=60, - score_threshold=0.0, -) - -result = retriever.run("지난달 할인 매출") -print("schemas:", [s["name"] for s in result.schemas]) -print("context:", result.context) -``` - -### 13-2. Flow로 바로 사용 (추천) -```python -from lang2sql import HybridNL2SQL -from lang2sql.integrations.db import SQLAlchemyDB -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.llm import OpenAILLM - -pipeline = HybridNL2SQL( - catalog=catalog, - llm=OpenAILLM(model="gpt-4o-mini"), - db=SQLAlchemyDB("sqlite:///sample.db"), - embedding=OpenAIEmbedding(), - documents=docs, - db_dialect="sqlite", - top_n=5, -) - -rows = pipeline.run("지난달 할인 매출") -print(rows) -``` - ---- - -## 14) 임베딩 교체 테스트 (v2 내장 + 사용자 구현) - -v2 내장 임베딩은 `OpenAIEmbedding` 1개입니다. -하지만 `EmbeddingPort`를 만족하는 클래스를 구현하면 다른 임베딩도 바로 테스트할 수 있습니다. - -### 14-1. 내장 OpenAIEmbedding -```python -from lang2sql.integrations.embedding import OpenAIEmbedding -embedding = OpenAIEmbedding(model="text-embedding-3-small") -``` - -### 14-2. API 키 없이 테스트용 FakeEmbedding -```python -class FakeEmbedding: - # 문자열 길이/토큰 카운트 기반 간단 임베딩 (테스트용) - def _vec(self, text: str) -> list[float]: - return [ - float(len(text)), - float(text.count("매출")), - float(text.count("주문")), - float(text.count("고객")), - ] - - def embed_query(self, text: str) -> list[float]: - return self._vec(text) - - def embed_texts(self, texts: list[str]) -> list[list[float]]: - return [self._vec(t) for t in texts] -``` - -### 14-3. 외부 임베딩 어댑터 예시 (선택) -```python -class SentenceTransformerEmbedding: - def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2"): - from sentence_transformers import SentenceTransformer - self._model = SentenceTransformer(model_name) - - def embed_query(self, text: str) -> list[float]: - return self._model.encode([text], normalize_embeddings=True)[0].tolist() - - def embed_texts(self, texts: list[str]) -> list[list[float]]: - return self._model.encode(texts, normalize_embeddings=True).tolist() -``` - ---- - -## 15) 벡터 스토어 교체 테스트 (v2 내장 + 사용자 구현) - -v2 내장 VectorStore는 `InMemoryVectorStore` 1개입니다. -하지만 `VectorStorePort`를 만족하면 어떤 백엔드든 연결할 수 있습니다. - -### 15-1. 내장 InMemoryVectorStore -```python -from lang2sql.integrations.vectorstore import InMemoryVectorStore -store = InMemoryVectorStore() -``` - -### 15-2. 사용자 구현 VectorStore 어댑터 (테스트용) -아래 코드는 "교체가 실제로 가능한지"를 검증하기 위한 최소 구현입니다. - -```python -class TinyVectorStore: - """ - 학습/테스트용 최소 VectorStore 구현. - 메모리에 id->vector를 저장하고 cosine brute-force 검색을 수행합니다. - """ - - def __init__(self): - self._rows = {} - - def upsert(self, ids: list[str], vectors: list[list[float]]) -> None: - for i, v in zip(ids, vectors): - self._rows[i] = v - - def search(self, vector: list[float], k: int) -> list[tuple[str, float]]: - import math - - def cosine(a, b): - dot = sum(x * y for x, y in zip(a, b)) - na = math.sqrt(sum(x * x for x in a)) + 1e-8 - nb = math.sqrt(sum(y * y for y in b)) + 1e-8 - return dot / (na * nb) - - ranked = sorted( - ((i, cosine(v, vector)) for i, v in self._rows.items()), - key=lambda x: x[1], - reverse=True, - ) - return ranked[:k] -``` - -### 15-3. 같은 코드에서 store만 갈아끼우기 -```python -from lang2sql import VectorRetriever - -# A) 내장 store -store_a = InMemoryVectorStore() - -# B) 사용자 구현 store -store_b = TinyVectorStore() - -# 나머지 코드(from_chunks/VectorRetriever)는 동일 -``` - -이게 의미하는 바: - -- 검색 정책(lang2sql 코어)은 유지 -- 저장소 구현체만 교체 - ---- - -## 16) 완전 수동 Advanced Flow 조합 - -아래는 고급 사용자가 실제로 많이 쓰는 패턴입니다. - -```python -from lang2sql import ( - CatalogChunker, - DirectoryLoader, - RecursiveCharacterChunker, - SQLExecutor, - SQLGenerator, - VectorRetriever, -) -from lang2sql.integrations.db import SQLAlchemyDB -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.llm import OpenAILLM - -# 1) 문서 로드 -docs = DirectoryLoader("docs/business").load() - -# 2) 명시적 파이프라인: split → from_chunks -embedding = OpenAIEmbedding(model="text-embedding-3-small") - -chunks = ( - CatalogChunker().split(catalog) + - RecursiveCharacterChunker().split(docs) -) - -retriever = VectorRetriever.from_chunks( - chunks, - embedding=embedding, - top_n=5, - score_threshold=0.2, -) - -# 3) 생성 / 실행 컴포넌트 개별 구성 -generator = SQLGenerator( - llm=OpenAILLM(model="gpt-4o-mini"), - db_dialect="sqlite", -) -executor = SQLExecutor(db=SQLAlchemyDB("sqlite:///sample.db")) - -# 4) 플로우 수동 실행 -query = "지난달 할인 반영 순매출" -retrieval = retriever.run(query) -sql = generator.run(query, retrieval.schemas, context=retrieval.context) -rows = executor.run(sql) - -print("SQL:", sql) -print("Rows:", rows) -``` - -이 패턴 장점: - -- split 단계가 코드에 보여 청킹 파라미터 튜닝이 직관적 -- 각 단계 결과를 모두 관측 가능 -- 임계값/청킹/임베딩/저장소를 독립 튜닝 가능 -- 실패 지점 분리 디버깅 쉬움 - ---- - -## 17) 관측성(Tracing)과 디버깅 - -`MemoryHook`으로 컴포넌트/플로우 이벤트를 추적할 수 있습니다. - -```python -from lang2sql import HybridNL2SQL, MemoryHook -from lang2sql.integrations.db import SQLAlchemyDB -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.llm import OpenAILLM - -hook = MemoryHook() - -pipeline = HybridNL2SQL( - catalog=catalog, - llm=OpenAILLM(model="gpt-4o-mini"), - db=SQLAlchemyDB("sqlite:///sample.db"), - embedding=OpenAIEmbedding(), - documents=docs, - db_dialect="sqlite", - top_n=5, - hook=hook, -) - -pipeline.run("지난달 주문 건수") - -for e in hook.snapshot(): - print(e.name, e.component, e.phase, e.duration_ms) -``` - -운영 관점 권장: - -- `duration_ms`를 컴포넌트별로 기록해 병목 확인 -- `error` 이벤트를 수집해 장애 패턴 분석 - ---- - -## 18) Best Practices 체크리스트 - -### 검색/인덱싱 -- `catalog`는 최소 `name`, `description`, `columns`를 충실히 작성 -- 문서는 한 파일에 너무 많은 주제를 넣지 말고 주제별 분리 -- `top_n`은 3~8 범위에서 시작해 실험 -- `score_threshold`는 0.0으로 시작 후 점진 상향 - -### 청킹 -- 기본은 `RecursiveCharacterChunker` -- 문서 품질이 중요하고 비용 허용 시 `SemanticChunker` 검토 -- `chunk_overlap`은 `chunk_size`보다 반드시 작게 설정 - -### 플로우 선택 -- 빠른 시작: `BaselineNL2SQL` -- 검색 품질 우선: `HybridNL2SQL` -- 완전 제어: 수동 컴포넌트 조합 - -### 운영 -- Hook 이벤트를 저장하고 p95 지표를 모니터링 -- 회귀 테스트를 정기 실행 - -```bash -pytest tests/test_components_vector_retriever.py -q -pytest tests/test_components_hybrid_retriever.py -q -pytest tests/test_components_loaders.py -q -``` - ---- - -## 19) 트러블슈팅 - -### Q1. `IntegrationMissingError: openai` -- 원인: `openai` 패키지 미설치 -- 해결: -```bash -pip install openai -``` - -### Q2. `chunk_overlap must be less than chunk_size` -- 원인: `RecursiveCharacterChunker` 파라미터 설정 오류 -- 해결: `chunk_overlap < chunk_size`로 수정 - -### Q3. VectorRetriever 결과가 비어 있음 -- 확인 순서: -1. `from_chunks(chunks, ...)` 또는 `from_sources(catalog=..., ...)` 가 실제로 호출되었는지 -2. `len(retriever._registry) > 0`인지 확인 -3. `score_threshold`가 너무 높지 않은지 (0.0으로 낮춰서 테스트) - -### Q4. `retriever.add()` 호출 시 타입 에러 -- 원인: `add()`는 `list[IndexedChunk]`만 받습니다. `TextDocument`를 직접 전달하면 에러가 발생합니다. -- 해결: 추가 전 반드시 `chunker.split(docs)`로 변환하세요: -```python -# ❌ 동작 안 함 -retriever.add(docs) - -# ✅ 올바른 방법 -retriever.add(RecursiveCharacterChunker().split(docs)) -``` - -### Q5. `IntegrationMissingError: pymupdf` -- 원인: `PDFLoader` 사용 시 `pymupdf` 미설치 -- 해결: -```bash -pip install pymupdf -``` - ---- - -## 마무리 - -이 문서의 순서대로 진행하면 아래 모든 경로를 실제로 검증할 수 있습니다. - -- Baseline keyword 플로우 -- VectorRetriever + 문서 인덱싱 -- HybridRetriever / HybridNL2SQL -- Loader/Chunker/Embedding/VectorStore 교체 -- 수동 Advanced Flow 및 tracing - -빠르게 시작하려면: - -1. 6단계(로컬 스모크 테스트) -2. 7단계(Baseline) -3. 13단계(HybridNL2SQL) - -고급 운영 튜닝까지 가려면: - -4. 11~16단계(from_chunks/어댑터/수동조합)까지 진행하세요. diff --git a/docs/tutorials/v2-usage-guide.md b/docs/tutorials/v2-usage-guide.md deleted file mode 100644 index 99885bd..0000000 --- a/docs/tutorials/v2-usage-guide.md +++ /dev/null @@ -1,263 +0,0 @@ -# lang2sql v2 Usage Guide - -이 문서는 `src/lang2sql` 기준의 새로운 v2 API만 다룹니다. -기존 `engine/`, `interface/`, `utils/llm/` 경로는 범위에서 제외합니다. - -자세한 단계별 실습은 [v2-complete-tutorial.md](./v2-complete-tutorial.md) 를 참고하세요. - -## 0) Why lang2sql - -- **운영 친화적인 기본 경로**: `Retriever -> Generator -> Executor`가 단순하고 디버깅 포인트가 명확합니다. -- **명시적 인덱싱 파이프라인**: `chunker.split(docs)` → `VectorRetriever.from_chunks(chunks)` 패턴으로 split/embed/store 각 단계가 코드에 보입니다. -- **프레임워크 락인 최소화**: 코어가 Protocol(`EmbeddingPort`, `VectorStorePort`, `DocumentChunkerPort`) 기반이라 구현체를 교체하기 쉽습니다. -- **관측성 내장**: Hook(`TraceHook`, `MemoryHook`)으로 컴포넌트 단위 실행 이벤트를 바로 수집할 수 있습니다. - -## 0-1) 튜토리얼 데이터 자동 준비 - -```bash -python scripts/setup_sample_db.py -python scripts/setup_sample_docs.py -``` - -문서 생성 후 `docs/business` 아래 파일을 로더 예제에서 그대로 사용합니다. - -## 1) v2에서 실제로 지원되는 기능 - -### Flows -- `BaselineNL2SQL`: BM25 `KeywordRetriever` 기반 기본 파이프라인 -- `HybridNL2SQL`: BM25 + Vector `HybridRetriever` 기반 파이프라인 -- `EnrichedNL2SQL`: Gate + 프로파일링 + 보강 + HybridRetriever 기반 풀 파이프라인 - -### Retrievers -- `KeywordRetriever` -- `VectorRetriever` -- `HybridRetriever` - -### Vector / Embedding (v2 내장) -- Embedding: `OpenAIEmbedding`, `AzureOpenAIEmbedding`, `GeminiEmbedding`, `BedrockEmbedding`, `OllamaEmbedding`, `HuggingFaceEmbedding` (6개) -- Vector store: `InMemoryVectorStore`, `FAISSVectorStore`, `PGVectorStore` (3개) - -### Chunking / Loading -- Chunkers: `CatalogChunker`, `RecursiveCharacterChunker`, `SemanticChunker` - - 모두 `.split(list)` 메서드 제공 — LangChain 스타일 batch 입력/출력 -- Loaders: `MarkdownLoader`, `PlainTextLoader`, `DirectoryLoader` - - `PDFLoader` (optional, `pip install pymupdf`) - -### Extensibility (Protocol) -- `EmbeddingPort`, `VectorStorePort`, `DocumentChunkerPort`, `DocumentLoaderPort` -- 즉, 내장 구현 외에도 사용자 어댑터를 연결할 수 있습니다. - -## 2) 빠른 선택 가이드 - -### 가장 쉬운 시작 -- 목적: 설치 후 바로 NL2SQL 확인 -- 선택: `BaselineNL2SQL` -- 특징: 벡터 인덱싱 없이 즉시 사용 - -### 검색 품질을 빠르게 올리고 싶을 때 -- 목적: 키워드 매칭 한계를 보완 -- 선택: `HybridNL2SQL` + `OpenAIEmbedding` -- 특징: BM25 + Vector RRF 결합으로 안정적인 검색 품질 - -### 고급 제어가 필요할 때 -- 목적: 청킹/임베딩/인덱싱/검색 파이프라인 세밀 제어 -- 선택: `chunker.split()` + `VectorRetriever.from_chunks()` + 수동 컴포넌트 조합 -- 특징: 증분 인덱싱, 커스텀 Chunker/VectorStore/Embedding 연동 가능 - -## 3) 최소 예제 - -### A. BaselineNL2SQL (키워드 기반) -```python -from lang2sql import BaselineNL2SQL -from lang2sql.integrations.db import SQLAlchemyDB -from lang2sql.integrations.llm import OpenAILLM - -catalog = [ - { - "name": "orders", - "description": "order table", - "columns": {"order_id": "pk", "amount": "order amount"}, - } -] - -pipeline = BaselineNL2SQL( - catalog=catalog, - llm=OpenAILLM(model="gpt-4o-mini"), - db=SQLAlchemyDB("sqlite:///sample.db"), - db_dialect="sqlite", -) - -rows = pipeline.run("지난달 주문 건수") -print(rows) -``` - -### B. HybridNL2SQL (키워드 + 벡터) -```python -from lang2sql import HybridNL2SQL -from lang2sql.integrations.db import SQLAlchemyDB -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.llm import OpenAILLM - -catalog = [ - { - "name": "orders", - "description": "order table", - "columns": {"order_id": "pk", "amount": "order amount"}, - } -] - -docs = [ - { - "id": "biz_rules", - "title": "매출 정의", - "content": "매출은 반품 제외 순매출이다.", - "source": "docs/biz_rules.md", - } -] - -pipeline = HybridNL2SQL( - catalog=catalog, - llm=OpenAILLM(model="gpt-4o-mini"), - db=SQLAlchemyDB("sqlite:///sample.db"), - embedding=OpenAIEmbedding(model="text-embedding-3-small"), - documents=docs, - db_dialect="sqlite", - top_n=5, -) - -rows = pipeline.run("지난달 순매출") -print(rows) -``` - -### C. 명시적 파이프라인: split → from_chunks (LangChain 스타일) -```python -from lang2sql import ( - CatalogChunker, - DirectoryLoader, - RecursiveCharacterChunker, - VectorRetriever, -) -from lang2sql.integrations.embedding import OpenAIEmbedding - -catalog = [ - { - "name": "orders", - "description": "order table", - "columns": {"order_id": "pk", "amount": "order amount"}, - } -] - -# 1) 문서 로딩 -docs = DirectoryLoader("docs/business").load() - -# 2) 각 소스를 명시적으로 split -catalog_chunks = CatalogChunker().split(catalog) -doc_chunks = RecursiveCharacterChunker(chunk_size=800, chunk_overlap=80).split(docs) - -# 3) chunks를 합쳐서 retriever 생성 (embed + store 자동) -retriever = VectorRetriever.from_chunks( - catalog_chunks + doc_chunks, - embedding=OpenAIEmbedding(model="text-embedding-3-small"), - top_n=5, -) - -result = retriever.run("순매출 계산 기준") -print("schemas:", [s["name"] for s in result.schemas]) -print("context:", result.context[:2]) -``` - -명시적 플로우의 장점: - -1. split 단계가 코드에 보임 — `chunker.split(docs)`가 명시적 -2. catalog chunks + doc chunks를 Python list로 자유롭게 조합 가능 -3. `registry = {}` 같은 내부 상태를 사용자가 직접 관리할 필요 없음 - -증분 추가 시에는 chunks를 미리 split한 뒤 전달합니다: - -```python -new_docs = DirectoryLoader("docs/new").load() -retriever.add(RecursiveCharacterChunker().split(new_docs)) -``` - -### D. DirectoryLoader → HybridNL2SQL 직결 - -문서를 로드한 뒤 바로 HybridNL2SQL에 전달하는 가장 간결한 패턴입니다. - -```python -from lang2sql import DirectoryLoader, HybridNL2SQL -from lang2sql.integrations.db import SQLAlchemyDB -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.llm import OpenAILLM - -docs = DirectoryLoader("docs/business").load() - -pipeline = HybridNL2SQL( - catalog=catalog, - llm=OpenAILLM(model="gpt-4o-mini"), - db=SQLAlchemyDB("sqlite:///sample.db"), - embedding=OpenAIEmbedding(model="text-embedding-3-small"), - documents=docs, - db_dialect="sqlite", -) - -rows = pipeline.run("지난달 순매출") -print(rows) -``` - -### E. PDFLoader — PDF 파일 인덱싱 - -PDF 파일은 `PDFLoader`로 로드합니다 (`pip install pymupdf` 필요). - -```python -from lang2sql import DirectoryLoader, MarkdownLoader, VectorRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.loaders import PDFLoader - -# PDFLoader를 DirectoryLoader에 등록 -docs = DirectoryLoader( - "docs/", - loaders={ - ".md": MarkdownLoader(), - ".pdf": PDFLoader(), - }, -).load() - -# 이후 from_chunks 패턴으로 인덱싱 -from lang2sql import CatalogChunker, RecursiveCharacterChunker - -chunks = ( - CatalogChunker().split(catalog) + - RecursiveCharacterChunker().split(docs) -) -retriever = VectorRetriever.from_chunks( - chunks, - embedding=OpenAIEmbedding(model="text-embedding-3-small"), -) -``` - -PDF는 페이지 단위로 `TextDocument`를 생성합니다: -- `id`: `"{filename}__p{page_number}"` -- `title`: `"{filename} page {page_number}"` - -## 4) 중요한 현재 제약 - -- `BaselineNL2SQL`은 `retriever` 주입 파라미터를 받지 않습니다. - - 벡터 기반 파이프라인은 `HybridNL2SQL` 또는 수동 조합을 사용하세요. -- `VectorRetriever` 결과의 `context`는 현재 `list[str]`입니다. - - 문서 출처 구조화가 필요하면 `metadata`를 별도 조회하거나 커스텀 래퍼를 두세요. -- `retriever.add()`는 **`list[IndexedChunk]`만 받습니다** — `TextDocument` 직접 전달 불가. - - 추가 전 반드시 `chunker.split(docs)`로 split한 결과를 전달하세요: - ```python - # ❌ 동작 안 함 - retriever.add(docs) - - # ✅ 올바른 방법 - retriever.add(RecursiveCharacterChunker().split(docs)) - ``` - -## 5) 추천 실습 순서 - -1. [v2-complete-tutorial.md](./v2-complete-tutorial.md) 1~4단계로 로컬 스모크 테스트 -2. 동일 문서 5~8단계로 실제 DB/LLM 연결 -3. 동일 문서 9~13단계로 벡터 인덱싱/문서 파싱/청킹 튜닝 -4. 동일 문서 14~18단계로 고급 조합과 커스텀 어댑터 테스트 diff --git a/docs/tutorials/vector-retriever.md b/docs/tutorials/vector-retriever.md deleted file mode 100644 index 1d45b26..0000000 --- a/docs/tutorials/vector-retriever.md +++ /dev/null @@ -1,559 +0,0 @@ -# VectorRetriever 튜토리얼 — 벡터 유사도 검색으로 NL2SQL 정확도 높이기 - -이 튜토리얼은 `VectorRetriever`를 처음 사용하는 분을 위한 단계별 가이드입니다. -`KeywordRetriever`(BM25 키워드 검색)와 다른 점, 설정 방법, 파이프라인에 연결하는 방법을 설명합니다. - ---- - -## 목차 - -1. [KeywordRetriever vs VectorRetriever — 언제 무엇을 쓸까?](#1-keywordretriever-vs-vectorretriever--언제-무엇을-쓸까) -2. [설치 — 임베딩 패키지 추가하기](#2-설치--임베딩-패키지-추가하기) -3. [가장 빠른 시작 — from_sources()](#3-가장-빠른-시작--from_sources) -4. [비즈니스 문서를 컨텍스트로 추가하기](#4-비즈니스-문서를-컨텍스트로-추가하기) -5. [파이프라인에 연결하기](#5-파이프라인에-연결하기) -6. [인덱스 점진적으로 추가하기 — add()](#6-인덱스-점진적으로-추가하기--add) -7. [고급 — 명시적 파이프라인 (from_chunks)](#7-고급--명시적-파이프라인-from_chunks) -8. [고급 — 청커 교체하기](#8-고급--청커-교체하기) -9. [점수 임계값과 top_n 조정](#9-점수-임계값과-top_n-조정) -10. [전체 체크리스트 — API 키 없이 실행](#10-전체-체크리스트--api-키-없이-실행) - ---- - -## 1. KeywordRetriever vs VectorRetriever — 언제 무엇을 쓸까? - -| | `KeywordRetriever` | `VectorRetriever` | -|---|---|---| -| **검색 방식** | BM25 키워드 매칭 | 벡터 코사인 유사도 | -| **강점** | 빠름, 외부 의존성 없음 | 동의어·의미 유사 쿼리에 강함 | -| **약점** | 질문과 컬럼명이 다를 때 누락 | 임베딩 API 또는 모델 필요 | -| **적합한 상황** | 카탈로그 규모가 작고 컬럼명이 명확할 때 | 카탈로그가 크거나, 비즈니스 용어가 컬럼명과 다를 때 | -| **비즈니스 문서 지원** | 없음 | 있음 (`context` 필드로 LLM에 전달) | - -> **판단 기준**: `"매출"` 이라고 물었을 때 `amount` 컬럼이 검색되지 않으면 VectorRetriever로 교체하세요. - ---- - -## 2. 설치 - -```bash -pip install lang2sql -``` - -`openai`는 lang2sql의 기본 의존성에 포함되어 있어 별도 설치가 필요 없습니다. - -> 임베딩 API 없이 테스트하고 싶다면 **섹션 10**의 `FakeEmbedding`을 먼저 실행해 보세요. - ---- - -## 3. 가장 빠른 시작 — from_sources() - -`VectorRetriever.from_sources()` 한 줄로 인덱스를 만들고 즉시 검색할 수 있습니다. - -```python -from lang2sql import VectorRetriever, CatalogEntry -from lang2sql.integrations.embedding import OpenAIEmbedding - -CATALOG: list[CatalogEntry] = [ - { - "name": "orders", - "description": "고객 주문 정보 테이블. 주문 건수, 매출, 날짜 조회에 사용.", - "columns": { - "order_id": "주문 고유 ID (PK)", - "customer_id": "주문한 고객 ID (FK → customers)", - "order_date": "주문 일시 (TIMESTAMP)", - "amount": "주문 금액 (DECIMAL)", - "status": "주문 상태: pending / confirmed / shipped / cancelled", - }, - }, - { - "name": "customers", - "description": "고객 마스터 데이터. 고객 이름, 가입일, 등급 조회에 사용.", - "columns": { - "customer_id": "고객 고유 ID (PK)", - "name": "고객 이름", - "grade": "고객 등급: bronze / silver / gold", - }, - }, -] - -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=OpenAIEmbedding(), # OPENAI_API_KEY 환경변수 필요 -) - -result = retriever("매출 상위 고객 목록") - -print(result.schemas) -# [{'name': 'orders', ...}, {'name': 'customers', ...}] - -print(result.context) -# [] — 문서를 추가하지 않았으므로 빈 리스트 -``` - -`from_sources()`는 내부적으로 다음을 자동으로 처리합니다: -- `InMemoryVectorStore` 생성 (외부 DB 불필요) -- 카탈로그 청킹 → 임베딩 → 저장 (`from_chunks()` 내부 호출) -- 검색 준비 완료된 `VectorRetriever` 반환 - ---- - -## 4. 비즈니스 문서를 컨텍스트로 추가하기 - -"매출"의 정의, KPI 계산 방식 같은 비즈니스 규칙을 문서로 등록하면 -LLM이 SQL 생성 시 해당 내용을 참고합니다. - -```python -from lang2sql import TextDocument - -DOCS: list[TextDocument] = [ - { - "id": "revenue_def", - "title": "매출 정의", - "content": "매출은 반품을 제외한 순매출(net sales)을 기준으로 한다. " - "취소(cancelled) 상태의 주문은 매출에서 제외한다.", - "source": "docs/revenue_definition.md", - }, - { - "id": "grade_policy", - "title": "고객 등급 정책", - "content": "gold 등급: 최근 3개월 누적 구매액 50만원 이상. " - "silver 등급: 20만원 이상. bronze: 그 외.", - "source": "docs/customer_grade.md", - }, -] - -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - documents=DOCS, # ← 문서 동시 인덱싱 - embedding=OpenAIEmbedding(), -) - -result = retriever("이번 달 매출을 집계해줘") - -print(result.schemas) # 관련 테이블 목록 -print(result.context) # 관련 문서 텍스트 — LLM 프롬프트에 포함됨 -# ['매출 정의: 매출은 반품을 제외한 순매출...'] -``` - -> `result.context`의 내용은 `SQLGenerator`가 프롬프트에 "Business Context" 섹션으로 자동 삽입합니다. - ---- - -## 5. 파이프라인에 연결하기 - -벡터 기반 검색을 사용하려면 `HybridNL2SQL`을 사용합니다. -(`BaselineNL2SQL`은 `KeywordRetriever`만 내부적으로 사용하며, retriever 주입 파라미터를 받지 않습니다.) - -```python -from lang2sql import HybridNL2SQL -from lang2sql.integrations.llm import AnthropicLLM -from lang2sql.integrations.db import SQLAlchemyDB -from lang2sql.integrations.embedding import OpenAIEmbedding - -pipeline = HybridNL2SQL( - catalog=CATALOG, - llm=AnthropicLLM(model="claude-sonnet-4-6"), - db=SQLAlchemyDB("sqlite:///sample.db"), - embedding=OpenAIEmbedding(), - documents=DOCS, - db_dialect="sqlite", -) - -rows = pipeline.run("취소 제외한 이번 달 매출 합계") -print(rows) -``` - -또는 `VectorRetriever`를 직접 조합해 수동 파이프라인을 구성할 수 있습니다: - -```python -from lang2sql import VectorRetriever, SQLGenerator, SQLExecutor -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.llm import AnthropicLLM -from lang2sql.integrations.db import SQLAlchemyDB - -retriever = VectorRetriever.from_sources( - catalog=CATALOG, documents=DOCS, embedding=OpenAIEmbedding(), -) -generator = SQLGenerator(llm=AnthropicLLM(model="claude-sonnet-4-6"), db_dialect="sqlite") -executor = SQLExecutor(db=SQLAlchemyDB("sqlite:///sample.db")) - -query = "취소 제외한 이번 달 매출 합계" -result = retriever(query) -sql = generator(query, result.schemas, context=result.context) -rows = executor(sql) -``` - ---- - -## 6. 인덱스 점진적으로 추가하기 — add() - -파이프라인이 실행 중일 때 새 문서를 동적으로 추가할 수 있습니다. -기존 카탈로그 인덱스는 그대로 유지됩니다. - -```python -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=OpenAIEmbedding(), -) - -# 나중에 문서가 생겼을 때 추가 -NEW_DOCS: list[TextDocument] = [ - { - "id": "discount_policy", - "title": "할인 정책", - "content": "VIP 고객(gold 등급)에게는 정가의 10% 할인을 적용한다.", - "source": "docs/discount.md", - }, -] - -retriever.add(RecursiveCharacterChunker().split(NEW_DOCS)) # pre-split 후 전달 - -result = retriever("VIP 고객 할인 금액 계산") -print(result.context) -# ['할인 정책: VIP 고객(gold 등급)에게는...'] -``` - -> **주의**: `add()`는 `list[IndexedChunk]`만 받습니다. -> `TextDocument`를 직접 전달하면 오류가 발생합니다. -> -> ```python -> # ❌ 동작 안 함 -> retriever.add(NEW_DOCS) -> -> # ✅ 올바른 방법 -> retriever.add(RecursiveCharacterChunker().split(NEW_DOCS)) -> ``` - ---- - -## 7. 고급 — 명시적 파이프라인 (from_chunks) - -영속 벡터스토어(FAISS, pgvector)를 사용하거나, -카탈로그와 문서를 따로 스케줄링하고 싶을 때 `from_chunks()`를 직접 사용합니다. - -```python -from lang2sql import CatalogChunker, RecursiveCharacterChunker, VectorRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding -from lang2sql.integrations.vectorstore import FAISSVectorStore - -embedding = OpenAIEmbedding() - -# (1) 청킹 — 각 소스를 명시적으로 split -catalog_chunks = CatalogChunker().split(CATALOG) -doc_chunks = RecursiveCharacterChunker(chunk_size=500).split(DOCS) -all_chunks = catalog_chunks + doc_chunks - -# (2) 영속 벡터스토어 지정 -store = FAISSVectorStore(index_path="./index/catalog.faiss") - -# (3) Retriever 생성 (embed + store 자동) -retriever = VectorRetriever.from_chunks( - all_chunks, - embedding=embedding, - vectorstore=store, -) -store.save() # 디스크에 저장 - -result = retriever("매출 정의") -``` - -`from_sources()` 대비 직접 제어가 필요한 경우: -- 벡터 저장소를 외부 DB(FAISS 파일, pgvector)로 교체할 때 -- 인덱스를 디스크에 저장하고 재사용할 때 -- 카탈로그와 문서를 따로 스케줄링할 때 -- 청킹 중간 결과를 검사하거나 필터링할 때 - ---- - -## 8. 고급 — 청커 교체하기 - -### 기본 청커 비교 - -| 청커 | 위치 | 특징 | -|------|------|------| -| `CatalogChunker` | `components/retrieval/chunker.py` | 테이블 헤더 + 컬럼 그룹으로 분할. 스키마 검색 전용. | -| `RecursiveCharacterChunker` | `components/retrieval/chunker.py` | 문단→줄→문장 순 재귀 분할. 외부 의존성 없음. | -| `SemanticChunker` | `integrations/chunking/semantic_.py` | 임베딩 기반 의미 단위 분할. 품질 우선 시 사용. | - -### SemanticChunker 사용하기 (opt-in) - -```bash -pip install sentence-transformers # 또는 openai 패키지 -``` - -```python -from lang2sql import VectorRetriever -from lang2sql.integrations.chunking import SemanticChunker -from lang2sql.integrations.embedding import OpenAIEmbedding - -embedding = OpenAIEmbedding() - -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - documents=DOCS, - embedding=embedding, - splitter=SemanticChunker(embedding=embedding), # ← 의미 기반 청킹 -) -``` - -### LangChain 청커 어댑터 (외부 라이브러리 연결) - -```python -from langchain_text_splitters import RecursiveCharacterTextSplitter -from lang2sql import IndexedChunk, TextDocument - -class LangChainChunkerAdapter: - """LangChain 텍스트 스플리터를 lang2sql DocumentChunkerPort에 맞게 감쌉니다.""" - - def __init__(self, splitter): - self._splitter = splitter - - def chunk(self, doc: TextDocument) -> list[IndexedChunk]: - texts = self._splitter.split_text(doc["content"]) - title = doc.get("title", "") - return [ - IndexedChunk( - chunk_id=f"{doc['id']}__{i}", - text=f"{title}: {text}" if title else text, - source_type="document", - source_id=doc["id"], - chunk_index=i, - metadata={"title": title, "source": doc.get("source", "")}, - ) - for i, text in enumerate(texts) - ] - - -lc_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50) - -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - documents=DOCS, - embedding=OpenAIEmbedding(), - splitter=LangChainChunkerAdapter(lc_splitter), -) -``` - ---- - -## 9. 점수 임계값과 top_n 조정 - -```python -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=OpenAIEmbedding(), - top_n=3, # 반환할 최대 스키마/문서 수 (기본값: 5) - score_threshold=0.5, # 유사도가 이 값보다 낮은 결과는 제외 (기본값: 0.0) -) -``` - -| 파라미터 | 기본값 | 설명 | -|----------|--------|------| -| `top_n` | 5 | 반환하는 스키마(schemas)와 문서(context) 각각의 최대 수 | -| `score_threshold` | 0.0 | 이 값 **이하**의 유사도 점수는 결과에서 제외. 낮은 관련성 결과를 걸러낼 때 사용 | - -> 관련 없는 테이블이 자꾸 검색된다면 `score_threshold`를 0.3~0.5 사이로 높여보세요. - ---- - -## 10. 전체 체크리스트 — API 키 없이 실행 - -아래 코드는 실제 임베딩 API 없이 `FakeEmbedding`으로 모든 기능을 확인합니다. - -```python -""" -VectorRetriever 전체 체크리스트 -API 키 없이 FakeEmbedding으로 실행 가능합니다. -""" - -# ── 0. FakeEmbedding 정의 ───────────────────────────────────────────────────── - -class FakeEmbedding: - """테스트용 고정 벡터 임베딩. 실제 유사도 계산은 하지 않습니다.""" - def embed_query(self, text: str) -> list[float]: - return [0.1, 0.2, 0.3, 0.4] - - def embed_texts(self, texts: list[str]) -> list[list[float]]: - return [[0.1, 0.2, 0.3, 0.4]] * len(texts) - - -# ── 1. 카탈로그와 문서 준비 ────────────────────────────────────────────────── - -from lang2sql import CatalogEntry, TextDocument - -CATALOG: list[CatalogEntry] = [ - { - "name": "orders", - "description": "고객 주문 정보 테이블", - "columns": {"order_id": "PK", "amount": "금액", "status": "상태"}, - }, - { - "name": "customers", - "description": "고객 마스터 데이터", - "columns": {"customer_id": "PK", "name": "이름", "grade": "등급"}, - }, -] - -DOCS: list[TextDocument] = [ - { - "id": "revenue_def", - "title": "매출 정의", - "content": "매출은 반품 제외 순매출이며 cancelled 주문은 제외한다.", - "source": "docs/revenue.md", - }, -] - - -# ── 2. from_sources() — 카탈로그만 ─────────────────────────────────────────── - -from lang2sql import VectorRetriever - -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=FakeEmbedding(), -) - -result = retriever("주문 건수") -print("✓ from_sources() — 카탈로그만") -print(f" schemas: {[s['name'] for s in result.schemas]}") -print(f" context: {result.context}") -assert isinstance(result.schemas, list) -assert result.context == [] - - -# ── 3. from_sources() — 문서 포함 ──────────────────────────────────────────── - -retriever2 = VectorRetriever.from_sources( - catalog=CATALOG, - documents=DOCS, - embedding=FakeEmbedding(), -) - -result2 = retriever2("매출 정의") -print("\n✓ from_sources() — 문서 포함") -print(f" schemas: {[s['name'] for s in result2.schemas]}") -print(f" context: {result2.context}") -assert len(result2.context) >= 1 - - -# ── 4. add() — 점진적 인덱싱 ───────────────────────────────────────────────── - -from lang2sql import RecursiveCharacterChunker - -initial_count = len(retriever._registry) - -NEW_DOC: list[TextDocument] = [ - { - "id": "grade_policy", - "title": "등급 정책", - "content": "gold 등급은 최근 3개월 50만원 이상 구매 고객이다.", - "source": "docs/grade.md", - }, -] - -retriever.add(RecursiveCharacterChunker().split(NEW_DOC)) # pre-split 필수 - -print("\n✓ add() — 점진적 인덱싱") -print(f" registry 크기: {initial_count} → {len(retriever._registry)}") -assert len(retriever._registry) > initial_count - - -# ── 5. score_threshold 필터링 ───────────────────────────────────────────────── - -from lang2sql import CatalogChunker -from lang2sql.integrations.vectorstore import InMemoryVectorStore - -catalog_chunks = CatalogChunker().split(CATALOG) -strict_retriever = VectorRetriever.from_chunks( - catalog_chunks, - embedding=FakeEmbedding(), - # FakeEmbedding은 항상 동일 벡터 반환 → 코사인 유사도 = 1.0 - # score_threshold=1.0 이면 1.0 <= 1.0 조건 충족 → 전부 필터링됨 - score_threshold=1.0, -) - -result3 = strict_retriever("주문") -print("\n✓ score_threshold=1.0 — 결과 필터링") -print(f" schemas: {result3.schemas} (빈 리스트 예상)") -assert result3.schemas == [] - - -# ── 6. from_chunks — 카탈로그 + 문서 병합 ──────────────────────────────────── - -catalog_chunks2 = CatalogChunker().split(CATALOG) -doc_chunks2 = RecursiveCharacterChunker().split(DOCS) -all_chunks2 = catalog_chunks2 + doc_chunks2 - -retriever3 = VectorRetriever.from_chunks( - all_chunks2, - embedding=FakeEmbedding(), -) - -catalog_ids = {c["chunk_id"] for c in catalog_chunks2} -for chunk_id in catalog_ids: - assert chunk_id in retriever3._registry, f"카탈로그 청크 '{chunk_id}' 유실!" - -print("\n✓ from_chunks — 카탈로그 + 문서 병합 확인") -print(f" 카탈로그 청크 수: {len(catalog_ids)} (모두 존재)") - - -# ── 7. public import 확인 ──────────────────────────────────────────────────── - -from lang2sql import ( - VectorRetriever, - CatalogChunker, - RecursiveCharacterChunker, - DocumentChunkerPort, - RetrievalResult, - TextDocument, - IndexedChunk, - EmbeddingPort, - VectorStorePort, -) -print("\n✓ 모든 VectorRetriever 관련 import 성공") - -print("\n" + "=" * 50) -print("모든 체크리스트 통과! VectorRetriever 사용 준비 완료.") -print("=" * 50) -``` - ---- - -## 참고: 아키텍처 한눈에 보기 - -``` -[CATALOG / DOCS] - │ - ▼ chunker.split() - CatalogChunker — 테이블 헤더 + 컬럼 그룹 분할 - RecursiveCharacterChunker — 문서 분할 (또는 SemanticChunker) - │ - ▼ list[IndexedChunk] - VectorRetriever.from_chunks() / from_sources() - │ embed_texts() - ▼ - EmbeddingPort — OpenAIEmbedding 등 (6개) - │ upsert() - ▼ - VectorStorePort — InMemoryVectorStore / FAISSVectorStore / PGVectorStore - │ - ▼ - VectorRetriever.__call__(query) - ├── embed_query(query) - ├── vectorstore.search(vector, k) - └── RetrievalResult - ├── .schemas — 관련 CatalogEntry 목록 (중복 제거됨) - └── .context — 관련 문서 텍스트 목록 - │ - ▼ - SQLGenerator — "Business Context" 섹션으로 프롬프트에 포함 -``` - -**확장 포인트:** - -| 인터페이스 | 구현할 메서드 | 용도 | -|-----------|------------|------| -| `EmbeddingPort` | `embed_query()`, `embed_texts()` | 임베딩 백엔드 교체 | -| `VectorStorePort` | `search()`, `upsert()` | 벡터 저장소 교체 (FAISS, pgvector 등) | -| `DocumentChunkerPort` | `chunk(doc)` | 청킹 전략 교체 | diff --git a/docs/tutorials/vector-store-backends.md b/docs/tutorials/vector-store-backends.md deleted file mode 100644 index 0a14ef4..0000000 --- a/docs/tutorials/vector-store-backends.md +++ /dev/null @@ -1,598 +0,0 @@ -# 벡터 저장소 백엔드 가이드 — InMemory / FAISS / pgvector - -> **버전**: lang2sql v0.3.0 -> **업데이트**: 2026-02-27 - ---- - -## 목차 - -1. [세 가지 백엔드 비교](#1-세-가지-백엔드-비교) -2. [의존성 — 별도 설치 불필요](#2-의존성--별도-설치-불필요) -3. [InMemoryVectorStore — 기본값](#3-inmemoryvectorstore--기본값) -4. [FAISSVectorStore — 로컬 파일 영속성](#4-faissvectorstore--로컬-파일-영속성) -5. [PGVectorStore — PostgreSQL 영속성](#5-pgvectorstore--postgresql-영속성) -6. [백엔드 교체 방법](#6-백엔드-교체-방법) -7. [커스텀 벡터 저장소 직접 구현하기](#7-커스텀-벡터-저장소-직접-구현하기) -8. [전체 체크리스트 — API 키 없이 실행](#8-전체-체크리스트--api-키-없이-실행) - ---- - -## 1. 세 가지 백엔드 비교 - -| | `InMemoryVectorStore` | `FAISSVectorStore` | `PGVectorStore` | -|---|---|---|---| -| **저장 위치** | 메모리 (휘발성) | 로컬 파일 (`.faiss` + `.meta`) | PostgreSQL DB | -| **영속성** | 없음 — 재시작 시 소멸 | 있음 — 파일로 저장/로드 | 있음 — DB에 영구 저장 | -| **Upsert** | true upsert (dict 기반) | append-only (동일 id 중복 주의) | true upsert (ON CONFLICT) | -| **멀티 서버** | 불가 | 불가 (파일 단일 접근) | 가능 | -| **권장 규모** | < 50k chunks | < 500k chunks | 500k+ chunks | -| **추가 설치** | 불필요 | 불필요 (기본 포함) | 불필요 (기본 포함) | -| **적합한 환경** | 개발/테스트, 소규모 | 단일 서버 운영, 중규모 | 팀 공유, 대규모 운영 | - ---- - -## 2. 의존성 — 별도 설치 불필요 - -세 백엔드 모두 `pip install lang2sql` 한 번으로 설치됩니다. -`pyproject.toml`의 기본 의존성(`dependencies`)에 포함되어 있습니다. - -| 패키지 | 고정 버전 | 역할 | -|--------|----------|------| -| `numpy` | `<2.0` | InMemoryVectorStore 행렬 연산 | -| `faiss-cpu` | `==1.10.0` | FAISSVectorStore 인덱스 엔진 | -| `psycopg2-binary` | `>=2.9.10,<3.0.0` | PGVectorStore PostgreSQL 연결 | -| `pgvector` | `==0.3.6` | PGVectorStore `vector` 타입 직렬화 | - -> **GPU 가속이 필요한 경우**: `faiss-cpu`를 직접 `faiss-gpu`로 교체할 수 있습니다. -> pyproject.toml의 `faiss-cpu==1.10.0`을 `faiss-gpu==1.10.0`으로 변경 후 `uv sync`. - ---- - -## 3. InMemoryVectorStore — 기본값 - -numpy 기반 브루트 포스 코사인 유사도. `vectorstore=` 를 생략하면 자동으로 사용됩니다. - -**특징:** -- true upsert — 동일 chunk_id를 두 번 넣으면 덮어씀 -- 검색 시 매번 행렬 재구성 (수만 벡터까지 충분히 빠름) -- 프로세스 종료 시 인덱스 소멸 - -```python -from lang2sql import VectorRetriever, CatalogEntry -from lang2sql.integrations.embedding import OpenAIEmbedding - -CATALOG: list[CatalogEntry] = [ - { - "name": "orders", - "description": "고객 주문 정보", - "columns": {"order_id": "PK", "amount": "금액", "status": "상태"}, - }, -] - -# vectorstore= 생략 → InMemoryVectorStore 자동 사용 -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=OpenAIEmbedding(), -) - -result = retriever("주문 건수") -print(result.schemas) -``` - ---- - -## 4. FAISSVectorStore — 로컬 파일 영속성 - -Facebook AI Research의 벡터 검색 라이브러리. -`IndexFlatIP` + L2 정규화로 정확한 코사인 유사도를 계산합니다. - -### 4-1. 기본 사용법 — from_sources() - -```python -from lang2sql import VectorRetriever -from lang2sql.integrations.vectorstore import FAISSVectorStore -from lang2sql.integrations.embedding import OpenAIEmbedding - -store = FAISSVectorStore(index_path="./index/catalog.faiss") - -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=OpenAIEmbedding(), - vectorstore=store, # ← FAISSVectorStore 주입 -) - -# 인덱스를 파일로 저장 -store.save() -# → ./index/catalog.faiss (FAISS 바이너리) -# → ./index/catalog.faiss.meta (chunk id 목록 JSON) -``` - -### 4-2. 명시적 파이프라인 — from_chunks() - -```python -from lang2sql import VectorRetriever, CatalogChunker, RecursiveCharacterChunker -from lang2sql import TextDocument -from lang2sql.integrations.vectorstore import FAISSVectorStore -from lang2sql.integrations.embedding import OpenAIEmbedding - -embedding = OpenAIEmbedding() -store = FAISSVectorStore(index_path="./index/catalog.faiss") - -DOCS: list[TextDocument] = [ - { - "id": "revenue_def", - "title": "매출 정의", - "content": "매출은 취소 주문을 제외한 순매출 기준이다.", - "source": "docs/revenue.md", - }, -] - -chunks = ( - CatalogChunker().split(CATALOG) + - RecursiveCharacterChunker(chunk_size=800, chunk_overlap=80).split(DOCS) -) - -retriever = VectorRetriever.from_chunks( - chunks, - embedding=embedding, - vectorstore=store, -) - -store.save() -``` - -### 4-3. 재시작 시 로드 - -```python -from lang2sql.integrations.vectorstore import FAISSVectorStore -from lang2sql import VectorRetriever -from lang2sql.integrations.embedding import OpenAIEmbedding - -# 파일에서 바로 로드 — 임베딩/인덱싱 없이 즉시 검색 가능 -store = FAISSVectorStore.load("./index/catalog.faiss") - -# registry는 from_chunks()가 자동 복원 불가 → 재인덱싱 필요 -# 실전에서는 프로세스 시작 시 from_sources()를 다시 실행하는 패턴 권장 -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=OpenAIEmbedding(), - vectorstore=store, # 이미 채워진 store — upsert() 추가로 호출됨 (append) -) -``` - -> **append-only 제한**: `FAISSVectorStore`는 동일 chunk_id를 두 번 upsert하면 -> FAISS 인덱스에 두 개의 항목이 생깁니다. 깨끗한 인덱스가 필요하면 -> 새 `FAISSVectorStore()` 인스턴스로 처음부터 인덱싱하세요. - -### 4-4. save/load 예외 처리 - -```python -# index_path 없이 생성한 경우 save()는 경로 필요 -store = FAISSVectorStore() -store.upsert(["a"], [[1.0, 0.0]]) -store.save("./index/catalog.faiss") # 경로 직접 지정 - -# upsert() 전에 save() 호출 → RuntimeError -store_empty = FAISSVectorStore(index_path="./out.faiss") -store_empty.save() # RuntimeError: Cannot save before any upsert() call. - -# 존재하지 않는 파일 로드 → FileNotFoundError -FAISSVectorStore.load("./nonexistent.faiss") # FileNotFoundError -``` - ---- - -## 5. PGVectorStore — PostgreSQL 영속성 - -PostgreSQL의 `pgvector` 확장을 사용합니다. -`ON CONFLICT DO UPDATE` true upsert로 중복 없이 멱등 인덱싱이 가능합니다. - -### 5-1. PostgreSQL 빠른 시작 (Docker) - -```bash -docker run -d \ - --name pgvector \ - -e POSTGRES_PASSWORD=postgres \ - -p 5432:5432 \ - pgvector/pgvector:pg16 -``` - -### 5-2. 기본 사용법 — from_sources() - -```python -from lang2sql import VectorRetriever -from lang2sql.integrations.vectorstore import PGVectorStore -from lang2sql.integrations.embedding import OpenAIEmbedding - -store = PGVectorStore( - connection="postgresql://postgres:postgres@localhost:5432/postgres", - table_name="lang2sql_vectors", # 자동 생성됨 -) - -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=OpenAIEmbedding(), - vectorstore=store, # ← PGVectorStore 주입 -) -# → upsert() 시점에 테이블이 없으면 자동 생성 -# → 같은 chunk_id를 다시 upsert하면 덮어씀 (true upsert) -``` - -### 5-3. 명시적 파이프라인 — from_chunks() - -```python -from lang2sql import VectorRetriever, CatalogChunker, RecursiveCharacterChunker -from lang2sql.integrations.vectorstore import PGVectorStore -from lang2sql.integrations.embedding import OpenAIEmbedding - -store = PGVectorStore( - connection="postgresql://postgres:postgres@localhost:5432/postgres", - table_name="lang2sql_vectors", -) - -chunks = ( - CatalogChunker().split(CATALOG) + - RecursiveCharacterChunker().split(DOCS) -) - -retriever = VectorRetriever.from_chunks( - chunks, - embedding=OpenAIEmbedding(), - vectorstore=store, -) -# save() 없음 — upsert()마다 DB에 즉시 반영 -``` - -### 5-4. 멱등 재인덱싱 - -같은 카탈로그로 여러 번 인덱싱해도 중복이 생기지 않습니다. - -```python -# 1차 실행 -retriever = VectorRetriever.from_sources( - catalog=CATALOG, embedding=embedding, vectorstore=store -) - -# 2차 실행 (카탈로그 변경 후) — 동일 chunk_id는 embedding이 갱신됨 -retriever = VectorRetriever.from_sources( - catalog=UPDATED_CATALOG, embedding=embedding, vectorstore=store -) -# DB에 중복 없이 덮어써짐 (ON CONFLICT DO UPDATE) -``` - -### 5-5. 자동 테이블 구조 - -첫 `upsert()` 시 아래 DDL이 실행됩니다: - -```sql -CREATE EXTENSION IF NOT EXISTS vector; -CREATE TABLE IF NOT EXISTS lang2sql_vectors ( - id TEXT PRIMARY KEY, - embedding vector(1536) -- 임베딩 모델 차원에 따라 자동 결정 -); -``` - ---- - -## 6. 백엔드 교체 방법 - -`vectorstore=` 파라미터만 바꾸면 됩니다. 나머지 파이프라인은 변경 없습니다. - -```python -from lang2sql import VectorRetriever -from lang2sql.integrations.vectorstore import ( - InMemoryVectorStore, - FAISSVectorStore, - PGVectorStore, -) -from lang2sql.integrations.embedding import OpenAIEmbedding - -embedding = OpenAIEmbedding() - -# ① InMemory (기본값) -retriever = VectorRetriever.from_sources( - catalog=CATALOG, embedding=embedding -) - -# ② FAISS — vectorstore= 한 줄 교체 -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=embedding, - vectorstore=FAISSVectorStore(index_path="./index/catalog.faiss"), -) - -# ③ pgvector — vectorstore= 한 줄 교체 -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=embedding, - vectorstore=PGVectorStore( - connection="postgresql://postgres:postgres@localhost:5432/postgres" - ), -) -``` - ---- - -## 7. 커스텀 벡터 저장소 직접 구현하기 - -`VectorStorePort` Protocol을 만족하는 클래스를 만들면 됩니다. -Chroma, Qdrant, Weaviate 등 어떤 벡터 DB든 연결 가능합니다. - -```python -from lang2sql import VectorStorePort # Protocol - -class ChromaVectorStore: - """Chroma를 lang2sql VectorStorePort에 연결하는 어댑터.""" - - def __init__(self, collection_name: str = "lang2sql"): - import chromadb - self._client = chromadb.Client() - self._col = self._client.get_or_create_collection(collection_name) - - def upsert(self, ids: list[str], vectors: list[list[float]]) -> None: - self._col.upsert(ids=ids, embeddings=vectors) - - def search(self, vector: list[float], k: int) -> list[tuple[str, float]]: - results = self._col.query(query_embeddings=[vector], n_results=k) - ids = results["ids"][0] - dists = results["distances"][0] - return [(id_, 1.0 - dist) for id_, dist in zip(ids, dists)] - - -# 사용 -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=OpenAIEmbedding(), - vectorstore=ChromaVectorStore("my_catalog"), -) -``` - -구현해야 할 메서드는 두 개뿐입니다: - -| 메서드 | 시그니처 | 역할 | -|--------|---------|------| -| `upsert` | `(ids: list[str], vectors: list[list[float]]) -> None` | 벡터 저장 | -| `search` | `(vector: list[float], k: int) -> list[tuple[str, float]]` | 유사도 검색 → `(chunk_id, score)`, score 높을수록 유사 | - ---- - -## 8. 전체 체크리스트 — API 키 없이 실행 - -아래 코드는 `FakeEmbedding`으로 API 키 없이 세 백엔드를 모두 검증합니다. -pgvector 테스트는 `TEST_POSTGRES_URL` 환경변수가 있을 때만 실행됩니다. - -```python -""" -벡터 저장소 백엔드 전체 체크리스트 -API 키 없이 FakeEmbedding으로 실행 가능합니다. - -실행: - python docs/tutorials/vector-store-backends.md # ← 이 블록만 별도 .py로 저장 후 실행 - -pgvector 테스트 포함: - TEST_POSTGRES_URL="postgresql://postgres:postgres@localhost:5432/postgres" \\ - python check_backends.py -""" - -import os - -# ── 공통 픽스처 ──────────────────────────────────────────────────────────────── - -class FakeEmbedding: - """테스트용 고정 벡터 임베딩. 4차원 단위벡터를 반환합니다.""" - def embed_query(self, text: str) -> list[float]: - return [1.0, 0.0, 0.0, 0.0] - - def embed_texts(self, texts: list[str]) -> list[list[float]]: - return [[1.0, 0.0, 0.0, 0.0]] * len(texts) - - -from lang2sql import CatalogEntry, TextDocument, VectorRetriever -from lang2sql import CatalogChunker, RecursiveCharacterChunker - -CATALOG: list[CatalogEntry] = [ - { - "name": "orders", - "description": "고객 주문 정보 테이블", - "columns": {"order_id": "PK", "amount": "금액", "status": "상태"}, - }, - { - "name": "customers", - "description": "고객 마스터 데이터", - "columns": {"customer_id": "PK", "name": "이름", "grade": "등급"}, - }, -] - -DOCS: list[TextDocument] = [ - { - "id": "revenue_def", - "title": "매출 정의", - "content": "매출은 취소 주문을 제외한 순매출 기준이다.", - "source": "docs/revenue.md", - }, -] - -embedding = FakeEmbedding() - - -# ── 1. InMemoryVectorStore ───────────────────────────────────────────────────── - -print("=" * 50) -print("1. InMemoryVectorStore") - -from lang2sql.integrations.vectorstore import InMemoryVectorStore - -retriever = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=embedding, - # vectorstore= 생략 → InMemoryVectorStore 자동 사용 -) -result = retriever("주문 건수") -assert isinstance(result.schemas, list) -assert len(result.schemas) > 0 -print(f" schemas: {[s['name'] for s in result.schemas]}") -print(" ✓ InMemoryVectorStore 정상") - - -# ── 2. FAISSVectorStore ──────────────────────────────────────────────────────── - -print("\n2. FAISSVectorStore") - -import tempfile, pathlib - -faiss = __import__("faiss") # 없으면 ImportError → 아래 try/except -try: - from lang2sql.integrations.vectorstore import FAISSVectorStore - - with tempfile.TemporaryDirectory() as tmpdir: - index_path = str(pathlib.Path(tmpdir) / "catalog.faiss") - - # 2-a. from_sources - store = FAISSVectorStore(index_path=index_path) - retriever_f = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=embedding, - vectorstore=store, - ) - result_f = retriever_f("주문 건수") - assert len(result_f.schemas) > 0 - print(f" from_sources schemas: {[s['name'] for s in result_f.schemas]}") - - # 2-b. save / load - store.save() - loaded = FAISSVectorStore.load(index_path) - result_loaded = VectorRetriever.from_sources( - catalog=CATALOG, - embedding=embedding, - vectorstore=loaded, - )("주문 건수") - assert len(result_loaded.schemas) > 0 - print(f" save/load schemas: {[s['name'] for s in result_loaded.schemas]}") - - # 2-c. from_chunks (명시적 파이프라인) - chunks = ( - CatalogChunker().split(CATALOG) + - RecursiveCharacterChunker().split(DOCS) - ) - store2 = FAISSVectorStore() - retriever_fc = VectorRetriever.from_chunks( - chunks, embedding=embedding, vectorstore=store2 - ) - result_fc = retriever_fc("매출 정의") - assert len(result_fc.context) > 0 - print(f" from_chunks context: {result_fc.context[0][:30]}...") - - # 2-d. 예외 처리 - try: - FAISSVectorStore().save() - assert False, "ValueError 미발생" - except ValueError: - pass - - try: - FAISSVectorStore.load("no_such_file.faiss") - assert False, "FileNotFoundError 미발생" - except FileNotFoundError: - pass - - print(" ✓ FAISSVectorStore 정상") - -except ImportError: - print(" ⚠ faiss 미설치 — 건너뜀") - - -# ── 3. PGVectorStore ─────────────────────────────────────────────────────────── - -print("\n3. PGVectorStore") - -PG_URL = os.getenv("TEST_POSTGRES_URL") -if not PG_URL: - print(" ⚠ TEST_POSTGRES_URL 미설정 — 건너뜀") - print(" 실행하려면: TEST_POSTGRES_URL=postgresql://... python