티스토리 뷰

반응형

Python으로 공부하는 OpenAI 11편 — RAG 답변에 출처까지 붙여야, 이제 진짜 “믿고 쓰는 서비스”가 됩니다

RAG를 붙이고 나면, 처음엔 그 자체로 꽤 감동적입니다.
“오, 내 문서를 찾아서 답하네?” 여기까지 오면 일단 반은 왔어요.

근데 실무에서는 여기서 꼭 한 번 더 걸립니다.

사용자 입장에서 제일 중요한 질문이 남아 있거든요.

“그래서 이 답이 어디 문서를 근거로 나온 건데?”

이 질문에 답을 못 하면, 서비스는 금방 애매해집니다.
답이 맞아 보여도 찜찜하고, 틀렸을 때 어디서 어긋났는지 추적도 어렵고, 운영하는 사람도 디버깅이 힘들어요.

그래서 이번 편은 출처 이야기입니다.
그냥 RAG가 아니라, citation-friendly RAG, 그러니까 근거를 같이 보여주는 RAG 응답 구조를 만들어보겠습니다.

OpenAI의 File Search 가이드는 Responses API에서 file_search 도구를 사용할 수 있고, include=["file_search_call.results"]를 넣으면 검색 결과 자체를 응답에 포함해 받을 수 있다고 설명합니다. 또 Responses API 레퍼런스도 file_search_call.results를 include 대상으로 명시하고 있습니다. Retrieval 가이드는 semantic search 결과에 관련 chunk와 similarity score, file of origin이 포함될 수 있다고 설명합니다. (OpenAI 개발자)


1. 왜 출처가 중요하냐고 묻는다면, 저는 거의 무조건 이렇게 답합니다

RAG는 “찾아서 답한다”는 점에서 이미 일반 챗봇보다 낫습니다.
그런데 찾았다는 사실어디서 찾았는지 보여주는 것은 또 다른 문제예요.

출처가 없으면 이런 일이 자주 생깁니다.

  • 답은 그럴듯한데, 사용자가 검증할 수 없음
  • 같은 질문을 다른 날 했을 때 왜 답이 달라졌는지 추적이 어려움
  • 운영자가 “검색은 잘 됐는지, 생성이 흔들린 건지” 분리해서 보기 어려움
  • 내부 문서 기반 서비스인데도 사용자가 체감하는 신뢰도가 낮음

반대로 출처가 붙으면 훨씬 좋아집니다.

  • 사용자가 직접 문서를 열어 확인할 수 있음
  • 잘못된 답변이 나왔을 때 어떤 chunk를 참고했는지 디버깅 가능
  • “문서에 없는 내용은 확실하지 않다”는 UX를 만들기 쉬움
  • B2B나 사내 도구처럼 신뢰가 중요한 제품에서 설득력이 올라감

OpenAI File Search는 모델이 문서를 검색해 답을 만들 수 있게 해주고, include 옵션으로 검색 결과를 함께 노출할 수 있습니다. Retrieval API도 검색 결과 메타데이터를 다룰 수 있게 설계되어 있어서, “답변”과 “근거”를 분리해 보여주기 좋은 구조입니다. (OpenAI 개발자)


2. 출처를 붙이는 방법은 크게 두 가지 감각으로 볼 수 있습니다

처음엔 그냥 파일명만 보여주면 되는 거 아닌가 싶어요.
근데 실제로는 조금 더 나눠서 생각하는 게 좋습니다.

첫 번째, 검색 결과 메타데이터를 그대로 노출하는 방식

예를 들면:

  • 파일명
  • file id
  • chunk 내용 일부
  • similarity score

이건 운영자와 개발자에게 특히 좋습니다.
왜 이런 답이 나왔는지 보기 쉽거든요.

두 번째, 사용자 친화적으로 다시 가공한 citation 방식

예를 들면:

  • 출처 1: refund-policy.md
  • 출처 2: onboarding-guide.pdf
  • 답변 중간중간에 [1], [2] 형태로 표시
  • 아래에 source list 제공

이건 사용자에게 더 친절합니다.
특히 사내 챗봇, 고객지원 봇, 문서 QA 툴처럼 “근거 기반 답변”이 중요한 서비스에 잘 맞아요.

OpenAI의 Deep Research 관련 문서는 최종 답변에 inline citations가 포함될 수 있다고 설명하고, File Search는 검색 결과를 별도로 include해서 개발자가 가공할 수 있게 합니다. 즉, “근거를 내부적으로 확보하는 것”과 “사용자에게 보기 좋게 보여주는 것”은 분리해서 설계할 수 있습니다. (OpenAI 개발자)


