2강 실습 중심 ⏱ 약 70분

 

0. 학습 목표

→ 배포받은 질의응답 10건을 검색할 수 있는 형태로 바꾸고, 벡터로 변환해 저장소에 넣습니다.

더보기

0.1 이번 글에서 다룰 내용

 

1강 9절에서 명령 두 줄을 실행해 봤습니다. 같은 명령을 다시 실행합니다.

 

쉘 프롬프트

# 문서에 적힌 말로 찾을 때와 다른 표현으로 찾을 때
cd ~/rag-basic
grep -c "국선대리인" data/ip_qa_basic.json
grep -c "무료" data/ip_qa_basic.json
3
0

이 두 숫자가 이번 강의의 출발점입니다. 무엇을 뜻하는지 데이터를 놓고 보겠습니다. 배포받은 10건 중 ip-004 문서는 다음과 같습니다.

{
  "id": "ip-004",
  "type": "법령",
  "title": "특허심판원국선대리인의선임및운영에관한규칙",
  "date": "2021-11-11",
  "question": "국선대리인의 선임 기준은 무엇인가요?",
  "answer": "국선대리인의 선임 기준은 특허심판원장이 설정하며, 특정 법률에 따라 수급자, 국가유공자, 장애인 등 지원이 필요한 이들에 대해 선임될 수 있습니다. 또한, 소기업 및 중기업과의 산업재산권 분쟁에 처해 있는 기업도 포함됩니다."
}

사용자가 다음과 같이 묻는다고 하겠습니다.

무료로 대리인을 지원받는 제도가 있나요?

 

사람이 읽으면 이 질문의 답이 위 문서에 있다는 것을 알 수 있습니다.

국선대리인 제도가 곧 그 내용입니다. 그런데 질문에 쓴 "무료"는 10건 어디에도 없고 "제도"도 없습니다.

위에서 실행한 grep -c "무료"0을 돌려준 것이 그 결과입니다.

 

지금 가진 방법은 글자가 겹치는지를 보는 것뿐입니다. 이 방법으로 위 질문을 처리하면 결과는 다음과 같습니다.

 

찾는 말 결과 이유
국선대리인 3줄에서 찾음 제목·질문·답변 세 줄에 그대로 들어 있음
무료 0줄 문서에 없는 단어
무료로 대리인을 지원받는 제도 0줄 문장 전체가 일치해야 하므로 더 찾기 어려움

질문한 사람이 문서에 적힌 단어를 미리 알고 있어야만 찾을 수 있습니다.

그런데 질문하는 사람은 보통 그 단어를 모릅니다. 알고 있다면 검색할 이유도 없습니다.

이것이 문제입니다.

 

그렇기 때문에 이번 강의는 글자가 아니라 뜻이 가까운 것을 찾을 수 있는 형태로 문서를 바꿔 저장합니다.

1강 3절에서 확인한 다섯 단계 중 앞의 세 개, 문서 준비와 임베딩과 저장이 여기에 해당합니다.

만들 파일은 세 개입니다.

 

파일 하는 일 다루는 절
build_docs.py JSON 10건을 한 줄에 문서 하나씩 담은 파일로 바꾼다 2절
embedder.py 텍스트를 Ollama에 보내 벡터로 바꾼다 3절
store.py 벡터와 원문을 컬렉션에 저장한다 4절

이번 강의를 마치면 프로젝트 폴더가 다음 상태가 됩니다. 🗸 표시는 이미 있는 것, ✏️ 표시는 이번 강의에서 만드는 것입니다.

rag-basic/
├── .venv/                   🗸 0강
├── .env                     🗸 0강
├── config.py                🗸 0강
├── build_docs.py            ✏️ 이번 강의 2절
├── embedder.py              ✏️ 이번 강의 3절
├── store.py                 ✏️ 이번 강의 4절
├── data/
│   ├── ip_qa_basic.json     🗸 0강에서 배치, 10건
│   └── 출처.md               🗸 0강에서 배치
├── docs/
│   └── qa_documents.jsonl   ✏️ 이번 강의 2절 산출물, 10줄
└── chroma_db/               ✏️ 이번 강의 4절 산출물

이번 강의에서는 검색을 만들지 않습니다. 저장까지만 합니다. 저장한 것을 질문으로 찾아 답변을 만드는 것은 3강입니다.

 

 

 

 

0.2 이번 강의 실습 내용

 

학습자가 직접 하는 작업은 1절부터 6절까지 이어집니다. 뒤 절이 앞 절에서 만든 파일을 그대로 입력으로 쓰므로, 각 절이 무엇을 받아 무엇을 넘기는지 먼저 확인합니다.

 

1절에서는 배포받은 데이터를 열어 봅니다.

10건이 모두 같은 여섯 개 키를 가지고 있다는 것을 확인하고, 이 파일이 실제 공개 데이터를 수업용으로 다듬은 것이라는 사실과 무엇을 다듬었는지를 함께 봅니다. 그다음 여섯 개 키 중 무엇을 검색 대상으로 삼을지 결정합니다. 이 결정이 2절 코드의 전제가 됩니다.

 

2절에서는 1절의 결정에 따라 build_docs.py를 만듭니다.

질문과 답변을 하나로 합쳐 검색 문서를 만들고, 종류·제목·날짜는 따로 붙여 둡니다. 결과는docs/qa_documents.jsonl 10줄입니다.

 

3절에서는 그 10줄을 벡터로 바꿀 준비를 합니다.

1강 4절에서 확인한 Ollama 서버 주소로 요청을 보내 봅니다. 먼저 명령 한 줄로 확인한 뒤 같은 요청을 하는 embedder.py를 만듭니다. 여기서 벡터의 차원을 학습자가 직접 확인해 적습니다.

 

4절에서는 2절의 문서와 3절의 함수를 합쳐 store.py를 만듭니다.

10줄을 모두 벡터로 바꿔 ChromaDB 컬렉션에 넣습니다. 이때 거리 공간을 코사인으로 지정하는데, 왜 지정해야 하는지를 6절에서 실행 결과로 확인합니다.

 

5절에서는 저장이 제대로 되었는지 네 항목으로 확인합니다.

건수·차원·거리 공간·메타데이터입니다. 3강에서 검색 결과가 이상할 때 이 네 항목을 먼저 확인하게 됩니다.

 

6절에서는 4절에서 거리 공간을 지정하지 않았다면 어떻게 되었을지 확인합니다.

실습용 컬렉션을 따로 하나 만들어 값을 비교하고, 확인이 끝나면 지웁니다. 본 컬렉션은 건드리지 않습니다.

 

