RAG2026-5일10차시 · 벡터저장소 이해7 / 19← 이전목차다음 →MetaContents

10차시. 벡터저장소 이해

4. 실습 ② — 불러와서 재사용하기

출처: 원본 PDF 10차시_벡터저장소이해.pdf 9~10쪽 · 쪽 그림 9쪽 10쪽

오늘의 핵심입니다. 저장한 인덱스를 불러오면 임베딩 비용이 0이 됩니다.

📝 src/load_index.py

Python
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

emb = OpenAIEmbeddings(model="text-embedding-3-small")

# 저장된 인덱스 불러오기 (비용 0, 즉시 완료)
store = FAISS.load_local(
    "faiss_index",
    emb,
    allow_dangerous_deserialization=True,
)
print("✓ 인덱스 로드 완료")

found = store.similarity_search("환불 규정", k=3)
print(f"검색 결과 {len(found)}건")

실행 결과 예시 (코드를 읽고 만든 예상 화면입니다. 실제 실행 결과, 특히 모델의 답은 다를 수 있습니다)

출력
✓ 인덱스 로드 완료
검색 결과 3건

⚠️ allow_dangerous_deserialization=True 가 뭔가요?
FAISS는 파이썬 pickle 형식으로 저장합니다.
pickle 파일은 열 때 코드가 실행될 수 있어서
모르는 사람이 만든 파일은 위험합니다.
그래서 라이브러리가 "정말 열 거냐"고 묻는 것입니다.

👉
내가 직접 만든 인덱스만 이 옵션으로 여세요.

👉 인터넷에서 받은 인덱스 파일은 절대 열지 마세요.

실습 코드 · 10/load_index.py

실습 코드: 실습/src/10/load_index.py · 위의 원본 PDF 10차시 9쪽 「4️⃣ 실습 ② — 불러와서 재사용하기」과 이어집니다.

Python
# ============================================================
# 10차시 | PPT: 10차시_벡터저장소이해.pptx
# 역할: 저장해 둔 FAISS 인덱스를 열고 질문과 가까운 chunk 3개를 출력
# 입력: faiss_index 폴더, 질문 "환불 규정이 궁금해요"
# 출력: 로드 문장, 검색 건수, 각 조각 본문
# 흐름:
#   prepare_chuncks (조각 준비 출력만 하고 검색에는 기존 인덱스 사용)
#     -> FAISS.load_local
#     -> similarity_search(질문 embedding) -> print
# ============================================================
# embedding: 질문을 숫자 목록(vector)으로 바꿀 때 쓰는 모델
# vector: 그 숫자 목록. FAISS: 저장해 둔 벡터에서 가까운 것을 찾는 저장소
# chunk: 인덱스 안에 들어 있는 문서 조각

from langchain_openai import OpenAIEmbeddings
from dotenv import load_dotenv
import os
import sys
from langchain_community.vectorstores import FAISS

load_dotenv()

# build_index.py와 같은 모델이어야 질문 벡터가 저장된 벡터와 같은 좌표계에 놓입니다.
emb = OpenAIEmbeddings(model="text-embedding-3-small")

CURRENT_DIR = os.path.dirname(__file__)
PREPARE_DIR = os.path.join(CURRENT_DIR, "..", "07")

sys.path.insert(0, PREPARE_DIR)
from prepare import prepare_chuncks

DOC_PATH = "../../data/manual.pdf"
INDEX_PATH = "faiss_index"
EMBED_MODEL = "text-embedding-3-small"

# 이 줄은 조각을 다시 만들 뿐, 아래 검색에는 쓰이지 않습니다. 지워도 검색 결과는 같습니다.
chunks = prepare_chuncks(DOC_PATH)
# print(f"총 {len(chunks)}개의 청크를 준비했습니다.")

# 디스크의 인덱스를 읽습니다. 질문 검색 때 질문 문장을 embedding으로 바꿉니다.
# allow_dangerous_deserialization=True는 내가 직접 만든 인덱스를 열 때만 씁니다.
store = FAISS.load_local(INDEX_PATH, emb, allow_dangerous_deserialization=True)

print("인덱스를 로드했습니다.")

query = "환불 규정이 궁금해요"

