엘라스틱 서치 분해하기 2
Hermaeus Mora ·
그럼 이걸로 된 것일까?
이번엔 쿠버네티스를 검색해보자.
GET test2/_analyze
{
\"analyzer\": \"my_analyzer\",
\"text\": \"쿠버네티스\",
\"explain\": true
}{
\"detail\": {
\"custom_analyzer\": true,
\"charfilters\": [],
\"tokenfilters\": [
{
\"name\": \"nori_filter\",
\"tokens\": [
{
\"token\": \"쿠\",
...,
\"leftPOS\": \"NNP(Proper Noun)\",
},
{
\"token\": \"버네\",
...,
\"leftPOS\": \"NNP(Proper Noun)\",
},
{
\"token\": \"티스\",
...,
\"leftPOS\": \"NNP(Proper Noun)\",
}
]
}
]
}
}외래어는 어근이 모두 NNP(고유 명사)로 취급되므로 노력이 물거품이 된 것을 확인할 수 있다. 사실 이런 경우에는 모든 외래어 또는 복합 명사를 고유 명사로 등록해야 하는데, 그 많은 복합 명사를 일일이 노가다하기가 여간 귀찮은 것이 아니다.
누군가 위키피디아 덤프를 가공한 데이터셋을 공유했는데, 해당 페이지에서 타이틀만 뽑아서 다운받아보았다. 이를 바로 사용할수는 없고, parquet포맷이기 때문에 이를 txt로 변환해주어야 한다.
import parquet from \"@dsnp/parquetjs\";
import fs from \"fs\";
async function createFile(
path: string,
data: string,
count = 1
): Promise<string> {
return new Promise((res, rej) => {
const o = count >= 1 ? `${path} (${count})` : path;
fs.writeFile(o, data, { flag: \"wx\" }, async function (err) {
if (err) {
count++;
try {
const result = await createFile(path, data, count);
if (result) res(result);
} catch (e) {}
} else {
res(o);
}
});
});
}
async function writeFile(i: string, content: string) {
return new Promise((res, rej) => {
fs.writeFile(i, content, (err) => {
if (err) {
rej(err);
} else {
res(\"ok\");
}
});
});
}
async function parquetToTxt(i: string, targetCol: string) {
let reader = await parquet.ParquetReader.openFile(i);
try {
let cursor = reader.getCursor();
let _o = i.substring(0, i.length - 8) + \".txt\";
const o = await createFile(_o, \"\");
let record: { [key: string]: string } = {};
let indices: { [key: string]: number } = {};
let content = \"\";
while ((record = (await cursor.next()) as typeof record)) {
let text = record[targetCol];
const obpos = text.indexOf(\"(\");
if (obpos > 0) {
text = text.substring(0, obpos - 1);
}
const words = text.split(\" \");
if (words.length > 3) {
const compound = `${words[0]}${words[1]}${words[2]}${words[3]} ${words[0]} ${words[1]} ${words[2]} ${words[3]}`;
indices[compound] = 0;
words.splice(0, 4);
} else if (words.length > 2) {
const compound = `${words[0]}${words[1]}${words[2]} ${words[0]} ${words[1]} ${words[2]}`;
indices[compound] = 0;
words.splice(0, 3);
} else if (words.length > 1) {
const compound = `${words[0]}${words[1]} ${words[0]} ${words[1]}`;
indices[compound] = 0;
words.splice(0, 2);
}
words.forEach((word) => {
indices[word] = 0;
``;
});
}
content = Object.keys(indices).reduce((prev, cur) => {
prev += cur + \"\
\";
return prev;
}, \"\");
await writeFile(o, content);
} catch (e) {
console.error(e);
} finally {
await reader.close();
}
}
parquetToTxt(\"korean_noun.parquet\", \"title\");그럼 다음과 같은 txt파일이 나온다.
--wikititles.txt--
벤조산나트륨 벤조산 나트륨
아세톤퍼옥사이드 아세톤 퍼옥사이드
질산칼슘 질산 칼슘
세일란지아EC 세일란지아 EC
시아노르치FC 시아노르치 FC
쿠버네티스이는 nori_tokenizer가 복합명사를 어떻게 분해해야할지를 명시한다. 단일명사는 그대로 작성하고, 복합명사의 경우 띄어쓰기 구분으로 명사을 나열한다.
새 인덱스를 만들어보자.
PUT test3
{
\"settings\": {
\"index\": {
\"analysis\": {
\"tokenizer\": {
\"nori_user_dict\": {
\"type\": \"nori_tokenizer\",
\"decompound_mode\": \"mixed\",
\"user_dictionary\": \"wikititles.txt\"
}
},
\"analyzer\": {
\"my_analyzer\": {
\"type\": \"custom\",
\"tokenizer\": \"nori_user_dict\",
\"filter\": [ \"nori_filter\" ]
}
},
\"filter\": {
\"nori_filter\": {
\"type\": \"nori_part_of_speech\",
\"stoptags\": [\"XSN\"]
}
}
}
}
}
}방금 만든 txt파일을 nori_tokenizer의 user_dictionary에 명시하면 된다. 절대 경로는 /usr/share/elasticsearch/config에 위치시켜야 한다.
GET test3/_analyze
{
\"analyzer\": \"my_analyzer\",
\"text\": \"쿠버네티스\",
\"explain\": true
}{
\"detail\": {
\"custom_analyzer\": true,
\"charfilters\": [],
\"tokenfilters\": [
{
\"name\": \"nori_filter\",
\"tokens\": [
{
\"token\": \"쿠버네티스\",
...,
\"leftPOS\": \"NNP(Proper Noun)\",
}
]
}
]
}
}그럼 이제 쿠버네티스가 하나의 단일 명사로 색인되는 것을 확인할 수 있다. 즉 쿠버네티스를 검색했을때 쿠앤크가 나오는 골때리는 상황은 모면했다고 볼 수 있다.
그럼 이제 진짜 된 것일까?
k8s를 검색해보자. 사실 당연히 나오지 않기 때문에 검색해볼 필요는 없다. 이름이 쿠버네티스라고해서 쿠버네티스로 검색하는 사람이 있는 반면, 축약어로 검색하는 사람들이 있기 마련이다. 이런 경우를 대비해서 동의어를 미리 정의해둘 수 있다.
--synonyms.txt--
k8s, 쿠버네티스, kubernetes
쿠버네ㅣ스 => 쿠버네티스쉼표로 나열한 단어들은 각기 다른 단어들의 동의어가 되고, 화살표로 연결한 경우에는 왼쪽의 단어를 오른쪽의 단어가 대체한다.
새 인덱스를 만들어보자.
PUT test4
{
\"settings\": {
\"index\": {
\"analysis\": {
\"tokenizer\": {
\"nori_user_dict\": {
\"type\": \"nori_tokenizer\",
\"decompound_mode\": \"mixed\",
\"user_dictionary\": \"wikititles.txt\"
}
},
\"analyzer\": {
\"my_analyzer\": {
\"type\": \"custom\",
\"tokenizer\": \"nori_user_dict\",
\"filter\": [ \"nori_filter\", \"synonym_filter\" ]
}
},
\"filter\": {
\"nori_filter\": {
\"type\": \"nori_part_of_speech\",
\"stoptags\": [\"XSN\"]
},
\"synonym_filter\": {
\"type\": \"synonym\",
\"synonyms_path\": \"synonyms.txt\"
}
}
}
}
}
}synonym 필터가 동의어 파일을 참조하도록 커스텀 후 애널라이저의 filter부분에 명시한다. 이번에도 절대 경로는 /usr/share/elasticsearch/config에 위치시켜야 한다.
그러면 k8s로 검색 시 쿠버네티스로 검색되는 것을 확인할 수 있다.