하는 일 앞 절에서 받는 것 다음 절에 넘기는 것
1 데이터 구조 확인과 검색 대상 결정 0강 4절에서 배치한 data/ip_qa_basic.json 무엇을 문서 본문으로 삼고 무엇을 따로 붙일지에 대한 결정
2 build_docs.py 작성과 실행 1절의 결정 docs/qa_documents.jsonl 10줄
3 embedder.py 작성과 차원 확인 0강 5절의 config.py, 1강 4절의 서버 주소 텍스트 목록을 벡터 목록으로 바꾸는 embed() 함수
4 store.py 작성과 실행 2절의 10줄, 3절의 embed() chroma_db/ip_docs 컬렉션 10건
5 저장 결과 확인 4절의 컬렉션 3강을 시작할 수 있는 상태
6 거리 공간 미지정 시의 차이 확인 4절의 컬렉션과 3절의 embed() 거리 공간을 지정해야 하는 이유

이번 강의를 마치면 다음을 할 수 있습니다.

 

  • 질의응답 형식의 데이터에서 무엇을 검색 대상 본문으로 삼고 무엇을 따로 저장할지 판단하고, 그 판단을 코드로 옮긴다.
  • Ollama 임베딩 API에 텍스트를 보내 벡터를 받고, 돌려받은 벡터의 차원을 확인한다.
  • 컬렉션을 만들 때 거리 공간을 지정하고, 지정하지 않았을 때 무엇이 달라지는지 값으로 설명한다.
  • 저장 결과를 건수·차원·거리 공간·메타데이터 네 항목으로 확인한다.
  • 같은 명령을 두 번 실행해도 건수가 늘지 않는 이유를 idupsert의 동작으로 설명한다.

선수 지식은 0강에서 구축한 실습 환경과 1강에서 정리한 RAG의 다섯 단계입니다. 파이썬 문법은 파일 읽기와 반복문, 함수 정의까지 사용합니다. 임베딩 모델이 벡터를 어떻게 계산하는지는 다루지 않습니다. 이 과정에서는 모델을 만들지 않고 이미 만들어진 모델에 요청을 보내 결과를 받습니다.

시작하기 전에 가상환경이 활성화되어 있는지 확인합니다. 프롬프트 앞에 (.venv)가 없으면 source .venv/bin/activate를 먼저 실행합니다.

 

1. 배포받은 데이터를 열어 구조와 가공 범위 확인하기

→ 파일 안에 무엇이 들어 있는지 확인하고, 그중 무엇을 검색 대상으로 삼을지 정합니다.

더보기

0.1에서 ip-004 한 건을 봤습니다. 코드를 쓰기 전에 나머지 9건도 같은 모양인지 확인해야 합니다. 한 건만 보고 코드를 쓰면 두 번째 건에서 멈춥니다.

1.1 10건의 구조 확인하기

 

 

쉘 프롬프트

# 건수, 키 이름, 종류별 개수, 날짜 형식을 한 번에 확인
python3 -c "
import json
from collections import Counter

rows = json.load(open('data/ip_qa_basic.json', encoding='utf-8'))
print('건수      :', len(rows))
print('첫 항목 키 :', list(rows[0].keys()))

key_sets = {tuple(row.keys()) for row in rows}
print('서로 다른 키 구성 수 :', len(key_sets))

for name, number in Counter(row['type'] for row in rows).items():
    print(f'  {name}: {number}건')

print('날짜 예시 :', [row['date'] for row in rows[:3]])
"
건수      : 10
첫 항목 키 : ['id', 'type', 'title', 'date', 'question', 'answer']
서로 다른 키 구성 수 : 1
  판결문: 2건
  법령: 2건
  심결례: 2건
  심결문: 2건
  유권해석: 2건
날짜 예시 : ['2017-05-19', '2016-06-03', '2024-08-07']

 

확인할 것은 세 번째 줄입니다. "서로 다른 키 구성 수"가 1이라는 것은 10건이 모두 같은 여섯 개 키를 가지고 있다는 뜻입니다. 그래서 이번 강의의 변환 코드는 항목마다 다른 처리를 하지 않아도 됩니다.

날짜도 YYYY-MM-DD 한 가지 형식입니다. 문자열을 그대로 저장해도 되고, 형식을 맞추는 코드가 필요 없습니다.

 

 

 

 

1.2 여섯 개 키 중 무엇을 검색 대상으로 삼을지 정하기

 

키가 여섯 개인데 검색에 쓰는 것은 그중 일부입니다. 먼저 결정하고 코드를 씁니다. 결정하지 않고 코드부터 쓰면 나중에 검색 결과가 이상할 때 무엇을 바꿔야 하는지 알 수 없습니다.

세 갈래로 나뉩니다.

 

어떻게 쓰는가 이유
question, answer 하나로 합쳐 검색 대상 본문으로 쓴다 질문과 뜻을 비교할 내용이 여기에 들어 있다
type, title, date 본문과 함께 저장하되 검색 대상에는 넣지 않는다 답변에 출처로 표시할 정보다. 뜻을 비교할 내용은 아니다
id 저장할 때 문서를 구분하는 이름으로 쓴다 같은 문서를 두 번 넣었을 때 늘어나지 않게 하는 기준이다

질문과 답변을 왜 합치는지가 이 절에서 가장 중요한 판단입니다. 세 가지 안을 놓고 보면 다음과 같습니다.

 

검색 대상 본문 문제
답변만 answer 사용자가 던지는 말은 질문 형태다. 질문끼리 비교할 대상이 없어진다
질문만 question 답변에만 있는 내용(날짜, 조문 번호, 대상 범위)으로는 찾을 수 없다
질문과 답변을 합침 question + answer 두 방향 모두 비교할 수 있다. 문서가 길어지지만 이 데이터는 최대 419자라 문제가 되지 않는다

이 과정에서는 세 번째를 고릅니다. 세 안 중 어느 것이 좋은지는 데이터에 따라 다르므로, 실제로 순위가 어떻게 달라지는지는 3강 실습 과제에서 직접 비교하게 됩니다.

 

typetitledate를 본문에 넣지 않는 이유도 같은 기준입니다. 2021-11-11이라는 문자열은 질문과 뜻을 비교할 대상이 아닙니다. 본문에 섞으면 비교에 잡음만 늘어납니다. 다만 답변에 출처로 표시해야 하므로 본문 옆에 따로 붙여 둡니다. 이렇게 함께 저장하는 정보를 메타데이터라고 부릅니다.

 

2. 질문과 답변을 합쳐 검색 문서 만들기

→ 1.3에서 정한 대로 JSON 10건을 한 줄에 문서 하나씩 담은 파일로 바꿉니다.

더보기

1.3에서 무엇을 어떻게 쓸지 정했습니다. 이제 그 결정을 파일로 만듭니다.

2.1 만들 파일의 형태 확인하기

 

