TIL/[TIL]

[TIL]Chroma VectorStore 기반 RAG와 FastAPI 기초

namerong 2026. 7. 24. 20:07

1. 학습 주제

RAG 흐름에서 검색 결과를 실제 LLM 답변으로 연결하는 구조와, FastAPI로 API 서버를 만드는 기본 흐름을 학습했다.

핵심 내용은 다음과 같다.

  • Chroma를 사용한 영구 VectorStore 구성
  • Retriever로 관련 문서 검색
  • 검색된 문서를 Context로 만들어 LLM에 전달
  • RAG 답변에 출처 포함하기
  • FastAPI의 Path, Query, Body 처리
  • Pydantic을 활용한 요청 검증
  • response_model과 예외 처리

2. Chroma VectorStore

이전에는 InMemoryVectorStore를 사용해 메모리 안에서만 문서를 저장하고 검색했다.
이번에는 Chroma를 사용해 벡터 데이터를 디스크에 저장하고 재사용하는 구조를 학습했다.

store = Chroma(
    collection_name="class_knowledge",
    embedding_function=create_embeddings(),
    persist_directory=str(CHROMA_PERSIST_DIRECTORY),
)

InMemoryVectorStore는 프로그램을 종료하면 데이터가 사라진다.
반면 Chroma는 persist_directory에 벡터 DB를 저장하므로, 다시 실행해도 기존 색인을 재사용할 수 있다.

흐름은 다음과 같다.

문서 로드
-> Document 변환
-> Embedding 생성
-> Chroma에 저장
-> 기존 색인이 있으면 재사용
-> 질문과 유사한 문서 검색

3. 색인과 재사용

VectorStore에 문서를 넣는 과정을 색인(indexing)이라고 볼 수 있다.
문서를 Embedding Vector로 바꾸고, 검색 가능한 형태로 저장하는 과정이다.

existing_ids = store.get()["ids"]
indexed_now = not existing_ids

if indexed_now:
    store.add_documents(docs, ids=[str(document.id) for document in docs])

이미 저장된 문서 ID가 있으면 다시 Embedding하지 않고 기존 색인을 재사용한다.
--rebuild-index 옵션을 사용하면 기존 collection을 초기화하고 다시 색인할 수 있다.

if rebuild_index:
    store.reset_collection()

정리하면 다음과 같다.

상황 처리
처음 실행 문서를 Embedding 후 Chroma에 저장
다시 실행 기존 Chroma 색인 재사용
--rebuild-index 사용 기존 색인 삭제 후 다시 생성

4. Retriever

Retriever는 VectorStore 위에서 검색 정책을 적용하는 객체이다.

retriever = store.as_retriever(
    search_kwargs={"k": top_k}
)

VectorStore가 벡터와 문서를 저장하는 역할이라면, Retriever는 “질문이 들어왔을 때 어떤 문서를 몇 개 가져올지” 정하는 역할이다.

질문
-> Retriever
-> VectorStore에서 유사 문서 검색
-> 상위 k개 Document 반환

top_k는 검색 결과를 몇 개 가져올지 정하는 값이다.

retrieved_docs = retriever.invoke(question)

5. Metadata Filter

문서에는 본문뿐 아니라 metadata도 함께 들어간다.

metadata={
    "source": path.name,
    "line": line_number,
    "topic": topic_for_text(text),
}

metadata는 검색 결과의 출처를 표시하거나, 특정 주제만 검색할 때 사용한다.

예를 들어 memory 관련 문서만 검색하고 싶다면 다음처럼 filter를 적용할 수 있다.

filter=lambda document: document.metadata.get("topic") == "memory"

이렇게 하면 전체 문서 중 질문과 비슷한 문서를 찾되, topic이 memory인 문서만 대상으로 검색한다.


6. RAG 답변 생성 흐름

RAG는 검색된 문서를 LLM에게 근거로 제공하고 답변을 생성하는 방식이다.

이번 구조는 다음과 같다.

사용자 질문
-> Retriever가 관련 Document 검색
-> 검색 결과를 Context 문자열로 변환
-> Prompt에 Context와 질문 삽입
-> LLM 호출
-> 문서 기반 답변 생성

검색된 문서는 다음처럼 Context로 변환한다.

def format_context(documents):
    return "\n\n".join(
        f"[출처: {doc.metadata.get('source')}:{doc.metadata.get('line')}]\n"
        f"{doc.page_content}"
        for doc in documents
    )

이 Context를 Prompt에 넣는다.

너는 AI 수업 도우미다.
아래 Context만 근거로 답하라.
근거가 없으면 문서에서 찾을 수 없다고 답하라.
답변 마지막에 사용한 출처를 표시하라.

[Context]
...

