나만의 정보탐색 파트너, 브라우징 코파일럿 RAG 도입기

LINER Browsing Copilot

라이너의 브라우징 코파일럿은 브라우저 익스텐션(Browser Extension, 이하 BE)에 설치된 채로, 유저의 정보탐색을 돕는 AI 에이전트입니다. 저는 유저가 ‘지우’이고, 브라우징 코파일럿이 ‘피카츄’가 아닌가 종종 생각하는데요, 이제는 사용하는 게 너무 익숙해져서 코파일럿 아이콘이 안 보이면 보면 심리적인 불안?까지 느끼는 지경에 이르렀습니다(웃음).

LLM에게 부족한 것이 있다면

OpenAI의 ChatGPT가 세상에 선보인 이래, 세상 사람들은 2021년까지의 웹 세상을 학습한 인공지능을 다양한 방식으로 맞이했습니다. 혹자는 일상적인 대화를 하기도, 누군가는 해야 할 일을 대신 해주는 똑똑한 비서로 사용하기도 했죠. 하지만 신기술이 주는 놀라움도 잠시, 사람들은 금세 적응하며 더 높은 기준을 갖게 되었습니다. 대표적으로 자신이 원하는 답변을 얻기 위해 조련(프롬프트 엔지니어링)을 하고, 유용성 향상을 위해 해당 프롬프트를 서로 활발히 공유했죠.

그렇게 더 많은 사람에게 활발히 쓰이면서, LLM의 한계도 서서히 드러나기 시작했습니다. 혹시 ChatGPT를 쓰실 때 21년도 데이터까지 밖에 알고 있지 못하다는 궁색한 답변을 받아보신 적이 있으신지요? 다양한 질문에 대체로 만족스러운 답하는 ChatGPT지만, 최신 데이터에 대한 태스크에서는 약한 모습을 보여줍니다. 이런 경우 저는 원하는 답변을 위해 필요한 맥락을 직접 ChatGPT에 제공하는 편입니다. 하지만 상당히 귀찮기도 한데요, 만약 정확한 관련 정보를 직접 주지 않아도 누군가가 알아서 ChatGPT에 전달해 준다면 얼마나 좋을까요?

이에 LLM을 활용하여 서비스를 만드는 사람들은 LLM을 한층 더 신뢰 받고 유용한 기술이 되도록 알아서 필요한 정보를 LLM에 넘겨줄 수 있는 RAG라고 부르는 프레임워크를 적용하기 시작합니다.

RAG가 뭐지?

RAG란 말 그대로 질문에 답하기 위해 필요한 정보를 곁들여서, LLM이 더 나은 품질의 결과를 생성하게 만드는 것을 뜻합니다. RAG는 다음 질문에 대한 대답으로 구성됩니다.

  1. 필요한 정보는 무엇이며 어떻게 제공할 수 있는가?
  2. 필요한 정보가 제공됐을 때 그것이 답변 개선으로 이어지나?

전자의 경우는 질문에 답하기 위해 필요한 정보(ground truth)를 찾을 수 있느냐는 질문으로, 후자의 경우는 LLM이 거짓말(hallucination)하지 않고 주어진 정보를 기반으로 답변하게 만들 수 있냐는 질문으로 치환됩니다.

네, 전자는 retrieval system이고, 후자는 프롬프트 엔지니어링입니다. 백엔드 플래닛의 엔지니어링 역량을 고려했을 때, 플래닛 고유의 임팩트는 첫 번째 질문에서 더 크게 날 것으로 판단하였습니다. 이에 저희는 다음의 문제를 해결하기로 합의했습니다.

👀 유저가 물어본 내용에 답이 되어줄 수 있는 특정 텍스트 영역을 어떻게 잡을 것인지?

LINER Copilot Sourcing System

LCSS는 브라우징 코파일럿의 주요 용례에서 다음과 같은 역할을 합니다.

  • 유튜브 영상 요약: 자막 내용의 일부를 LLM에 전달
  • 웹 문서 요약: 문서 내용의 일부를 LLM에 전달
  • PDF에 대한 질문/답변: 유저의 질문에 알맞는 PDF 내용을 검색하여 전달

요약의 경우, LLM의 context window 제한에 맞추어 본문 내용을 잘라서 보내주고 있습니다. 이를 저희는 fetch라고 부르고 있습니다.

질/답의 경우에는 하이브리드 서치를 활용하여 답변에 필요한 내용을 검색한 뒤, 콘텐츠 타입과 용례에 따라 후처리하여 보내주고 있습니다. 이를 저희는 search 라고 부르고 있습니다.