만들 파일은 docs/qa_documents.jsonl입니다. 확장자가 .json이 아니라 .jsonl인 이유는 한 줄에 JSON 하나씩 넣기 때문입니다.

 

형식 구조 이 과정에서의 장단점
.json 파일 전체가 하나의 JSON 한 건만 보려 해도 전체를 읽어야 한다
.jsonl 한 줄이 JSON 하나 head -1로 한 건만 볼 수 있고, 줄 수가 곧 건수다

한 줄의 모양은 다음과 같습니다. 실제로는 한 줄이지만 읽기 쉽게 줄을 나눠 표시했습니다.

{
  "id": "ip-004",
  "document": "질문: 국선대리인의 선임 기준은 무엇인가요?\n답변: 국선대리인의 선임 기준은 특허심판원장이 설정하며, ...",
  "metadata": {"type": "법령", "title": "특허심판원국선대리인의선임및운영에관한규칙", "date": "2021-11-11"}
}

document가 검색 대상 본문이고, metadata가 함께 저장할 정보입니다. 1.3의 표에서 정한 세 갈래가 그대로 세 개의 키가 되었습니다.

 

 

 

 

2.2 build_docs.py 만들기

 

0강 1.3에서 열어 둔 VSCode에서 rag-basic 폴더에 build_docs.py 파일을 만들고 아래 내용을 넣습니다.

"""배포받은 질의응답 JSON을 검색용 문서 파일로 변환한다."""

from __future__ import annotations

import json

from config import PROJECT_DIR

INPUT_PATH = PROJECT_DIR / "data" / "ip_qa_basic.json"
OUTPUT_PATH = PROJECT_DIR / "docs" / "qa_documents.jsonl"
REQUIRED_KEYS = ("id", "type", "title", "date", "question", "answer")


def check_keys(row: dict, order: int) -> None:
    """한 항목에 필요한 키가 모두 있는지 확인한다."""
    missing = [key for key in REQUIRED_KEYS if key not in row]
    if missing:
        raise SystemExit(f"{order}번째 항목에 없는 키가 있습니다: {missing}")


def build_record(row: dict) -> dict:
    """검색에 사용할 본문과 함께 저장할 정보를 한 건으로 묶는다."""
    return {
        "id": row["id"],
        "document": f"질문: {row['question']}\n답변: {row['answer']}",
        "metadata": {"type": row["type"], "title": row["title"], "date": row["date"]},
    }


def main() -> None:
    if not INPUT_PATH.exists():
        raise SystemExit(f"데이터 파일이 없습니다: {INPUT_PATH}")

    with INPUT_PATH.open(encoding="utf-8") as source:
        rows = json.load(source)

    OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
    with OUTPUT_PATH.open("w", encoding="utf-8") as target:
        for order, row in enumerate(rows, start=1):
            check_keys(row, order)
            target.write(json.dumps(build_record(row), ensure_ascii=False) + "\n")

    print(f"완료: {len(rows)}건")
    print(f"저장 위치: {OUTPUT_PATH}")


if __name__ == "__main__":
    main()

 

 

 

 

2.3 코드가 하는 일 확인하기

 

네 부분으로 나뉩니다. 각 부분이 전체에서 맡는 역할은 다음과 같습니다.

 

경로를 config.pyPROJECT_DIR 기준으로 잡습니다.

0강 5.3에서 PROJECT_DIR__file__ 기준으로 만들어 두었기 때문에, 어느 폴더에서 실행해도 같은 파일을 읽고 같은 곳에 씁니다. 현재 폴더 기준으로 잡으면 다른 위치에서 실행했을 때 파일을 못 찾습니다.

 

check_keys()는 항목마다 키가 다 있는지 확인하고 없으면 멈춥니다.

이 데이터는 1.1에서 10건이 모두 같은 키를 가진 것을 확인했으므로 이 검사는 통과합니다. 그런데도 넣어 두는 이유가 있습니다. 검사가 없으면 키가 빠진 데이터를 만났을 때 KeyError라는 메시지만 나오고 몇 번째 항목의 어떤 키가 문제인지 알 수 없습니다. 2단계에서 실제 데이터를 이 코드에 넣으면 이 검사가 먼저 걸립니다.

 

build_record()가 1.3에서 정한 결정을 실행합니다.

질문과 답변을 질문: 답변: 표시를 붙여 한 문자열로 합치고, 나머지 세 개를 metadata로 묶습니다. 표시를 붙이는 이유는 3강에서 이 본문이 그대로 LLM에게 근거로 전달되기 때문입니다. 표시가 없으면 어디까지가 질문이고 어디부터가 답변인지 구분되지 않습니다.

 

json.dumps(..., ensure_ascii=False)가 한글을 그대로 씁니다.

이 옵션이 없으면 한글이 국선 같은 형태로 저장되어 파일을 열어 확인할 수 없습니다. 프로그램은 어느 쪽이든 읽지만, 실습에서는 파일을 열어 눈으로 확인하는 것이 중요하므로 이 옵션을 켭니다.

 

 

 

 

2.4 실행하고 결과 확인하기

 

 

쉘 프롬프트

# 검색 문서 만들기
python3 build_docs.py
완료: 10건
저장 위치: /home/사용자이름/rag-basic/docs/qa_documents.jsonl

만들어진 파일이 1.3에서 정한 대로 되었는지 확인합니다.

 

쉘 프롬프트

# 줄 수, 키, 종류별 건수, 본문 길이 확인
python3 -c "
import json
from collections import Counter

records = [json.loads(line) for line in open('docs/qa_documents.jsonl', encoding='utf-8')]
print('줄 수    :', len(records))
print('키       :', list(records[0].keys()))

for name, number in Counter(record['metadata']['type'] for record in records).items():
    print(f'  {name}: {number}건')

lengths = sorted(len(record['document']) for record in records)
print('본문 길이: 최소', lengths[0], '/ 최대', lengths[-1])
"
줄 수    : 10
키       : ['id', 'document', 'metadata']
  판결문: 2건
  법령: 2건
  심결례: 2건
  심결문: 2건
  유권해석: 2건
본문 길이: 최소 130 / 최대 419

네 가지를 확인합니다. 줄 수가 10이고, 키가 세 개이고, 다섯 종류가 각 2건이고, 본문이 419자를 넘지 않습니다.

마지막 항목이 이 과정의 조건 하나를 정합니다. 임베딩 모델은 한 번에 처리할 수 있는 길이에 한계가 있어서, 긴 문서는 잘라서 넣어야 합니다. 이 데이터는 가장 긴 것이 419자라 자를 필요가 없습니다. 자르는 작업은 8. RAG 강좌 3단계에서 다룹니다.

한 줄을 직접 열어 봅니다.

 

쉘 프롬프트

# 첫 줄 한 건만 보기
head -1 docs/qa_documents.jsonl

