검색창을 달고 처음 받은 불만
이 블로그의 첫 검색은 단순했습니다. 제목, 태그, 소제목에 검색어가 그대로 들어 있으면 보여주는 방식이었습니다.
글이 몇 편 안 될 때는 그걸로 충분해 보였습니다. 그런데 직접 써 보니 금방 막혔습니다. 글에 쓰인 표현을 정확히 알아야만 찾을 수 있었고, 본문에만 있는 단어는 아예 걸리지 않았습니다.
사람은 글에 쓰인 단어로 검색하지 않습니다. 자기가 겪은 증상으로 검색합니다.
데이터베이스를 띄우기엔 글이 너무 적었다
검색 품질을 올리는 정석은 검색 엔진이나 벡터 데이터베이스를 붙이는 것입니다. 하지만 이 블로그는 빌드한 HTML을 그대로 올리는 정적 사이트입니다. 글 12편을 찾자고 서버를 하나 더 운영하는 건 과했습니다.
그래서 색인을 빌드할 때 한 번 만들기로 했습니다. 파이썬이 글을 읽어 JSON을 쓰고, 브라우저가 그 파일만으로 검색합니다.
색인의 뼈대는 TF-IDF입니다. 같은 단어라도 어디에 나왔는지에 따라 무게를 달리 줬습니다.
| 위치 | 가중치 |
|---|---|
| 제목 | 3 |
| 태그 | 2.5 |
| 카테고리·소제목 | 2 |
| 설명 | 1.5 |
| 출처 | 1.2 |
| 본문 | 1 |
여기에 문단 단위로 함께 쓰인 단어 쌍을 세서 연관어를 붙였습니다. '누수'를 찾으면 같은 문단에 자주 나온 '순환 참조'까지 끌려오게 하려는 것이었습니다.
이 정도로도 체감은 꽤 좋아졌습니다. 문제는 다른 데서 터졌습니다.
'사례'가 검색을 끌고 간 날
"누수 방지하는 사례"를 검색했더니 순위가 이상했습니다. 메모리 누수 글보다 '사례'라는 단어가 많이 나오는 글이 앞에 섰습니다.
점수를 뜯어보니 이유가 분명했습니다. '누수'와 '사례'를 똑같이 취급하고 있었습니다. 그런데 이 검색어의 주제는 '누수 방지'입니다. '사례'는 어떤 종류의 글을 원하는지 덧붙인 말일 뿐입니다.
그래서 검색어의 단어를 역할로 나눴습니다.
- 주제어: 무엇에 대한 글인지 정하는 단어
- 형식어: '사례', '방법', '팁'처럼 글의 종류를 말하는 단어. 주제로 찾은 글 안에서 최대 25%까지만 점수를 더합니다.
- 일반 동작어: '관리', '구축', '활용'처럼 어디에나 붙는 단어. 변별력을 0.3배로 낮춥니다.
주제어끼리도 무게가 다릅니다. '에이전트'는 12편 중 8편에 나옵니다. '누수'는 두 편에만 나옵니다. 그래서 단어별 점수를 IDF로 가중해 평균을 냈습니다. 드문 단어를 놓친 글은 순위가 내려가고, 흔한 단어는 순위를 덜 흔듭니다.
단어 사전으로는 부족해서, 온톨로지
다음 벽은 "데이터베이스"였습니다. BigQuery를 다룬 글이 여러 편인데, 그 글들에는 '데이터베이스'라는 단어가 거의 없습니다. 사람은 BigQuery가 데이터베이스의 한 종류라는 걸 압니다. 검색은 몰랐습니다.
처음에는 동의어 사전을 만들었습니다. 금방 한계가 보였습니다. 'BigQuery'를 '데이터베이스'의 동의어로 넣으면, 반대로 BigQuery를 찾을 때 모든 데이터베이스 글이 끌려옵니다. 둘은 같은 말이 아니라 상위와 하위 관계입니다.
관계를 제대로 표현하려고 다른 회사들의 모델링을 찾아봤습니다.
- Palantir Ontology: 데이터 위에 object와 link로 의미 계층을 따로 둡니다.
- Google Dataplex 용어집: 용어끼리 '동의어'와 '연관 용어'를 구분하고, 둘 다 양방향입니다.
- schema.org: 글이 '무엇에 대한 것인지(about)'와 '무엇을 언급하는지(mentions)'를 나눕니다.
- Spanner Graph: 노드와 엣지를 타입별 테이블로 선언합니다.
결론은 사람이 손대는 부분을 작게 유지하는 것이었습니다. 제가 관리하는 건 스키마와 사전뿐입니다. 글과 개념 사이의 관계는 빌드가 글을 읽고 만듭니다.
# entities.yaml: 개체 하나가 한 줄
- {id: bigquery, label: "BigQuery", type: product, aliases: ["bigquery", "빅쿼리"],
instance_of: [data-platform, database], made_by: google-cloud}
개념(주제)과 개체(제품·기술·조직)를 나눈 게 핵심이었습니다. BigQuery는 개념이 아니라 개체이고, instance_of로 '데이터베이스' 개념에 속합니다.
스키마에 선언한 관계 타입 일부
link_types:
about: {from: article, to: concept, provenance: [asserted, inferred]}
mentions: {from: article, to: entity, provenance: [asserted, extracted]}
related_to: {from: article, to: article, provenance: inferred, symmetric: true}
broader: {from: concept, to: concept, provenance: asserted, transitive: true}
instance_of: {from: entity, to: concept, provenance: asserted}
part_of: {from: entity, to: entity, provenance: asserted, transitive: true}
선언하지 않은 관계나 from/to 타입이 틀린 관계는 빌드가 실패합니다. 모든 관계에는 사람이 적었는지(asserted), 본문에서 뽑았는지(extracted), 규칙으로 계산했는지(inferred)를 남깁니다.
관계를 따라 넓히는 검색
이제 검색은 단어만 보지 않습니다. 검색어가 개념이나 개체의 별칭에 걸리면, 관계를 따라 범위를 넓힙니다.
| 관계 | 가중치 |
|---|---|
| 그 개념을 직접 다룬 글 | 0.9 |
| 하위 개념·소속 개체를 다룬 글 | 0.55 |
| 연관 개념 | 0.4 |
| 상위 개념 | 0.25 |
"데이터베이스"를 치면 BigQuery 글이 나옵니다. 결과 옆에는 이유가 붙습니다. "'데이터베이스' → 데이터베이스의 하위 'BigQuery' 다룬 글".
이유를 보여주는 건 생각보다 중요했습니다. 검색 결과가 이상할 때 어느 관계가 문제인지 바로 보입니다. 별칭 하나를 고치면 끝나는 경우가 대부분이었습니다.
글이 늘어날 때를 대비해 둔 것
지금은 글이 12편이지만, 수백 편이 되면 두 가지가 문제가 됩니다.
하나는 '모두가 모두와 비슷해지는' 현상입니다. 이 블로그의 글 대부분이 Google Cloud Next '26을 다룹니다. 그대로 두면 행사 이름 하나로 모든 글이 서로 관련 글이 됩니다. 그래서 글 사이의 유사도를 계산할 때 특징마다 IDF를 곱했습니다. 두루 나오는 행사명이나 플랫폼명은 자연히 무게를 잃습니다.
다른 하나는 관리 부담입니다. publisher ontology 명령이 어느 개념에도 연결되지 않은 태그, 아무 글도 언급하지 않은 개체를 보고합니다. 새 글을 쓰면 이 목록을 보고 개체를 한 줄씩 추가합니다.
색인 파일도 페이지마다 싣지 않고 따로 뺐습니다. 검색창을 처음 누를 때만 불러오니, 홈 페이지 크기가 331KB에서 244KB로 줄었습니다.
여기까지 하고 나니 남는 질문이 하나 있었습니다. 검색어의 단어가 글에도, 사전에도 하나도 없으면 어떻게 할까요. "램이 계속 늘어남"으로 검색하면 여전히 아무것도 나오지 않았습니다. 다음 글에서는 이 문제에 LLM을 붙여 보고, 정말 나아졌는지 숫자로 따져 봅니다.