3강 실습 중심 ⏱ 약 70분

 

0. 학습 목표

→ 저장한 문서를 질문으로 찾고, 찾은 문서를 근거로 답변을 만들고, 근거가 없으면 답하지 않게 만듭니다.

더보기

0.1 이번 글에서 다룰 내용

 

2강 9절 마지막에서 명령 하나를 실행해 봤습니다. 같은 명령을 다시 실행합니다.

 

쉘 프롬프트

# 질문과 가까운 문서 한 건 찾기
cd ~/rag-basic
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

 

첫 줄은 기대한 결과입니다. 질문에 쓴 "무료"와 "제도"가 문서에 한 번도 나오지 않는데도 ip-004 국선대리인 규칙 문서를 찾아냈습니다. 2강에서 벡터로 바꿔 저장한 결과입니다.

 

문제는 두 번째 줄입니다. 이 상태로 프로그램을 만들면 사용자에게 다음과 같은 화면을 보여 주게 됩니다.

질문: 무료로 대리인을 지원받는 제도가 있나요?
1위 문서: ip-004
거리: 0.xxxx

 

사용자가 이 화면으로 할 수 있는 일이 없습니다. 세 가지가 빠져 있습니다.

 

빠진 것 지금 상태 사용자가 못 하는 일
답변 문장 문서 번호만 나온다 문서 번호를 보고 원문을 직접 찾아 읽어야 한다
값을 읽는 기준 거리가 얼마면 관련 있는 것인지 정해지지 않았다 이 결과를 믿어도 되는지 판단할 수 없다
관련 없을 때의 처리 관련 없는 질문에도 1위가 나온다 엉뚱한 문서가 1위로 나와도 알 수 없다

세 번째를 직접 확인해 봅니다. 데이터와 아무 관계없는 질문을 같은 명령에 넣습니다.

 

쉘 프롬프트

# 데이터와 관계없는 질문으로 같은 검색 실행
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-0xx
거리     : 0.xxxx

 

점심 메뉴를 물었는데도 1위 문서가 나옵니다.

벡터 저장소는 "관련 있는 문서가 없습니다"라고 답하지 않습니다. 10건 중에서 가장 가까운 것을 고를 뿐입니다. 이 결과를 그대로 LLM에 근거로 건네면, 지식재산권 문서를 근거로 점심 메뉴에 대한 그럴듯한 답변이 만들어집니다.

 

이것이 문제입니다. 그렇기 때문에 이번 강의는 거리 값을 사람이 읽을 수 있는 기준으로 바꾸고, 그 기준을 넘지 못하면 답하지 않는 규칙을 만듭니다. 1강 3절에서 확인한 다섯 단계 중 남은 두 개, 검색과 근거 기반 생성이 여기에 해당합니다.

만들 파일은 두 개입니다.

 

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

이번 강의를 마치면 프로젝트 폴더가 다음 상태가 됩니다.

rag-basic/
├── .venv/                   🗸 0강
├── .env                     🗸 0강
├── config.py                🗸 0강
├── build_docs.py            🗸 2강
├── embedder.py              🗸 2강
├── store.py                 🗸 2강
├── search.py                ✏️ 이번 강의 1절
├── ask.py                   ✏️ 이번 강의 4절
├── data/
│   ├── ip_qa_basic.json     🗸 0강
│   └── 출처.md               🗸 0강
├── docs/
│   └── qa_documents.jsonl   🗸 2강, 10줄
└── chroma_db/               🗸 2강, ip_docs 컬렉션 10건

이번 강의가 이 과정의 마지막 강의입니다. 마치면 명령 한 줄로 질문하고 근거와 함께 답변을 받는 프로그램이 완성됩니다.

 

 

 

 

0.2 이번 강의 실습 내용

 

학습자가 직접 하는 작업은 1절부터 6절까지 이어집니다. 1절에서 만든 search.py를 2절과 3절에서 고쳐 완성하고, 4절의 ask.py가 그 결과를 받아 씁니다. 각 절이 무엇을 받아 무엇을 넘기는지 먼저 확인합니다.

 

1절에서는 search.py의 첫 형태를 만듭니다.

0.1의 명령을 파일로 옮기고, 결과를 여러 건 받아 보기 좋게 출력합니다. 이 시점의 출력에는 아직 거리 값이 그대로 나옵니다.

 

2절에서는 그 거리 값을 유사도로 바꿉니다.

1강 7절에서 "거리와 유사도는 방향이 반대"라고 예고한 부분입니다. 일부러 뒤집어 읽어 보고 어떤 증상이 나오는지 확인한 뒤 올바른 계산으로 되돌립니다.

 

3절에서는 범위 안 질문과 범위 밖 질문의 유사도를 나란히 비교합니다.

0강 5.2에서 .env에 적어 둔 MIN_SIMILARITY=0.35가 두 경우를 가르는지 값으로 확인합니다. 이 확인이 4절 이후의 분기 조건이 됩니다.

 

4절에서는 ask.py를 만들어 근거를 프롬프트로 조립하고 답변을 받습니다.

검색으로 고른 문서에 번호를 붙여 LLM에게 건네고, 답변과 함께 출처를 표시합니다.

 

5절에서는 근거가 없을 때 LLM을 호출하지 않게 만듭니다.

3절에서 확인한 기준을 조건으로 씁니다. 호출하지 않았다는 것을 응답 시간 차이로 확인합니다.

 

6절에서는 5절의 규칙을 지우면 무엇이 나오는지 확인합니다. 확인이 끝나면 반드시 되돌리고 같은 질문으로 다시 검증합니다.

 

하는 일 앞 절에서 받는 것 다음 절에 넘기는 것
1 search.py 첫 형태 작성 2강의 ip_docs 컬렉션, embed() 거리 값이 붙은 검색 결과
2 거리를 유사도로 변환 1절의 검색 결과 similarity 값이 붙은 검색 결과
3 범위 안·밖 질문의 유사도 비교 2절의 유사도 근거로 쓸지 판단하는 기준값
4 ask.py로 근거를 붙여 답변 생성 3절의 기준, search() 함수 답변과 출처
5 근거 없을 때 호출하지 않는 분기 4절의 ask.py 완성된 프로그램
6 분기를 지웠을 때의 증상 확인 5절의 ask.py 근거 없음 정책이 필요한 이유

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

 

  • 질문을 문서와 같은 모델로 벡터화해야 하는 이유를 설명하고, 그 처리를 코드로 옮긴다.
  • 거리 값을 유사도로 바꿔 읽고, 두 값의 방향이 반대라는 것을 실행 결과로 보인다.
  • 범위 안 질문과 범위 밖 질문의 유사도를 비교해 기준값이 두 경우를 가르는지 판단한다.
  • 검색으로 고른 문서를 번호가 붙은 근거로 조립해 LLM에 전달하고, 답변에 출처를 함께 표시한다.
  • 근거가 없을 때 LLM을 호출하지 않도록 만들고, 호출하지 않았다는 것을 응답 시간으로 확인한다.