3. Retrieval 방식이라면, 출처를 붙이기 가장 쉬운 이유가 있습니다

지난 편에서 Retrieval API를 먼저 다뤘죠.
저는 출처 UX를 만들 때 Retrieval 방식이 꽤 좋다고 느낍니다.

왜냐하면 검색과 생성을 분리했기 때문에,
어떤 문서 조각을 모델에 넣었는지 내가 이미 알고 있기 때문이에요.

즉, 흐름이 이렇습니다.

  1. 검색 결과를 직접 받음
  2. 상위 chunk를 선별함
  3. 그 chunk의 파일명과 본문 일부를 저장함
  4. 모델에 넣어 답 생성
  5. 응답과 함께 sources 배열도 같이 반환

이 방식은 구조가 명확해서 디버깅할 때 정말 좋습니다.
OpenAI Retrieval 가이드는 vector store 검색 결과가 semantically relevant results를 반환하며, 관련 chunk와 file of origin 정보를 포함한다고 설명합니다. (OpenAI 개발자)


4. File Search tool 방식은 더 빨리 붙일 수 있다는 장점이 있습니다

반대로 Responses API 안에서 file_search tool을 직접 쓰는 방법도 있습니다.

이 방식은 대체로 이런 느낌입니다.

  • 문서 검색을 모델 워크플로 안에 넣는다
  • 모델이 검색 결과를 참고해서 바로 답한다
  • 필요하면 include=["file_search_call.results"]로 검색 결과도 함께 받는다

이게 좋은 이유는 데모나 첫 버전을 빨리 만들기 쉽다는 점이에요.
OpenAI File Search 가이드는 tools=[{"type":"file_search","vector_store_ids":[...]}] 형태와 max_num_results, include=["file_search_call.results"] 사용 예시를 제공합니다. Responses API 레퍼런스도 file_search_call.results를 include 가능한 항목으로 명시합니다. (OpenAI 개발자)

즉, File Search tool은
“검색 결과를 내가 별도 API로 직접 안 받아도, 모델과 함께 굴릴 수 있는 방식”
정도로 이해하면 됩니다.


5. 이번 편에서 추천하는 구조

반응형

이번엔 Retrieval 기반으로 설명하겠습니다.
이유는 출처를 붙이는 구조를 이해하기 가장 쉽기 때문이에요.

프로젝트 구조는 이런 느낌이면 충분합니다.

app/
├── schemas/
│   └── rag_response.py
├── services/
│   ├── retrieval_service.py
│   ├── citation_service.py
│   ├── context_builder_service.py
│   └── rag_chat_service.py
└── api/
    └── rag_router.py

역할은 단순합니다.

  • retrieval_service.py
    관련 chunk 검색
  • citation_service.py
    검색 결과를 citation-friendly 형태로 정리
  • context_builder_service.py
    모델에 넣을 문맥 조립
  • rag_chat_service.py
    최종 답변 생성
  • rag_router.py
    answer와 sources를 함께 반환

이렇게 나누면, 나중에 citation 포맷을 바꾸고 싶을 때도 코드가 덜 꼬입니다.


6. 먼저 응답 스키마부터 citation-friendly 하게 바꿔봅시다

이 단계에서 제일 중요한 건,
“답변 텍스트”와 “출처 목록”을 처음부터 분리해서 생각하는 겁니다.

app/schemas/rag_response.py

from pydantic import BaseModel, Field


class SourceItem(BaseModel):
    id: str = Field(description="UI에서 참조할 출처 번호 또는 식별자")
    source: str = Field(description="파일명 또는 문서명")
    excerpt: str = Field(description="사용자에게 보여줄 짧은 발췌문")


class RagAnswerResponse(BaseModel):
    answer: str
    sources: list[SourceItem]

이 구조가 좋은 이유는 아주 단순합니다.

  • 프론트엔드는 answer를 그대로 보여주고
  • 출처 리스트는 별도 UI로 렌더링할 수 있고
  • 나중에 [1], [2] 같은 citation 마커를 붙여도 대응하기 쉽습니다

RAG 서비스는 결국 “답”만 던지는 게 아니라 “검증 가능한 답”을 던져야 오래 갑니다.
이걸 응답 모델 차원에서 먼저 강제하는 게 꽤 중요해요.


7. Retrieval 결과를 가공해서 source list를 만드는 서비스

검색 결과는 원래 그대로 쓰기엔 좀 거칠어요.
파일명, chunk 내용, 점수, 내부 메타데이터가 섞여 있거든요.

그래서 citation용으로 한 번 가공하는 단계가 필요합니다.

app/services/citation_service.py

