Jasontreks Blog

DM 보내기

메세지는 텔레그램 챗봇에 의해 익명으로 전달됩니다. 답장을 받으려면 이메일을 입력하세요.
Send

Django 웹앱 구축 및 Ollama 챗봇 적용

우리 팀은 RAG 챗봇 구축을 위한 많은 라이브러리들이 파이썬의 생태계에 맞춰져있다는점을 고려해 웹 프레임워크도 Django를 선택함으로서 언어적 호환성을 맞추기로 하였다.

Django

Django는 파이썬 기반 웹 애플리케이션 프레임워크로, MTV(Model-Template-View) 구조를 하고 있다.

  • Model: 데이터를 담당한다. 파이썬 클래스 문법으로 테이블을 정의하고 쿼리 결과를 객체화하여 다루는 ORM 기능을 제공한다.
  • Template: 화면을 담당한다. 사용자에게 보여질 HTML파일이며 Django 템플릿 문법을 통해 파이썬에서 넘겨받아 동적 웹페이지를 구성한다.
  • View: 로직을 담당한다. 사용자 요청을 처리하고 Model을 통해 DB와 연동하며 필요한 비즈니스 로직을 수행한다.

Django 프레임워크를 통한 웹앱 구현 흐름은 다음과 같다.

  1. 템플릿 파일 작성 (.html, .css)

    먼저 사용자에게 보여줄 페이지를 작성한다. HTML 파일은 Django 프로젝트 폴더 내 templates 폴더에 저장한다. 또한 static 폴더에도 CSS파일을 작성하고 불러와 스타일을 부여한다.
  2. view 함수 작성 (.py)

    어떤 요청에 대응하는 백엔드 로직을 작성한다. 요청은 페이지 이동 말고도 로그인, 데이터 조회, AI기능 관련 처리 수행, 채팅 입력등이 있다. ORM으로 DB에서 데이터를 가져오고, Ollama API를 호출하고, 모듈로 만들어둔 PDF 추출, 유사도 검색등의 기능을 불러와 작업을 수행한다.
  3. url 라우팅 (.py)

    웹에서 온 요청에 대해 알맞은 뷰 함수가 호출될 수 있도록 각 뷰함수에 대응하는 URL을 지정한다.
  4. JS 스크립트 작성 (.js)

    이벤트 발생 시 알맞은 URL이 호출되도록 요청 로직을 작성한다. 입력이나 UI로부터 얻은 데이터를 필요한 형태로 가공하고, 요청의 종류(GET 또는 POST)에 따라 URL 파라미터 또는 JSON으로 구성해 요청을 호출한다.

Ollama

Ollama는 로컬에서 인공지능 모델 파일을 돌려 추론 작업을 수행할 수 있게 해주는 소프트웨어다. Ollama를 이용해 무료로 배포되는 언어 모델이나 임베딩 모델을 설치하고 사용할 수 있다.

Ollama는 보통 11434번 포트를 점유하고 외부 시스템에서 접근할 수 있는 서비스 형태로 존재하며, 프로그램에서 응용할 수 있도록 API를 제공한다.

파이썬에서 Ollama가 제공하는 API를 사용하려면 ollama 라이브러리를 설치하면 된다.

pip install ollama

주요 API 호출 메서드는 다음과 같다.

  • ollama.list(): 설치된 모델 리스트를 반환받는다.
  • ollama.chat(모델명, 메세지): 대화형 호출. 언어 모델을 돌려 프롬프트에 대한 응답을 얻는다.
  • ollama.embeddings(모델명, 텍스트): 임베딩 호출. 텍스트를 벡터로 변환한다.
  • ollama.AsyncClient(): 비동기 클리이언트 호출. 비동기 뷰에서 스트리밍 응답 객체를 반환하기 위해 필요하다.

AsyncClient에 대한 자세한 설명은 뒷부분에서 서술할것이다.

chat 호출 예제

import ollama

response = ollama.chat(model='llama3', messages=[
  {
    'role': 'user',
    'content': '왜 하늘은 파란색이야?',
  },
])
print(response['message']['content'])

스트리밍 챗 구현

LLM이 긴 문장을 생성하는 기본적인 원리는 현재의 문맥으로 다음에 올 토큰을 하나씩 예측하여 생성하는 것이다. 그래서 LLM과의 대화에서 답변을 출력할 때 마지막 토큰까지 만들어지기를 기다리다가 한꺼번에 출력하는 방법이 있고, 토큰이 생성될때마다 바로 출력하여 실시간성을 높이는 방법이 있다. 많은 LLM 서비스에서는 사용자가 마냥 기다리지 않고 답변이 출력되는 과정을 바로 확인할 수 있도록 일반적으로 두번째 방법을 많이 사용한다.