선수 지식은 2강에서 만든 ip_docs 컬렉션 10건입니다. 2강을 실행하지 않았으면 이번 강의를 시작할 수 없습니다. 1강 6절에서 확인한 두 실행 경로 중 저장 경로를 한 번도 실행하지 않으면 질문 경로가 아무것도 찾지 못합니다.

이번 강의에서는 최소 유사도 값을 데이터에서 측정해 정하는 방법을 다루지 않습니다. 문서가 10건뿐이라 유사도의 분포를 잴 표본이 부족하기 때문이며, 그 이유는 3.2에서 값과 함께 설명합니다. 문서 종류로 검색 범위를 좁히는 조건 검색과, 만든 프로그램을 다른 사람이 화면으로 쓸 수 있게 감싸는 작업도 다루지 않습니다. 각각 8. RAG 강좌 2단계와 4단계에서 이어집니다.

시작하기 전에 2강의 결과가 남아 있는지 확인합니다.

 

쉘 프롬프트

# 2강에서 저장한 컬렉션이 그대로 있는지 확인
python3 -c "
import chromadb

from config import get_settings

settings = get_settings()
client = chromadb.PersistentClient(path=str(settings.chroma_path))
print('건수:', client.get_collection(settings.collection_name).count())
"
건수: 10

10이 아니거나 오류가 나면 2강 4.5의 python3 store.py --reset을 먼저 실행합니다.

 

1. 질문으로 가까운 문서 찾기

→ 0.1의 명령을 파일로 옮기고 결과를 여러 건 받아 봅니다.

더보기

0.1에서 실행한 명령은 한 건만 찾고 문서 번호와 거리만 보여 줬습니다. 여러 건을 받아 무엇이 나왔는지 내용까지 확인할 수 있게 만듭니다.

1.1 질문도 같은 모델로 벡터로 바꿔야 하는 이유

 

코드를 쓰기 전에 하나를 확인합니다. 질문을 벡터로 바꿀 때도 문서를 바꿀 때와 같은 모델을 써야 합니다.

2강 3절에서 만든 embed() 함수를 그대로 씁니다. 이 함수는 .envEMBEDDING_MODEL 값을 읽으므로, 저장할 때와 검색할 때가 자동으로 같아집니다.

다른 모델을 쓰면 어떻게 되는지는 숫자로 설명할 수 있습니다. 임베딩 모델마다 벡터의 차원이 다르고, 차원이 같더라도 각 자리의 숫자가 뜻하는 바가 다릅니다.

 

상황 결과
저장과 검색이 같은 모델 두 벡터를 비교할 수 있다
차원이 다른 모델 저장소가 오류를 돌려준다. 잘못된 것을 바로 알 수 있다
차원은 같고 종류가 다른 모델 오류가 나지 않는다. 비교는 되지만 결과가 뒤죽박죽이 된다

 

세 번째가 위험합니다. 오류가 없으므로 검색 결과가 이상해도 원인을 찾기 어렵습니다. 1강 6절에서 embedder.py가 저장 경로와 질문 경로에 모두 등장하는 이유가 이것입니다. 같은 함수를 쓰면 모델이 어긋날 수 없습니다.

 

 

 

 

1.2 search.py 첫 형태 만들기

 

VSCode에서 rag-basic 폴더에 search.py를 만들고 아래 내용을 넣습니다. 이 코드는 이번 절의 형태이며 2절에서 한 부분을 고칩니다.

"""질문과 가까운 문서를 벡터 저장소에서 찾는다."""

from __future__ import annotations

import argparse

import chromadb

from config import get_settings
from embedder import embed


def get_collection():
    """2강에서 만든 컬렉션을 연다."""
    settings = get_settings()
    if not settings.chroma_path.exists():
        raise SystemExit("벡터 저장소가 없습니다. 2강의 store.py를 먼저 실행하십시오.")

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


def search(question: str, top_k: int | None = None) -> list[dict]:
    """질문을 벡터로 바꿔 가까운 문서를 가까운 순서로 돌려준다."""
    question = question.strip()
    if not question:
        raise ValueError("질문이 비어 있습니다.")

    settings = get_settings()
    collection = get_collection()
    question_vector = embed([question])[0]

    result = collection.query(
        query_embeddings=[question_vector],
        n_results=top_k or settings.top_k,
        include=["documents", "metadatas", "distances"],
    )

    hits = []
    for doc_id, document, metadata, distance in zip(
        result["ids"][0],
        result["documents"][0],
        result["metadatas"][0],
        result["distances"][0],
    ):
        hits.append(
            {
                "id": doc_id,
                "document": document,
                "metadata": metadata,
                "distance": distance,
            }
        )
    return hits


def main() -> None:
    parser = argparse.ArgumentParser(description="질문과 가까운 문서를 찾는다.")
    parser.add_argument("question", help="검색할 질문 한 줄")
    parser.add_argument("--top-k", type=int, default=None, help="가져올 문서 수")
    args = parser.parse_args()

    try:
        hits = search(args.question, args.top_k)
    except ValueError as error:
        raise SystemExit(f"입력 오류: {error}")

    print(f"질문: {args.question}")
    print()

    for rank, hit in enumerate(hits, start=1):
        preview = hit["document"].replace("\n", " ")[:60]
        print(f"{rank}위 [{hit['id']}] 거리 {hit['distance']:.4f}")
        print(f"    {hit['metadata']['type']} / {hit['metadata']['title']} / {hit['metadata']['date']}")
        print(f"    {preview}...")
        print()


if __name__ == "__main__":
    main()

 

 

 

 

1.3 코드가 하는 일 확인하기

 

get_collection()이 저장소를 여는 부분을 따로 떼어 두었습니다. 4절의 ask.py도 같은 컬렉션을 쓰는데, 그때는 search()를 통해 쓰므로 이 함수를 다시 만들 필요가 없습니다. 저장소가 없으면 2강을 먼저 실행하라고 알리고 멈춥니다.

 

search()가 질문을 받아 결과 목록을 돌려줍니다. main()이 아니라 함수로 분리한 이유는 4절에서 ask.py가 이 함수를 그대로 불러 쓰기 때문입니다. 화면에 출력하는 일과 결과를 만드는 일을 나누어 두면, 같은 결과를 다른 형식으로 쓸 수 있습니다.

n_results=top_k or settings.top_k가 가져올 건수를 정합니다. 명령에서 --top-k를 주지 않으면 .envTOP_K 값인 3이 쓰입니다. 0강 5.2에서 적어 둔 값이 여기에서 처음 쓰입니다.

include=[...]가 무엇을 함께 받을지 정합니다. 지정하지 않으면 벡터까지 돌려받는데, 1024개 실수를 10건 받아 봐야 화면에서 할 일이 없습니다. 필요한 것만 받습니다.

result["ids"][0]처럼 [0]이 붙는 이유query()가 질문 여러 개를 한 번에 처리할 수 있게 만들어져 있기 때문입니다. 질문을 하나만 보냈으므로 첫 번째 묶음을 꺼냅니다.

zip()으로 네 목록을 나란히 묶습니다. ids, documents, metadatas, distances가 각각 같은 순서의 목록으로 오므로, 같은 자리끼리 묶으면 문서 한 건이 됩니다.

 

 

 

 