grep으로 같은 것을 찾아 보면 1강에서 본 숫자와 달라집니다.

 

쉘 프롬프트

# 원본 JSON과 변환한 JSONL에서 같은 말을 찾아 비교
grep -c "국선대리인" data/ip_qa_basic.json
grep -c "국선대리인" docs/qa_documents.jsonl
3
1

같은 내용인데 3과 1로 다릅니다. 원본은 제목·질문·답변이 각각 다른 줄에 있어 세 줄에서 찾히고, 변환한 파일은 한 문서가 한 줄이라 한 줄에서 찾힙니다. 이제 줄 수가 곧 문서 수입니다. 이 형태가 다음 절에서 벡터로 바꿀 단위가 됩니다.

⚠ 자주 겪는 오류

ModuleNotFoundError: No module named 'config'가 나오면 rag-basic 폴더가 아닌 다른 위치에서 실행한 것입니다. cd ~/rag-basic으로 이동한 뒤 다시 실행합니다.

데이터 파일이 없습니다가 나오면 0강 4절의 데이터 배치를 하지 않은 것입니다. ls data/로 파일 두 개가 있는지 확인합니다.

 

3. Ollama 임베딩 API로 텍스트를 벡터로 바꾸기

→ 2절에서 만든 문서를 숫자 목록으로 바꾸는 함수를 만듭니다.

더보기

2절에서 10줄짜리 파일을 만들었습니다. 그런데 이 파일은 여전히 글자입니다. 글자끼리 비교하면 0.1에서 본 결과와 달라지지 않습니다. 뜻을 비교하려면 다른 형태가 필요합니다.

1강 3절에서 확인한 두 번째 단계, 임베딩이 그 일을 합니다. 텍스트를 정해진 개수의 실수 목록으로 바꾸고, 뜻이 비슷한 텍스트는 비슷한 숫자 목록이 되도록 만들어진 모델이 계산합니다. 그 모델이 0강 3.3에서 내려받은 bge-m3입니다.

3.1 명령 한 줄로 먼저 확인하기

 

파이썬 코드를 쓰기 전에 서버가 무엇을 돌려주는지 먼저 봅니다. 1강 4.2에서 /api/tags로 서버가 살아 있는 것을 확인했습니다. 이번에는 /api/embed에 텍스트를 보냅니다.

 

쉘 프롬프트

# 문장 하나를 보내고 돌려받은 벡터의 모양 확인
curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model": "bge-m3", "input": "국선대리인의 선임 기준은 무엇인가요?"}' \
  | python3 -c "
import json, sys

body = json.load(sys.stdin)
vectors = body['embeddings']
print('돌려받은 벡터 수:', len(vectors))
print('벡터 차원       :', len(vectors[0]))
print('앞 5개 값       :', [round(value, 4) for value in vectors[0][:5]])
"
돌려받은 벡터 수: 1
벡터 차원       : 1024
앞 5개 값       : [-0.0271, 0.0163, -0.0409, 0.0102, 0.0057]

▶ 지금 해보세요

  1. 화면에 나온 벡터 차원 숫자를 적어 두십시오. 이 값은 모델이 정하는 것이며 bge-m3는 1024입니다. 다른 모델로 바꾸면 이 숫자가 달라지고, 그때는 저장해 둔 벡터를 전부 다시 만들어야 합니다. 앞 5개 값은 실행할 때마다 같은 숫자가 나오는지도 함께 확인합니다.

세 가지를 확인합니다.

 

확인 항목
돌려받은 벡터 수 1 보낸 텍스트가 하나이므로 벡터도 하나. 여러 개를 보내면 같은 순서로 여러 개가 온다
벡터 차원 1024 bge-m3가 만드는 실수의 개수. 문장 길이와 상관없이 항상 같다
앞 5개 값 소수 사람이 읽고 뜻을 알 수는 없다. 비교에만 쓴다

두 번째가 중요합니다. 문장이 30자든 400자든 벡터는 항상 1024개의 실수입니다. 길이가 항상 같아야 두 문서를 숫자로 비교할 수 있습니다.

 

 

 

 

3.2 embedder.py 만들기

 

같은 요청을 파이썬에서 보내는 파일을 만듭니다. VSCode에서 rag-basic 폴더에 embedder.py를 만들고 아래 내용을 넣습니다.

"""텍스트를 Ollama 임베딩 모델에 보내 벡터로 바꾼다."""

from __future__ import annotations

import requests

from config import get_settings


def embed(texts: list[str]) -> list[list[float]]:
    """텍스트 목록을 한 번에 보내고 같은 순서의 벡터 목록을 돌려받는다."""
    if not texts:
        raise ValueError("임베딩할 텍스트가 없습니다.")

    settings = get_settings()
    response = requests.post(
        f"{settings.ollama_base_url}/api/embed",
        json={"model": settings.embedding_model, "input": texts},
        timeout=120,
    )
    response.raise_for_status()
    return response.json()["embeddings"]


if __name__ == "__main__":
    vectors = embed(["상표권 침해 여부는 어떤 기준으로 판단하나요?"])
    print("돌려받은 벡터 수:", len(vectors))
    print("벡터 차원       :", len(vectors[0]))
    print("앞 5개 값       :", [round(value, 4) for value in vectors[0][:5]])

 

 

 

 

3.3 코드가 하는 일 확인하기

 

주소와 모델 이름을 코드에 직접 쓰지 않고 get_settings()로 읽습니다. 0강 5.1에서 정한 구조가 여기에서 처음 쓰입니다. 모델을 바꿀 때 이 파일을 열 필요가 없고 .env 한 줄만 고치면 됩니다.

 

texts를 목록으로 받습니다. 문자열 하나가 아니라 목록으로 받는 이유는 4절에서 10건을 한 번에 보내기 위해서입니다. 10번 요청하는 것보다 한 번에 10개를 보내는 쪽이 빠릅니다. 돌려받은 벡터의 순서는 보낸 순서와 같습니다.

 

if not texts가 빈 목록을 막습니다. 빈 목록을 보내면 서버가 오류를 돌려주는데, 그 메시지만으로는 원인을 알기 어렵습니다. 보내기 전에 확인해 무엇이 잘못되었는지 알립니다.

 

response.raise_for_status()가 실패한 요청을 그냥 지나치지 않게 합니다. 이 줄이 없으면 서버가 오류를 돌려줬을 때 다음 줄에서 KeyError: 'embeddings'가 나고, 원인이 서버 오류인지 코드 오류인지 구분되지 않습니다.

 

timeout=120은 응답을 기다릴 최대 시간입니다. 이 값이 없으면 서버가 응답하지 않을 때 프로그램이 멈춘 채로 남습니다. 모델을 처음 부를 때는 메모리에 올리는 시간이 더 걸리므로 넉넉하게 잡았습니다.

 

