Skip to content

Commit 8add446

Browse files
seapyclaude
andcommitted
fix: bigramSim을 Dice 계수로 변경하여 마켓컬리→컬리 검색 오류 수정
query-relative 점수(matched/query_bigrams)는 12k 기업 중 동점 59개가 발생해 "컬리"가 39위로 top 10 밖으로 밀려났음. Dice = 2*matched / (|query_bigrams| + |target_bigrams|) 로 변경하면 짧고 정확한 매칭("컬리" 0.5)이 긴 노이즈 회사보다 높게 랭크되고 긴 회사명은 자동으로 필터링된다. 테스트도 실제 12k 데이터 기반 통합 테스트 추가: - mock store에 실제와 동일한 노이즈 회사 포함 - 컬리가 1위로 반환되는지 명시적으로 검증 - 실제 캐시 파일이 있을 때 실행되는 Integration 테스트 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a9f026a commit 8add446

2 files changed

Lines changed: 169 additions & 68 deletions

File tree

internal/cache/corpcode.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,29 +182,34 @@ func stripLegalForm(s string) string {
182182
return strings.TrimSpace(legalFormReplacer.Replace(s))
183183
}
184184

185-
// bigramSim returns the fraction of query's character bigrams that appear in target.
185+
// bigramSim returns the Dice coefficient of character bigrams between query and target.
186+
// Dice = 2 * |intersection| / (|query bigrams| + |target bigrams|)
187+
// This penalizes long target strings that share only a few bigrams, reducing false positives.
186188
// Uses rune-level bigrams for correct Korean handling.
187189
func bigramSim(query, target string) float64 {
188190
qr := []rune(query)
189191
tr := []rune(target)
190-
if len(qr) < 2 {
192+
qLen := len(qr) - 1
193+
tLen := len(tr) - 1
194+
if qLen < 1 || tLen < 1 {
191195
return 0
192196
}
193197
// Build target bigram frequency map
194-
tBig := make(map[[2]rune]int, len(tr))
195-
for i := 0; i < len(tr)-1; i++ {
198+
tBig := make(map[[2]rune]int, tLen)
199+
for i := 0; i < tLen; i++ {
196200
tBig[[2]rune{tr[i], tr[i+1]}]++
197201
}
198202
// Count how many query bigrams appear in target
199203
matched := 0
200-
for i := 0; i < len(qr)-1; i++ {
204+
for i := 0; i < qLen; i++ {
201205
k := [2]rune{qr[i], qr[i+1]}
202206
if tBig[k] > 0 {
203207
matched++
204208
tBig[k]--
205209
}
206210
}
207-
return float64(matched) / float64(len(qr)-1)
211+
// Dice coefficient
212+
return float64(2*matched) / float64(qLen+tLen)
208213
}
209214

210215
// Status returns cache file info.

internal/cache/corpcode_test.go

Lines changed: 158 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,39 @@
11
package cache
22

33
import (
4+
"os"
45
"testing"
56
)
67

7-
// testStore returns a Store built from representative sample data.
8+
// testStore builds a Store from representative sample data.
89
func testStore() *Store {
910
corps := []*CorpInfo{
1011
{CorpCode: "00126380", CorpName: "삼성전자", StockCode: "005930"},
1112
{CorpCode: "01153956", CorpName: "컬리"},
1213
{CorpCode: "01494172", CorpName: "컬리넥스트마일"},
14+
{CorpCode: "01713402", CorpName: "컬리페이"},
1315
{CorpCode: "01547845", CorpName: "당근마켓"},
1416
{CorpCode: "01717824", CorpName: "당근페이"},
1517
{CorpCode: "01138364", CorpName: "더핑크퐁컴퍼니", StockCode: "403850"},
1618
{CorpCode: "00293886", CorpName: "카카오"},
1719
{CorpCode: "00000002", CorpName: "카카오뱅크"},
1820
{CorpCode: "00000003", CorpName: "카카오페이"},
1921
{CorpCode: "01154811", CorpName: "주식회사 오늘의집"},
20-
// 법인 형태어 노이즈 테스트용: "주식회사"를 포함하는 무관한 회사
21-
{CorpCode: "99999999", CorpName: "두성에스비텍주식회사(구:두성공업주식회사)"},
22+
// "마켓컬리" 검색 시 노이즈가 될 수 있는 실제 데이터와 유사한 회사들
23+
{CorpCode: "N001", CorpName: "게이트마켓"},
24+
{CorpCode: "N002", CorpName: "지마켓"},
25+
{CorpCode: "N003", CorpName: "알루마켓"},
26+
{CorpCode: "N004", CorpName: "비즈마켓"},
27+
{CorpCode: "N005", CorpName: "올인마켓"},
28+
{CorpCode: "N006", CorpName: "레어마켓"},
29+
{CorpCode: "N007", CorpName: "마켓비"},
30+
{CorpCode: "N008", CorpName: "와마켓"},
31+
{CorpCode: "N009", CorpName: "맥쿼리IMM마켓뉴트럴혼합형사모펀드"},
32+
{CorpCode: "N010", CorpName: "다이와증권캐피탈마켓서울지점"},
33+
{CorpCode: "N011", CorpName: "에이치앤디마켓플레이스"},
34+
{CorpCode: "N012", CorpName: "코리아마켓팅"},
35+
// "주식회사 오늘의집" 검색 시 법인 형태어 노이즈
36+
{CorpCode: "N099", CorpName: "두성에스비텍주식회사(구:두성공업주식회사)"},
2237
}
2338
return buildStore(corps)
2439
}
@@ -68,7 +83,7 @@ func TestSearch_Substring_당근(t *testing.T) {
6883
}
6984

7085
func TestSearch_Substring_카카오(t *testing.T) {
71-
// "카카오" 이름 완전일치가 존재하므로 정확히 1건만 반환 (substring 전에 리턴)
86+
// "카카오" 이름 완전일치가 존재하므로 1건만 반환 (substring 전에 리턴)
7287
s := testStore()
7388
results := s.Search("카카오")
7489
if len(results) != 1 || results[0].CorpName != "카카오" {
@@ -77,11 +92,11 @@ func TestSearch_Substring_카카오(t *testing.T) {
7792
}
7893

7994
func TestSearch_Substring_카카오계열(t *testing.T) {
80-
// "카카오뱅" 처럼 완전일치 없는 쿼리는 substring 으로 카카오뱅크를 찾음
95+
// 완전일치 없는 쿼리는 substring으로 찾음
8196
s := testStore()
8297
results := s.Search("카카오뱅")
8398
if len(results) != 1 || results[0].CorpName != "카카오뱅크" {
84-
t.Fatalf("'카카오뱅' substring 검색: 카카오뱅크 기대, got %v", corpNames(results))
99+
t.Fatalf("'카카오뱅' substring 검색 실패: got %v", corpNames(results))
85100
}
86101
}
87102

@@ -93,46 +108,34 @@ func TestSearch_Substring_핑크퐁(t *testing.T) {
93108
}
94109
}
95110

96-
// --- fuzzy tests ---
97-
98-
func TestSearch_Fuzzy_마켓컬리(t *testing.T) {
111+
func TestSearch_Substring_오늘의집(t *testing.T) {
112+
// "오늘의집"은 "주식회사 오늘의집"의 substring
99113
s := testStore()
100-
results := s.Search("마켓컬리")
101-
if len(results) == 0 {
102-
t.Fatal("'마켓컬리' fuzzy 검색: 결과 없음 (기대: '컬리' 포함)")
103-
}
104-
found := false
105-
for _, r := range results {
106-
if r.CorpName == "컬리" {
107-
found = true
108-
}
109-
}
110-
if !found {
111-
t.Fatalf("'마켓컬리' fuzzy 결과에 '컬리' 없음: got %v", corpNames(results))
114+
results := s.Search("오늘의집")
115+
if len(results) != 1 || results[0].CorpName != "주식회사 오늘의집" {
116+
t.Fatalf("'오늘의집' substring 검색 실패: got %v", corpNames(results))
112117
}
113-
t.Logf("'마켓컬리' fuzzy 결과: %v", corpNames(results))
114118
}
115119

116-
func TestSearch_Fuzzy_오늘의집(t *testing.T) {
117-
// "오늘의집" → substring 으로도 찾힘
120+
// --- fuzzy tests ---
121+
122+
// TestSearch_Fuzzy_마켓컬리_컬리상위랭크 는 핵심 케이스:
123+
// "마켓컬리" 검색 시 마켓XX 회사들이 다수 있어도 "컬리"가 1위여야 한다.
124+
// (Dice 계수 기반: 컬리=0.5, 마켓4글자회사=0.333)
125+
func TestSearch_Fuzzy_마켓컬리_컬리상위랭크(t *testing.T) {
118126
s := testStore()
119-
results := s.Search("오늘의집")
127+
results := s.Search("마켓컬리")
120128
if len(results) == 0 {
121-
t.Fatal("'오늘의집' 검색: 결과 없음")
122-
}
123-
found := false
124-
for _, r := range results {
125-
if r.CorpName == "주식회사 오늘의집" {
126-
found = true
127-
}
129+
t.Fatal("'마켓컬리' fuzzy 검색: 결과 없음")
128130
}
129-
if !found {
130-
t.Fatalf("'오늘의집' 결과에 '주식회사 오늘의집' 없음: got %v", corpNames(results))
131+
if results[0].CorpName != "컬리" {
132+
t.Fatalf("'마켓컬리' fuzzy 1위는 '컬리'여야 함: got %v", corpNames(results))
131133
}
134+
t.Logf("'마켓컬리' fuzzy 결과: %v", corpNames(results))
132135
}
133136

137+
// TestSearch_Fuzzy_법인형태어_노이즈제거: "주식회사"만 공유하는 무관한 회사가 나오면 안 됨
134138
func TestSearch_Fuzzy_법인형태어_노이즈제거(t *testing.T) {
135-
// "주식회사 오늘의집" 검색 시 "주식회사"를 공유하는 무관한 회사가 나오면 안 됨
136139
s := testStore()
137140
results := s.Search("주식회사 오늘의집")
138141
for _, r := range results {
@@ -143,6 +146,58 @@ func TestSearch_Fuzzy_법인형태어_노이즈제거(t *testing.T) {
143146
t.Logf("'주식회사 오늘의집' 검색 결과: %v", corpNames(results))
144147
}
145148

149+
func TestSearch_NoResult_완전엉뚱한검색어(t *testing.T) {
150+
s := testStore()
151+
results := s.Search("xyzxyz없는기업명zyx")
152+
// 완전히 무관한 검색어는 결과가 없어야 함 (있어도 오탐이지만 경고만)
153+
if len(results) != 0 {
154+
t.Logf("경고: 무관한 검색어에 fuzzy 결과 %d건: %v", len(results), corpNames(results))
155+
}
156+
}
157+
158+
// --- bigramSim unit tests ---
159+
160+
func TestBigramSim_완전동일(t *testing.T) {
161+
// 동일 문자열은 1.0
162+
score := bigramSim("삼성전자", "삼성전자")
163+
if score != 1.0 {
164+
t.Fatalf("동일 문자열 bigramSim: 1.0 기대, got %f", score)
165+
}
166+
}
167+
168+
func TestBigramSim_Dice_짧은타겟이_높은점수(t *testing.T) {
169+
// Dice 계수 검증: 짧은 타겟("컬리")이 긴 타겟("컬리넥스트마일")보다 높은 점수여야 함
170+
// "마켓컬리" (3 bigrams) vs "컬리" (1 bigram): 2*1/(3+1)=0.500
171+
// "마켓컬리" (3 bigrams) vs "컬리넥스트마일" (6 bigrams): 2*1/(3+6)=0.222
172+
short := bigramSim("마켓컬리", "컬리")
173+
long := bigramSim("마켓컬리", "컬리넥스트마일")
174+
if short <= long {
175+
t.Fatalf("'컬리' score(%f) > '컬리넥스트마일' score(%f) 이어야 함", short, long)
176+
}
177+
if short < 0.3 {
178+
t.Fatalf("'마켓컬리' vs '컬리': threshold 0.3 이상 기대, got %f", short)
179+
}
180+
}
181+
182+
func TestBigramSim_긴노이즈_필터링(t *testing.T) {
183+
// 긴 회사명은 Dice로 낮아져서 threshold 이하가 되어야 함
184+
// "마켓컬리" vs "맥쿼리IMM마켓뉴트럴혼합형사모펀드": 겹치는 bigram 1개, 타겟이 매우 길어 Dice<0.3
185+
score := bigramSim("마켓컬리", "맥쿼리imm마켓뉴트럴혼합형사모펀드")
186+
if score >= 0.3 {
187+
t.Fatalf("긴 노이즈 회사명은 threshold 0.3 미만이어야 함, got %f", score)
188+
}
189+
}
190+
191+
func TestBigramSim_단일문자(t *testing.T) {
192+
// 단일 rune은 bigram 없음 → 0
193+
score := bigramSim("가", "가나다라")
194+
if score != 0 {
195+
t.Fatalf("단일 문자 bigramSim: 0 기대, got %f", score)
196+
}
197+
}
198+
199+
// --- stripLegalForm tests ---
200+
146201
func TestStripLegalForm(t *testing.T) {
147202
cases := []struct{ in, want string }{
148203
{"주식회사 오늘의집", "오늘의집"},
@@ -159,45 +214,86 @@ func TestStripLegalForm(t *testing.T) {
159214
}
160215
}
161216

162-
func TestSearch_NoResult_완전엉뚱한검색어(t *testing.T) {
163-
s := testStore()
164-
results := s.Search("xyzxyz없는기업명zyx")
165-
if len(results) != 0 {
166-
t.Logf("'xyzxyz없는기업명zyx' 검색 결과 (fuzzy 허용): %v", corpNames(results))
217+
// --- integration tests (실제 캐시 데이터 기반) ---
218+
219+
// realStore 는 실제 캐시가 없으면 nil 반환.
220+
func realStore(t *testing.T) *Store {
221+
t.Helper()
222+
path, err := CorpCodePath()
223+
if err != nil {
224+
t.Skip("캐시 경로 확인 불가:", err)
167225
}
226+
if _, err := os.Stat(path); os.IsNotExist(err) {
227+
t.Skip("캐시 파일 없음 — dartcli cache refresh 후 재시도")
228+
}
229+
store, err := loadFromDisk(path)
230+
if err != nil {
231+
t.Skip("캐시 로드 실패:", err)
232+
}
233+
return store
168234
}
169235

170-
// --- bigramSim unit tests ---
236+
func TestIntegration_마켓컬리_컬리반환(t *testing.T) {
237+
s := realStore(t)
238+
results := s.Search("마켓컬리")
239+
if len(results) == 0 {
240+
t.Fatal("'마켓컬리' 검색: 결과 없음 (기대: 컬리 포함)")
241+
}
242+
// "컬리"가 결과에 포함되어야 함
243+
found := false
244+
for _, r := range results {
245+
if r.CorpName == "컬리" {
246+
found = true
247+
}
248+
}
249+
if !found {
250+
t.Fatalf("'마켓컬리' 결과에 '컬리' 없음: got %v", corpNames(results))
251+
}
252+
// "컬리"가 1등이어야 함 (Dice 계수로 짧은 매칭이 우선)
253+
if results[0].CorpName != "컬리" {
254+
t.Errorf("'마켓컬리' 1위 기대='컬리', got='%s' (전체: %v)", results[0].CorpName, corpNames(results))
255+
}
256+
t.Logf("결과: %v", corpNames(results))
257+
}
171258

172-
func TestBigramSim_완전동일(t *testing.T) {
173-
score := bigramSim("삼성전자", "삼성전자")
174-
if score != 1.0 {
175-
t.Fatalf("동일 문자열 bigramSim: 1.0 기대, got %f", score)
259+
func TestIntegration_삼성전자(t *testing.T) {
260+
s := realStore(t)
261+
results := s.Search("삼성전자")
262+
if len(results) != 1 || results[0].CorpName != "삼성전자" {
263+
t.Fatalf("'삼성전자' 검색 실패: got %v", corpNames(results))
176264
}
177265
}
178266

179-
func TestBigramSim_부분겹침(t *testing.T) {
180-
// "마켓컬리" bigrams: [마켓, 켓컬, 컬리]
181-
// "컬리" bigrams: [컬리] → 1 match / 3 query bigrams = 0.333
182-
score := bigramSim("마켓컬리", "컬리")
183-
if score < 0.3 {
184-
t.Fatalf("'마켓컬리' vs '컬리': 0.3 이상 기대, got %f", score)
267+
func TestIntegration_당근(t *testing.T) {
268+
s := realStore(t)
269+
results := s.Search("당근")
270+
names := corpNames(results)
271+
foundMarket := false
272+
for _, n := range names {
273+
if n == "당근마켓" {
274+
foundMarket = true
275+
}
276+
}
277+
if !foundMarket {
278+
t.Fatalf("'당근' 검색에 '당근마켓' 없음: got %v", names)
185279
}
186280
}
187281

188-
func TestBigramSim_핑크퐁(t *testing.T) {
189-
// "핑크퐁" bigrams: [핑크, 크퐁]
190-
// "더핑크퐁컴퍼니" bigrams: [더핑, 핑크, 크퐁, 퐁컴, 컴퍼, 퍼니] → 2 match / 2 = 1.0
191-
score := bigramSim("핑크퐁", "더핑크퐁컴퍼니")
192-
if score != 1.0 {
193-
t.Fatalf("'핑크퐁' vs '더핑크퐁컴퍼니': 1.0 기대, got %f", score)
282+
func TestIntegration_핑크퐁(t *testing.T) {
283+
s := realStore(t)
284+
results := s.Search("핑크퐁")
285+
if len(results) == 0 {
286+
t.Fatal("'핑크퐁' 검색: 결과 없음")
287+
}
288+
if results[0].CorpName != "더핑크퐁컴퍼니" {
289+
t.Fatalf("'핑크퐁' 1위 기대='더핑크퐁컴퍼니', got='%s'", results[0].CorpName)
194290
}
195291
}
196292

197-
func TestBigramSim_단일문자(t *testing.T) {
198-
// 단일 rune은 bigram 없음 → 0
199-
score := bigramSim("가", "가나다라")
200-
if score != 0 {
201-
t.Fatalf("단일 문자 bigramSim: 0 기대, got %f", score)
293+
func TestIntegration_종목코드_삼성전자(t *testing.T) {
294+
s := realStore(t)
295+
results := s.Search("005930")
296+
if len(results) != 1 || results[0].CorpName != "삼성전자" {
297+
t.Fatalf("종목코드 '005930' 검색 실패: got %v", corpNames(results))
202298
}
203299
}

0 commit comments

Comments
 (0)