1.4 실행하고 결과 확인하기

 

 

쉘 프롬프트

# 0.1과 같은 질문으로 검색
python3 search.py "무료로 대리인을 지원받는 제도가 있나요?"
질문: 무료로 대리인을 지원받는 제도가 있나요?

1위 [ip-004] 거리 0.xxxx
    법령 / 특허심판원국선대리인의선임및운영에관한규칙 / 2021-11-11
    질문: 국선대리인의 선임 기준은 무엇인가요? 답변: 국선대리인의 선임 기준은 특허심판원장이...

2위 [ip-0xx] 거리 0.xxxx
    ...

3위 [ip-0xx] 거리 0.xxxx
    ...

세 건이 나오고 1위가 ip-004이면 정상입니다. .envTOP_K가 3이므로 세 건입니다.

▶ 지금 해보세요

  1. 세 건의 거리 값을 적습니다. 1위와 3위의 차이가 얼마인지 봅니다.
  2. --top-k 10을 붙여 다시 실행하고, 10건 전체의 거리 값이 어떻게 퍼져 있는지 봅니다.
  3. 빈 질문으로 실행해 봅니다. python3 search.py ""를 실행하면 어떤 메시지가 나오는지 적습니다.

세 번째를 실행하면 다음과 같이 나옵니다.

입력 오류: 질문이 비어 있습니다.

파이썬 오류 메시지가 아니라 우리가 쓴 문장이 나옵니다. search() 안의 raise ValueErrormain()except ValueError가 짝을 이룬 결과입니다. 이 처리가 없으면 빈 질문이 그대로 임베딩 요청으로 나가고, 서버가 돌려주는 오류 메시지를 사용자가 보게 됩니다.

 

2. 거리를 유사도로 바꿔 읽기

→ 1절의 출력에 나온 거리 값을 사람이 판단할 수 있는 값으로 바꿉니다.

더보기

1.4에서 세 건의 거리 값을 적었습니다. 그 숫자를 보고 "관련 있다"고 판단할 수 있습니까? 아직 할 수 없습니다. 거리가 얼마면 관련 있는 것인지 정해지지 않았기 때문입니다.

2.1 거리와 유사도의 방향

 

1강 7절의 용어표에서 두 값을 이렇게 정리했습니다.

 

방향 이 컬렉션에서의 범위
거리 (distance) 작을수록 관련이 크다 0에 가까울수록 가깝다
유사도 (similarity) 클수록 관련이 크다 1에 가까울수록 가깝다

2강 4.2에서 컬렉션의 거리 공간을 코사인으로 지정했습니다. 코사인 거리 공간에서는 두 값이 다음 관계입니다.

유사도 = 1 - 거리

같은 내용이면 거리가 0에 가깝고 유사도가 1에 가깝습니다. 관련이 없으면 거리가 커지고 유사도가 작아집니다.

이 계산은 거리 공간이 코사인일 때만 성립합니다. 2강 6절에서 거리 공간을 지정하지 않은 컬렉션의 거리 값이 다르게 나온 것을 확인했습니다. 그 컬렉션에 같은 계산을 하면 다른 숫자가 나오고, 오류는 나지 않습니다.

 

 

 

 

2.2 뒤집어 읽으면 어떤 결과가 나오는지 확인하기

 

계산을 반대로 하면 어떻게 되는지 먼저 봅니다. 유사도 = 거리로 잘못 읽었다고 가정하고 두 방식의 순위를 나란히 출력합니다.

 

쉘 프롬프트

# 거리를 그대로 유사도로 읽었을 때와 1에서 뺐을 때를 비교
python3 -c "
from search import search

hits = search('무료로 대리인을 지원받는 제도가 있나요?', top_k=5)

print('[제대로 읽은 순서] 유사도 = 1 - 거리, 큰 것부터')
for hit in sorted(hits, key=lambda h: 1 - h['distance'], reverse=True):
    print('   ', hit['id'], round(1 - hit['distance'], 4), hit['metadata']['title'][:20])

print()
print('[뒤집어 읽은 순서] 거리를 그대로 유사도로 보고 큰 것부터')
for hit in sorted(hits, key=lambda h: h['distance'], reverse=True):
    print('   ', hit['id'], round(hit['distance'], 4), hit['metadata']['title'][:20])
"
[제대로 읽은 순서] 유사도 = 1 - 거리, 큰 것부터
    ip-004 0.xxxx 특허심판원국선대리인의선임및...
    ...

[뒤집어 읽은 순서] 거리를 그대로 유사도로 보고 큰 것부터
    ...
    ip-004 0.xxxx 특허심판원국선대리인의선임및...

두 목록의 순서가 정확히 반대입니다. 뒤집어 읽으면 가장 관련 있는 문서가 마지막에 오고, 가장 관련 없는 문서가 1위가 됩니다.

이 실수가 위험한 이유는 오류가 나지 않기 때문입니다. 프로그램은 정상 종료하고 화면에는 숫자가 나옵니다. 관련 없는 문서를 근거로 답변이 만들어지고, 사용자는 그 답변이 왜 이상한지 알 수 없습니다.

검색 결과가 이상할 때 이 계산을 먼저 확인합니다. 1위로 나온 문서가 눈으로 봐도 관련이 없고 오히려 마지막 문서가 관련 있어 보인다면, 방향이 뒤집힌 것입니다.

 

 

 

 

2.3 search.py에 유사도 추가하기

 

search.pysearch() 함수에서 hits.append(...) 부분에 한 줄을 추가합니다.

        hits.append(
            {
                "id": doc_id,
                "document": document,
                "metadata": metadata,
                "distance": distance,
                "similarity": 1.0 - distance,
            }
        )

main()의 출력 부분도 유사도를 함께 보여 주도록 바꿉니다. .env에 적어 둔 기준값과 비교한 결과도 같이 표시합니다.

def main() -> None:
    parser = argparse.ArgumentParser(description="질문과 가까운 문서를 찾는다.")
    parser.add_argument("question", help="검색할 질문 한 줄")
    parser.add_argument("--top-k", type=int, default=None, help="가져올 문서 수")
    args = parser.parse_args()

    try:
        hits = search(args.question, args.top_k)
    except ValueError as error:
        raise SystemExit(f"입력 오류: {error}")

    settings = get_settings()
    print(f"질문           : {args.question}")
    print(f"최소 유사도 기준: {settings.min_similarity}")
    print()

    for rank, hit in enumerate(hits, start=1):
        judgement = "근거로 사용" if hit["similarity"] >= settings.min_similarity else "기준 미만"
        preview = hit["document"].replace("\n", " ")[:60]
        print(f"{rank}위 [{hit['id']}] 거리 {hit['distance']:.4f} / 유사도 {hit['similarity']:.4f} ({judgement})")
        print(f"    {hit['metadata']['type']} / {hit['metadata']['title']} / {hit['metadata']['date']}")
        print(f"    {preview}...")
        print()

 

쉘 프롬프트