사실 fetchsearch의 대상이 되는 정보들을 전처리하고 저장하는 과정이 먼저 일어나게 되는데, 이를 저희는 ingest라고 부르고 있습니다.

한마디로 LCSS는 특정 정보 묶음(청크)에 대하여 ingest, fetch, search를 하는 시스템입니다. 그리고 ingest layer에서는 전처리와 ingest를, retrieve layer에서는 fetchsearch를 수행합니다.

Ingest Layer of LCSS

사전 텍스트 처리

현재 브라우징 코파일럿이 다루고 있는 콘텐츠는 유튜브, 웹페이지, PDF로 3종류 입니다. Ingest layer에 인입되기 전, 브라우징 코파일럿이 각 콘텐츠를 텍스트로 바꾸는 과정이 선행됩니다.

YouTube

유튜브 영상의 경우, 익스텐션을 활용하여 자막을 가져오게 됩니다. 해당 자막의 타임스탬프와 내용을 리스트의 형태로 LCSS에 전달하게 됩니다.

Web Page

웹 페이지의 경우 익스텐션이 HTML을 긁어오고, 확정적으로 유효한 내용을 담고 있지 않는 HTML 태그를 제거한 뒤 LCSS에 전달하게 됩니다.

PDF

PDF의 경우 텍스트 데이터만 긁어와서 LCSS에 전달하게 됩니다. OCR을 활용하여 이미지 형태로 되어있는 텍스트도 가져올 수도 있지만, 퍼포먼스 이슈와 서버 비용 이슈로 인하여 더 효율적인 적용안을 탐색하고 있습니다.

LCSS는 각 콘텐츠 타입에 따라 중간 가공된 문자열과 함께 생성시간, 콘텐츠 타입, 콘텐츠 URL을 제공 받습니다.

Ingestion

Chunking

콘텐츠 타입에 맞추어 문자열을 가공합니다.

  • HTML의 경우는 내부 유틸리티 서비스를 통해 정제된 텍스트 본문을 파싱합니다.
  • 페이지 정보(PDF), 영상 시간(YouTube)의 경우에는 해당 콘텐츠의 위치를 보존하기 위해 별도의 포맷으로 정제합니다.

정제된 텍스트는 정해진 사이즈에 따라 청크로 쪼개집니다. 청크 사이즈의 단위는 메인 LLM 모델의 토큰입니다. 사실 이렇게 단순히 순서대로 조각내는 방법에는 개선의 여지가 많아 보입니다. 이론적으로 이상적인 청킹은 청크 간에는 의미가 독립적이고, 청크 내에서는 의미의 응집도가 높은 상태입니다. 하지만 이를 위해서는 본문을 해석하고 해체하는 작업이 수반되어야 합니다. 역시 모든 것은 트레이드 오프 아니겠습니까. 성능이 얼마나 올라갈지, 해석 비용이 얼마나 될지를 알아야 합니다. 후자는 바로 알 수 있지만, 성능에 대해서는 결국 청킹 관련 실험과 평가가 선행되어야 합니다.

개발 당시에는 평가 시스템이 없어 눈으로만 품질을 판단해야했고, 선행작업에 드는 처리 과정의 효용을 따지기가 어려웠습니다. 이에 아직까지는 정해진 청크 사이즈대로 본문 순서에 따라 조각내고 있습니다만, 향후 RAG 평가 시스템을 통해 다양한 청킹 방법을 실험할 예정입니다.

Embedding

청크 각각에 대해 임베딩을 만드는 과정입니다. 라이너에서는 OpenAI의 Ada 임베딩을 사용하고 있습니다. 비용은 토큰 수로 측정하기 때문에 청크 사이즈와는 상관 없이 원문의 길이에만 영향을 받습니다.

만약 청크 사이즈가 작다면 어떻게 될까요? 아무래도 문장과 같이 세부 단위로 임베딩 검색이 가능해지고, 임베딩이 표현하는 텍스트도 적어지며 노이즈도 적어지겠지만 저장 비용과 검색 비용이 증가합니다. 실제로 Ada 임베딩의 차원이 1537으로 큰 편이기 때문에 발생하는 저장 비용을 무시할 수 없었습니다. 그렇다고 무작정 청크 사이즈가 늘어난다면, 검색 비용과 저장 비용은 줄지만, 벡터 서치의 효용이 떨어지겠다고 판단했습니다.

결국 저장, 검색 비용과 벡터 서치 정확도 간의 트레이드 오프이고 이 역시 적절한 조합을 찾기 위해서 retrieval에 대한 평가가 필요하다는 것을 알 수 있었습니다.