from dataclasses import dataclass


@dataclass
class RetrievedChunk:
    source: str
    content: str
    score: float | None = None


class CitationService:
    def build_sources(self, chunks: list[RetrievedChunk], max_excerpt_length: int = 180):
        sources = []

        for index, chunk in enumerate(chunks, start=1):
            excerpt = chunk.content.strip().replace("\n", " ")
            if len(excerpt) > max_excerpt_length:
                excerpt = excerpt[:max_excerpt_length].rstrip() + "..."

            sources.append({
                "id": str(index),
                "source": chunk.source,
                "excerpt": excerpt,
            })

        return sources

이 서비스의 핵심은
검색 결과를 사용자에게 보여줄 형태로 변환한다는 점입니다.

OpenAI Retrieval은 관련 결과를 반환하지만, 실제 사용자 화면에서 어떻게 보일지는 결국 애플리케이션이 설계해야 합니다. File Search도 검색 결과를 include할 수는 있지만, 그걸 바로 예쁜 citation UX로 보여주려면 별도 가공이 필요합니다. (OpenAI 개발자)


8. 답변 본문에 [1], [2]를 붙이고 싶다면 이렇게 가면 됩니다

이건 진짜 자주 원하는 기능이에요.
“답변 하단에 출처 목록만 있지 말고, 본문 중간에도 참조 마커가 보였으면 좋겠다.”

방법은 여러 가지가 있지만, 입문자에게는 아래 두 단계가 제일 덜 꼬입니다.

  1. 검색 결과를 번호 매긴 source list로 만든다
  2. 모델에게 그 번호를 참고해서 답변하도록 지시한다

예를 들어 context builder에서 문서를 이렇게 넣습니다.

app/services/context_builder_service.py

class ContextBuilderService:
    def build_messages(self, user_message: str, retrieved_chunks: list[dict]) -> list[dict]:
        doc_blocks = []

        for idx, chunk in enumerate(retrieved_chunks, start=1):
            doc_blocks.append(
                f"[{idx}] 출처: {chunk['source']}\n{chunk['content']}"
            )

        docs_text = "\n\n".join(doc_blocks)

        return [
            {
                "role": "system",
                "content": (
                    "당신은 문서 근거 기반으로 답변하는 도우미입니다. "
                    "반드시 아래 문서를 우선 참고해 답하세요. "
                    "문서에 근거한 문장 끝에는 가능한 경우 [번호] 형식의 출처 표시를 붙이세요. "
                    "문서에 없는 내용은 추측하지 말고 불확실하다고 말하세요."
                ),
            },
            {
                "role": "system",
                "content": f"참고 문서:\n\n{docs_text}",
            },
            {
                "role": "user",
                "content": user_message,
            },
        ]

이 방식은 단순하지만 꽤 효과적입니다.
검색 결과를 내가 직접 번호 매겨 넣었기 때문에, 모델도 그 번호 체계를 따라가기 쉬워집니다.

물론 완벽하게 100% 붙는다고 장담하면 안 됩니다.
하지만 citation-friendly UX의 첫 버전으로는 충분히 좋습니다.


9. Retrieval 기반 FastAPI 엔드포인트 예시

이제 흐름을 한 번에 보면 이렇습니다.

app/api/rag_router.py

from typing import Annotated

from fastapi import APIRouter, Depends

from app.schemas.rag_response import RagAnswerResponse
from app.services.retrieval_service import RetrievalService
from app.services.citation_service import CitationService
from app.services.context_builder_service import ContextBuilderService
from app.services.rag_chat_service import RagChatService

router = APIRouter(prefix="/rag", tags=["rag"])


@router.get("/ask", response_model=RagAnswerResponse)
async def ask_rag(
    q: str,
    retrieval_service: Annotated[RetrievalService, Depends()],
    citation_service: Annotated[CitationService, Depends()],
    context_builder: Annotated[ContextBuilderService, Depends()],
    rag_chat_service: Annotated[RagChatService, Depends()],
):
    chunks = retrieval_service.search(q, top_k=4)
    sources = citation_service.build_sources(chunks)

    retrieved_chunks_for_prompt = [
        {"source": chunk.source, "content": chunk.content}
        for chunk in chunks
    ]

    messages = context_builder.build_messages(
        user_message=q,
        retrieved_chunks=retrieved_chunks_for_prompt,
    )

    answer = await rag_chat_service.answer(messages)

    return {
        "answer": answer,
        "sources": sources,
    }

이 구조가 좋은 이유는 명확합니다.

  • 검색 결과는 chunks
  • 사용자용 citation은 sources
  • 모델 입력은 messages
  • 최종 출력은 answer + sources