# 유사도를 함께 표시하도록 고친 뒤 다시 실행
python3 search.py "무료로 대리인을 지원받는 제도가 있나요?"
질문           : 무료로 대리인을 지원받는 제도가 있나요?
최소 유사도 기준: 0.35

1위 [ip-004] 거리 0.xxxx / 유사도 0.xxxx (근거로 사용)
    법령 / 특허심판원국선대리인의선임및운영에관한규칙 / 2021-11-11
    질문: 국선대리인의 선임 기준은 무엇인가요? 답변: 국선대리인의 선임 기준은 특허심판원장이...

2위 [ip-0xx] 거리 0.xxxx / 유사도 0.xxxx (기준 미만)
    ...

3위 [ip-0xx] 거리 0.xxxx / 유사도 0.xxxx (기준 미만)
    ...

각 줄 끝에 근거로 사용인지 기준 미만인지 표시됩니다. 아직 이 판단이 답변에 반영되지는 않습니다. 지금은 화면에 표시만 하고, 실제로 걸러 내는 것은 5절입니다.

거리유사도를 더하면 1이 되는지 확인해 보십시오. 2.1의 관계가 실제로 성립하는지 값으로 확인하는 방법입니다.

 

3. 범위 안 질문과 범위 밖 질문의 유사도 비교하기

→ 0강에서 정한 기준값 0.35가 두 경우를 실제로 가르는지 확인합니다.

더보기

2.3에서 근거로 사용기준 미만이 화면에 표시되기 시작했습니다. 그 기준이 되는 0.35는 어디에서 온 값입니까? 0강 5.2에서 .env에 적어 둔 값이고, 그때 "지금 시점에서는 가정값"이라고 했습니다.

이 절에서는 그 가정값이 이 데이터에서 쓸 만한지 확인합니다.

3.1 성격이 다른 질문 네 개로 비교하기

 

질문을 네 종류로 나눠 실행합니다.

 

구분 질문 기대하는 결과
A. 문서의 말을 그대로 쓴 질문 국선대리인의 선임 기준은 무엇인가요? 유사도가 가장 높다
B. 뜻은 같고 표현이 다른 질문 무료로 대리인을 지원받는 제도가 있나요? 기준을 넘는다
C. 같은 분야지만 데이터에 없는 내용 특허 출원 수수료는 얼마인가요? 판단이 갈릴 수 있다
D. 분야가 아예 다른 질문 오늘 점심 메뉴로 뭐가 좋을까요? 기준을 넘지 못한다

 

쉘 프롬프트

# 네 질문의 1위 유사도를 나란히 비교
python3 -c "
from config import get_settings
from search import search

questions = [
    ('A. 문서의 말 그대로', '국선대리인의 선임 기준은 무엇인가요?'),
    ('B. 표현이 다름    ', '무료로 대리인을 지원받는 제도가 있나요?'),
    ('C. 같은 분야·없는 내용', '특허 출원 수수료는 얼마인가요?'),
    ('D. 분야가 다름    ', '오늘 점심 메뉴로 뭐가 좋을까요?'),
]
threshold = get_settings().min_similarity
print('기준값:', threshold)
print()
for label, question in questions:
    top = search(question, top_k=1)[0]
    mark = '넘음' if top['similarity'] >= threshold else '못 넘음'
    print(label, '1위', top['id'], '유사도', round(top['similarity'], 4), '->', mark)
"
기준값: 0.35

A. 문서의 말 그대로 1위 ip-004 유사도 0.xxxx -> 넘음
B. 표현이 다름     1위 ip-004 유사도 0.xxxx -> 넘음
C. 같은 분야·없는 내용 1위 ip-0xx 유사도 0.xxxx -> 넘음 또는 못 넘음
D. 분야가 다름     1위 ip-0xx 유사도 0.xxxx -> 못 넘음

 

0.xxxxip-0xx 자리에는 실행하면 실제 값이 나옵니다. C는 데이터에 없는 내용을 같은 분야의 말로 물은 것이라 어느 쪽으로 나올지 정해져 있지 않습니다. 그 결과를 보는 것이 이 실습의 목적입니다.

▶ 지금 해보세요

  1. 네 줄의 유사도를 모두 적으십시오. A와 B가 기준을 넘고 D가 넘지 못하면 이 기준값을 그대로 쓸 수 있습니다. C는 어느 쪽으로 나오는지 적고, 어느 쪽이 맞는 처리라고 생각하는지 한 문장으로 적습니다.

 

 

 

 

3.2 이 값을 이 과정에서 측정하지 않는 이유

 

3.1의 결과로 0.35가 A·B와 D를 가른다는 것은 확인할 수 있습니다. 그런데 이 값이 이 데이터에 맞는 값이라고 말하려면 표본이 더 필요합니다.

문서가 10건뿐이므로 범위 안 질문과 범위 밖 질문의 유사도가 각각 어느 범위에 퍼져 있는지 잴 수 없습니다. 질문 네 개로 확인한 것은 "이 기준이 이 네 질문을 가른다"이지 "0.35가 이 데이터에 맞는 값이다"가 아닙니다.

 

이 과정에서 하는 일 실제 도입할 때 해야 하는 일
기본값 0.35로 두 경우가 갈리는 것을 확인 범위 안 질문 수십 개와 범위 밖 질문 수십 개의 유사도를 모아 분포를 본다
값을 .env에서 바꿀 수 있게 만들어 둠 두 분포가 갈리는 지점을 찾아 값을 정하고 근거를 문서에 남긴다

값을 재어 정하는 작업은 8. RAG 강좌 2단계에서 다룹니다. 지금 확인해 둘 것은 이 값이 코드가 아니라 .env에 있다는 사실입니다. 값이 바뀌어도 코드를 고칠 필요가 없습니다.

.env의 값을 바꿔 실행해 보면 화면의 판단이 달라지는지 확인할 수 있습니다.

 

쉘 프롬프트

# 기준값을 잠시 높여 실행하고 판단이 달라지는지 확인
MIN_SIMILARITY=0.9 python3 search.py "무료로 대리인을 지원받는 제도가 있나요?" | head -4
질문           : 무료로 대리인을 지원받는 제도가 있나요?
최소 유사도 기준: 0.9

1위 [ip-004] 거리 0.xxxx / 유사도 0.xxxx (기준 미만)

같은 질문, 같은 검색 결과인데 판단만 바뀌었습니다. 명령 앞에 붙인 MIN_SIMILARITY=0.9는 그 한 번의 실행에만 적용되며 .env 파일은 바뀌지 않습니다. 다음 실행부터는 다시 0.35가 쓰입니다.

 

4. 근거를 프롬프트로 조립해 답변 만들기

→ 검색으로 고른 문서를 LLM에게 근거로 건네 답변 문장을 받습니다.

더보기

3절까지 만든 것은 문서를 찾아 유사도를 보여 주는 프로그램입니다. 사용자는 여전히 문서를 직접 읽어야 합니다. 0.1의 표에서 빠져 있다고 한 세 가지 중 첫 번째, 답변 문장을 만듭니다.

4.1 무엇을 어떤 형식으로 보낼지 정하기

 

