Doputer

검색어 강조 알고리즘 개발기

기존 Knoticle의 검색 결과

Knoticle 서비스에서 "페이지" 라는 키워드로 검색했을 때 기존에는 다음과 같이 검색됩니다.

0

제목과 내용에 "페이지" 라는 검색어가 포함된 글들이 나타나고 있다는 걸 확인할 수 있습니다.

그런데 사용자 입장에서는 원하는 검색 결과가 나타났는지 알기 힘들다는 생각이 들었습니다. 사용자가 검색한 검색어가 글의 어떤 맥락에 들어있는지 알게 해준다면, 원하는 검색 결과를 빠르게 선택할 수 있을 것입니다.

1

위와 같이 검색 결과에 검색어가 강조되는 기능을 구현한 과정을 소개하려고 합니다.

검색어 강조하기

Knoticle의 글(article)의 내용(content)은 다음과 같은 형식으로 저장되어있습니다.

국토와 자원은 국가의 보호를 받으며, 국가는 그 균형있는 개발과 이용을 위하여 필요한 계획을 수립한다. 모든 국민은 통신의 비밀을 침해받지 아니한다. 감사원은 세입·세출의 결산을 매년 검사하여 대통령과 차년도국회에 그 결과를 보고하여야 한다. 대한민국은 민주공화국이다. 국가는 재해를 예방하고 그 위험으로부터 국민을 보호하기 위하여 노력하여야 한다.

이때 "국민" 과 "예방" 이라는 검색어로 검색한 경우에 어떤 식으로 해당 단어를 강조해줄 수 있을까요?

우선 문자열을 앞에서 부터 읽으면서 "국민" 과 "예방" 중에 먼저 등장하는 단어를 찾아야합니다.

...

모든 국민은 통신의 비밀을 침해받지 아니한다.

...

코드로는 다음과 같이 작성할 수 있습니다.

const getFirstKeyword = (text: string, keywords: string[]) => {
  const keywordMap = new Map<number, string>();

  keywords.forEach((keyword) => {
    const index = text.toLowerCase().indexOf(keyword.toLowerCase());

    if (index !== -1) keywordMap.set(index, text.slice(index, index + keyword.length));
  });

  if (keywordMap.size === 0) return { keyword: '', index: -1, validKeywords: [] };

  const firstKeywordIndex = Math.min(...Array.from(keywordMap.keys()));
  const firstKeyword = keywordMap.get(firstKeywordIndex);

  return {
    keyword: firstKeyword || '',
    index: firstKeywordIndex,
    validKeywords: Array.from(new Set(keywordMap.values())),
  };
};

검색어들을 순회하면서 내용에서 가장 먼저 등장하는 인덱스를 구합니다. 그 후에 가장 낮은 인덱스를 가진 단어(= 가장 앞에 등장하는 단어)를 찾습니다.

이때 영어 검색의 경우 대소문자 구분 없이 찾기 위해서 .toLowerCase()를 이용합니다. 또한 한번 찾지 못한 검색어는 같은 문장에서 다시 등장하지 않기 때문에 이를 필터링해주기 위해 validKeywords에 유효한 검색어만 필터링해서 같이 반환해줍니다.

가장 먼저 등장하는 단어를 찾았다면 다음과 같이 단어를 기준으로 문장을 세 등분(단어 앞 문장, 단어, 단어 뒷 문장)으로 나눈 후 사이에 <b> 태그를 넣어서 강조해줄 수 있습니다.

...

모든 <b>국민</b> 은 통신의 비밀을 침해받지 아니한다.

...

단어 하나를 강조했다면 단어 뒷 문장은 동일한 방법으로 강조처리를 해주면 됩니다.

  1. 가장 먼저 등장하는 검색어 찾기
  2. 검색어를 기준으로 검색어 앞 문장, 검색어, 검색어 뒷 문장으로 나누기
  3. 나눠진 문장 사이에 <b> 태그 추가하기
  4. 검색어 뒷 문장에서 가장 먼저 등장하는 검색어 찾기
  5. 반복

코드로는 다음과 같이 작성할 수 있습니다.

export const highlightKeyword = (text: string, keywords: string[]): React.ReactNode => {
  const { keyword, index, validKeywords } = getFirstKeyword(text, keywords);

  if (index === -1) return text;

  const endIndex = index + keyword.length;

  return (
    <>
      {text.slice(0, index)}
      <b>{keyword}</b>
      {highlightKeyword(text.slice(endIndex), validKeywords)}
    </>
  );
};

강조한 검색어가 보이지 않는 문제

검색어를 강조할 수 있는 로직은 작성했는데 일부 검색 결과에서 문제가 있었습니다.

검색 결과의 제한된 뷰포트로 인해 강조한 검색어가 문장 뒷 부분에 등장하는 경우 검색 결과에서 보이지 않게 되었습니다.

2

검색어가 포함된 문장이 보이게 해준다면 사용자는 자신의 검색어가 글의 어떤 맥락에서 등장하는지 쉽게 찾을 수 있게 됩니다.

이를 해결하기 위해서는 최초 검색어의 앞 부분을 제거해주면 됩니다.

국토와 자원은 국가의 보호를 받으며, 국가는 그 균형있는 개발과 이용을 위하여 필요한 계획을 수립한다.

모든 국민은 통신의 비밀을 침해받지 아니한다.

...

다만 위와 같이 검색어 바로 앞쪽부터 지우게 되면 검색어가 포함된 문장도 지워지기 때문에 사용자가 맥락을 파악하기 힘들어집니다.

검색어가 포함된 문장은 살리되 그 앞쪽은 모두 지우기 위해선 줄바꿈 문자를 이용하면 됩니다. 검색어가 포함된 문장과 가장 가까이 있는 줄바꿈 문자를 찾아서 그 앞쪽을 지우면 앞서 말한 문제들을 해결할 수 있게 됩니다.

국토와 자원은 국가의 보호를 받으며, 국가는 그 균형있는 개발과 이용을 위하여 필요한 계획을 수립한다.\n

모든 국민은 통신의 비밀을 침해받지 아니한다.

...

코드로는 다음과 같이 작성할 수 있습니다.

환경에 따라 다르지만 뷰포트에는 최대 400자 정도가 들어올 수 있기 때문에 불필요한 뒷 부분도 함께 제거해줬습니다.

export const getTextAfterLastNewLine = (text: string, keywords: string[]) => {
  const { index } = getFirstKeyword(text, keywords);

  const newLineIndex = text.slice(0, index).lastIndexOf('\n');

  return newLineIndex === -1 ? text.slice(0, 400) : text.slice(newLineIndex, newLineIndex + 400);
};
ⓒ 2024. 김도현. All Rights Reserved.