[RESUMAI 프로젝트 6] LLM API, Pinecone과 DRF를 함께 써보자! (feat. Langchain, RAG, Lost-In-the-Middle)

Youngjoon Jang
30 min readJun 30, 2024

--

지난 글에서는 AWS를 이용해서 서버를 최종적으로 배포하는 방법에 대해서 알아보았다. 현재 우리는 로그인이 구현돼있고, 서버가 배포된 상태이다.

초라한 ERD에서 우선 회원 단계는 끝났다. 이제 자기소개서와 채팅 피처를 개발해보자.

1. 자기소개서

우리의 목적은 다음과 같다.

  1. 유저가 답변하고자 하는 질문에 대한 가이드라인 생성
  2. 유저의 답변과 우대 공고를 기반으로 자기소개서 생성 후 저장

각각의 피처 개발에 대해 자세하게 알아보기 전에, UI를 먼저 첨부한다.
(UI를 먼저 보고 아래 아티클을 읽으면 더 이해가 잘될 것이다 !)

[왼쪽 2개] 지원서 정보 입력 1,2 / [오른쪽] 자기소개서 관련 정보 작성

1. 가이드라인

본 피처는 유저가 자기소개서를 작성할 때 마주하는 질문 (ex. 지원 동기, 직무 관심 계기 등)에 답변하는 것을 돕기 위한 가이드라인을 제공한다.

본 피처는 기업이 제시한 질문을 input으로 받아, LLM api를 통해 답변을 받고, 해당 답변을 json 형태로 내보낸다. 프롬프트 및 코드는 다음과 같다.

# resume/serializers.py
class GuidelineSerializer(serializers.Serializer):
result = serializers.ListField(
child=serializers.CharField(max_length=200)
)

# resume/views.py
class GetGuidelinesView(APIView):
permission_classes = [IsAuthenticated]
serializer_class = GuidelineSerializer

@extend_schema(
summary="가이드라인 생성",
description="질문을 기반으로 가이드라인을 생성합니다.",
parameters=[
OpenApiParameter(
name="question",
type=str,
description="기업이 제시한 질문을 입력합니다.",
)
],
)
def get(self, request):
question = request.GET.get("question")
try:
prompt = GUIDELINE_PROMPT.format(question=question)
guideline_string = get_chat_openai(prompt)
guideline_list = json.loads(guideline_string.replace("'", '"'))
guideline_json = {"result": guideline_list}
return JsonResponse(guideline_json)
except Exception as e:
error_message = {
"error": "가이드라인 생성 중 오류가 발생했습니다. 질문을 올바르게 입력해 주세요."
}
return JsonResponse(error_message, status=500)
# prompts.py
GUIDELINE_PROMPT = f"""
당신은 자기소개서 컨설턴트입니다.

당신은 주어진 질문에 대한 고객의 답변 작성을 돕기 위해 가이드라인을 만들어 주어야 합니다.
가이드의 개수는 **정확히 3개**이어야 합니다.

## 규칙
- 반드시 생성한 가이드라인을 list 형태로 반환해 주세요.
- 각 문장의 끝은 반드시 '작성해 주세요' 또는 '서술해 주세요'로 끝나야 합니다.

예시)
Q: 당신의 '지원동기'에 대해서 소개해주세요.
A: ['왜 이 회사여야만 하는가에 대해서 작성해 주세요.', '회사-직무-본인과의 적합성에 대해 서술해 주세요.', '실현가능한 목표와 비전에 대해 서술해 주세요.']

Q: 당신이 지원한 직무에 대한 '직무 관심 계기'에 대해서 소개해주세요.
A: ['해당 직무에 관심을 가지게 된 구체적인 사건이나 경험을 작성해 주세요.', '직무에 대한 당신의 열정과 관심이 어떻게 발전해 왔는지 서술해 주세요.', '이 직무를 통해 달성하고자 하는 개인적 또는 전문적 목표에 대해 작성해 주세요.']

Q: 당신이 이전에 근무했던 회사의 '회사 경력'에 대해서 소개해주세요.
A: ['회사에서의 주요 업무와 책임에 대해 작성해 주세요.', '경력 동안 달성한 주요 성과와 그 성과가 어떻게 당신의 전문성을 반영하는지 서술해 주세요.', '직무와 관련된 중요한 배움이나 성장의 경험에 대해 작성해 주세요.']

-------------

Q: 당신의 '{{question}}'에 대해서 소개해주세요.
A:
"""
# utils/openai_call.py
from openai import OpenAI
import environ
from pathlib import Path
import os

