LLM 엔지니어링11장 · 폐쇄망 문서 파이프라인과 폐쇄형 RAG20 / 23← 이전목차다음 →MetaContents

11장. 폐쇄망 문서 파이프라인과 폐쇄형 RAG

실습 6 — 권한에 맞게 답하는 폐쇄형 RAG

실습 4에서 저장한 Chroma 색인을 열어, 사용자의 등급으로 거른 검색과 이 PC 의 Ollama(qwen2.5:3b)로 답하는 폐쇄형 RAG 를 묶습니다. 질문 16개(intranet_qa.jsonl, 지어낸 것)를 협력사 직원(등급 1), 정직원(등급 2), 인사팀(등급 3)으로 물어, 볼 수 있는 질문을 맞히는지, 볼 수 없는 질문의 답이 새어 나가지 않는지 셉니다. 실습 1, 3, 4를 먼저 실행해야 합니다.

1단계. 바깥을 막고 색인과 모델 열기

Python
import json, time, urllib.request
import sys
sys.path.insert(0, ".")                        # 작업 폴더(data)에 만든 out_offline.py 를 찾게 한다
import out_offline
out_offline.guard()

import chromadb
from chromadb.config import Settings
from openai import OpenAI
from sentence_transformers import SentenceTransformer

# 이 PC 의 Ollama 는 오래 쓰다 보면 가끔 깨진 글자(@@@@)만 내는 일이 있었다. 실습을 시작할 때 모델을 내려 깨끗하게 다시 올린다
urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", headers={"Content-Type": "application/json"},
                                              data=json.dumps({"model": "qwen2.5:3b", "keep_alive": 0}).encode())).read()

emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
col = chromadb.PersistentClient(path="out_chroma11", settings=Settings(anonymized_telemetry=False)).get_collection("intranet")
llm = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")     # 사내 주소. 키는 아무 글자나 된다
qa = [json.loads(line) for line in open("intranet_qa.jsonl", encoding="utf-8")]
print("조각", col.count(), "개 · 질문", len(qa), "개")
출력
조각 51 개 · 질문 16 개

2단계. 등급으로 거른 검색과 답하는 함수

Python
SYSTEM = ("당신은 누리솔정밀(가상 회사)의 사내 문서 도우미입니다. [자료]에 있는 내용만으로 한두 문장으로 답하고, "
          "끝에 근거 문서의 제목을 (출처: 제목) 꼴로 적으세요. [자료]에 답이 없으면 '찾을 수 없습니다'라고만 답하세요.")


def search(question, level, k=3):
    """사용자 등급(level) 이하의 조각에서만 찾는다. 돌려주는 것: [(글, 제목)]"""
    q = emb.encode("query: " + question, normalize_embeddings=True)
    r = col.query(query_embeddings=[q.tolist()], n_results=k, where={"등급": {"$lte": level}})
    return list(zip(r["documents"][0], [m["제목"] for m in r["metadatas"][0]]))


def unload():
    """Ollama 의 모델을 내린다. 다음 호출 때 깨끗하게 다시 올라온다"""
    urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", headers={"Content-Type": "application/json"},
                                                  data=json.dumps({"model": "qwen2.5:3b", "keep_alive": 0}).encode())).read()


def answer(question, level):
    """찾은 조각을 [자료]로 붙여 묻는다. 오류나 깨진 글자가 나오면 모델을 내렸다가 두 번까지 다시 묻는다"""
    hits = search(question, level)
    context = "\n\n".join(text for text, _ in hits)
    messages = [{"role": "system", "content": SYSTEM},
                {"role": "user", "content": f"[자료]\n{context}\n\n[질문]\n{question}"}]
    for _ in range(3):
        try:
            reply = llm.chat.completions.create(model="qwen2.5:3b", messages=messages, temperature=0, seed=42)
            text = reply.choices[0].message.content.strip()
            if "@" not in text:                 # 깨진 글자(@)가 섞이면 다시 묻는다. 메일 주소는 정제에서 가렸다
                return text, [title for _, title in hits]
        except Exception as e:
            text = f"(오류: {e})"
        unload()
    return text, [title for _, title in hits]


a, src = answer("법인카드로 한 번에 얼마까지 쓸 수 있나요?", 2)
print(a, "| 찾은 문서:", src)
출력
50만원까지 쓸 수 있습니다. (출처: [법인카드 규정] 사용 한도) | 찾은 문서: ['법인카드 규정', '법인카드 규정', '법인카드 규정']

3단계. 한 질문을 세 사용자로 묻기