1강 4.1에서 확인했듯 답변을 만드는 것은 Ollama의 /api/chat 경로이고 모델은 qwen3.5:4b입니다. 이 요청에는 두 종류의 내용을 보냅니다.

 

보낼 내용 역할 이 과정에서의 내용
지시문 (system) 어떻게 답할지 정하는 규칙 근거 안에서만 답하고, 근거 번호를 표시하고, 단정하지 않는다
사용자 입력 (user) 근거와 질문 검색으로 고른 문서 + 사용자의 질문

지시문에 넣을 규칙은 다섯 가지입니다. 각 규칙이 무엇을 막으려는 것인지가 중요합니다.

 

규칙 막으려는 것
근거에 적힌 내용만 사용 모델이 학습한 내용으로 답해 1강에서 본 상태로 돌아가는 것
근거에 없으면 없다고 밝힘 근거를 조금 벗어난 질문에 추측으로 답하는 것
문장 끝에 근거 번호 표시 어느 문장이 어느 문서에서 나왔는지 알 수 없게 되는 것
법적 판단을 단정하지 않음 문서를 전달하는 답변이 법률 자문처럼 읽히는 것
한국어로 답변 질문과 다른 언어로 답이 나오는 것

네 번째가 이 데이터에서 특히 중요합니다. 지식재산권 자료를 근거로 만든 답변이 "침해에 해당합니다" 같은 단정으로 읽히면, 그 문장을 그대로 업무 판단에 쓰게 됩니다. 1강 2.3에서 정리했듯 답변을 검토하고 업무에 쓸지 결정하는 것은 사람의 몫입니다. 지시문은 그 구분이 문장에 드러나게 하는 장치입니다.

근거는 다음과 같은 형식으로 조립합니다.

[근거]
[근거 1] 종류: 법령 / 제목: 특허심판원국선대리인의선임및운영에관한규칙 / 날짜: 2021-11-11
질문: 국선대리인의 선임 기준은 무엇인가요?
답변: 국선대리인의 선임 기준은 특허심판원장이 설정하며, ...

[근거 2] 종류: ... / 제목: ... / 날짜: ...
질문: ...
답변: ...

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

번호를 붙이는 이유는 답변에서 그 번호를 다시 쓰게 하기 위해서입니다. 번호가 없으면 모델이 어느 근거를 썼는지 표시할 방법이 없습니다.

2강 2.3에서 문서 본문에 질문: 답변: 표시를 붙여 둔 것이 여기에서 쓰입니다. 표시가 없으면 근거 안에서 어디까지가 질문이고 어디부터가 답변인지 구분되지 않습니다.

 

 

 

 

4.2 ask.py 만들기

 

VSCode에서 rag-basic 폴더에 ask.py를 만들고 아래 내용을 넣습니다. 이 코드에는 5절에서 다룰 분기가 이미 들어 있습니다. 4절에서는 근거가 있는 질문으로 먼저 확인하고, 5절에서 분기 부분을 자세히 봅니다.

"""근거 문서를 찾아 답변을 만들고, 근거가 없으면 답하지 않는다."""

from __future__ import annotations

import argparse
import time

import requests

from config import get_settings
from search import search

SYSTEM_PROMPT = (
    "당신은 지식재산권 질의응답 자료를 찾아 정리하는 도우미입니다.\n"
    "아래 [근거]에 적힌 내용만 사용해 한국어로 답하십시오.\n"
    "근거에 없는 내용은 추측하지 말고 근거 문서에 없다고 밝히십시오.\n"
    "답변에 사용한 근거를 문장 끝에 [근거 1] 형태로 표시하십시오.\n"
    "법적 판단을 단정하지 말고 문서에 적힌 내용을 그대로 전달하십시오."
)

REFUSAL_MESSAGE = (
    "가지고 있는 문서에서 이 질문과 관련된 근거를 찾지 못했습니다.\n"
    "질문을 다르게 표현하거나, 해당 내용을 담은 문서를 추가한 뒤 다시 시도하십시오."
)


def build_prompt(question: str, hits: list[dict]) -> str:
    """검색으로 고른 문서를 번호가 붙은 근거 목록으로 만들어 질문과 합친다."""
    blocks = []
    for number, hit in enumerate(hits, start=1):
        metadata = hit["metadata"]
        blocks.append(
            f"[근거 {number}] 종류: {metadata['type']} / "
            f"제목: {metadata['title']} / 날짜: {metadata['date']}\n"
            f"{hit['document']}"
        )
    evidence = "\n\n".join(blocks)
    return f"[근거]\n{evidence}\n\n[질문]\n{question}"


def call_llm(prompt: str) -> str:
    """조립한 프롬프트를 Ollama에 보내 답변 문장을 받는다."""
    settings = get_settings()
    response = requests.post(
        f"{settings.ollama_base_url}/api/chat",
        json={
            "model": settings.llm_model,
            "stream": False,
            "messages": [
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": prompt},
            ],
        },
        timeout=300,
    )
    response.raise_for_status()
    return response.json()["message"]["content"].strip()


def ask(question: str) -> dict:
    """검색 결과를 최소 유사도로 거르고, 남은 것이 있을 때만 LLM을 호출한다."""
    settings = get_settings()
    hits = search(question)
    grounded = [hit for hit in hits if hit["similarity"] >= settings.min_similarity]

    if not grounded:
        return {"answer": REFUSAL_MESSAGE, "sources": [], "llm_called": False}

    answer = call_llm(build_prompt(question, grounded))
    sources = [
        {
            "id": hit["id"],
            "type": hit["metadata"]["type"],
            "title": hit["metadata"]["title"],
            "similarity": round(hit["similarity"], 4),
        }
        for hit in grounded
    ]
    return {"answer": answer, "sources": sources, "llm_called": True}


def main() -> None:
    parser = argparse.ArgumentParser(description="근거 문서를 찾아 답변한다.")
    parser.add_argument("question", help="질문 한 줄")
    args = parser.parse_args()

    started = time.perf_counter()
    try:
        result = ask(args.question)
    except ValueError as error:
        raise SystemExit(f"입력 오류: {error}")
    except requests.exceptions.ConnectionError:
        raise SystemExit("Ollama 서버에 연결하지 못했습니다. sudo systemctl start ollama 로 시작한 뒤 다시 실행하십시오.")
    elapsed = time.perf_counter() - started

    called = "예" if result["llm_called"] else "아니오"
    print(f"[LLM 호출: {called} / 소요 {elapsed:.1f}초]")
    print()
    print(result["answer"])
    print()

    if result["sources"]:
        print("근거:")
        for source in result["sources"]:
            print(
                f"  - {source['type']} / {source['title']} "
                f"(유사도 {source['similarity']:.4f}, 문서번호 {source['id']})"
            )
    else:
        print("근거: 없음")


if __name__ == "__main__":
    main()

 

 

 

 

4.3 코드가 하는 일 확인하기

 

from search import search가 3절까지 만든 것을 그대로 씁니다. 검색 코드를 다시 쓰지 않습니다. 검색 방식을 고치면 search.py 한 곳만 고쳐도 ask.py의 동작이 함께 바뀝니다.

