[최종 프로젝트] 2주차: 벡터 DB에 문서 저장 (250730)

해피해피슈크림·2025년 7월 30일

✅ 오늘의 업무: 벡터 DB에 문서 저장하기

오늘 할 일은 벡터 DB에 데이터를 임베딩해서 저장하고, Langchain 구조로 RAG를 구현하는 것이었다. 나는 벡터 DB에 데이터를 저장하는 일을, 같이 일한 다른 팀원은 langchain을 설계하고 프롬프트를 짜는 일을 했다.

Qdrant를 로컬에서 돌리면 내 컴퓨터에서 벡터 DB를 구축해도 다른 팀원과 공유할 수 없기에, 내가 DB를 구축하는 코드를 써서 다른 팀원에게 전달해 RAG를 구현했다.

  • Qdrant 벡터 DB에 데이터 임베딩해서 저장하기
  • 메타데이터도 임베딩 벡터와 함께 저장

RAG 구현 결과


질문을 입력하면 문서를 찾아 LLM에 전달하고, 답변을 생성해준다~!


🔎 벡터 DB 구축 과정

Qdrant 환경 설정: Docker, WSL

먼저 Qdrant DB를 사용하기 위해 Docker와 WSL을 준비해야 했다. Docker 사용자 이름 입력창에 자꾸 한글이 깨진듯한 오류가 떠서 강사님께 여쭤봤는데, 강사님이 오류 뜨면 재설치하면 되니까요~ 하시면서 깨진 문자 옆에 그대로 사용자 이름 적었더니 됐다 😮


코드 리뷰: 벡터 DB 구축

여러 개의 DOCX 워드 문서를 읽어, 조문(조항) 단위로 텍스트를 분할하고, 각 조문을 다시 여러 개의 작은 청크(chunk)로 나눈 뒤, OpenAI 임베딩 모델 "text-embedding-3-large"를 사용해 텍스트 임베딩을 생성하고, Qdrant 벡터 데이터베이스에 임베딩과 함께 메타데이터(본문, 카테고리)를 저장하는 전체 파이프라인이다.

아래는 코드 각 부분별 상세 설명이다.

1. load_docx_text(filepath)

def load_docx_text(filepath):
    doc = Document(filepath)
    return '\n'.join([p.text.strip() for p in doc.paragraphs if p.text.strip()])
  • 기능: 주어진 filepath의 DOCX 파일을 열어서 모든 문단(paragraph)에서 텍스트를 추출한다.
  • 빈 줄(내용 없는 단락)은 제외하고, 각 문단 텍스트에 대해 앞뒤 공백을 제거한 후 한 줄씩 합쳐 하나의 큰 문자열로 반환한다.
  • 결과: 텍스트 전체가 하나의 큰 스트링으로 구성됨.

2. split_by_articles(text)

def split_by_articles(text):
    pattern = r'(제\d+조.*?)(?=제\d+조|$)'
    return re.findall(pattern, text, re.DOTALL)
  • 기능: 전체 본문 텍스트에서 “제1조”, “제2조” 등 ‘제숫자조’ 로 시작하는 조문 단위로 텍스트를 분리해서 리스트로 반환한다.
  • re.DOTALL 옵션을 통해 줄바꿈 문자를 포함해 전체를 매칭하도록 한다.
  • 패턴 설명:
    • (제\d+조.*?) : ‘제숫자조’부터 가능한 가장 적게(최소 매칭) 텍스트 추출
    • (?=제\d+조|$) : 다음 조문 혹은 텍스트 끝까지

결과적으로 조문 리스트 형태가 된다.


3. get_openai_embedding(text: str)

def get_openai_embedding(text: str):
    response = openai.embeddings.create(
        model="text-embedding-3-large",
        input=text
    )
    return response.data[0].embedding
  • OpenAI API를 사용해 text에 대한 임베딩 벡터를 생성한다.
  • 사용 모델은 "text-embedding-3-large"로 3072차원의 고차원 벡터를 반환합니다.
  • API 호출 결과(JSON)에서 임베딩 벡터만 추출해 반환.
  • 참고: OpenAI API 키가 환경변수나 코드에서 설정되어 있어야 정상 작동한다.

4. 문서 - 카테고리 매핑 (category_map)

category_map = {
    "복리후생 규정.docx": "복지",
    ...
}
  • 각 DOCX 파일명에 대응하는 카테고리(주제 분류)를 미리 지정해둔 딕셔너리이다.
  • 문서별로 적절한 카테고리 값을 메타데이터 저장 시 함께 부여하기 위해 사용한다.
  • 만약 맵에 없으면 "기타"로 기본 처리하게 되어 있다.