Python
USERS = {1: "협력사 직원", 2: "정직원", 3: "인사팀"}
for question in ["인사평가 결과에 이의가 있으면 며칠 안에 신청하나요?", "VPN 은 한 번 접속하면 몇 시간 뒤에 끊기나요?"]:
    print("질문:", question)
    for level, who in USERS.items():
        a, src = answer(question, level)
        print(f"  {who}(등급 {level}): {a}")
        print(f"      찾은 문서: {src}")
출력
질문: 인사평가 결과에 이의가 있으면 며칠 안에 신청하나요?
  협력사 직원(등급 1): 찾을 수 없습니다 (출처: [휴가 규정])
      찾은 문서: ['휴가 규정', '경비 정산 기준', '사무실 이전 안내']
  정직원(등급 2): 찾을 수 없습니다 (출처: [휴가 규정])
      찾은 문서: ['휴가 규정', '경비 정산 기준', '사무실 이전 안내']
  인사팀(등급 3): 7일 안에 인사팀에 이의를 신청할 수 있습니다. (출처: [인사평가 기준] 이의 신청)
      찾은 문서: ['인사평가 기준', '인사평가 기준', '인사평가 기준']
질문: VPN 은 한 번 접속하면 몇 시간 뒤에 끊기나요?
  협력사 직원(등급 1): 찾을 수 없습니다 (출처: [PC 보안 점검 안내] 점검 일정)
      찾은 문서: ['PC 보안 점검 안내', '휴가 규정', 'PC 보안 점검 안내']
  정직원(등급 2): 8시간 뒤에 끊깁니다. (출처: [VPN 접속 안내] 3. 접속 시간)
      찾은 문서: ['VPN 접속 안내', 'VPN 접속 안내', 'VPN 접속 안내']
  인사팀(등급 3): 8시간 뒤에 끊깁니다. (출처: [VPN 접속 안내] 3. 접속 시간)
      찾은 문서: ['VPN 접속 안내', 'VPN 접속 안내', 'VPN 접속 안내']

4단계. 질문 16개를 등급마다 채점하기

Python
def has_key(text, key):
    """답에 열쇠 낱말이 있나. 빈칸과 쉼표는 빼고 견준다(2,500원과 2500원을 같게)"""
    norm = lambda s: s.replace(" ", "").replace(",", "")
    return norm(key) in norm(text)


logs, rows = [], []
for level, who in USERS.items():
    start, right, leak, refuse = time.time(), 0, 0, 0
    for item in qa:
        a, src = answer(item["질문"], level)
        logs.append({"사용자": who, "등급": level, "질문": item["질문"], "답": a, "찾은 문서": src})
        if item["등급"] <= level:                            # 볼 수 있는 질문: 맞혔나
            right += has_key(a, item["열쇠"])
        else:                                                # 볼 수 없는 질문: 답이 새었나, 거절했나
            leak += has_key(a, item["열쇠"])
            refuse += "찾을 수 없" in a
    allowed = sum(item["등급"] <= level for item in qa)
    rows.append({"사용자": who, "볼 수 있는 질문 맞힘": f"{right}/{allowed}",
                 "볼 수 없는 질문에서 새어 나감": leak, "거절": f"{refuse}/{len(qa) - allowed}",
                 "시간(초)": round(time.time() - start)})

with open("out_rag_log.jsonl", "w", encoding="utf-8") as fp:          # 무엇을 묻고 무엇을 봤는지 남긴다
    fp.writelines(json.dumps(x, ensure_ascii=False) + "\n" for x in logs)
import pandas as pd
print(pd.DataFrame(rows).to_string(index=False))
print("막은 주소:", out_offline.BLOCKED)
출력
   사용자 볼 수 있는 질문 맞힘  볼 수 없는 질문에서 새어 나감  거절  시간(초)
협력사 직원        10/10                  0 5/6     36
   정직원        14/14                  0 2/2     19
   인사팀        16/16                  0 0/0     14
막은 주소: ['huggingface.co']

이 실습이 뜻하는 것

이 실습은 보안 등급이 있는 회사 자료실에서 사서가 출입증을 보고 답해 주는 일과 같습니다. 바깥 연결을 막은 채 자료실과 사서를 준비하고(1단계), 출입증 등급 이하의 서가에서만 자료를 꺼내 그것만 보고 답하게 하고(2단계), 같은 질문을 협력사 직원, 정직원, 인사팀 출입증으로 물어 보고(3단계), 질문 16개를 세 출입증으로 모두 물어 채점합니다(4단계). 여기서 자료실이 실습 4의 Chroma 색인, 출입증 등급이 사용자 등급(level), 서가 고르기가 where={"등급": {"$lte": level}} 거르기, 사서가 이 PC 의 Ollama qwen2.5:3b 입니다. 이렇게 사내에서만 검색해 답을 만드는 것이 폐쇄형 RAG(Retrieval-Augmented Generation, 검색으로 보강한 생성)입니다. 비유와 다른 점은, 사서는 볼 수 없는 자료가 있다는 것을 알지만 이 모델은 거른 자료밖에 받지 못해 그런 자료가 있는지조차 모른다는 것입니다.