하지만 청크 사이즈 결정에 숨은 복병은 따로 있었는데요, 바로 Ada API의 RPM 제한이었습니다. 청크 사이즈를 작게 자르니 임베딩 생성 요청 수가 빠르게 소진되며 요청이 막혔습니다. 현재는 문제가 발생하지 않는 수준에서 청크 사이즈를 키워두고, 동시성을 조종하는 등 적정선을 찾아가고 있습니다.

결국 청크 사이즈를 결정하는 일은 서버 퍼포먼스, 저장 비용, 검색 비용 그리고 벡터 서칭 정확도까지 모두 고려해야 했습니다.

Global Document ID

브라우징 코파일럿을 통해 인입되는 다양한 콘텐츠는 URL에 의해 구분됩니다. 유저가 보고 있는 콘텐츠를 코파일럿도 같이 봐야 하므로 콘텐츠의 고유한 식별자는 매우 중요합니다. 라이너는 URL을 활용하여 콘텐츠 식별자를 생성하고 있습니다.

다만 query parameter, fragment 등 URL path를 구성하는 요소들은 너무나 조합이 다양하여, 때로는 같은 내용인데도 다른 URL이 구성하기도, 다른 내용인데도 같은 URL이기도 합니다(e.g. SNS 피드). 그 때문에 한 번 저장한 콘텐츠를 올바르게 재사용하기 위해서는 계속해서 정책을 개선해야 했습니다.

정책 수립을 위해 100만개의 URL에 대해 query parameter를 최빈도 순으로 정렬하고, 페이지 내용 변경에 영향을 주지 않는 키들을 선정하여 정제하고 있습니다. 이전에는 이 로직이 서비스 내부 시스템 곳곳에 흩어져 있었지만, 지금은 id-maker라는 서비스를 통해서 정책을 일원화하여 관리하고 업데이트하고 있습니다.

Elasticsearch에 저장

ES에서 제공하는 벡터 유사도 계산(코사인 유사도)과 텀 매칭 기능을 활용하기 위해, 필요한 필드를 채우고 인덱싱합니다.

  • 원문의 경우, 이전에 저장되었던 내용과 달라지는 경우를 파악하기 위해 체크섬을 별도로 저장합니다. 본문의 내용이 바뀌어 체크섬이 바뀌는 경우, 데이터가 업데이트 됩니다.
  • 청크의 경우 청크 실험을 포함하여 설정 변경에 대응하기 위해 버전 정보를 넣어주고 있습니다. 이전 청크보다 높은 버전의 서비스가 청크를 넣는 경우 데이터가 업데이트 됩니다.

스키마를 보시면 id 값을 URL에 기반한 고유 식별자로 정한걸 알 수 있습니다. 여기에는 사연이 있습니다.

초기 구현에서는 id를 ES에서 제공하는 기본 값을 사용했습니다. 그리고 원문 검색을 위해 프로퍼티 중 하나인 content_id 를 활용하여 텀 매칭을 했습니다. 하지만 트래픽이 몰리는 경우 ES의 search query 퍼포먼스에 영향이 있어 원문이 fetch되지 않았습니다. 브라우징 코파일럿의 첫 대화가 fetch를 활용한 summary였기 때문에 벙어리가 된 브라우징 코파일럿을 자주 볼 수 있었죠.

이후 id에 바로 global_id를 넣고, GET하는 방식으로 지연 없이 원문을 가져올 수 있었습니다. summary 이후 이어지는 코파일럿과의 대화에서는 인덱싱 되는 시간을 벌어두었기 때문에, 문제 없이 유저와 대화를 나눌 수 있었습니다.

여기까지 브라우징 코파일럿이 올려준 데이터가 LCSS에 조회 가능한 형태로 저장되는 과정이었습니다. 이후부터는 인입되는 소싱 요청에 따라 구분자를 걸고 텀 매칭과 벡터 유사도 검색하거나 원문을 전달하게 됩니다.

Retrieve Layer of LCSS

키워드 추출

유저의 질문이 본문 내용 일부를 정확히 기술하는 경우에는 텀 매칭이 잘 작동합니다. 하지만 대부분 유저가 하는 질문은 모호한 경우가 많고, 넓은 범위의 의미를 묻는 경우가 많습니다. 따라서 텀 매칭이 잘 동작하지 않았고 텀 매칭에 쓸 키워드를 LLM으로 뽑아내는 과정이 필요했습니다.