env = environ.Env(DEBUG=(bool, False))
BASE_DIR = Path(__file__).resolve().parent.parent
environ.Env.read_env(os.path.join(BASE_DIR, ".env"))

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))


def get_chat_openai(prompt, model="gpt-4o"):
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
output = response.choices[0].message.content
return output


def get_embedding(text, model="text-embedding-3-small"):
# text = text.replace("\n", " ")
response = client.embeddings.create(input=[text], model=model).data
response = response[0].embedding
return response

차근차근 살펴보자. 우선 과정은 다음과 같다.

  1. 사용자가 입력한 ‘기업 제공 질문'을 question 필드를 통해 가져온다.
  2. 가져온 question을 가이드라인 프롬프트에 넣는다. 위 가이드라인 프롬프트는 자기소개서 질문-답변 쌍 예시 3가지가 주어져 있다. 이렇게 Few-shot 프롬프트를 제공하여, LLM이 In-Context Learning (ICL) 을 하여 최종적으로 향상된 답변을 할 수 있도록 유도하였다.
  3. 해당 프롬프트를 get_chat_openai 코드를 통해 LLM(여기서는 GPT-4o)에게 전달하고, 답변을 반환받는다.
  4. 아쉽게도 LLM이 답변할 수 있는 것은 항상 string 형태이기 때문에, list로 변경하는 과정을 거쳐야 한다.
  5. 이렇게 최종적으로 LLM이 만들어준 가이드라인을 json 객체에 담아 반환한다.
생성된 가이드라인

2. 자기소개서

본 피처는 1. 가이드라인에 대한 사용자의 답변, 2. 사용자의 답변을 기반으로 retrieve한 유사한 합격 자기소개서, 3. 우대사항을 LLM에게 프롬프트로 주어 새로운 자기소개서를 생성하도록 한다.

1. models.py 정의