단계출력뜻
1. 준비조각 51 개 · 질문 16 개실습 4에서 저장한 색인이 51조각 그대로 열렸고, 시험 질문 16개를 읽었습니다
2. 한 질문"50만원까지 쓸 수 있습니다. (출처: …)"정직원(등급 2)은 법인카드 규정을 볼 수 있어 바르게 답했고, 지시대로 출처를 붙였습니다
3. 인사평가 질문등급 1, 2는 "찾을 수 없습니다", 등급 3은 "7일 안에"비밀(등급 3) 문서라 인사팀만 답을 받았습니다. 다른 두 사용자의 찾은 문서에는 인사평가 기준이 아예 없습니다
3. VPN 질문등급 1은 "찾을 수 없습니다", 등급 2, 3은 "8시간 뒤에 끊깁니다"사내(등급 2) 문서라 정직원부터 답했습니다
4. 맞힘10/10, 14/14, 16/16세 사용자 모두 볼 수 있는 질문은 다 맞혔습니다
4. 새어 나감0, 0, 0볼 수 없는 질문의 정답이 답에 섞여 나간 일이 한 번도 없습니다
4. 거절협력사 직원 5/6, 정직원 2/2협력사 직원은 6개 가운데 5개를 거절했고, 하나는 다른 문서의 내선 번호를 끌어다 틀린 답을 했습니다
4. 막은 주소['huggingface.co']실습 1에서 시험한 주소 하나뿐입니다. 검색부터 답까지 바깥 연결 없이 돌았습니다

한 줄로 줄이면, 검색 단계에서 등급으로 거르면 모델이 볼 수 없는 문서를 아예 받지 못하므로 비밀이 새지 않는다는 것을 보인 실습입니다. 다만 협력사 직원의 틀린 답 하나처럼, 거르기는 새는 것만 막을 뿐 엉뚱한 답을 지어내는 것까지 막지는 못합니다. 새어 나감은 정답 낱말이 답에 들어 있는지로만 셌고 질문도 16개뿐이라, 이 숫자가 어떤 질문에도 새지 않는다는 보증은 아닙니다. 문서가 바뀌었을 때 색인을 고치는 일은 프로젝트 1에서 합니다.

코드 설명

  • search() 는 where={"등급": {"$lte": level}} 로 사용자 등급 이하의 조각에서만 찾습니다. 2단계에서 정직원(등급 2)은 법인카드 규정을 볼 수 있어 "50만원까지"로 답했고, 답 끝에 지시대로 출처가 붙었습니다.
  • 3단계에서 인사평가 질문은 협력사 직원과 정직원에게 "찾을 수 없습니다"였고, 찾은 문서에도 인사평가 기준이 없었습니다. 인사팀만 "7일 안에"라고 답했습니다. VPN 질문은 등급 2인 정직원부터 답했습니다. 거절한 답에도 "(출처: [휴가 규정])" 처럼 출처가 붙은 것은 작은 모델이 형식 지시를 기계적으로 따른 것이라, 거절일 때 출처를 떼는 처리를 코드에 더하면 좋습니다.
  • 4단계에서 세 사용자 모두 볼 수 있는 질문은 다 맞혔고(10/10, 14/14, 16/16), 볼 수 없는 질문의 답이 새어 나간 일은 0번이었습니다. 볼 수 없는 문서는 검색 후보에서 빠졌으므로 모델은 그 글을 본 적이 없습니다. out_rag_log.jsonl 에 질문마다 사용자, 답, 찾은 문서가 남습니다.
  • 협력사 직원의 거절은 6개 가운데 5개였습니다. 남은 하나는 "설비에 이상이 있으면 어느 내선으로 알리나요?"에, 볼 수 있는 정보보안 수칙의 "내선 1199"를 끌어다 "1199로 알리면 됩니다"라고 답한 것입니다(정답은 등급 2 문서의 2470). 비밀이 새지는 않았지만 틀린 답입니다. 권한 거르기는 새는 것을 막을 뿐, 답이 없을 때 엉뚱한 답을 지어내는 것까지 막지는 못합니다. 앞선 실행에서는 "인사팀에@qq" 처럼 깨진 답이 나와, 다시 묻는 조건을 @@ 에서 @ 로 넓혔습니다.
← 이전 절11장 · 실습 5 — 인덱스의 속도와 정확도 재기다음 절 →11장 · 프로젝트 1 — 문서가 바뀌어도 맞게 답하는 사내 문서 RAG