본문의 일부를 fetch 하여 프롬프트에 맥락을 전달하고, 유저의 질문 의도를 파악하여 검색에 쓰일 키워드를 추출하는 로직을 ML 플래닛의 도움을 받아 구현했습니다. 이렇게 뽑힌 키워드는 search 요청에서 사용됩니다.

실제 운영하면서 키워드 추출 정책을 계속해서 수정했는데요, 그중에서도 글의 메인 키워드가 다량으로 추출되는 경우, 세부 키워드가 포함된 청크가 검색이 잘되지 않는 문제가 있었습니다. 그 때문에 키워드 추출의 개수를 1~2개로 줄이고, 다른 범위의 키워드를 생성하지 못하도록 유저 질의에 포함된 키워드와 동의어만 추출하도록 변경했습니다. 이후에 기존에 잘 잡아내지 못했던 개념 질문에 대답하는 것을 확인할 수 있었습니다.

하이브리드 서치

search { request ->
  request
    .indices(chunkedSourcesIndex)
    .source(
      SearchSourceBuilder()
      .size(size)
      .query(
         ScriptScoreQueryBuilder(
           BoolQueryBuilder()
             .filter(
               TermQueryBuilder("content_type", sourceType.name),
             )
             .filter(
               TermQueryBuilder("content_id", sourceId),
             )
             .should(
               MatchQueryBuilder("content", queryTerm)
             ),
           Script(
             Script.DEFAULT_SCRIPT_TYPE,
             Script.DEFAULT_SCRIPT_LANG,
             "cosineSimilarity(params.query, 'vector_content') + _score",
             mapOf("query" to vector)
           )
         )
       )
   )
}

유저의 질문을 Ada API를 통해 임베딩 벡터로 바꾸고 전달된 키워드와 함께 서칭 스코어를 계산합니다. ES의 script 쿼리를 통해 최종 점수를 구했습니다. 보시면 스크립트 내 코사인 유사도와 텀 매칭 스코어를 휴리스틱 하게 조합하고 있습니다. 이 또한 지속적인 실험과 평가를 통해 개선해야 할 부분입니다. ES 버전 8.9 버전 릴리즈 노트에 공식적으로 RRF(Reciprocal Rank Fusion)가 적용된 하이브리드 검색 기능을 제공한다고 했는데, 이 기능도 추후 실험해 볼 예정입니다.

서비스 데이터 맥락 추가 제공 (TBU)

유저가 라이너를 통해 수집한 정보와 코파일럿과의 상호작용 히스토리가 답변 맥락에 도움이 될 수 있다고 생각했고, 필요한 경우 해당 정보가 제공될 수 있도록 LCSS의 초기 설계에 포함했습니다. 예를 들면 이전에 유저가 코파일럿과 나누었던 대화, 혹은 최근에 라이너에서 하이라이트했던 데이터들입니다.

코파일럿이 유저에 대해 더 잘 이해할 수 있도록 돕는 것이 목적이며, 나를 더 잘 아는 코파일럿 경험을 만드는 것을 목표로 곧 추가될 예정입니다.

LCSS 조감도

앞서 말씀드린 모든 요소들을 한데 모아 위와 같은 시스템이 탄생했습니다!

Future Works

IO Handling Performance

언급된 서비스 로직의 대부분이 IO라는 점을 눈치채셨나요? LLM 관련 애플리케이션의 백엔드를 만들 때 가장 중요한 것은 IO 동시성을 얼마나 잘 다루는지에 달렸습니다. 궁극적으로는 내부 큐를 써서 비동기 작업을 분리하고, 각 작업에 배정된 리소스를 최대로 가용해야 합니다. 현재는 코루틴을 최대한 잘 써서 IO 작업을 메모리 이슈 없이 다루고 있는데, 개선할 여지는 많이 남아있습니다.

추가로 다양한 유틸리티 및 서비스 정보 조회 API 요청의 안정성 문제가 계속해서 있었기 때문에 LCSS 말고 다른 서비스의 안정성도 높여야 합니다. 라이너에는 k6를 통해 멀티리전 로드 테스트 인프라를 구축하였는데요, 추후 이를 활용하여 퍼포먼스를 고도화할 예정입니다.

RAG Evaluation

LLM 애플리케이션의 모호함은 대부분 답변이 확정적이지 않다는 점과 결과물이 자연어라는 점에서 기인합니다. 개발 단계에서 답변의 품질이 괜찮았더라도, 실제 서비스에서 답변을 내는 경우가 늘어나면, 변동성에 의해 품질이 흔들리게 됩니다. 또한 평가 시에도 답변을 읽고 이해하는 과정이 필요하기 때문에, 숫자 비교처럼 단순히 데이터를 대조하는 것으로는 충분하지 않습니다. 따라서 비용이 적은 자동화 테스트가 어렵습니다.

