Skip to content

Commit

Permalink
기본이 중요하다 [SHORTS Keyword Extractor]
Browse files Browse the repository at this point in the history
  • Loading branch information
K-Diger committed Mar 4, 2024
1 parent bdf54e6 commit 4e78f01
Showing 1 changed file with 101 additions and 48 deletions.
149 changes: 101 additions & 48 deletions _posts/2023-01-01-SHORTS-2.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ mermaid: true

---

# 크롤링한 뉴스의 키워드 추출 과정

## 1. 키워드 추출에 관한 서드파티 기술 조사
# 1. 키워드 추출에 관한 기술 조사

- [Jsoup](https://jsoup.org/)
- [Lucene](https://mvnrepository.com/artifact/org.apache.lucene/lucene-core)
Expand Down Expand Up @@ -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<String, Int>()
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<String, Int> {
val wordFrequencies = mutableMapOf<String, Int>()
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, Int>): 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<String, Int>()
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<String, Int>()
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`옵션은 복합명사로 분리하고 원본 데이터는 유지한다. 잠실역 -> [잠실, 역, 잠실역]

0 comments on commit 4e78f01

Please sign in to comment.