# 저장된 조각은 다시 임베딩하지 않습니다. 질문 한 문장만 임베딩합니다.
found = store.similarity_search(query, k=3)

print(f"'{query}'에 대한 검색 결과:")
print(f"총 {len(found)}개의 결과를 찾았습니다.")
print()

# for i, doc in enumerate(found):
#     print(f"\n결과 {i + 1}:")
#     print(doc.page_content)
#     print(f"청크 ID: {doc.metadata['chunk_id']}")

print(f"검색 결과 : {len(found)}건")

# 번호를 1부터 붙여 가까운 순서대로 본문을 찍습니다.
for i, doc in enumerate(found, 1):
    print(f"\n[{i}]")
    print(doc.page_content)

실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)

출력
===== 실행 결과 샘플 =====
실행: cd 실습/src/10
      python load_index.py
예시 출력 (API 키 필요, 실행하지 않음)
3쪽 로딩 완료 (총 1529 자)
3쪽 로딩 완료 (총 1529 자)
11 개
인덱스를 로드했습니다.
'환불 규정이 궁금해요'에 대한 검색 결과:
총 3개의 결과를 찾았습니다.

검색 결과 : 3건

[1]
(아래 본문은 검색 순위가 아니라, manual.pdf에 있는 환불 문장을 형식에 맞춘 예시입니다.)
4. 환불 및 교환 규정
환불
상품 수령 후 7 일 이내에 환불을 신청하실 수 있습니다.

[2]
단순 변심의 경우 왕복 배송비는 고객 부담입니다.

[3]
교환
상품 불량의 경우 30 일 이내에 교환이 가능합니다.
(유사도 점수는 이 파일의 활성 print에 없고, 기록된 점수도 없습니다.)

코드 해설

저장해 둔 FAISS 인덱스를 열고 질문과 가까운 chunk 3개를 출력입니다. 입력은 faiss_index 폴더, 질문 "환불 규정이 궁금해요"이고, 출력은 로드 문장, 검색 건수, 각 조각 본문입니다.

코드 앞에서 고정해 두는 말입니다.

  • prepare_chuncks (조각 준비 출력만 하고 검색에는 기존 인덱스 사용)
  • -> FAISS.load_local
  • -> similarity_search(질문 embedding) -> print
  • embedding: 질문을 숫자 목록(vector)으로 바꿀 때 쓰는 모델
  • vector: 그 숫자 목록. FAISS: 저장해 둔 벡터에서 가까운 것을 찾는 저장소
  • chunk: 인덱스 안에 들어 있는 문서 조각

함수로 나누지 않고 위에서 아래로 실행됩니다.

  • store = FAISS.load_local(INDEX_PATH, emb, allow_dangerous_deserialization=True) — print(f"총 {len(chunks)}개의 청크를 준비했습니다.") 디스크의 인덱스를 읽습니다. 질문 검색 때 질문 문장을 embedding으로 바꿉니다.
  • print(f"검색 결과 : {len(found)}건") — for i, doc in enumerate(found): print(f"\n결과 {i + 1}:") print(doc.page_content) print(f"청크 ID: {doc.metadata['chunk_id']}")

파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.

4-1) 있으면 불러오고 없으면 만들기

실무에서 쓰는 패턴입니다. 이 함수 하나면 앞으로 고민할 일이 없습니다.

📝 src/indexer.py — 완성본

Python
import os
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
from prepare import prepare_chunks

INDEX_PATH  = "faiss_index"
EMBED_MODEL = "text-embedding-3-small"
DOC_PATH    = "data/manual.pdf"

def get_store(rebuild: bool = False):
    # 인덱스가 있으면 불러오고, 없으면 새로 만든다
    emb = OpenAIEmbeddings(model=EMBED_MODEL)

    if os.path.exists(INDEX_PATH) and not rebuild:
        print("→ 저장된 인덱스를 불러옵니다 (비용 0)")
        return FAISS.load_local(
            INDEX_PATH, emb, allow_dangerous_deserialization=True)

    print("→ 인덱스를 새로 만듭니다 (임베딩 비용 발생)")
    chunks = prepare_chunks(DOC_PATH)
    store = FAISS.from_documents(chunks, emb)
    store.save_local(INDEX_PATH)
    print(f"✓ {INDEX_PATH}/ 에 저장 완료")
    return store