SYSTEM_PROMPTREFUSAL_MESSAGE를 파일 위쪽에 상수로 두었습니다. 이 두 문자열은 답변의 성격을 정하는 부분이라 자주 고치게 됩니다. 함수 안에 흩어져 있으면 어디를 고쳐야 하는지 찾기 어렵습니다.

ask()가 판단하고 main()이 출력합니다. ask()는 화면에 아무것도 쓰지 않고 결과를 사전으로 돌려줍니다. 그래서 나중에 이 함수를 화면 프로그램이나 HTTP API에서 그대로 쓸 수 있습니다. 8. RAG 강좌 4단계에서 이 구조가 쓰입니다.

llm_called 값을 결과에 넣었습니다. 이 값이 없으면 화면만 보고는 LLM을 불렀는지 알 수 없습니다. 5절에서 이 값이 판단의 근거가 됩니다.

time.perf_counter()로 걸린 시간을 잽니다. 5절에서 근거가 없을 때 응답이 빨라지는 것을 확인하는 데 씁니다.

stream: False를 보냅니다. 이 값이 없으면 Ollama가 답변을 조각조각 나눠 보내고, response.json()이 그 응답을 읽지 못합니다. 답변이 다 만들어진 뒤 한 번에 받으려면 이 값이 필요합니다.

 

 

 

 

4.4 근거가 있는 질문으로 실행하기

 

 

쉘 프롬프트

# 근거가 있는 질문으로 실행
python3 ask.py "무료로 대리인을 지원받는 제도가 있나요?"
[LLM 호출: 예 / 소요 xx.x초]

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

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

답변 문장은 실행할 때마다 달라집니다. LLM은 같은 입력에도 같은 문장을 돌려주지 않습니다. 확인할 것은 문장이 같은지가 아니라 다음 세 가지입니다.

 

확인 항목 통과 기준
근거 표시 답변 문장에 [근거 1] 형태의 표시가 있다
출처 목록 답변 아래 근거:에 종류·제목·유사도·문서번호가 나온다
내용 대조 답변에 나온 대상(수급자, 국가유공자, 장애인, 소기업·중기업)이 ip-004 원문과 맞는다

세 번째가 이 과정에서 가장 중요한 확인입니다. 답변이 그럴듯한지가 아니라 근거 원문과 맞는지를 봅니다. 원문은 근거:에 표시된 문서번호로 찾을 수 있습니다.

 

쉘 프롬프트

# 답변에 표시된 문서번호로 원문 확인
python3 -c "
import json

records = [json.loads(line) for line in open('docs/qa_documents.jsonl', encoding='utf-8')]
for record in records:
    if record['id'] == 'ip-004':
        print(record['document'])
"

1강 1절에서 확인한 것과 비교해 봅니다. 그때는 답변만 나오고 출처가 없어 맞는지 확인할 방법이 없었습니다. 이제는 답변 아래에 문서번호가 있고, 그 번호로 원문을 찾아 대조할 수 있습니다.

 

다만 답변이 원문을 잘못 요약할 가능성은 남아 있습니다. 1강 2.3에서 정리한 세 번째 한계입니다. RAG는 답변의 정확성을 보장하지 않고, 근거를 밝혀 사람이 검토할 수 있게 할 뿐입니다.

⚠ 자주 겪는 오류

답변이 나오지 않고 오래 멈춰 있으면 모델을 처음 메모리에 올리는 중일 수 있습니다. qwen3.5:4b는 약 3.4GB이므로 첫 호출이 오래 걸립니다.

KeyError: 'message'가 나오면 stream 값을 빠뜨린 것입니다. 4.2 코드의 "stream": False 줄을 확인합니다.

답변이 한국어가 아닌 언어로 나오면 SYSTEM_PROMPT의 첫 부분이 제대로 전달되지 않은 것입니다. messagesrolesystem인지 확인합니다.

 

5. 근거가 없을 때 LLM을 호출하지 않게 만들기

→ 0.1에서 확인한 문제, 관련 없는 질문에도 문서가 나오는 상황을 처리합니다.

더보기

0.1에서 "오늘 점심 메뉴로 뭐가 좋을까요?"를 넣었더니 지식재산권 문서가 1위로 나왔습니다. 3.1에서 그 질문의 유사도가 기준을 넘지 못하는 것도 확인했습니다. 이제 그 판단을 프로그램의 동작으로 옮깁니다.

5.1 걸러 내는 부분 확인하기

 

4.2의 코드에서 그 일을 하는 부분은 다음 네 줄입니다.

    hits = search(question)
    grounded = [hit for hit in hits if hit["similarity"] >= settings.min_similarity]

    if not grounded:
        return {"answer": REFUSAL_MESSAGE, "sources": [], "llm_called": False}

grounded는 기준을 넘은 문서만 남긴 목록입니다. search()가 세 건을 돌려줘도 그중 기준을 넘는 것이 하나뿐이면 근거는 하나입니다.

if not grounded: 다음 줄에서 함수가 끝납니다. 아래에 있는 call_llm()까지 가지 않습니다. 이것이 이 절의 핵심입니다.

거르지 않으면 무엇이 달라지는지 정리하면 다음과 같습니다.

 

처리 범위 밖 질문일 때의 동작
거르지 않음 가장 가까운 문서 세 건을 근거로 붙여 LLM 호출. 관련 없는 문서를 근거로 답변이 만들어진다
기준으로 거름 남는 근거가 없으므로 LLM을 부르지 않고 거부 문구를 돌려준다

세 가지가 함께 달라집니다. 잘못된 답변이 나오지 않고, 응답이 빨라지고, 사용자가 "이 질문은 이 문서 묶음의 범위 밖"이라는 것을 알게 됩니다.

 

 

 

 

5.2 범위 밖 질문으로 실행하기

 

 

쉘 프롬프트

# 데이터와 관계없는 질문으로 실행
python3 ask.py "오늘 점심 메뉴로 뭐가 좋을까요?"
[LLM 호출: 아니오 / 소요 x.x초]

가지고 있는 문서에서 이 질문과 관련된 근거를 찾지 못했습니다.
질문을 다르게 표현하거나, 해당 내용을 담은 문서를 추가한 뒤 다시 시도하십시오.

근거: 없음

세 가지를 확인합니다.

 

확인 항목 통과 기준
LLM 호출 아니오
소요 시간 4.4의 실행보다 눈에 띄게 짧다
근거: 없음

두 번째가 이 강의에서 가장 중요한 관찰입니다. 화면의 LLM 호출: 아니오는 우리가 만든 표시일 뿐이지만, 응답 시간 차이는 실제로 호출하지 않았다는 증거입니다. LLM은 문장을 만드는 데 수 초에서 수십 초가 걸리고, 검색과 임베딩만 하면 1초 안팎입니다.

▶ 지금 해보세요

  1. 4.4의 질문과 5.2의 질문을 각각 실행하고 소요 시간을 적습니다. 몇 배 차이인지 계산합니다.
  2. 3.1의 C 질문(특허 출원 수수료는 얼마인가요?)을 ask.py로 실행하고, LLM 호출이 어느 쪽으로 나오는지 확인합니다.
  3. 2번에서 LLM이 호출되었다면 답변을 읽고, 답변이 근거 문서의 내용을 벗어나지 않는지 확인합니다.