이 네 개가 서로 역할이 분리돼 있어요.


10. File Search tool을 쓴다면, include 결과를 citation 데이터로 재사용할 수 있습니다

지난 편까지의 흐름이 Retrieval 중심이었다면,
이번엔 File Search tool 방식도 같이 알아둘 필요가 있어요.

OpenAI File Search 가이드는 Responses API에서 include=["file_search_call.results"]를 주면 검색 결과를 응답에 함께 포함할 수 있다고 설명합니다. API 레퍼런스도 file_search_call.results를 include 가능한 필드로 명시합니다. (OpenAI 개발자)

즉, 이런 식으로 갈 수 있습니다.

from openai import OpenAI

client = OpenAI()

response = client.responses.create(
    model="gpt-4.1",
    input="환불 정책 문서를 기준으로 설명해줘",
    tools=[{
        "type": "file_search",
        "vector_store_ids": ["vs_123"],
        "max_num_results": 3,
    }],
    include=["file_search_call.results"],
)

그다음 응답 객체에서:

  • 최종 답변 텍스트
  • file search results
  • filename / file_id / excerpt 비슷한 정보

를 꺼내서 citation list로 가공하면 됩니다.

이 방식은 장점이 있어요.
모델이 검색과 생성을 한 흐름으로 처리하면서도,
개발자는 검색 결과를 별도로 받아서 출처 UI를 만들 수 있으니까요. (OpenAI 개발자)


11. 사용자에게 출처를 어떻게 보여줄지는 생각보다 중요합니다

이건 진짜 UX 얘기예요.
코드보다 오히려 여기서 서비스 느낌이 갈릴 때가 많습니다.

제가 추천하는 기본 구조는 이렇습니다.

본문 안에는 가벼운 마커

예:

  • 환불은 결제 후 7일 이내에 가능합니다. [1]
  • 배포 전 체크리스트가 먼저 필요합니다. [2]

본문 아래에는 source list

예:

  • [1] refund-policy.md — “결제 완료 후 7일 이내 환불 가능...”
  • [2] onboarding-guide.pdf — “배포 전 staging 체크리스트 수행...”

필요하면 “문서에서 확인되지 않음”도 명시

이건 생각보다 중요합니다.
문서 근거형 챗봇이라면, 모르는 건 모른다고 말하는 게 오히려 신뢰를 높입니다.

OpenAI Accuracy 가이드도 retrieval에서 잘못된 문맥이나 irrelevant context가 hallucination을 늘릴 수 있다고 설명합니다. 즉, citation UX는 단순 장식이 아니라 근거 없는 단정을 줄이는 장치이기도 합니다. (OpenAI 개발자)


12. source는 많이 보여준다고 항상 좋은 게 아닙니다

이건 정말 많이 놓칩니다.

검색 결과가 6개 나왔다고 해서
그걸 전부 다 source list로 보여주면 오히려 지저분해져요.

보통은 이렇게 가는 게 낫습니다.

  • 모델에 넣은 chunk가 4개여도
  • 실제 citation list는 2~3개 정도로 정리
  • 중복 파일은 묶기
  • relevance 낮은 건 빼기

예를 들어 같은 refund-policy.md에서 chunk가 3개 잡혔다면,
사용자에겐 그냥 그 파일 하나를 대표 출처처럼 보여주는 게 더 깔끔할 수 있습니다.

OpenAI File Search는 max_num_results를 조절할 수 있고, Retrieval도 상위 결과를 개발자가 직접 선택할 수 있으니, citation list 역시 “그중 사용자에게 보여줄 것만 고른다”는 감각이 필요합니다. (OpenAI 개발자)


13. 실무에서 꽤 유용한 패턴: answer와 sources를 서로 다른 책임으로 보기

이건 진짜 추천합니다.

초보자일수록 answer 생성과 source 만들기를 한 함수 안에서 다 하려는 경우가 많아요.
그럼 나중에 수정하기가 어려워집니다.

저는 보통 이렇게 봅니다.

  • answer: 모델이 잘 써야 하는 영역
  • sources: 검색 결과를 애플리케이션이 정리하는 영역

즉,
답변은 AI가 만들고, 출처는 서버가 책임진다
이렇게 분리하는 쪽이 구조가 오래 갑니다.

이렇게 해두면 나중에:

  • 모델을 바꾸거나
  • 프롬프트를 바꾸거나
  • File Search에서 Retrieval로 바꾸거나
  • source UI를 바꾸거나

해도 영향 범위를 줄이기 쉬워요.


14. 자주 하는 실수들

실수 1. 출처는 파일명만 던지고 끝낸다