if __name__ == "__main__":
    store = get_store()

실행 결과 예시 (코드를 읽고 만든 예상 화면입니다. 실제 실행 결과, 특히 모델의 답은 다를 수 있습니다)

출력
→ 저장된 인덱스를 불러옵니다 (비용 0)

💡 rebuild 옵션을 둔 이유
문서를 바꿨거나 chunk_size를 조정했다면
인덱스를 다시 만들어야 합니다.
get_store(rebuild=True)
이렇게 강제로 재생성할 수 있게 해두면 편합니다.
14차시 성능 실험에서 자주 쓰게 됩니다.

실습 코드 · 10/indexer.py

실습 코드: 실습/src/10/indexer.py · 위의 원본 PDF 10차시 9~10쪽 「4-1) 있으면 불러오고 없으면 만들기」과 이어집니다.

Python
# ============================================================
# 10차시 | PPT: 10차시_벡터저장소이해.pptx
# 역할: FAISS 인덱스가 있으면 열고, 없을 때만 새로 만들어 메타 정보와 함께 저장
# 입력: ../../data/manual.pdf, 폴더 faiss_index
# 출력: 새로 저장하면 "저장 완료", 이어서 벡터 개수와 설정값
# 흐름:
#   get_store()
#     -> 인덱스 폴더가 있고 rebuild가 아니면 FAISS.load_local
#     -> 아니면 chunk -> embedding -> save_local + build_info.json
# ============================================================
# embedding: 문장을 숫자 목록으로 바꾸는 모델
# vector: 그 숫자 목록. FAISS가 개수(index.ntotal)를 가지고 있습니다.
# chunk: prepare_chuncks가 만든 문서 조각
# metadata: 여기 파일에서는 인덱스 빌드 정보를 JSON으로 따로 저장합니다.

# datetime 모듈 안의 datetime 클래스를 바로 가져옵니다.
from datetime import datetime
import json

from langchain_openai import OpenAIEmbeddings
from dotenv import load_dotenv
from langchain_community.vectorstores import FAISS

import os
import sys

# 7차시 prepare.py를 import 경로에 넣습니다.
CURRENT_DIR = os.path.dirname(__file__)
PREPARE_DIR = os.path.join(CURRENT_DIR, "..", "07")
sys.path.insert(0, PREPARE_DIR)
from prepare import prepare_chuncks

# .env의 OPENAI_API_KEY를 읽습니다.
load_dotenv()

# build_index.py, load_index.py와 같은 경로와 모델 이름입니다.
DOC_PATH = "../../data/manual.pdf"
INDEX_PATH = "faiss_index"
EMBED_MODEL = "text-embedding-3-small"

# 아래 두 값은 JSON에만 기록됩니다. 실제 자르기는 prepare.py의 200 / 50을 씁니다.
CHUNK_SIZE = 500
CHUNK_OVERLAP = 50

def save_with_meta(store, path, info):
    # 인덱스 파일 두 개(index.faiss, index.pkl)를 먼저 씁니다.
    store.save_local(path)

    # datetime는 클래스입니다. datetime.datetime은 없어, 이 줄은 AttributeError가 납니다.
    info["created_at"] = datetime.datetime.now().isoformat()

    # 같은 폴더에 설정 메모(build_info.json)를 남깁니다. ensure_ascii=False라야 한글이 그대로 저장됩니다.
    meta_path = os.path.join(path, "build_info.json")
    with open(meta_path, "w", encoding="utf-8") as file:
        json.dump(
            info,
            file,
            ensure_ascii=False,
            indent=2
        )