if __name__ == "__main__": 아래는 이 파일을 직접 실행했을 때만 동작합니다. 다른 파일에서 from embedder import embed로 불러 쓸 때는 실행되지 않습니다. 함수 하나만 있는 파일을 그 자체로 확인해 볼 수 있게 넣어 둔 것입니다.

 

 

 

 

3.4 실행하고 확인하기

 

 

쉘 프롬프트

# embedder.py를 직접 실행해 동작 확인
python3 embedder.py
돌려받은 벡터 수: 1
벡터 차원       : 1024
앞 5개 값       : [-0.0184, 0.0335, -0.0271, -0.0093, 0.0212]

 

3.1의 curl 결과와 차원이 같으면 정상입니다. 앞 5개 값은 보낸 문장이 다르므로 다릅니다.

⚠ 자주 겪는 오류

requests.exceptions.ConnectionError가 나오면 Ollama가 실행 중이 아닙니다. sudo systemctl start ollama로 시작한 뒤 다시 실행합니다.

404 Client Error가 나오면 .envEMBEDDING_MODEL 값에 해당하는 모델이 없는 것입니다. ollama listbge-m3가 있는지 확인합니다.

ModuleNotFoundError: No module named 'requests'가 나오면 가상환경이 활성화되지 않은 것입니다. 프롬프트에 (.venv)가 있는지 확인합니다.

 

4. 벡터와 원문을 컬렉션에 저장하기

→ 2절의 10줄을 3절의 함수로 벡터로 바꿔 저장소에 넣습니다.

더보기

2절에서 문서 10줄을, 3절에서 벡터로 바꾸는 함수를 만들었습니다. 이제 둘을 합칩니다.

합치기 전에 정할 것이 하나 있습니다. 벡터를 어디에 어떻게 넣을지입니다.

4.1 벡터를 저장하는 프로그램이 필요한 이유

 

벡터를 파일에 그냥 저장할 수도 있습니다. 그렇게 하면 질문이 들어올 때마다 10개 벡터를 전부 읽어 하나씩 비교해야 합니다. 10건이면 가능하지만 건수가 늘면 감당하지 못합니다.

 

벡터 저장소는 이 일을 대신합니다. 이 과정에서는 0강 2절에서 설치한 ChromaDB를 씁니다.

 

하는 일 이 과정에서 쓰는 방법
벡터와 원문과 메타데이터를 함께 보관 upsert()
질문 벡터와 가까운 것을 찾아 돌려줌 query() — 3강에서 사용
보관 내용을 디스크에 남김 PersistentClient(path=...)

저장 단위는 컬렉션입니다. 문서 묶음 하나가 컬렉션 하나입니다. 이 과정의 컬렉션 이름은 0강 5.2에서 .envCOLLECTION_NAME에 적어 둔 ip_docs입니다.

 

 

 

 

4.2 거리 공간을 지정해야 하는 이유

 

컬렉션을 만들 때 두 벡터가 얼마나 가까운지 재는 방법을 함께 정합니다. 이것을 거리 공간이라고 합니다.

이 과정에서는 코사인(cosine)을 지정합니다. 코사인은 두 벡터가 가리키는 방향이 얼마나 비슷한지를 재고, 길이 차이는 보지 않습니다.

 

문서 길이가 제각각인 이 데이터에서 그 성질이 필요합니다. 2.4에서 확인했듯 본문이 130자인 문서도 있고 419자인 문서도 있습니다. 길이를 함께 재는 방법을 쓰면 내용과 상관없이 길이가 비슷한 문서끼리 가깝게 나올 수 있습니다.

 

ChromaDB는 거리 공간을 지정하지 않으면 기본값을 씁니다. 기본값은 코사인이 아닙니다. 지정하지 않았을 때 무엇이 달라지는지는 6절에서 값으로 확인합니다.

 

 

 

 

4.3 store.py 만들기

 

VSCode에서 rag-basic 폴더에 store.py를 만들고 아래 내용을 넣습니다.

"""검색 문서를 벡터로 바꿔 ChromaDB 컬렉션에 저장한다."""

from __future__ import annotations

import argparse
import json
import shutil

import chromadb

from config import PROJECT_DIR, get_settings
from embedder import embed

DOCS_PATH = PROJECT_DIR / "docs" / "qa_documents.jsonl"


def load_records() -> list[dict]:
    """build_docs.py가 만든 파일을 한 줄씩 읽어 목록으로 돌려준다."""
    if not DOCS_PATH.exists():
        raise SystemExit(f"문서 파일이 없습니다: {DOCS_PATH}\n먼저 build_docs.py를 실행하십시오.")

    with DOCS_PATH.open(encoding="utf-8") as source:
        records = [json.loads(line) for line in source if line.strip()]

    if not records:
        raise SystemExit("문서 파일이 비어 있습니다. build_docs.py를 다시 실행하십시오.")
    return records


def main() -> None:
    parser = argparse.ArgumentParser(description="검색 문서를 임베딩해 벡터 저장소에 넣는다.")
    parser.add_argument("--reset", action="store_true", help="저장 폴더를 지우고 처음부터 저장한다")
    args = parser.parse_args()

    settings = get_settings()
    if args.reset and settings.chroma_path.exists():
        shutil.rmtree(settings.chroma_path)
        print(f"기존 저장 폴더를 지웠습니다: {settings.chroma_path}")

    records = load_records()
    vectors = embed([record["document"] for record in records])
    print(f"임베딩 완료: {len(vectors)}건, 차원 {len(vectors[0])}")

    client = chromadb.PersistentClient(path=str(settings.chroma_path))
    collection = client.get_or_create_collection(
        name=settings.collection_name,
        metadata={"hnsw:space": "cosine"},
    )
    collection.upsert(
        ids=[record["id"] for record in records],
        documents=[record["document"] for record in records],
        metadatas=[record["metadata"] for record in records],
        embeddings=vectors,
    )

    print(f"저장 완료: {len(records)}건")
    print(f"컬렉션 이름: {collection.name}")
    print(f"전체 건수  : {collection.count()}")


if __name__ == "__main__":
    main()

 

 

 

 

4.4 코드가 하는 일 확인하기

 

load_records()가 2절의 결과를 읽습니다. 파일이 없으면 build_docs.py를 먼저 실행하라고 알리고 멈춥니다. 이 안내가 없으면 학습자는 FileNotFoundError만 보고 무엇을 해야 할지 알 수 없습니다.

 

embed()에 10건을 한 번에 넘깁니다. 3.3에서 목록으로 받게 만든 이유가 여기에 있습니다. 돌려받은 벡터의 순서가 보낸 문서의 순서와 같으므로, 아래 upsert()에서 같은 순서로 짝지어집니다.

 