두 번째와 세 번째가 이 과정에서 판단이 갈리는 자리입니다. 데이터에 없는 내용인데 유사도가 기준을 넘으면 LLM이 호출되고, 그때 지시문의 "근거에 없으면 없다고 밝히라"는 규칙이 남은 방어선이 됩니다. 기준값과 지시문 두 가지가 함께 작동합니다.

 

6. 최소 유사도 필터를 지웠을 때의 증상 확인하고 되돌리기

→ 5절에서 만든 규칙을 잠시 지우고 무엇이 나오는지 봅니다.

더보기

5.2에서 범위 밖 질문이 거부되는 것을 확인했습니다. 그런데 그 규칙이 없으면 어떤 답변이 나오는지는 아직 보지 않았습니다. 규칙이 무엇을 막고 있는지는 막지 않았을 때를 봐야 알 수 있습니다.

6.1 필터를 지우고 실행하기

 

ask.py를 고치지 않고, 필터를 뺀 동작을 명령으로 확인합니다. 원본 파일은 그대로 두는 방법입니다.

 

쉘 프롬프트

# 최소 유사도로 거르지 않고 검색 결과를 그대로 근거로 써 본다
python3 -c "
from ask import build_prompt, call_llm
from search import search

question = '오늘 점심 메뉴로 뭐가 좋을까요?'
hits = search(question)

print('근거로 쓰게 되는 문서:')
for hit in hits:
    print('   ', hit['id'], round(hit['similarity'], 4), hit['metadata']['title'][:20])
print()

answer = call_llm(build_prompt(question, hits))
print(answer)
"
근거로 쓰게 되는 문서:
    ip-0xx 0.xxxx ...
    ip-0xx 0.xxxx ...
    ip-0xx 0.xxxx ...

(지식재산권 문서를 근거로 만들어진 답변 문장)

유사도가 기준에 한참 못 미치는 문서 세 건이 근거로 들어갔습니다. 그 상태에서 나온 답변이 화면에 있습니다.

▶ 지금 해보세요

  1. 화면에 나온 답변을 그대로 적어 두고 다음 세 가지를 판단합니다.
  2. 답변이 질문에 대한 답인가, 아니면 근거 문서의 내용을 설명하고 있는가?
  3. 답변이 "근거 문서에 없습니다"라고 밝히는가, 아니면 그냥 답하는가?
  4. 이 답변을 처음 보는 사용자가 잘못된 답이라는 것을 알아차릴 수 있는가?

답변 내용은 실행할 때마다 다르지만, 세 번째 질문의 답은 대체로 같습니다. 화면에는 답변과 근거 문서가 함께 나오므로 형식은 정상으로 보입니다. 무엇이 잘못되었는지 알려면 사용자가 근거 문서를 열어 읽어야 합니다.

4.1에서 지시문에 "근거에 없으면 없다고 밝히라"는 규칙을 넣어 둔 것이 이 상황에서 얼마나 작동하는지도 함께 확인합니다. 지시문만으로는 충분하지 않습니다. 근거로 들어간 문서가 애초에 관련이 없으면, 그 문서를 설명하는 답변이 만들어질 수 있습니다.

 

 

 

 

6.2 원래 상태로 돌아왔는지 확인하기

 

6.1은 ask.py를 고치지 않고 함수를 직접 불러 확인한 것이므로 되돌릴 것이 없습니다. 그래도 파일이 그대로인지 확인하고 다음으로 넘어갑니다.

 

쉘 프롬프트

# ask.py의 필터 부분이 그대로인지 확인하고 다시 실행
grep -n "min_similarity" ask.py
python3 ask.py "오늘 점심 메뉴로 뭐가 좋을까요?" | head -1
70:    grounded = [hit for hit in hits if hit["similarity"] >= settings.min_similarity]
[LLM 호출: 아니오 / 소요 x.x초]

LLM 호출: 아니오가 다시 나오면 정상입니다. 줄 번호는 편집 상태에 따라 다를 수 있습니다.

이 절에서 확인한 것을 한 문장으로 정리하면 다음과 같습니다. 검색은 언제나 무언가를 돌려주므로, 돌려받은 것을 쓸지 말지는 검색 밖에서 판단해야 합니다.

 

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

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

더보기

 

증상 원인 해결
벡터 저장소가 없습니다로 멈춤 2강의 store.py를 실행하지 않음 2강 4.5를 실행한 뒤 다시 시도
Collection ip_docs does not exist 저장 폴더는 있는데 컬렉션이 없음 python3 store.py --reset 실행
검색 결과가 0건 컬렉션이 비어 있음 collection.count()로 건수 확인. 0이면 2강 4.5 다시
1위 문서가 눈으로 봐도 관련이 없음 거리와 유사도를 뒤집어 계산 2.2의 비교 명령으로 방향 확인
모든 질문의 유사도가 비슷하게 낮음 저장할 때와 검색할 때 모델이 다름 .envEMBEDDING_MODEL을 바꾼 적이 있으면 store.py --reset으로 다시 저장
KeyError: 'message' 요청에 "stream": False가 없음 4.2 코드의 해당 줄 확인
requests.exceptions.ConnectionError Ollama가 실행 중이 아님 sudo systemctl start ollama
답변에 [근거 1] 표시가 없음 모델이 지시문을 따르지 않음 오류가 아님. 8절의 남는 한계에 해당한다
응답이 매우 느림 모델을 처음 메모리에 올리는 중 두 번째 실행부터 빨라진다

 

다섯 번째가 원인을 찾기 가장 어렵습니다. 오류가 나지 않고 결과만 이상하기 때문입니다. 1.1에서 정리한 세 번째 상황이 이것입니다. .env의 모델 이름을 바꿨다면 저장한 벡터를 반드시 다시 만들어야 합니다.

 

8. 이 과정에서 정하지 않은 것 확인하기

→ 이 과정에서 쉬웠던 이유와 다음 단계에서 만날 것을 정리합니다.

더보기

프로그램이 완성되었습니다. 그런데 이 과정에서 우리가 정하지 않고 넘어간 것들이 있습니다.

1강 8절에서 같은 표를 미리 봤습니다. 이번에는 직접 만든 코드를 놓고 다시 봅니다. 각 항목이 어느 파일의 어느 부분에 해당하는지 함께 적었습니다.

 

