diff --git a/_posts/2023-01-01-SHORTS-2.md b/_posts/2023-01-01-SHORTS-2.md index 26eb073..73bd8d2 100644 --- a/_posts/2023-01-01-SHORTS-2.md +++ b/_posts/2023-01-01-SHORTS-2.md @@ -11,9 +11,7 @@ mermaid: true --- -# 크롤링한 뉴스의 키워드 추출 과정 - -## 1. 키워드 추출에 관한 서드파티 기술 조사 +# 1. 키워드 추출에 관한 기술 조사 - [Jsoup](https://jsoup.org/) - [Lucene](https://mvnrepository.com/artifact/org.apache.lucene/lucene-core) @@ -44,75 +42,130 @@ mermaid: true --- -## 2. 두 가지 기술 스택을 바탕으로 키워드 추출 로직 구현 +# 2. 두 가지 기술 스택을 바탕으로 키워드 추출 로직 구현 -### Lucene Analyzer +## Lucene Analyzer ```kotlin -internal fun extractKeyword(content: String): String { - val keywordCount = 4 - val analyzer = KoreanAnalyzer() - - val wordFrequencies = mutableMapOf() - val reader = StringReader(content) - val tokenStream = analyzer.tokenStream("text", reader) - val charTermAttribute: CharTermAttribute = tokenStream.addAttribute(CharTermAttribute::class.java) - - tokenStream.reset() - while (tokenStream.incrementToken()) { - val term = charTermAttribute.toString() - if (term !in stopWords && term.length > 1) { - wordFrequencies[term] = wordFrequencies.getOrDefault(term, 0) + 1 +@Component +@Qualifier("LuceneAnalyzerKeywordExtractor") +class LuceneAnalyzerKeywordExtractor : KeywordExtractor { + + override fun extractKeyword(title: String, content: String): String { + val titleFrequencies = calculateWordFrequencies(title, TITLE_WEIGHT) + val contentFrequencies = calculateWordFrequencies(content, CONTENT_WEIGHT) + val wordFrequencies = titleFrequencies.toMutableMap() + + contentFrequencies.forEach { (key, value) -> + wordFrequencies[key] = wordFrequencies.getOrDefault(key, DEFAULT_FREQUENCY) + value + } + + return formatResult(wordFrequencies) + } + + private fun calculateWordFrequencies(text: String, weight: Double): Map { + val wordFrequencies = mutableMapOf() + val tokenStream = createTokenStream(text) + tokenStream.use { token -> + token.reset() + while (token.incrementToken()) { + val term = token.getAttribute(CharTermAttribute::class.java).toString() + if (term !in stopWords && term.length > 1) { + val frequency = wordFrequencies.getOrDefault(term, DEFAULT_FREQUENCY) + wordFrequencies[term] = frequency + weight.toInt() + } + } + token.end() } + return wordFrequencies } - tokenStream.end() - tokenStream.close() - val sortedKeywords = wordFrequencies.entries.sortedByDescending { it.value } - val topKeywords = sortedKeywords.take(keywordCount).map { it.key } + private fun createTokenStream(text: String): TokenStream { + val reader = StringReader(text) + return luceneKoreanAnalyzer.tokenStream(TOKEN_STREAM_FIELD_NAME_TYPE, reader) + } + + private fun formatResult(wordFrequencies: Map): String { + val sortedKeywords = wordFrequencies.entries.sortedByDescending { it.value } + val topKeywords = sortedKeywords.take(KEYWORD_COUNT).map { it.key } + return topKeywords.joinToString(", ") + } - return topKeywords.joinToString(", ") - .replace("[", "") - .replace("]", "") + companion object { + private const val KEYWORD_COUNT = 5 + private const val TITLE_WEIGHT = 1.5 + private const val CONTENT_WEIGHT = 1.0 + private const val DEFAULT_FREQUENCY = 0 + private const val TOKEN_STREAM_FIELD_NAME_TYPE = "text" + + private val luceneKoreanAnalyzer = object : Analyzer() { + override fun createComponents(fieldName: String?): TokenStreamComponents { + val koreanTokenizer = KoreanTokenizer( + KoreanTokenizer.DEFAULT_TOKEN_ATTRIBUTE_FACTORY, + null, + DecompoundMode.NONE, + true + ) + return TokenStreamComponents(koreanTokenizer) + } + } + } } ``` +위 코드에는 기사 제목과 기사 본문에 대한 가중치를 추가해줬다. 뉴스 기사는 제목에 핵심이 들어있기 때문에 제목으로부터 추출한 키워드의 가중치를 높여 조금 더 핵심을 담은 키워드 요약 기능으로 사용자 경험을 개선하고자 했다. + --- ### Komoran ```kotlin -internal fun extractKeywordV2(content: String): String { - val keywordCount = 5 - val komoran = Komoran(DEFAULT_MODEL.FULL) - val nouns = komoran.analyze(content).nouns - val nounsCountingMap = HashMap() - val nounsSet = HashSet(nouns) - - nounsSet.map { - val frequency = Collections.frequency(nouns, it) - nounsCountingMap[it] = frequency - } +@Component +@Deprecated("Replaces morphological analysis with Komoran Library.") +@Qualifier("KomoranKeywordExtractor") +class KomoranKeywordExtractor : KeywordExtractor { + + override fun extractKeyword(title: String, content: String): String { + val keywordCount = 5 + val komoran = Komoran(DEFAULT_MODEL.FULL) + val nouns = komoran.analyze(content).nouns + val nounsCountingMap = HashMap() + val nounsSet = HashSet(nouns) + + nounsSet.map { + val frequency = Collections.frequency(nouns, it) + nounsCountingMap[it] = frequency + } - val hotKeyword = nounsCountingMap.entries - .sortedByDescending { it.value } - .take(keywordCount).map { it.key } + val hotKeyword = nounsCountingMap.entries + .sortedByDescending { it.value } + .take(keywordCount).map { it.key } + + return hotKeyword.joinToString(", ") + .replace("[", "") + .replace("]", "") + } - return hotKeyword.joinToString(", ") - .replace("[", "") - .replace("]", "") } ``` --- -## 3. 채택 근거 +## 3. 둘 중 어떤걸 선택했는가? -본문의 내용 중 정확도가 더 높아 보이는 오픈소스를 활용하기로 결정하여 Komoran을 채택합니다. +Lucene Analyzer를 사용하기로했다. -판단 근거) 최저임금에 관한 기사를 키워드로 요약할 시 Apache Lucene은 아래와 같이 +```kotlin +val koreanTokenizer = KoreanTokenizer( + KoreanTokenizer.DEFAULT_TOKEN_ATTRIBUTE_FACTORY, + null, + DecompoundMode.NONE, + true +) +``` -최저, 임금 ... 으로 "최저임금"이라는 적합한 키워드로 추출하지 못하였으나 Komoran은 "최저임금"을 키워드로 추출할 수 있었습니다. +위와 같이 KoreanTokenizer를 생성할 때 `DecompoundMode.NONE`이라는 옵션을 줄 수 있는데 이 옵션은 복합명사를 분리하지 않는 옵션이다. 뉴스 기사에는 복합명사로 이루어진 구문이 다수 있기 때문에 이 옵션을 위해 Lucene을 채택했다. -![image](https://github.com/mash-up-kr/SeeYouAgain_Spring/assets/60564431/7f3765b8-a674-479d-a840-9119cd914034) +이와 달리 `DISCARD`옵션은 복합명사로 분리하고 원본 데이터는 삭제한다. +`MIXED`옵션은 복합명사로 분리하고 원본 데이터는 유지한다. 잠실역 -> [잠실, 역, 잠실역]