get_or_create_collection()이 컬렉션이 없으면 만들고 있으면 엽니다. metadata={"hnsw:space": "cosine"}이 4.2에서 정한 거리 공간을 지정하는 부분입니다.

 

upsert()는 같은 id가 이미 있으면 덮어쓰고 없으면 새로 넣습니다. 여기에 쓰는 id는 1.3에서 정한 대로 데이터 파일의 ip-001 형식 값입니다. 그래서 이 명령을 두 번 실행해도 건수가 20이 되지 않고 10 그대로입니다. 5절에서 실제로 두 번 실행해 확인합니다.

 

--reset은 저장 폴더를 통째로 지우고 다시 만듭니다. 문서 내용을 고쳤는데 id는 그대로일 때 확실히 새로 넣고 싶은 경우에 씁니다. 이 옵션이 없으면 upsert()가 덮어쓰므로 대부분은 필요 없습니다.

 

4.4.1 chromadb 버전에 따라 거리 공간을 지정하는 방법이 다른 경우

0강 2절에서 chromadb 버전을 특정 숫자로 고정하지 않았으므로, 설치 시점에 따라 거리 공간을 지정하는 방법이 둘 중 하나가 됩니다.

 

지정 방법 사용하는 버전
metadata={"hnsw:space": "cosine"} 오래전부터 지금까지 동작한다. 최근 버전에서는 오래된 방식이라는 경고가 나올 수 있다
configuration={"hnsw": {"space": "cosine"}} 최근 버전에서 권하는 방법

위 코드를 실행했을 때 metadata 방식이 오래되었다는 경고가 나오면 get_or_create_collection() 부분을 다음과 같이 바꿉니다.

    collection = client.get_or_create_collection(
        name=settings.collection_name,
        configuration={"hnsw": {"space": "cosine"}},
    )

두 방법 모두 거리 공간을 코사인으로 지정한다는 뜻은 같고, 5절에서 확인하는 절차도 같습니다. 경고가 나오지 않으면 원래 코드를 그대로 둡니다.

 

 

 

 

4.5 실행하기

 

 

쉘 프롬프트

# 문서 10건을 벡터로 바꿔 저장
python3 store.py --reset
임베딩 완료: 10건, 차원 1024
저장 완료: 10건
컬렉션 이름: ip_docs
전체 건수  : 10

첫 실행은 모델을 메모리에 올리는 시간이 더해져 수십 초가 걸릴 수 있습니다. 폴더가 만들어졌는지 확인합니다.

 

쉘 프롬프트

# 저장 폴더가 생겼는지 확인
ls chroma_db

0강 6절의 통합 점검에서 "아직 없음"으로 나왔던 폴더가 이제 만들어졌습니다.

 

5. 저장 결과를 네 항목으로 확인하기

→ 4절의 실행 결과가 의도한 대로인지 항목별로 확인합니다.

더보기

4절의 화면에 저장 완료: 10건이 나왔습니다. 그런데 이 문장은 우리가 만든 print()일 뿐입니다. 저장소에 실제로 무엇이 들어갔는지는 저장소에 물어봐야 알 수 있습니다.

5.1 네 항목 조회하기

 

 

쉘 프롬프트

# 건수·차원·거리 공간·메타데이터 확인
python3 -c "
import chromadb

from config import get_settings

settings = get_settings()
client = chromadb.PersistentClient(path=str(settings.chroma_path))
collection = client.get_collection(settings.collection_name)

sample = collection.peek(limit=1)
print('1. 저장 건수 :', collection.count())
print('2. 벡터 차원 :', len(sample['embeddings'][0]))
print('3. 거리 공간 :', collection.metadata)
print('4. 메타데이터:', sample['metadatas'][0])
print('   본문 앞부분:', sample['documents'][0][:30])
"
1. 저장 건수 : 10
2. 벡터 차원 : 1024
3. 거리 공간 : {'hnsw:space': 'cosine'}
4. 메타데이터: {'type': '판결문', 'title': '상표법위반', 'date': '2017-05-19'}
   본문 앞부분: 질문: 상표권 침해 여부를 판단할 때 어떤 기준이 적용

peek()은 저장된 것 중 한 건을 꺼내 보여 주는 명령이며 어느 건이 나올지는 정해져 있지 않습니다. 위 화면과 다른 문서가 나와도 정상입니다. 확인할 것은 어느 문서가 나왔는지가 아니라 네 항목의 값입니다.

 

네 항목이 각각 무엇을 확인하는지와, 값이 다를 때 어디를 봐야 하는지는 다음과 같습니다.

 

항목 기대 결과 다를 때 확인할 곳
1. 저장 건수 10 2절의 JSONL 줄 수. 10줄이 아니면 build_docs.py부터 다시
2. 벡터 차원 3.1에서 적어 둔 숫자와 같음 (bge-m3는 1024) .envEMBEDDING_MODEL. 모델을 바꾸면 차원이 달라진다
3. 거리 공간 cosine 4.3의 get_or_create_collection() 인자
4. 메타데이터 type·title·date 세 키 2절 build_record()metadata 부분

3번이 None으로 나오거나 hnsw:space 항목이 보이지 않으면 4.4의 버전 안내에 있는 configuration= 방식으로 만들어진 것입니다. 이때는 다음 명령으로 확인합니다.

 

쉘 프롬프트

# metadata에 거리 공간이 보이지 않을 때 확인하는 방법
python3 -c "
import chromadb

from config import get_settings

settings = get_settings()
client = chromadb.PersistentClient(path=str(settings.chroma_path))
collection = client.get_collection(settings.collection_name)
print('설정 내용:', collection.configuration_json)
"

이 출력 안에 spacecosine으로 들어 있으면 정상입니다.

 

 

 

 

5.2 두 번 실행해도 건수가 늘지 않는 것 확인하기

 

4.4에서 upsert()가 같은 id를 덮어쓴다고 했습니다. 실제로 그런지 확인합니다.

 

쉘 프롬프트

# --reset 없이 한 번 더 실행
python3 store.py
임베딩 완료: 10건, 차원 1024
저장 완료: 10건
컬렉션 이름: ip_docs
전체 건수  : 10

 

마지막 줄이 20이 아니라 10입니다. ip-001부터 ip-010까지 같은 id로 다시 넣었으므로 새로 쌓이지 않고 덮어쓰기만 일어났습니다.

 

이 성질이 실무에서 중요합니다. 문서가 추가되거나 내용이 바뀌었을 때 전체를 지우고 다시 넣지 않아도 됩니다. 같은 명령을 그대로 실행하면 바뀐 것은 갱신되고 새것은 추가됩니다.

 

반대로 id를 실행할 때마다 새로 만들면 같은 문서가 여러 번 쌓이고, 검색 결과에 같은 내용이 반복해서 나옵니다. 저장하는 쪽에서 id를 무엇으로 정하느냐가 검색 결과의 품질을 정합니다.