# rebuild=True로 부르면 폴더가 있어도 새로 만듭니다. 문서나 chunk_size를 바꿨을 때 씁니다.
def get_store(rebuild=False):
    emb = OpenAIEmbeddings(model="text-embedding-3-small")

    # 폴더가 있고 다시 만들라는 표시가 없으면 저장본을 엽니다.
    if os.path.exists(INDEX_PATH) and not rebuild:
        store = FAISS.load_local(
            INDEX_PATH,
            emb,
            allow_dangerous_deserialization=True
        )
        # 여기서 바로 돌아가므로 아래의 임베딩 비용이 들지 않습니다.
        return store

    # 여기부터는 폴더가 없거나 rebuild=True일 때만 실행됩니다.
    chunks = prepare_chuncks(DOC_PATH)

    # 조각마다 임베딩을 구해 새 인덱스를 만듭니다. 비용이 드는 곳입니다.
    store = FAISS.from_documents(
        chunks,
        emb
    )

    # 나중에 "이 인덱스는 어떤 설정이었지?"를 알 수 있게 적어 둘 내용입니다.
    build_info = {
        "source": DOC_PATH,
        "embed_model": EMBED_MODEL,
        "chunk_size": CHUNK_SIZE,
        "chunk_overlap": CHUNK_OVERLAP,
        "chunk_count": len(chunks),
    }

    # 인덱스와 설정 메모를 함께 저장합니다. 위에서 말한 datetime 오류는 여기서 터집니다.
    save_with_meta(store,INDEX_PATH,build_info)

    # save_with_meta 안에서 이미 저장하므로 아래 줄은 꺼 두었습니다.
    # store.save_local(INDEX_PATH)
    print("저장 완료")

    return store

if __name__ == "__main__":
    store = get_store()
    # index.ntotal은 FAISS 안에 들어 있는 벡터 수, 곧 조각 수입니다.
    print(f"총 {store.index.ntotal}개의 벡터를 저장했습니다.")
    print(f"인덱스 경로: {INDEX_PATH}")
    print(f"임베딩 모델: {EMBED_MODEL}")
    print(f"청크 크기: {CHUNK_SIZE}")
    print(f"청크 겹침: {CHUNK_OVERLAP}")
    print(f"총 청크 수: {store.index.ntotal}")

실행 결과 샘플 (교안 기록. API 키가 필요한 코드는 키 없이 맞춘 예시이고, 모델의 답은 실행할 때마다 달라집니다)

출력
===== 실행 결과 샘플 =====
실행: cd 실습/src/10
      python indexer.py
예시 출력 (API 키 필요, 실행하지 않음)
(faiss_index 폴더가 있으면 임베딩을 다시 하지 않고 로드한 뒤 아래만 출력합니다.
 벡터 개수는 인덱스를 열어야 알 수 있어 숫자를 적지 않습니다.)
총 (벡터 개수 미기록)개의 벡터를 저장했습니다.
인덱스 경로: faiss_index
임베딩 모델: text-embedding-3-small
청크 크기: 500
청크 겹침: 50
총 청크 수: (벡터 개수 미기록)
(폴더가 없어 새로 저장하는 분기는 save_with_meta의 datetime.datetime 줄에서 AttributeError가 납니다.)

코드 해설

FAISS 인덱스가 있으면 열고, 없을 때만 새로 만들어 메타 정보와 함께 저장입니다. 입력은 ../../data/manual.pdf, 폴더 faiss_index이고, 출력은 새로 저장하면 "저장 완료", 이어서 벡터 개수와 설정값입니다.

주석에 적힌 흐름입니다.

출력
get_store()
-> 인덱스 폴더가 있고 rebuild가 아니면 FAISS.load_local
-> 아니면 chunk -> embedding -> save_local + build_info.json

코드 앞에서 고정해 두는 말입니다.

  • embedding: 문장을 숫자 목록으로 바꾸는 모델
  • vector: 그 숫자 목록. FAISS가 개수(index.ntotal)를 가지고 있습니다.
  • chunk: prepare_chuncks가 만든 문서 조각
  • metadata: 여기 파일에서는 인덱스 빌드 정보를 JSON으로 따로 저장합니다.

함수와 클래스로 나누어 읽습니다.

  • save_with_meta: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.
  • get_store: 이름 그대로 한 가지 일만 합니다. 본문 주석을 같이 읽습니다.

파일 맨 아래 실행 결과 샘플 주석은 실행되지 않습니다. 화면 출력은 다음 절에 다시 적어 두었습니다.

← 이전 절10차시 · 3. 실습 ① — 인덱스 만들고 저장하기다음 절 →10차시 · 5. 실습 ③ — 점수까지 확인하는 검색