어쩔 수 없이 LLM의 평가에 다시 LLM을 사용하는 경우가 많습니다. 하지만 막연히 LLM에 점수를 매겨달라고 할 수는 없는데요, 어떤 메트릭으로 평가하여 LLM을 사용할지에 대하여 Ragas라는 오픈소스 평가 프레임워크를 참고해 볼 수 있습니다. 아래는 Ragas에서 제시된 메트릭입니다.

  • faithfulness: 할루시네이션 카운터 메트릭, retrieval에 의해 제공되는 맥락(context)에서 벗어난 발언을 하지 않는지 검증
    1. 생성된 답변을 명제로 분해(by LLM)
    2. 분해된 명제가 제공된 맥락과 일치하는 지 검사 (by LLM)
  • answer relevancy: 답변과 질문이 얼마나 연관성이 있는지 체크 (by LLM)
  • context relevancy: retrieval로 가져오는 맥락에서 ground truth의 비중 (signal-to-noise)
  • context recall: retrieval로 가져오는 맥락에서 ground truth를 얼마나 가져올 수 있는지

평가할 수 없다면 개선할 수 없기 때문에, RAG를 통해 실제 답변이 개선되는지 확인하고, 해당 과정에 대한 테스트를 자동화하기 위한 노력, 어떤 메트릭으로 측정할지에 대한 탐구가 꼭 필요합니다. 평가가 마련된 이후에는 여러 실험을 통해 최적의 청크, 임베딩 조합을 찾는 것도 가능하며, 프롬프트 변경에 대한 품질 관리도 가능해질 것으로 보입니다. 앞서 말씀드린 것처럼 현재 진행하고 있는 LLM 품질 테스트를 확장하여 RAG의 모든 플로우에 대해 테스트가 가능하도록 우선순위를 높여 설계하고 개발하고 있습니다.

Hierarchical Search

본문의 청크를 바로 활용하는 것이 아니라, 청크를 정해진 수만큼 모아 새로운 상위 청크를 만드는 방식입니다. 이렇게 만들어진 청크에는 이전 청크들이 갖고 있는 중요 정보들이 담긴다고 가정하며, 단계를 올라 최상위까지 올라가면 문서의 요약이 되는 형태입니다.

유저의 질문이 모호한 경우가 문제가 된다고 말씀드렸는데요, 이렇게 종합적인 정보를 트리 형태로 들고 있는 경우, 낮은 단계의 청크들에 있는 노이즈에 임베딩 매칭이 되는 것이 아닌, 상위 청크에 응집된 중요 정보가 모인 청크에서 임베딩을 통한 의미 매칭이 되며 중요 정보를 더 정확하게 가져갈 수 있다고 주장하는 방법론입니다. 세부 정보가 더 필요한 경우, 트리를 타며 하위 수준 노드, 즉 청크를 조회하게 됩니다. 벡터 서치와 트리 서치의 조합인 셈이죠.

하지만 청크를 어떻게 그루핑하고, 모인 청크를 합치는 과정에서 LLM이 잘 동작해줄지도 미지수 입니다. 프롬프트 엔지니어링과 해당 작업이 몇 수준까지 이루어져야 하는지, 백그라운드로 빠진다면 어떤 단계에서 이루어져야 UX에 지장이 없을지도 함께 고려되어야 합니다.

앞서 말씀드린 평가 시스템을 통해 POC를 진행하고, 이외에도 본문을 이해하여 청크를 나누는 여러가지 방식을 도입하여 실험할 예정입니다. 실제로 평가해봐야 뭐가 좋은지 알 수 있겠죠.

Outro

지금까지 라이너의 브라우징 코파일럿을 뒷받침하고 있는 소싱 시스템을 설명드렸습니다. 데이터의 인입부터 조회까지 어떻게 구현했고 어떤 문제들을 해결했는지 간략히 설명해 드렸습니다. LLM이 파워풀한 기술임은 모두가 알지만, 실제로 서비스되기 위해서는 안정성과 평가 그리고 사실성과 신뢰성을 보장해야 한다는 것도 이 글을 통해 느끼셨을 것이라 생각합니다. 앞으로 라이너의 코파일럿이 더 다재다능해지며 실제로 세상의 정보들을 필요한 사람들에게 효용을 줄 수 있도록 노력하겠습니다.

아! 혹시 이 과정 속에서 다양한 난관을 헤쳐나가며 배움을 축적해나가실 훌륭한 엔지니어가 계시다면, 저희는 언제나 환영입니다! 😁