class Resume(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
title = models.CharField(max_length=255)
company = models.CharField(max_length=255) # 지원하려는 기업
position = models.CharField(max_length=255) # 지원하려는 기업의 지원하려는 직무
question = models.TextField() # 작성하려는 자소서에서 답변할 질문
content = models.TextField() # 질문에 대한 답변
due_date = models.DateField(null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_finished = models.BooleanField(default=False)
is_liked = models.BooleanField(default=False)

def __str__(self):
return self.title

먼저 resume model에는 작성한 유저, 자기소개서 제목, 답변할 질문, 질문에 대한 답변 등이 필수로 들어가 있어야 한다. 그 외 자소서와 관련된 자잘한 정보들도 포함시켰다.

2. 사용자의 답변을 기반으로 합격자소서 retrieve

이제 우리는 LLM에게 few-shot 프롬프트를 제공하여, In-Context Learning을 가능하도록 하기 위해 사용자의 답변과 비슷한, 잘 작성된 합격 자기소개서를 retrieve 할 예정이다.

그러기 위해서는 합격 자소서 예시들유사한 자소서를 retrieve하여 LLM에게 프롬프트로 주는 파이프라인이 필요한데, 이를 구현해보자.

a. 합격 자소서들을 크롤링하여 Pinecone에 적재한 과정에 대해서는 다음 블로그에서 확인할 수 있다. (편의성을 위해 이전 블로그에서 썼던 내용을 다시 작성하지는 않겠다.)

b. 다음으로 사용자가 가이드라인을 바탕으로 작성한 경험과 가장 유사한 자기소개서 데이터를 Pinecone으로 부터 가져오는 코드를 작성해보자.

# resume/utils.py

pc = Pinecone(api_key=os.environ.get("PINECONE_API_KEY"))


def retrieve_similar_answers(user_qa):
try:
pc = Pinecone()
index = pc.Index("resumai-self-introduction-index")
query_embedding = get_embedding(user_qa)
retrieved_data = index.query(
vector=query_embedding, top_k=2, include_metadata=True
)
return retrieved_data["matches"]

except Exception as e:
print(e)
return []

사실 pinecone 문법이 굉장히 쉬워 코드가 잘 읽힐 것이다.
위 코드에서는 {기업이 제시한 질문} + {유저의 답변}을 합쳐 (user_qa), 임베딩하고, index.query를 통해 가장 유사한 2개 (top_k=2)의 자기소개서를 가져온다.

2–3. 그럼 최종적인 파이프라인을 살펴보자.

다시 한번 언급하지만 우리는 유저가 작성한 답변을 받아, 유사한 자기소개서를 retrieve하고, 우대사항을 프롬프트로 넣어 LLM에게 전달 후, DB에 저장한다.

class GenerateResumeView(APIView):
permission_classes = [IsAuthenticated]

@extend_schema(
summary="자소서 생성",
description="답변을 기반으로 자기소개서를 생성합니다.",
responses={
201: inline_serializer(
name="CreateResumeResponse",
fields={"id": serializers.IntegerField(help_text="생성된 자소서의 ID")},
)
},
request=GenerateResumeSerializer,
)
def post(self, request):
serializer = GenerateResumeSerializer(data=request.data)

title = request.data["title"]
position = request.data["position"]
company = request.data["company"]
due_date = request.data["due_date"]
question = request.data["question"]
guidelines = request.data["guidelines"]
answers = request.data["answers"]
free_answer = request.data["free_answer"]
favor_info = request.data["favor_info"]

# 답변을 guideline + answer + free_answer로 구성
total_answer = ""
for index, answer in enumerate(answers):
# answer 값이 존재하는 경우에만 처리
if answer:
total_answer += guidelines[index] + "\n" + answer + "\n\n"
if free_answer:
total_answer += free_answer

# 예시 retrieve
examples = retrieve_similar_answers(total_answer)
if len(examples) == 0:
error_message = {
"error": "유사한 질문을 가져오는 도중 문제가 발생했습니다. 다시 시도해 주세요."
}
return JsonResponse(error_message, status=500)

examples_str = "\n\n".join(
[
f"예시{i}) \nQuestion: {ex['metadata']['question']} \nAnswer: {ex['metadata']['answer']}"
for i, ex in enumerate(examples, start=1)
]
)

# 프롬프트 작성
prompt = GENERATE_SELF_INTRODUCTION_PROMPT.format(
question=question,
answer=total_answer,
favor_info=favor_info,
examples=examples_str,
)

# 자소서 생성
generated_self_introduction = get_chat_openai(prompt)

serializer = PostResumeSerializer(
data={
"title": title,
"company": company,
"position": position,
"question": question,
"content": generated_self_introduction,
"due_date": due_date,
"is_finished": False,
"is_liked": False,
}
)

# 데이터 유효성 검사
if serializer.is_valid():
# 유효한 데이터의 경우, 자소서 저장
saved_instance = serializer.save(user=request.user)
resume = get_object_or_404(Resume, pk=saved_instance.id)
new_chat_history = ChatHistory(
resume=resume, query=prompt, response=generated_self_introduction
)
new_chat_history.save()

return Response({"id": saved_instance.id}, status=status.HTTP_201_CREATED)
else:
# 데이터가 유효하지 않은 경우, 에러 메시지 반환
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
GENERATE_SELF_INTRODUCTION_PROMPT = f"""
당신은 자기소개서 컨설턴트입니다.
당신은 기업 우대사항과 예시들을 활용하여 주어진 질문에 대한 고객의 답변 작성을 첨삭해 주서야 합니다.

다음은 답변해야 하는 질문과 해당 질문에 대한 고객의 답변입니다.
고객의 답변은 제공된 '가이드라인 + 답변' 쌍으로 구성되어 있습니다.
Q: {{question}} \n
A: {{answer}}

아래는 잘 작성된 몇 가지 자기소개서 예시입니다.
아래 예시들을 **참고만 하고**, 고객의 답변과 우대사항을 최대한 반영하여 첨삭된 자기소개서를 작성해 주세요.
{{examples}}

다음은 해당하는 기업의 조직 소개 및 우대사항입니다.
{{favor_info}}

당신은 **반드시** 자기소개서 외에 어떠한 항목도 출력하시면 안됩니다.
Question, Answer 등의 접두어들도 모두 제외시키고 오직 자기소개서만 출력하세요.
"""

코드를 조각별로 살펴보자.

# 답변을 guideline + answer + free_answer로 구성
total_answer = ""
for index, answer in enumerate(answers):
# answer 값이 존재하는 경우에만 처리
if answer:
total_answer += guidelines[index] + "\n" + answer + "\n\n"
if free_answer:
total_answer += free_answer

# 예시 retrieve
examples = retrieve_similar_answers(total_answer)
if len(examples) == 0:
error_message = {
"error": "유사한 질문을 가져오는 도중 문제가 발생했습니다. 다시 시도해 주세요."
}
return JsonResponse(error_message, status=500)

examples_str = "\n\n".join(
[
f"예시{i}) \nQuestion: {ex['metadata']['question']} \nAnswer: {ex['metadata']['answer']}"
for i, ex in enumerate(examples, start=1)
]
)

# 프롬프트 작성
prompt = GENERATE_SELF_INTRODUCTION_PROMPT.format(
question=question,
answer=total_answer,
favor_info=favor_info,
examples=examples_str,
)

# 자소서 생성
generated_self_introduction = get_chat_openai(prompt)

이 코드에서는 1. 기업이 제시한 질문, 2. 가이드라인-유저 답변 쌍, 3. 유저가 자유롭게 작성한 텍스트, 4. 유저가 첨부한 기업 우대사항을 프롬프트에 담아 LLM에게 제공한다.

사실 이 프롬프트가 조금 난잡하다고 생각하는 분들도 있을 수 있겠지만, 나름 근거 있게 (?) 구성된 프롬프트이다. 이와 관련된 설명을 짧게 하고 가겠다.

얼마 전, lost-in-the-middle 이라는 논문을 접했다 (논문 원본, 블로그 리뷰). 논문을 간단하게 요약하면, 아래 그림과 같이, 긴 context를 LLM에게 주입한 경우, 가운데에 있는 정보를 가장 많이 망각한다는 것을 스탠포드 대학에서 여러 실험을 통해 증명해냈다는 것이다.

Lost in the Middle: How Language Models Use Long Contexts, Liu et. al.

이 결과를 반영하여, 나는 상대적으로 덜 중요하다고 생각한 정보인 ‘유사한 합격자소서'를 가장 가운데에, 상대적으로 중요하다고 생각한 정보인 ‘유저의 답변'과 ‘기업 우대사항'을 양 끝에 두었다.

연구실을 다니면서 연구와 실제 프로덕션은 정말 큰 거리가 있다고 생각했는데, 이렇게 프로덕션에 연구 결과를 써 먹으니 뭔가 신기한 경험이었다.

코드를 이어서 보자.

        serializer = PostResumeSerializer(
data={
"title": title,
"company": company,
"position": position,
"question": question,
"content": generated_self_introduction,
"due_date": due_date,
"is_finished": False,
"is_liked": False,
}
)

# 데이터 유효성 검사
if serializer.is_valid():
# 유효한 데이터의 경우, 자소서 저장
saved_instance = serializer.save(user=request.user)
resume = get_object_or_404(Resume, pk=saved_instance.id)
new_chat_history = ChatHistory(
resume=resume, query=prompt, response=generated_self_introduction
)
new_chat_history.save()

return Response({"id": saved_instance.id}, status=status.HTTP_201_CREATED)
else:
# 데이터가 유효하지 않은 경우, 에러 메시지 반환
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

이제 이렇게 생성된 자기소개서를 serializer로 직렬화하여 DB와 chat_history에 저장한다. chat_history는 아래 채팅 피처에서 더 알아보겠다.

좀 호흡이 길었지만, 이렇게 유저의 답변, 유사한 합격자소서, 기업 우대사항을 기반으로 자기소개서를 생성하고 DB에 저장하는 과정을 구현할 수 있었다 !!

자기소개서 수정, 스크랩 등의 기능도 구현했지만, 따로 설명하지는 않겠다. (깃헙 참고)

2. 채팅

본 피처에서는 ChatGPT를 사용하듯이 챗봇과의 대화를 통해 자신이 작성한 자기소개서를 첨삭하는 작업을 진행한다. 우리의 목적은 다음과 같다:

유저의 의견을 반영하여 자기소개서 첨삭하는 챗봇 제작 후 DB에 저장

이번에도 각각의 피처 개발에 대해 자세하게 알아보기 전에, UI를 먼저 보고 가자.

왼쪽처럼 자신의 요구사항을 챗봇에게 전달하고, 오른쪽처럼 최종 자기소개서를 선택하여 저장한다.

1. 챗봇 제작

채팅을 위해서는 채팅 히스토리가 있어야 한다. ChatHistory model을 제작하자.

class ChatHistory(models.Model):
resume = models.ForeignKey(Resume, on_delete=models.CASCADE)
query = models.TextField(null=True) # 사용자의 질문
response = models.TextField(null=True) # 챗봇의 응답
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

FK로 관련 자소서를 가리키고, 유저로부터 받은 요구사항 (query)와 LLM이 생성한 response를 저장한다.

이제 채팅을 하는 view를 제작하자. 본래 ‘채팅’ 이라고 하면 socket으로 실시간 통신이 가능하도록 제작하는 것이 맞지만…시간에 쫓겨 socket은 쓰지 못했고, post, get 메소드로 코드를 작성했다..ㅋㅋㅋ (근본없음주의)

# resume/views.py
class ChatView(APIView):
permission_classes = [IsAuthenticated]

@extend_schema(
summary="챗봇 대화",
description="챗봇과의 대화를 통해 자기소개서를 첨삭 받습니다..",
responses={200: {"answer": "string"}},
request={
"application/json": {
"type": "object",
"properties": {
"query": {"type": "string"},
},
},
},
)
def post(self, request, id):
user = request.user
today = datetime.now().date()

# # 채팅 횟수 count
# if user.chat_count <= 0:
# return JsonResponse(
# {"error": "채팅 횟수가 모두 소진되었습니다."},
# status=status.HTTP_403_FORBIDDEN,
# )

query = request.data.get("query", "")
resume = get_object_or_404(Resume, pk=id)

# 해당 resume에 대한 이전 대화 내역을 가져옴
chat_history_instances = ChatHistory.objects.filter(resume=resume)
chat_history = [
{"query": instance.query, "response": instance.response}
for instance in chat_history_instances
]

recently_generated_resume = chat_history[-1]["response"]

# if len(chat_history) == 1:
prompted_query = CHAT_PROMPT.format(query=query, recently_generated_resume=recently_generated_resume)

chatbot_response = run_llm(query=prompted_query, chat_history=chat_history)

# 새로운 대화 기록을 생성 + 저장
new_chat_history = ChatHistory(
resume=resume, query=query, response=chatbot_response
)
new_chat_history.save()
# user.available_chat_count -= 1
user.save()
# else:
# # 챗봇으로부터 응답을 받음
# chatbot_response = run_llm(query=query, chat_history=chat_history)
#
# # 새로운 대화 기록을 생성하고 저장
# new_chat_history = ChatHistory(
# resume=resume, query=query, response=chatbot_response
# )
# new_chat_history.save()
# # user.available_chat_count -= 1
# user.save()

return JsonResponse({"answer": chatbot_response}, status=status.HTTP_200_OK)
# prompts.py
CHAT_PROMPT = f"""
당신은 자기소개서 컨설턴트입니다.
당신은 이전 대화에서 생성된 자기소개서를 보고, 고객의 요구사항과 공고 우대사항을 반영하여 유용한 자기소개서를 생성해야 합니다.

이전 대화에서 생성된 자기소개서는 다음과 같습니다.
{{recently_generated_resume}}

고객의 요구사항은 다음과 같습니다.
{{query}}

이전 대화에서 생성된 자기소개서를 기반으로 고객의 요구사항을 만족하는 새로운 자기소개서를 생성해 주세요.
당신은 **반드시** 자기소개서 외에 어떠한 항목도 출력하시면 안됩니다.
"""
from utils.openai_call import get_embedding

from langchain_openai import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory, ConversationSummaryBufferMemory

llm = ChatOpenAI(verbose=True, temperature=0, model_name="gpt-4")
# memory = ConversationBufferMemory()
memory = ConversationSummaryBufferMemory()


def run_llm(query: str, chat_history: list[dict[str, any]]) -> any:
for chat in chat_history:
memory.save_context(
inputs={"human": chat["query"]}, outputs={"ai": chat["response"]}
)
conversation = ConversationChain(llm=llm, verbose=True)
return conversation.predict(input=query)

본 view에서는 post 메소드로 query와 reusme의 고유 id를 받아, 해당 resume에 대한 이전 채팅 기록을 가져와, 위처럼 프롬프팅하고, LLM에게 주입한다. 이렇게 최종적으로 생성된 응답을 chat_history에 저장한다.

트러블슈팅: 메모리 관련

Langchain을 사용하기로 결정한 가장 큰 이유가 이 채팅 피처 때문이다. 채팅을 위해서는 memory가 있어야 하는데, 직접 구현하기보다는 library를 사용하는 것이 낫다고 판단했기 때문이다.

처음에는 ConversationBufferMemory를 사용했었다. 그러나, 대화가 길어질수록 context가 길어졌고, 이로 인해 에러가 발생했다. (조금만 생각했으면 당연한 것이었는데, 친구에게 시연하면서 에러가 나길래 아차 싶었다..)
그래서 바로 ConversationSummaryBufferMemory로 바꿨다. ConversationSummaryBufferMemory는 ConversationBufferMemory와는 다르게 이전의 채팅 내용을 summary로 요약해서 저장하는 memory buffer다. (사실 이렇게 summary로 저장했기 때문에 query를 굳이 CHAT_PROMPT에 넣지 않아도 될 것 같긴 한데, 일단은 프롬프트를 유지했다.)

트러블슈팅: LLM API 사용량 관련

그리고 코드에 주석으로 처리된 부분들이 좀 있다. 원래는 GPT-4o API 가격 이슈로 인해 인당 채팅 기회를 5회로 한정하여, 채팅 기회를 모두 소진 시 채팅을 하지 못하도록 하려고 했는데, 우선 빠르게 출시하는 것이 급선무인 것 같아 차후 작업으로 미뤄두었다. 나중에는 Cron으로 24시간에 한번씩 갱신되도록 할 예정이다.

— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —

채팅을 마친 후, 2번째 ui처럼 가장 마음에 드는 결과를 골라 PUT 메소드로 최종 자기소개서를 저장한다. 코드는 다음과 같다.

class UpdateResumeView(APIView):
permission_classes = [IsAuthenticated]

@extend_schema(
summary="자소서 업데이트",
request=UpdateResumeSerializer,
responses={200: PostResumeSerializer},
)
def put(self, request, *args, **kwargs):
user = request.user
resume_id = kwargs.get("id") # URL에서 resume의 id를 가져옵니다.

try:
resume = Resume.objects.get(
id=resume_id, user=user
) # 요청한 사용자의 resume만 선택
except Resume.DoesNotExist:
return Response(
{"error": "Resume not found"}, status=status.HTTP_404_NOT_FOUND
)

serializer = PostResumeSerializer(
resume, data=request.data, partial=True
) # 업데이트 대상 인스턴스를 지정하고 부분 업데이트 가능

if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

이렇게 최종적으로 자기소개서 생성, 채팅 피처를 모두 완성했다. 호흡이 좀 길었지만, 그래도 잘 완성해서 다행이었다. 특히 연구 논문을 기반으로 프롬프트를 개선한 부분은 너무 마음에 들었다. ㅎ

다음 글에서는 프로젝트를 최종 정리하면서, 생성된 자기소개서 결과에 대해서도 다루고, 향후 진행방향에 관해서도 포스팅 해보도록 하겠다.

--

--