파일명만 있으면 사용자가 “왜 이게 근거인지” 바로 이해하기 어렵습니다.
짧은 excerpt를 같이 주는 편이 훨씬 좋습니다.

실수 2. answer 안의 citation 번호와 source list 순서가 어긋난다

이건 UX가 확 깨집니다.
번호 체계를 서버 쪽에서 먼저 고정하는 편이 안전합니다.

실수 3. 문서에 없는 내용에도 citation을 억지로 붙인다

이건 오히려 신뢰를 깎습니다.
“관련 문서는 찾았지만 이 질문에 직접 답하는 문장은 없었다”는 식의 태도가 더 좋습니다.

실수 4. 검색 결과 전체를 사용자가 그대로 보게 한다

운영자 디버깅에는 좋지만, 일반 사용자 UI에는 너무 거칠 수 있습니다.
사용자용 source는 가공해서 보여주는 게 낫습니다.

실수 5. citation이 있으면 무조건 정확하다고 믿는다

아닙니다.
검색된 문서가 질문에 완전히 맞지 않을 수도 있고, 모델이 chunk를 과하게 일반화할 수도 있어요. citation은 신뢰를 높여주지만, 자동으로 진실을 보장하진 않습니다.


15. 지금 단계에서 추천하는 가장 현실적인 설계

주니어 개발자가 FastAPI 기반 RAG에 출처를 붙일 때는 이 정도가 가장 균형이 좋습니다.

  1. Retrieval 또는 File Search로 top 3~5 결과를 확보
  2. 사용자에게 보여줄 source는 2~3개 정도로 정리
  3. 각 source에는
    • 번호(id)
    • 파일명(source)
    • 짧은 발췌(excerpt)
      를 포함
  4. 모델 프롬프트에는 [1], [2] 식 citation 번호 사용을 유도
  5. 응답 JSON은
    • answer
    • sources
      두 축으로 고정
  6. 디버깅용으로는 원본 검색 결과를 별도 로그에 저장

이 구조면
사용자 UX도 괜찮고, 운영자 디버깅도 가능하고, 나중에 확장하기도 좋습니다.


16. 오늘 글의 핵심 요약

이번 글에서 꼭 가져가야 할 건 이것입니다.

RAG가 실무에서 신뢰를 얻으려면, 답변만 잘 만드는 것보다 “어디 문서를 근거로 답했는지”를 함께 보여주는 구조가 필요하다.

정리하면:

  • Retrieval은 관련 chunk와 file of origin 같은 검색 결과를 활용할 수 있다. (OpenAI 개발자)
  • File Search는 Responses API에서 include=["file_search_call.results"]로 검색 결과를 함께 받을 수 있다. (OpenAI 개발자)
  • citation-friendly 응답은 보통 answer + sources 구조가 관리하기 좋다.
  • source는 파일명만 던지지 말고 excerpt까지 같이 주는 편이 낫다.
  • 답변 생성과 출처 생성은 책임을 분리할수록 유지보수가 쉬워진다.

이제 여기까지 오면,
RAG가 단순히 “문서를 찾아주는 기능”을 넘어서
사람이 믿고 확인할 수 있는 응답 시스템으로 바뀌기 시작합니다.


다음 편 예고

다음 글에서는 여기서 더 실무적으로 들어가겠습니다.

Python + FastAPI에서 OpenAI 비용과 토큰 사용량을 추적하고 최적화하는 방법

이 주제로,

  • 어디서 토큰이 많이 새는지
  • 히스토리, RAG, 스트리밍에서 비용이 어떻게 달라지는지
  • 로그에 무엇을 남겨야 하는지
  • 운영 단계에서 가장 먼저 줄여야 할 비용 포인트

까지 이어가보겠습니다.


출처

  • OpenAI File Search 가이드 — Responses API에서 file_search tool 사용 가능, vector_store_ids, max_num_results, include=["file_search_call.results"] 지원. (OpenAI 개발자)
  • OpenAI Responses API 레퍼런스 — file_search_call.results는 include 가능한 응답 필드. (OpenAI 개발자)
  • OpenAI Retrieval 가이드 — semantic search, vector store 기반 검색, 관련 결과와 file of origin 활용 가능. (OpenAI 개발자)
  • OpenAI Deep Research 문서 — final answer와 inline citations 개념 예시. (OpenAI 개발자)

 

Python, OpenAI, FastAPI, RAG, citation, source attribution, File Search, Retrieval, Responses API, Vector Store, AI 백엔드, 문서 기반 챗봇, 신뢰도 높은 AI, 출처 표시, 주니어 개발자

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함
반응형