이번 과정에서는 실제 데이터에서는 해당하는 코드 다루는 단계
파일 하나에 10건이 들어 있었다 폴더 다섯 개에 나뉜 파일 25개를 찾아 읽어야 한다 build_docs.pyINPUT_PATH 한 줄 2단계
모든 건의 키가 같았다 문서 종류마다 키 이름이 다르다 build_docs.pyREQUIRED_KEYS 2단계
제목이 모든 건에 있었다 심결례에는 제목 키가 없다 build_record()row["title"] 2단계
날짜 형식이 하나였다 네 가지 형식이 섞여 있다 build_record()row["date"] 2단계
임계값을 기본값 그대로 썼다 자기 데이터에서 재어 정해야 한다 .envMIN_SIMILARITY 2단계
문서 종류로 범위를 좁히지 않았다 종류를 지정해 좁혀야 한다 search()collection.query() 2단계
문서 하나가 최대 419자였다 사례 하나가 3만 5천 자가 넘는다 build_record()의 문서 조립 3단계
파일이 모두 정상이었다 일부 파일이 깨져 있다 build_docs.pyjson.load() 3단계
만든 사람이 쉘에서 혼자 썼다 다른 사람이 화면으로 쓸 수 있어야 한다 ask()가 사전을 돌려주는 구조 4단계
결과를 눈으로 확인했다 고칠 때마다 자동으로 확인해야 한다 이 과정의 모든 확인 절차 4단계

왼쪽 칸이 이 과정에서 쉬웠던 이유입니다. 데이터가 정리되어 있어서 쉬웠던 것이지 RAG가 원래 쉬운 것이 아닙니다.

 

표의 오른쪽 두 칸을 함께 보십시오. 열 개 항목 중 여섯 개가 build_docs.py.env에 몰려 있습니다. 검색과 답변을 만드는 search.pyask.py는 실제 데이터를 만나도 크게 달라지지 않습니다. RAG에서 데이터를 읽어 들이는 부분이 가장 많이 바뀌는 곳입니다.

이 표는 8. RAG 강좌 2단계 1강에서 다시 사용합니다. 그때는 미리 채워진 표를 보는 것이 아니라, 이번 과정에서 만든 build_docs.py를 실제 데이터 폴더에 그대로 실행해 보고 어디에서 멈추는지를 학습자가 직접 적습니다.

 

9. 실습 과제

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

더보기

9.1 과제

 

 

과제 1. 자기 질문 다섯 개 만들기

데이터의 다섯 종류(판결문·법령·심결례·심결문·유권해석)에서 한 건씩 골라, 문서에 적힌 문장을 그대로 베끼지 말고 다른 표현으로 질문을 만듭니다. 각 질문에 대해 어느 문서가 1위로 나올지 미리 적어 둔 다음 search.py로 실행하고, 예상과 실제를 비교합니다.

 

과제 2. 답하면 안 되는 질문 다루기

"제가 만든 로고가 다른 회사 상표를 침해한 건가요?"처럼 판단을 요구하는 질문을 만들어 ask.py로 실행합니다. 답변이 단정적인 판단으로 읽히면 SYSTEM_PROMPT를 고쳐 다시 실행하고, 고치기 전과 후의 답변을 함께 남깁니다.

 

과제 3. 기준값을 바꿔 경계 찾기

3.1의 C 질문(특허 출원 수수료는 얼마인가요?)에 대해 MIN_SIMILARITY 값을 바꿔 가며 실행해, 거부와 답변이 갈리는 지점을 찾습니다. 3.2의 방법으로 명령 앞에 값을 붙여 실행하고 .env 파일은 고치지 않습니다.

 

과제 4. 질문을 빼고 답변만 저장했을 때의 순위 비교하기

2강 1.3에서 검토한 세 가지 안 중 "답변만"을 실제로 확인합니다. build_docs.py의 문서 본문을 답변만으로 바꾸고, 컬렉션 이름을 answer_only로 하여 따로 저장합니다. 과제 1에서 만든 질문 다섯 개로 두 컬렉션의 순위와 유사도를 비교합니다. 비교가 끝나면 build_docs.py를 되돌리고 answer_only 컬렉션을 지웁니다.

 

 

 

 

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

 

 

과제 확인할 것
1 다섯 질문 중 예상과 실제가 다른 것이 있으면, 왜 다른 문서가 1위가 되었는지 두 문서의 본문을 놓고 설명할 수 있다
2 고치기 전과 후의 답변을 나란히 놓고, SYSTEM_PROMPT의 어느 문장이 어떤 변화를 만들었는지 짚을 수 있다
3 갈리는 지점의 값을 소수 둘째 자리까지 적을 수 있고, 그 값이 3.1에서 적은 C 질문의 유사도와 같다는 것을 확인했다
4 두 컬렉션의 순위가 달라진 질문이 어떤 것인지 적고, 질문과 답변을 합치기로 한 2강의 결정이 이 데이터에서 타당했는지 판단해 적었다

 

과제 4가 이 과정의 판단을 되짚는 과제입니다. 2강 1.3에서 세 가지 안 중 하나를 골랐고 그때는 근거가 설명뿐이었습니다. 검색을 만든 지금은 그 선택이 옳았는지 값으로 확인할 수 있습니다.

 

10. 다음 단계에서 확인할 것 — 실제 데이터로 같은 코드 실행하기

→ 이 과정을 마친 뒤 무엇으로 이어지는지 봅니다.

더보기

이 과정에서 파일 여섯 개를 만들어 RAG 한 벌을 완성했습니다. 1강 3절의 다섯 단계가 모두 코드가 되었습니다.

 

단계 만든 파일 이 과정에서 확인한 것
1. 문서 준비 build_docs.py JSONL 10줄, 종류별 2건
2. 임베딩 embedder.py 벡터 차원 1024
3. 저장 store.py 컬렉션 10건, 거리 공간 cosine
4. 검색 search.py 글자가 겹치지 않는 질문으로 ip-004가 1위
5. 근거 기반 생성 ask.py 범위 안에서는 근거와 함께 답변, 범위 밖에서는 호출 없이 거부

8. RAG 강좌의 다음 단계는 같은 내용의 실제 데이터를 다룹니다. 내용이 같고 구조만 다르므로, 2단계 1강은 다음과 같이 시작합니다.

이 과정에서 만든 build_docs.py를 실제 데이터 폴더에 그대로 실행한다
        ↓
동작하지 않는다
        ↓
왜 동작하지 않는지가 곧 2단계에서 배울 목록이 된다

8절의 표를 그때 다시 꺼내 직접 채우게 됩니다. 이번에는 예고가 아니라 실행 결과로 채웁니다.

마지막으로 이 과정에서 만든 것이 남아 있는지 확인해 둡니다. 다음 단계에서 이 코드를 그대로 가져다 쓰기 때문입니다.

 

쉘 프롬프트

# 이 과정에서 만든 파일 여섯 개와 산출물 확인
cd ~/rag-basic
ls *.py
wc -l docs/qa_documents.jsonl
python3 -c "
import chromadb

from config import get_settings

settings = get_settings()
client = chromadb.PersistentClient(path=str(settings.chroma_path))
print('컬렉션 건수:', client.get_collection(settings.collection_name).count())
"
ask.py  build_docs.py  config.py  embedder.py  search.py  store.py
10 docs/qa_documents.jsonl
컬렉션 건수: 10

파일 여섯 개, 문서 10줄, 컬렉션 10건이면 이 과정이 끝난 상태입니다. 0강 7절의 삭제 절차는 다음 단계를 마친 뒤에 실행하십시오. Ollama와 모델은 다음 단계에서도 그대로 씁니다.