▶ 지금 해보세요

  1. 5.1의 조회 명령을 실행해 네 항목을 모두 확인하고, 2번 차원 값이 3.1에서 적어 둔 숫자와 같은지 대조합니다.
  2. python3 store.py를 한 번 더 실행하고 전체 건수가 여전히 10인지 확인합니다.
  3. peek(limit=1)peek(limit=3)으로 바꿔 실행하고, sample['metadatas']에 몇 건이 들어오는지 확인합니다.

 

6. 거리 공간을 지정하지 않으면 무엇이 달라지는지 확인하기

→ 4.2에서 지정한 cosine을 빼면 어떤 값이 나오는지 비교합니다.

더보기

5.1에서 거리 공간이 cosine인 것을 확인했습니다. 그런데 그것이 왜 중요한지는 아직 값으로 보지 않았습니다. 지정하지 않은 컬렉션을 하나 더 만들어 같은 문서를 넣고 비교합니다.

본 컬렉션 ip_docs는 건드리지 않습니다. 실습용으로 space_test라는 이름을 따로 씁니다.

6.1 거리 공간을 지정하지 않은 컬렉션 만들어 비교하기

 

 

쉘 프롬프트

# 거리 공간을 지정하지 않은 컬렉션을 만들어 같은 문서를 넣고 값을 비교
python3 -c "
import json

import chromadb

from config import PROJECT_DIR, get_settings
from embedder import embed

settings = get_settings()
records = [json.loads(line) for line in open(PROJECT_DIR / 'docs' / 'qa_documents.jsonl', encoding='utf-8')]
vectors = embed([record['document'] for record in records])

client = chromadb.PersistentClient(path=str(settings.chroma_path))
test = client.get_or_create_collection(name='space_test')
test.upsert(
    ids=[record['id'] for record in records],
    documents=[record['document'] for record in records],
    embeddings=vectors,
)

question_vector = embed(['무료로 대리인을 지원받는 제도가 있나요?'])[0]
for name in ['ip_docs', 'space_test']:
    collection = client.get_collection(name)
    result = collection.query(query_embeddings=[question_vector], n_results=1)
    distance = result['distances'][0][0]
    print(name)
    print('  거리 공간 :', collection.metadata)
    print('  1위 문서  :', result['ids'][0][0])
    print('  거리      :', round(distance, 4))
    print('  1 - 거리  :', round(1 - distance, 4))
"
ip_docs
  거리 공간 : {'hnsw:space': 'cosine'}
  1위 문서  : ip-004
  거리      : 0.xxxx
  1 - 거리  : 0.xxxx
space_test
  거리 공간 : None
  1위 문서  : ip-004
  거리      : 0.xxxx
  1 - 거리  : 0.xxxx

0.xxxx 자리에는 실행하면 실제 숫자가 나옵니다. 모델과 실행 시점에 따라 값이 달라지므로 원고에는 자리만 표시했습니다. 화면에 나온 숫자를 직접 적으십시오.

두 컬렉션의 거리 값이 다릅니다. 같은 문서, 같은 벡터, 같은 질문인데도 그렇습니다. 재는 방법이 다르기 때문입니다.

▶ 지금 해보세요

  1. 화면에 나온 네 숫자를 적어 두십시오. 두 컬렉션의 거리1 - 거리 값입니다. 어느 쪽이 맞는 값인지는 숫자만 보고는 알 수 없습니다. 컬렉션을 만들 때 무엇을 지정했는지를 알아야 판단할 수 있습니다.

여기에서 확인할 것은 순위가 아니라 값의 의미입니다. 3강에서는 거리를 1 - 거리 계산으로 유사도로 바꿔 읽고, 그 유사도를 .envMIN_SIMILARITY 값과 비교해 근거로 쓸지 판단합니다.

 

이 계산은 거리 공간이 코사인일 때를 전제로 한 것입니다. 거리 공간이 다르면 같은 계산을 해도 다른 숫자가 나오고, 같은 기준값으로 비교하면 판단이 어긋납니다. 오류가 나지 않고 숫자만 달라지기 때문에 틀린 것을 알아차리기 어렵습니다.

컬렉션을 만드는 코드 한 줄에 거리 공간을 적어 두는 것이 이 문제를 막는 방법입니다.

 

 

 

 

6.2 실습용 컬렉션 삭제하기

 

 

6.2.1 space_test 컬렉션 삭제하기

확인이 끝났으므로 실습용 컬렉션을 지웁니다. ip_docs가 아니라 space_test를 지우는 것인지 이름을 확인하고 실행합니다.

 

쉘 프롬프트

# 실습용 컬렉션만 삭제하고 본 컬렉션은 그대로 두기
python3 -c "
import chromadb

from config import get_settings

settings = get_settings()
client = chromadb.PersistentClient(path=str(settings.chroma_path))
client.delete_collection('space_test')

print('남은 컬렉션 :', client.list_collections())
print('ip_docs 건수:', client.get_collection(settings.collection_name).count())
"
남은 컬렉션 : ['ip_docs']
ip_docs 건수: 10

남은 컬렉션 줄은 chromadb 버전에 따라 이름만 나오기도 하고 Collection(name=ip_docs) 형태로 나오기도 합니다. 어느 쪽이든 ip_docs만 남아 있고 space_test가 사라졌으면 정상입니다.

ip_docs가 남아 있고 건수가 10이면 정상입니다. 이 상태로 3강을 시작합니다.

 

7. 자주 만나는 오류와 해결 방법

→ 2절부터 6절까지에서 나오는 오류를 원인별로 정리합니다.

더보기

앞 절마다 그 자리에서 나오는 오류를 안내했습니다. 여기에서는 어느 절에서 났는지와 관계없이 원인이 같은 것들을 모아 둡니다. 실습 중 막혔을 때 이 표부터 확인합니다.

 

증상 원인 해결
ModuleNotFoundError: No module named 'chromadb' 가상환경이 활성화되지 않음 source .venv/bin/activate 실행 후 프롬프트에 (.venv) 확인
ModuleNotFoundError: No module named 'config' rag-basic 폴더 밖에서 실행 cd ~/rag-basic 후 다시 실행
requests.exceptions.ConnectionError Ollama가 실행 중이 아님 sudo systemctl start ollamaollama list로 확인
404 Client Error/api/embed에서 발생 .envEMBEDDING_MODEL에 해당하는 모델이 없음 ollama listbge-m3 확인. 없으면 0강 3.3 다시
문서 파일이 없습니다로 멈춤 build_docs.py를 실행하지 않음 2.4를 먼저 실행
저장 건수가 10이 아님 JSONL 줄 수가 10이 아니거나 id가 중복됨 wc -l docs/qa_documents.jsonl로 줄 수 확인
저장은 되는데 metadata가 비어 있음 upsert()metadatas를 넘기지 않음 4.3 코드의 metadatas= 줄 확인
Expected embeddings to be ... 형태의 오류 문서 수와 벡터 수가 맞지 않음 embed()에 넘긴 목록과 ids 목록이 같은 데이터에서 나왔는지 확인
첫 실행이 유난히 오래 걸림 모델을 메모리에 처음 올리는 중 오류가 아님. 두 번째 실행부터 빨라짐