이를 구현하기 위해서는 Django의 응답 객체로 StreamingHTTPResponse를 사용해야 한다.

StreamingHTTPResponse

일반적인 응답에서는 서버가 클라이언트로 데이터를 보낼 때 데이터의 총 크기를 알려준다. 그래서 클라이언트는 서버가 알려준 크기의 데이터를 다 받고나면 자동으로 연결이 종료되므로 1회성 통신이 이루어지는 것이다.

하지만 스트리밍 응답 방식은 서버가 처음에 응답 데이터 크기를 알려주지 않는다. 그래서 클라이언트는 서버가 보내는 데이터를 총 얼마나 수신해야 하는지 모르기 때문에 연결을 계속 유지한다.

'제너레이터'는 반복적으로 데이터를 만들어내는 객체로, 한번의 호출에 한번만 반환하지 않고 여러번 반환할 수 있는 특별한 함수라고 볼 수 있다. for문으로 제너레이터를 순회할 수 있다. 이 제너레이터는 응답을 데이터를 전송하는 서버와 받은 데이터를 처리하는 클라이언트 양쪽 모두에서 필요로 한다.

서버 측 스트리밍 응답 전송 코드

async def stream_chat(req):
    message = ""
    try:
        session_id = req.GET.get('session_id')
        if not session_id:
            message = "유효하지 않은 URL"
            raise Exception(message)

        session_inst = await ChatSession.objects.aget(pk=session_id)

        bd = json.loads(req.body)
        user_question = bd.get('question')
        if not user_question:
            message = "질문을 입력해야 합니다."
            raise Exception(message)
        
        recent_questions = await sync_to_async(lambda: list(Conversation.objects.filter(session=session_inst).order_by('-added_time').values('question'))[:2])()
        recent_questions.reverse()

        new_conversation = await Conversation.objects.acreate(
            session=session_inst,
            question=user_question,
        )

        product_id = await sync_to_async(lambda: session_inst.product.pk)()
        rag_settings = await RAGSettings.objects.aget(product_id=product_id)

        path = await sync_to_async(lambda: session_inst.product.data_path)()
        # ********************************
        # 스트리밍 응답 구현 부분
        # ********************************
        # 대화 생성(제너레이터 함수)
        async def event_stream():
            # Ollama API 호출 -> LLM 가동 -> 제너레이터 객체(stream_response) 반환
            stream_response, response_detail = await RAGWithLLM(rag_settings).
            generate_stream_response(
                product_path=path,
                question=user_question,
                recent_questions=recent_questions
            )

            new_conversation.response_detail = response_detail
            await new_conversation.asave()

            if stream_response is not None:
                # 제너레이터 순회를 위한 async for 문법
                async for chunk in stream_response:
                    if 'message' in chunk and 'content' in chunk['message']:
                        content = chunk['message']['content']
                        # LLM이 생성한 문장 조각(토큰) 반환
                        yield content
            else:
                yield response_detail['message']

        return StreamingHttpResponse(event_stream(), content_type='text/plain')

    except IntegrityError as e:
        message = f"DB 오류: {e}"
        logging.error(message)

    except Exception as e:
        message = f"응답 생성 오류: {e}"
        logging.error(e)

    return JsonResponse(
        data={
            'message': message
        },
        status=500,
        safe=False
    )

클라이언트 측 스트리밍 응답 수신 코드

try {
  // 요청
  const response = await fetch(`/markit/chat/stream_chat/?session_id=${selected_session_id}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CSRFToken": getCsrfToken(),
    },
    body: JSON.stringify({
      question: raw_message,
    })
  });

  // response.body: Django에서 응답한 ReadableStream 객체
  if (!response.body) {
    botContentDiv.textContent = "Error: ReadableStream not supported.";
    botContentDiv.classList.remove('generating');
    return;
  }

  chatInput.disabled = true;
  sendBtn.disabled = true;

  // reader: Django가 계속 보내는 Streaming 응답을 읽는 객체
  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  // Streaming 응답 read -> 텍스트 decode -> 챗봇의 말풍선에 삽입
  // read가 done을 반환하면 Streaming 종료 -> while문 탈출
  let fullText = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const chunk = decoder.decode(value, { stream: true });
    fullText += chunk;
    
    // Markdown 렌더링 (marked 라이브러리 사용)
    botContentDiv.innerHTML = marked.parse(fullText);

    scrollToBottom();
  }

스트리밍 응답 출력이 구현된 모습

다음 포스트

Django 웹앱 구축 및 Ollama 챗봇 적용