핵심은 LLM이 마음대로 답하지 않도록 “검색된 문서만 근거로 답하라”는 제약을 주는 것이다.


7. FastAPI 기본 구조

FastAPI는 Python으로 API 서버를 만들 수 있는 웹 프레임워크이다.

기본 객체를 만들고, decorator로 URL과 함수를 연결한다.

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello FastAPI"}

이 구조는 다음 의미를 가진다.

GET /
-> root 함수 실행
-> dict 반환
-> FastAPI가 JSON 응답으로 변환

FastAPI는 기본적으로 /docs에서 Swagger 문서를 자동 제공한다.
API를 만들면서 바로 테스트할 수 있다는 장점이 있다.


8. Path Parameter와 Query Parameter

Path Parameter는 URL 경로 안에 들어가는 값이다.

@app.get("/items/{item_id}")
async def read_item(item_id: int):
    return {"item_id": item_id}

예시:

GET /items/10

Query Parameter는 URL 뒤에 ?key=value 형태로 붙는 값이다.

@app.get("/items")
async def list_items(skip: int = 0, limit: int = 3):
    ...

예시:

GET /items?skip=0&limit=3

정리하면 다음과 같다.

구분 예시 사용 목적
Path Parameter /items/10 특정 자원 식별
Query Parameter /items?limit=3 검색, 필터, 페이징 조건

9. Request Body와 Pydantic

POST나 PUT 요청에서는 JSON Body를 받을 수 있다.
FastAPI에서는 Pydantic 모델을 사용해 Body 구조를 정의한다.

class ItemCreate(BaseModel):
    name: str = Field(min_length=1, max_length=50)
    price: float = Field(gt=0)
@app.post("/items")
async def create_item(item: ItemCreate):
    return item.model_dump()

Pydantic 모델을 쓰면 좋은 점은 다음과 같다.

  • 필수값 검증
  • 타입 검증
  • 문자열 길이 검증
  • 숫자 범위 검증
  • Swagger 문서 자동 생성

단순히 dict로 받으면 내부 필드 검증이 약해진다.
그래서 API 요청 Body는 가능하면 Pydantic 모델로 정의하는 것이 좋다.


10. Validation

FastAPI와 Pydantic은 요청값 검증을 자동으로 처리해준다.

name: str = Field(min_length=2, max_length=50)
price: float = Field(gt=0, le=1_000_000)
category: Literal["book", "course", "tool"]

검증 조건을 만족하지 않으면 FastAPI가 자동으로 422 Unprocessable Entity 응답을 반환한다.

직접 검증 로직도 추가할 수 있다.

@field_validator("name")
@classmethod
def normalize_name(cls, value: str) -> str:
    normalized = value.strip()
    if not normalized:
        raise ValueError("상품명은 공백만 입력할 수 없습니다.")
    return normalized

이런 방식으로 API 입구에서 잘못된 데이터를 막을 수 있다.


11. Response Model과 예외 처리

response_model은 응답으로 내보낼 데이터 구조를 제한한다.

class UserResponse(BaseModel):
    id: int
    username: str
    display_name: str
@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
    return find_user_or_404(user_id)

DB나 내부 객체에 password 같은 값이 있어도, response_model에 없는 필드는 응답에서 제외된다.

사용자를 찾지 못한 경우에는 HTTPException으로 명확한 에러를 반환한다.

raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")

API에서는 정상 응답뿐 아니라 실패 상황도 명확히 설계해야 한다.


12. 핵심 정리

  1. Chroma는 벡터 데이터를 디스크에 저장하고 재사용할 수 있는 VectorStore이다.
  2. 색인은 문서를 Embedding Vector로 변환해 검색 가능한 상태로 저장하는 과정이다.
  3. Retriever는 VectorStore에서 질문과 관련 있는 문서를 가져오는 검색 담당 객체이다.
  4. top_k는 검색 결과를 몇 개 가져올지 정하는 값이다.
  5. metadata는 출처 표시와 필터 검색에 사용된다.
  6. RAG는 검색된 문서를 Context로 만들고, 질문과 함께 LLM에 전달해 답변을 생성한다.
  7. FastAPI는 decorator로 HTTP Method와 URL을 함수에 연결한다.
  8. Path Parameter는 특정 자원을 식별하고, Query Parameter는 검색이나 페이징 조건에 자주 사용된다.
  9. Request Body는 Pydantic 모델로 정의하면 타입과 값 검증을 자동화할 수 있다.
  10. response_model은 응답 데이터 구조를 제한해 민감한 필드 노출을 막는 데 도움이 된다.
  11. HTTPException을 사용하면 API 실패 상황을 명확한 상태 코드와 메시지로 처리할 수 있다.