사용자의 검색 경험 향상을 위해 맞춤법 및 오탈자 검사 알고리즘에 대해 조사하고 간단한 테스트를 진행했다.
1. T5 기반의 Typo Corrector 모델
기대만큼 성능이 좋지 않다. 문장의 흐름이나 단어의 쓰임에 대해 구체적인 이해가 부족한 것 같다. 또한 학습되지 않은 사전에 없는 단어는 처리되지 않는 것 같다.
테스트한 결과는 다음과 같다.
| 입력 | 출력 |
| "변후사가 하는 일이 뭐야?" | "변후사가 하는 일이 뭐야?" |
| "아녕하세요 제 일므은 정유진입니ㅏㄷ." | "안녕하세요? 제 일은 정유진입니다." |
1. "변후사"라는 단어는 존재하지 않지만, 사람은 뒤에 "하는 일이 뭐야?"라는 문장과 함께 쉽게 "변후사"가 "변호사"를 잘못 작성했음을 파악할 수 있을 것이다.
2. "일므은"을 "일"로 수정하였다. 안녕하세요라는 인사말과 함께 "정유진"은 정확한 뜻은 몰라도 행위가 아닌 어떤 고유명사라는 것을 알기 때문에 사람이라면 자연스럽게 "일므은"을 "이름은"로 고칠 수 있다.
supervised learning 방법으로 학습된 모델의 한계를 명확히 가지고 있다.
from transformers import T5ForConditionalGeneration, T5Tokenizer
import torch
# T5 모델 로드
model = T5ForConditionalGeneration.from_pretrained("j5ng/et5-typos-corrector")
tokenizer = T5Tokenizer.from_pretrained("j5ng/et5-typos-corrector")
device = "cuda:0" if torch.cuda.is_available() else "cpu"
# device = "mps:0" if torch.cuda.is_available() else "cpu" # for mac m1
model = model.to(device)
# 예시 입력 문장
input_text = "변오사"
input_text = "아녕하세요 제 일므은 정유진입니ㅏㄷ."
# 입력 문장 인코딩
input_encoding = tokenizer("맞춤법을 고쳐주세요: " + input_text, return_tensors="pt")
input_ids = input_encoding.input_ids.to(device)
attention_mask = input_encoding.attention_mask.to(device)
# T5 모델 출력 생성
output_encoding = model.generate(
input_ids=input_ids,
attention_mask=attention_mask,
max_length=128,
num_beams=5,
early_stopping=True,
)
# 출력 문장 디코딩
output_text = tokenizer.decode(output_encoding[0], skip_special_tokens=True)
# 결과 출력
print(output_text)
2. Word Embedding 모델
word embedding 모델로 오탈자를 고쳐보려고 시도했다. 그러나 테스트를 거친 후 word embedding 모델은 오탈자를 위한 모델이 아님을 깨달았다. 임베딩 공간에는 오탈자가 난 단어를 맵핑할 곳이 없는게 당연한데 이걸 생각 못했다..
테스트한 결과는 다음과 같다.
| 입력 | 출력 | 처리 시간 |
| 변오사 | 해체되고: 0.5851 해골물: 0.5775 공화국엘살바도르에스토니아피지핀란드독일과테말라온두라스홍콩헝가리아이슬란드인도인도네시아아일랜드이탈리아일본멕시코네덜란드뉴질랜드니콰라과니우에노르웨이파나마파라과이페루필리핀폴란드포르투갈루마니아러시아사모아싱가포르슬로바키아슬로베니아남: 0.5282 단체봉화경찰서봉화교육청봉화문화원봉화우체국농협봉화군지부봉화군종합자원봉사센터새마을운동봉화군지회: 0.5166 awkwafina: 0.5154 대한항공채용: 0.5100 전체기사화장품미용트렌드해외특집피플캠퍼스: 0.5090 소책자블로그호스팅도메인테마플러그인디자인비즈니스상점테마플러그인이메일: 0.5022 공화국네덜란드노르웨이뉴질랜드대한민국덴마크독일라트비아러시아루마니아룩셈부르크리투아니아리히텐슈타인마케도니아말레이시아멕시코모나코몬테네그로몰도바몰타몽골미국베네수엘라베트남벨기에벨라루스보스니아: 0.5021 공화국스페인스웨덴스와질란드대만태국통가우크라이나영국우루과이바누아투close: 0.4991 |
33초 |
| 변호사 | 로펌고우: 0.6688 최진녕: 0.6673 항목해설: 0.6514 백성문: 0.6485 이혼전문: 0.6265 합동법률사무소: 0.6213 변호사의: 0.6183 도태우: 0.6114 장승수: 0.6048 변호사가: 0.6007 |
30초 |
from gensim import models
import time
start_time = time.time()
ko_model = models.fasttext.load_facebook_model('./test/cc.ko.300.bin')
end_time = time.time()
print(f"모델 로드 시간: {end_time - start_time}초")
word = "변오사"
word = "변호사"
start_time = time.time()
for w, sim in ko_model.similar_by_word('변우사', 10):
print(f'{w}: {sim}')
end_time = time.time()
print(f"처리 시간: {end_time - start_time}초")
3. 네이버 맞춤법 검사기 API 기반 hanspell 라이브러리
성능이 꽤 좋다. 그러나 API에 요청을 보내기 위해 필요한 Key가 계속 변해서 신경써야 하고, 결과가 살짝 아쉽다. 더 나은 방법이 없을까 고민하게 된다.
테스트한 결과는 다음과 같다.
| 입력 | 출력 |
| 변오사 | 변호사, errors=1, words=OrderedDict([('변호사', 4)]) |
| 변후사 | 변 후 사, errors=1, words=OrderedDict([('변', 2), ('후', 2), ('사', 2)]) |
| 변우사 | 변무사, errors=1, words=OrderedDict([('변무사', 4)]) |
import re
import requests
def get_passport_key():
"""네이버에서 '네이버 맞춤법 검사기' 페이지에서 passportKey를 획득
- 네이버에서 '네이버 맞춤법 검사기'를 띄운 후
html에서 passportKey를 검색하면 값을 찾을 수 있다.
- 찾은 값을 spell_checker.py 48 line에 적용한다.
"""
url = "https://search.naver.com/search.naver?where=nexearch&sm=top_hty&fbm=0&ie=utf8&query=네이버+맞춤법+검사기"
res = requests.get(url)
html_text = res.text
match = re.search(r'passportKey=([^&"}]+)', html_text)
if match:
passport_key = match.group(1)
return passport_key
else:
return False
def fix_spell_checker_py_code(file_path, passportKey):
"""획득한 passportkey를 spell_checker.py파일에 적용
"""
pattern = r"'passportKey': '.*'"
print(passportKey)
with open(file_path, 'r', encoding='utf-8') as input_file:
content = input_file.read()
modified_content = re.sub(pattern, f"'passportKey': '{passportKey}'", content)
with open(file_path, 'w', encoding='utf-8') as output_file:
output_file.write(modified_content)
return
spell_checker_file_path = './test/hanspell/spell_checker.py'
passport_key = get_passport_key()
if passport_key:
fix_spell_checker_py_code(spell_checker_file_path, passport_key)
else:
print("passportKey를 찾을 수 없습니다.")
from hanspell import spell_checker
result = spell_checker.check(["변후사", ])
print(result)
4. 오픈 소스 LLM Llama 3 8B
hanspell을 꽤나 믿었는데 아쉬운점이 있어 고민하다가 만능 LLM에 관심이 갔다. 성능이 우수하기는 한데, 리소스 이슈로 인해 실제 사용을 어렵다.. 내 노트북으로 인퍼런스도 안돼서 RTX 4080이 있는 친구 컴퓨터를 빌려서 테스트를 진행했다.
테스트한 결과는 다음과 같다.
| 입력 | 출력 | 처리 시간(sec) |
| 변오사 | 변호사 | 0.3482 |
| 변후사 | 변호사 | 0.3542 |
| 변우사 | 변우사 | 0.3578 |
import transformers
import torch
import time
print('set model')
model_id = "MLP-KTLim/llama-3-Korean-Bllossom-8B"
pipeline = transformers.pipeline(
"text-generation",
model=model_id,
model_kwargs={"torch_dtype": torch.bfloat16},
device_map="auto",
)
pipeline.model.eval()
PROMPT = '''
사용자가 입력한 단어에서 오탈자가 있다면, 올바른 단어로 고쳐주세요.
단어 하나만 출력하세요.
'''
instruction = "변후사"
messages = [
{"role": "system", "content": f"{PROMPT}"},
{"role": "user", "content": f"{instruction}"}
]
print('set messages')
prompt = pipeline.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
terminators = [
pipeline.tokenizer.eos_token_id,
pipeline.tokenizer.convert_tokens_to_ids("<|eot_id|>")
]
print('send messages')
start_time = time.time()
outputs = pipeline(
prompt,
max_new_tokens=2048,
eos_token_id=terminators,
do_sample=True,
temperature=0.6,
top_p=0.9
)
end_time = time.time()
print(f"처리 시간: {end_time - start_time}초")
print(outputs[0]["generated_text"][len(prompt):])
5. 편집 거리 알고리즘 (1) SymSpell
맞춤법 및 오탈자를 수행하는 이유가 사전에 정의된 DB에서 검색 성능을 높이기 위해서였는데,, 즉, 어차피 이미 정의된 단어들에서 하나를 선택하면 되는 일이기 때문에 꼭 인공지능에 집착할 필요가 있나 싶은 생각이 문득 들었다. 그래서 편집 거리 알고리즘으로 오탈자를 수정하는 방법을 찾아봤다.
테스트 결과는 다음과 같다.
| 입력 | 출력 | 편집 거리 |
| 변오사 | 변호사 | 1 |
| 변후사 | 변호사 | 1 |
| 변우사 | 변리사 | 2 |
| 가스수도전기점검원 | - | - |
SymSpell 여전히 아쉽다. 그리고 max edit distance가 고작 2이다. 사실 이런 편집 거리 알고리즘을 실제 활용해본적이 없어 그 임계값에 대한 기준을 잘 모르지만, Chat GPT에 물어보니 아무리 많이 설정해도 3이고, 3을 넘어가면 급격한 성능저하가 일어난다고 한다. 예를들어 "가스수도전기점검원"같이 max edit distance가 넘어가는 오탈자는 아예 잡지 않는다.
6. 편집 거리 알고리즘 (2) Fuzzy Search
더 조사하니 Fuzzy Search가 있었다. 이것도 시간복잡도가 사전 수에 비례하지만, SymSpell 알고리즘의 단점을 보완하면서 현재 내가 사용하려는 DB 크기에 충분히 적용할 수 있을 것 같다. 다만, 성능이 불만족스럽다.
테스트 결과는 다음과 같다.
| 입력 | 출력 | 유사도(%) |
| 변오사 | 변리사 | 66.6666 |
| 변후사 | 변리사 | 66.6666 |
| 변우사 | 변리사 | 66.6666 |
| 가스수도전기점검원 | 가스ㆍ수도ㆍ전기 계기 검침원 및 안전점검원 | 63.5294 |
from rapidfuzz import process
import pandas as pd
# 사전 단어 목록
file_path = './data/EncyclopediaAptiInter.xlsx'
df = pd.read_excel(file_path)
word_list = df['job_nm'].tolist()
print(sorted(word_list))
# 오타가 난 단어
input_word = '가스수도전기점검원'
best_match = process.extractOne(input_word, word_list)
print(f"입력한 단어: {input_word}")
print(f"가장 유사한 단어: {best_match[0]} (유사도: {best_match[1]}%)")
7. 편집 거리 알고리즘 (2) Fuzzy Search + decompose
Fuzzy Search가 None 값을 리턴하지 않고 어떻게든 값을 도출하고, 만족스럽지는 않지만 "변리사"라는 그래도 비슷한 출력을 해서.. 좀 더 디테일하게 검사할 수 있도록 초성, 중성, 종성을 구분하는 알고리즘을 넣어봤다.
테스트 결과는 다음과 같다.
| 입력 | 출력 | 유사도(%) |
| 변오사 | 변호사 | 85.7142 |
| 변후사 | 변호사 | 85.7142 |
| 변우사 | 변리사 | 71.4285 |
| 가스수도전기점검원 | 가수 | 77.4128 |
from rapidfuzz import process
import pandas as pd
def decompose_hangul(s):
CHO = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']
JUNG = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ']
JONG = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']
result = []
for char in s:
if '가' <= char <= '힣': # 한글 음절만 처리
# 유니코드 값을 기준으로 초성, 중성, 종성 분해
code = ord(char) - ord('가')
cho_idx = code // (21 * 28) # 초성 인덱스
jung_idx = (code % (21 * 28)) // 28 # 중성 인덱스
jong_idx = code % 28 # 종성 인덱스
result.extend([e for e in (CHO[cho_idx], JUNG[jung_idx], JONG[jong_idx]) if e])
else:
result.extend(char) # 한글이 아닌 문자는 처리하지 않음
return ''.join(result)
# 사전 단어 목록
file_path = './data/EncyclopediaAptiInter.xlsx'
df = pd.read_excel(file_path)
word_list = df['job_nm'].tolist()
word_dict = {decompose_hangul(w): w for w in word_list}
word_list = [decompose_hangul(w) for w in word_list]
# 오타가 난 단어
input_word = '가스수도전기점검원'
input_word = decompose_hangul(input_word)
best_match = process.extractOne(input_word, word_list)
print(f"입력한 단어: {input_word}")
print(f"가장 유사한 단어: {word_dict[best_match[0]]} (유사도: {best_match[1]}%)")
8. 결론
맞춤법 및 오탈자를 잡기 위해 다양한 모델과 알고리즘에 대해 실험을 진행했다. 사실 마음에 쏙 드는 그런 결과는 없었다.. 다만 이 모델들과 알고리즘을 활용해서 앙상블을 한다든가, 아니면 알고리즘의 깊은 이해를 통해 더 만족스러운 결과가 도출되도록 수정하든가, 이런저런 시도를 해 볼 가능성을 알 수 있는 작업이었다고 생각한다. 그리고 NLP 데이터 처리에 대해서는 잘 몰랐는데 이번 기회로 맛 볼 수 있었다. 또한 AI 개발자이지만, 결국에는 코딩, 프로그램 작성을 통해 주어진 문제를 해결하는 것이 최종 목표이기 때문에 AI 모델뿐만아니라 CS 지식도 충분히 갖추는 것이 중요하다는 것을 다시금 깨달았다.