5. Qdrant 클라이언트 초기화 및 컬렉션 생성

folder = r"C:\SKN13SM\SKN13_FINAL_6Team\생성 문서"
collection_name = "regulations"
client = QdrantClient(host="localhost", port=6333)

if client.collection_exists(collection_name):
    client.delete_collection(collection_name)

client.create_collection(
    collection_name=collection_name,
    vectors_config=VectorParams(size=3072, distance=Distance.COSINE),
)
  • 변수
    • folder : DOCX 문서들이 저장된 폴더 경로
    • collection_name : Qdrant 내에 새로 생성할 컬렉션 이름
  • Qdrant 클라이언트(로컬호스트 6333 포트) 연결
  • 컬렉션이 이미 있으면 삭제 후 재생성 (delete_collectioncreate_collection)
  • 임베딩 벡터 차원(3072), 거리 메트릭(COSINE 유사도) 설정

6. RecursiveCharacterTextSplitter 초기화

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=100
)
  • 자주 쓰는 텍스트 분할 도구인 LangChain 내 RecursiveCharacterTextSplitter 클래스.
  • 각 조문 내 긴 텍스트를 최대 1000자 크기(문자 단위)로 분할하면서, 각 청크별로 100자씩 겹쳐서 분할하여 의미 단절 최소화.
  • 토큰 단위가 아닌 문자 수 기준임을 유의

7. 전체 문서 대상 임베딩용 포인트 리스트 생성

points = []

for file in os.listdir(folder):
    if not file.endswith(".docx"):
        continue
    
    path = os.path.join(folder, file)
    category = category_map.get(file, "기타")
    text = load_docx_text(path)
    articles = split_by_articles(text)

    for article in articles:
        chunks = text_splitter.split_text(article)  # 조문별 텍스트를 다시 청크로 분할
        
        for chunk in chunks:
            if not chunk.strip():
                continue
            
            embedding = get_openai_embedding(chunk)  # OpenAI 임베딩 API 호출
            point = PointStruct(
                id=str(uuid.uuid4()),          # 임의 UUID로 고유 ID 생성
                vector=embedding,              # 3072차원 임베딩 벡터
                payload={
                    "text": chunk,             # 원문 청크 텍스트
                    "category": category       # 해당 문서 카테고리 메타데이터
                }
            )
            points.append(point)
  • 폴더 내 모든 .docx 파일을 순회
  • 각 문서로부터 텍스트 추출 → 조문 단위 분할
  • 각 조문은 1000자 크기 청크들로 다시 분할
  • 각 청크에 대하여 OpenAI 임베딩 생성
  • 생성된 벡터와 청크 텍스트, 카테고리 메타데이터를 담은 Qdrant 포인트(PointStruct) 생성
  • points 리스트에 모두 쌓음

8. Qdrant에 임베딩 및 메타데이터 업로드

client.upsert(collection_name=collection_name, points=points)
print("✅ Qdrant 업로드 완료!")
  • 모든 생성한 벡터 포인트를 Qdrant 컬렉션에 일괄 업로드
  • 업로드 완료 시 메시지 출력

요약

단계설명
1. DOCX 텍스트 읽기각 문서 내 모든 텍스트 불러오기
2. 조문별 분할“제숫자조” 패턴을 이용해 조항 단위로 분리
3. 긴 조문 청크별 분할각 조문을 1000자 미만 청크 단위로 나눔
4. OpenAI 임베딩 생성각 청크 텍스트를 "text-embedding-3-large" 모델로 벡터화
5. Qdrant 포인트 생성UUID, 벡터, 원문 텍스트, 카테고리 포함
6. Qdrant에 벡터 및 메타데이터 저장모두 모아서 한꺼번에 업로드

추가 참고사항

  • 임베딩 생성 API는 호출량이 많으면 비용/속도에 영향 있으니, 대용량 처리 시 배치(batch) 처리나 캐싱 고려.
  • 임베딩 벡터의 차원이 code 내 VectorParams와 맞아야 하며, 모델 변경 시 차원도 맞춰야 한다.
  • category 외에 다른 메타데이터를 더 추가하려면 payload에 추가 키-값 쌍을 포함시키면 된다.
  • Qdrant 검색 시 메타데이터(예: category)로 필터링하거나 상세 표시가 가능하다.

Qdrant Docker 서버 연결