세 번째와 네 번째가 가장 자주 나옵니다. 둘 다 파이썬 코드의 문제가 아니라 Ollama 쪽 상태 문제이므로, 코드를 고치기 전에 ollama list부터 실행해 보는 습관을 들입니다.

 

8. 실습 과제

→ 안내대로 따라 한 것을 조건을 바꿔 다시 해 봅니다.

더보기

8.1 과제

 

 

과제 1. 메타데이터에 항목 하나 추가하기

build_docs.pybuild_record()에서 metadatalength 항목을 추가합니다. 값은 document의 글자 수입니다. build_docs.pystore.py를 다시 실행한 뒤 5.1의 조회 명령으로 length가 들어갔는지 확인합니다.

 

과제 2. 문서 하나를 직접 추가하기

data/ip_qa_basic.json에 같은 형식으로 항목 하나를 직접 써서 넣습니다. idip-011로 합니다. --reset 없이 build_docs.pystore.py를 다시 실행하고 건수를 확인합니다.

 

과제 3. 키를 하나 빼고 실행해 보기

data/ip_qa_basic.json을 복사해 data/broken.json을 만들고, 그중 한 항목에서 title 키를 지웁니다. build_docs.pyINPUT_PATH를 이 파일로 잠시 바꿔 실행하고 화면에 나오는 메시지를 적습니다. 확인한 뒤 INPUT_PATH를 되돌립니다.

 

 

 

 

8.2 과제를 마쳤는지 판단하는 기준

 

 

과제 확인할 것
1 5.1 조회의 4. 메타데이터 줄에 length가 보이고, 값이 본문 앞부분의 문서 길이와 맞는다
2 전체 건수가 11이다. 기존 10건이 20건으로 늘지 않은 이유를 idupsert로 설명할 수 있다
3 KeyError가 아니라 n번째 항목에 없는 키가 있습니다: ['title'] 형태의 메시지가 나온다. 몇 번째 항목인지 화면에서 알 수 있다

과제 3이 이 강의에서 확인할 것을 가장 잘 보여 줍니다. 같은 잘못된 데이터를 넣어도, 검사를 넣어 둔 프로그램은 어느 항목의 어떤 키가 없는지 알려 주고 검사가 없는 프로그램은 KeyError: 'title'만 알려 줍니다. 2단계에서 실제 데이터를 다룰 때 이 차이가 크게 벌어집니다.

 

9. 다음에 만들 것 — 질문으로 근거를 찾아 답변 만들기

→ 다음 강의에서 만들 것과 그 이유를 봅니다.

더보기

이번 강의에서 10건을 벡터로 바꿔 저장했습니다. 그런데 저장소에 무엇이 들었는지 질문으로 물어볼 방법은 아직 없습니다.

지금 할 수 있는 것은 건수를 세는 것뿐입니다.

 

쉘 프롬프트

# 지금 할 수 있는 것과 할 수 없는 것
python3 -c "
import chromadb

from config import get_settings

settings = get_settings()
client = chromadb.PersistentClient(path=str(settings.chroma_path))
collection = client.get_collection(settings.collection_name)

print('저장된 건수 :', collection.count())
print('저장된 문서 :', collection.get()['ids'])
"
저장된 건수 : 10
저장된 문서 : ['ip-001', 'ip-002', 'ip-003', 'ip-004', 'ip-005', 'ip-006', 'ip-007', 'ip-008', 'ip-009', 'ip-010']

건수와 문서 번호는 알 수 있지만, 이 열 개 중 무엇이 "무료로 대리인을 지원받는 제도가 있나요?"라는 질문과 관련 있는지는 알 수 없습니다. 문서 번호는 순서일 뿐이고 내용과 질문을 비교한 결과가 아닙니다.

 

6.1에서 query()를 한 번 써 보기는 했습니다. 다만 그것은 두 컬렉션의 거리 값을 비교하기 위한 확인용이었고, 그 거리 값을 어떻게 읽어야 관련 있다고 판단할 수 있는지는 정하지 않았습니다.

 

3강에서는 1강 3절의 다섯 단계 중 남은 두 개, 검색과 근거 기반 생성을 만듭니다. 파일 두 개를 추가합니다.

 

파일 하는 일
search.py 질문을 같은 모델로 벡터로 바꿔 가까운 문서를 찾고, 거리를 유사도로 바꿔 보여 준다
ask.py 찾은 문서를 근거로 붙여 답변을 만들고, 근거가 없으면 LLM을 부르지 않는다

3강을 마치면 0.1에서 본 질문에 다음과 같이 답하는 프로그램이 완성됩니다.

$ python3 ask.py "무료로 대리인을 지원받는 제도가 있나요?"
[LLM 호출: 예 / 소요 xx.x초]

특허심판원은 국선대리인을 선임해 줄 수 있으며, 대상은 수급자와 국가유공자,
장애인 등 지원이 필요한 사람과 산업재산권 분쟁 중인 소기업·중기업입니다. [근거 1]

근거:
  - 법령 / 특허심판원국선대리인의선임및운영에관한규칙 (유사도 0.xxxx, 문서번호 ip-004)

시작하기 전에 저장한 벡터로 한 번 검색해 보십시오. 아래 명령은 3강에서 만들 search.py가 하는 일의 가장 짧은 형태입니다.

 

쉘 프롬프트

# 질문과 가까운 문서 한 건만 찾아 보기
python3 -c "
import chromadb

from config import get_settings
from embedder import embed

settings = get_settings()
client = chromadb.PersistentClient(path=str(settings.chroma_path))
collection = client.get_collection(settings.collection_name)

question_vector = embed(['무료로 대리인을 지원받는 제도가 있나요?'])[0]
result = collection.query(query_embeddings=[question_vector], n_results=1)
print('1위 문서 :', result['ids'][0][0])
print('거리     :', round(result['distances'][0][0], 4))
"
1위 문서 : ip-004
거리     : 0.xxxx

질문에 쓴 "무료"와 "제도"는 10건 어디에도 없는데 ip-004가 1위로 나왔습니다. 0.1에서 grep0을 돌려주던 그 질문입니다. 3강은 이 결과의 두 번째 줄, 거리 값을 어떻게 읽어야 하는지에서 시작합니다.