[WinError 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다는 Qdrant 서버(즉, Docker 컨테이너)가 실행 중이 아니어서 발생하는 대표적인 오류이다.
Docker의 Qdrant 서버를 반드시 실행시켜야 합니다!

1. 여러 DOCX 파일 한 번에 처리하기 코드 예시

여러 워드 파일을 한 번에 처리하려면 os.listdir 또는 glob 라이브러리를 활용해 폴더 내 모든 .docx 파일을 순회하면 됩니다.

아래는 이 기능이 반영된 전체 코드 예시입니다:

import os
import re
from docx import Document
from sentence_transformers import SentenceTransformer
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
import numpy as np
import uuid

# 📄 DOCX 파일에서 텍스트 추출
def load_docx_text(filepath):
    doc = Document(filepath)
    full_text = []
    for para in doc.paragraphs:
        if para.text.strip():  # 빈 줄 제거
            full_text.append(para.text.strip())
    return '\n'.join(full_text)

# ✂️ "제1조", "제2조" 등의 조문 단위로 분할
def split_by_articles(text):
    pattern = r'(제\d+조.*?)(?=제\d+조|$)'
    articles = re.findall(pattern, text, re.DOTALL)
    return articles

if __name__ == "__main__":
    folder = "C:/SKN13SM/clean_repo_SM/13_Langchain/documents"  # 워드 파일 폴더 경로
    docx_files = [os.path.join(folder, file) for file in os.listdir(folder) if file.endswith(".docx")]

    all_articles = []
    file_infos = []

    for filepath in docx_files:
        text = load_docx_text(filepath)
        articles = split_by_articles(text)
        all_articles.extend(articles)
        file_infos.extend([{"filename": os.path.basename(filepath)}] * len(articles))
    print(f"✅ 전체 조문 개수: {len(all_articles)}")

    # 임베딩
    model = SentenceTransformer("jhgan/ko-sbert-nli")
    embeddings = model.encode(all_articles, convert_to_numpy=True, show_progress_bar=True)

    # Qdrant 연결 및 컬렉션 생성
    client = QdrantClient(host="localhost", port=6333)
    collection_name = "regulations"

    if client.collection_exists(collection_name):
        client.delete_collection(collection_name)
    client.recreate_collection(
        collection_name=collection_name,
        vectors_config=VectorParams(size=embeddings.shape[1], distance=Distance.COSINE),
    )
    print("✅ Qdrant 컬렉션 생성 완료!")

    # Qdrant에 업로드 (파일명 정보까지)
    points = [
        PointStruct(
            id=str(uuid.uuid4()),
            vector=embedding.tolist(),
            payload={"text": article, **info}  # 파일명 등 정보 추가
        )
        for article, embedding, info in zip(all_articles, embeddings, file_infos)
    ]
    client.upsert(collection_name=collection_name, points=points)
    print("✅ Qdrant에 업로드 완료!")

2. "연결을 거부했습니다" 오류 원인과 Qdrant Docker 서버 실행법

오류 원인:
[WinError 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다는 Qdrant 서버(즉, Docker 컨테이너)가 실행 중이 아니어서 발생하는 대표적인 오류입니다.
Docker의 Qdrant 서버를 반드시 실행시켜야 합니다!

Qdrant Docker 서버 실행법

  1. Docker Desktop을 반드시 실행시킨다.
  2. 터미널(또는 명령 프롬프트/WSL)에서 아래 명령을 순서대로 입력한다.
docker pull qdrant/qdrant

(이미 pull 했다면 생략 가능)

docker run -p 6333:6333 -p 6334:6334 -v Qdrant_데이터_저장_폴더의_경로:/qdrant/storage qdrant/qdrant
  • 위 명령은 6333(REST/web) 및 6334(gRPC) 포트를 연다.

  • 윈도우 CMD/PowerShell에서 경로 표기만 주의:

  • /qdrant/storage ← 컨테이너 내부의 데이터 저장 경로 (이건 바꾸지 마세요)

  • :(콜론)으로 구분

  1. 정상적으로 실행된 경우, 브라우저에서 http://localhost:6333 접속 시 Qdrant 환영 메시지가 떠야 한다.

요약

  • Qdrant 서버(Docker) 실행 → Python 코드 실행 순서로 하기
  • Docker 컨테이너가 종료되어 있거나 포트가 막혀있으면 연결 오류가 발생한다.
  • Qdrant는 기본적으로 6333 포트로 HTTP API를 제공하니, client 인스턴스도 그에 맞게 설정해야 한다.

0개의 댓글