Skip to content

Commit 6c0b2b4

Browse files
tae2089claude
andcommitted
fix: honest token benchmark — recall measurement + expander + description search
- TokenBenchResult에 FilesHit/SymbolsHit/Recall 필드 추가 - RunTokenBench가 항상 Description으로 검색 (expected_symbols는 정답 매칭에만 사용) - CLI에 gormstore.New(deps.DB) expander 주입 (1-hop 확장 활성화) - searchAndCollect 추출: 단어별 개별 FTS 검색으로 AND 제약 회피 - extractASCIITerms: 한국어 설명에서 영어 코드 심볼만 추출 - gin 재측정: 평균 ~57x (이전 286x oracle-cheated → 정직한 값) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 5321610 commit 6c0b2b4

2 files changed

Lines changed: 120 additions & 95 deletions

File tree

internal/benchmark/token_bench.go

Lines changed: 118 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"path/filepath"
99
"strings"
1010
"time"
11-
"unicode"
1211

1312
"gorm.io/gorm"
1413

@@ -65,19 +64,30 @@ type TokenBenchResult struct {
6564
Ratio float64 `json:"ratio"`
6665
SearchElapsedMs int64 `json:"search_elapsed_ms"`
6766
ResultCount int `json:"result_count"`
67+
// Recall: 정답 파일/심볼이 결과에 포함되었는지 측정
68+
FilesHit int `json:"files_hit"`
69+
FilesTotal int `json:"files_total"`
70+
SymbolsHit int `json:"symbols_hit"`
71+
SymbolsTotal int `json:"symbols_total"`
72+
Recall float64 `json:"recall"`
6873
}
6974

70-
// sanitizeFTSQuery는 FTS5 쿼리에서 특수문자를 공백으로 대체한다.
71-
func sanitizeFTSQuery(query string) string {
72-
var sb strings.Builder
73-
for _, r := range query {
74-
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' {
75-
sb.WriteRune(r)
76-
} else {
77-
sb.WriteRune(' ')
75+
// extractASCIITerms는 텍스트에서 ASCII 영숫자 단어만 추출한다.
76+
// 한국어 등 비ASCII 문자를 포함한 설명에서 코드 심볼에 해당하는 영어 단어만 반환한다.
77+
func extractASCIITerms(text string) []string {
78+
var terms []string
79+
for _, word := range strings.Fields(text) {
80+
var clean strings.Builder
81+
for _, r := range word {
82+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
83+
clean.WriteRune(r)
84+
}
85+
}
86+
if clean.Len() > 1 {
87+
terms = append(terms, clean.String())
7888
}
7989
}
80-
return strings.TrimSpace(sb.String())
90+
return terms
8191
}
8292

8393
// readLines는 파일에서 startLine~endLine 범위의 텍스트를 반환한다 (1-based).
@@ -95,86 +105,48 @@ func readLines(path string, startLine, endLine int) string {
95105
return strings.Join(lines[lo:hi], "\n")
96106
}
97107

98-
// GraphTokens는 backend.Query 결과와 실제 코드 내용을 합산해 토큰 수, 경과 시간(ms), 결과 수를 반환한다.
99-
// expander가 nil이 아니면 1-hop 이웃 노드와 어노테이션도 포함한다.
100-
// repoRoot가 비어있으면 코드 내용은 포함하지 않는다.
101-
func GraphTokens(ctx context.Context, db *gorm.DB, backend SearchBackend, expander NodeExpander, query, repoRoot string, limit int) (tokens int, elapsedMs int64, count int, err error) {
102-
start := time.Now()
103-
nodes, err := backend.Query(ctx, db, sanitizeFTSQuery(query), limit)
104-
elapsedMs = time.Since(start).Milliseconds()
105-
if err != nil {
106-
return 0, elapsedMs, 0, err
108+
// searchAndCollect는 FTS 검색 후 1-hop 확장까지 수행해 노드 목록과 텍스트를 반환한다.
109+
// seen은 호출 간 중복 제거에 사용되며 nil이면 새로 생성한다.
110+
func searchAndCollect(
111+
ctx context.Context, db *gorm.DB, backend SearchBackend, expander NodeExpander,
112+
query, repoRoot string, limit int, seen map[uint]struct{},
113+
) (nodes []model.Node, text string, elapsedMs int64, err error) {
114+
if seen == nil {
115+
seen = make(map[uint]struct{})
107116
}
117+
118+
terms := extractASCIITerms(query)
119+
if len(terms) == 0 {
120+
return nil, "", 0, nil
121+
}
122+
108123
var sb strings.Builder
109124
writeNode := func(n model.Node) {
125+
nodes = append(nodes, n)
110126
fmt.Fprintf(&sb, "%s %s %s\n", n.QualifiedName, n.Kind, n.FilePath)
111127
if repoRoot != "" && n.FilePath != "" && n.StartLine > 0 {
112128
code := readLines(filepath.Join(repoRoot, n.FilePath), n.StartLine, n.EndLine)
113129
sb.WriteString(code)
114130
sb.WriteByte('\n')
115131
}
116132
}
117-
seen := make(map[uint]struct{})
118-
for _, n := range nodes {
119-
seen[n.ID] = struct{}{}
120-
writeNode(n)
121-
if expander == nil {
122-
continue
123-
}
124-
// 1-hop 이웃 확장
125-
edges, eerr := expander.GetEdgesFrom(ctx, n.ID)
126-
if eerr != nil {
127-
continue
128-
}
129-
neighborIDs := make([]uint, 0, len(edges))
130-
for _, e := range edges {
131-
if _, dup := seen[e.ToNodeID]; !dup {
132-
neighborIDs = append(neighborIDs, e.ToNodeID)
133-
}
134-
}
135-
if len(neighborIDs) == 0 {
136-
continue
137-
}
138-
neighbors, nerr := expander.GetNodesByIDs(ctx, neighborIDs)
139-
if nerr != nil {
140-
continue
141-
}
142-
for _, nb := range neighbors {
143-
seen[nb.ID] = struct{}{}
144-
writeNode(nb)
145-
}
146-
// 어노테이션 포함
147-
if ann, aerr := expander.GetAnnotation(ctx, n.ID); aerr == nil && ann != nil {
148-
fmt.Fprintf(&sb, "annotation: %s\n", ann.RawText)
149-
}
150-
}
151-
return EstimateTokens(sb.String()), elapsedMs, len(nodes), nil
152-
}
153133

154-
// graphTokensMulti는 여러 심볼을 개별 검색해 중복 없이 토큰을 합산한다.
155-
func graphTokensMulti(ctx context.Context, db *gorm.DB, backend SearchBackend, expander NodeExpander, symbols []string, repoRoot string, limitEach int) (tokens int, elapsedMs int64, count int, err error) {
156-
seen := make(map[uint]struct{})
157-
var sb strings.Builder
158-
var totalElapsed int64
159-
for _, sym := range symbols {
134+
// 각 단어를 개별 검색해 FTS5 AND 제약을 피하고 결과를 누적한다.
135+
for _, term := range terms {
160136
start := time.Now()
161-
nodes, qerr := backend.Query(ctx, db, sanitizeFTSQuery(sym), limitEach)
162-
totalElapsed += time.Since(start).Milliseconds()
137+
found, qerr := backend.Query(ctx, db, term, limit)
138+
elapsedMs += time.Since(start).Milliseconds()
163139
if qerr != nil {
164-
return 0, totalElapsed, 0, qerr
140+
return nil, "", elapsedMs, qerr
165141
}
166-
for _, n := range nodes {
167-
if _, dup := seen[n.ID]; dup {
168-
continue
169-
}
170-
seen[n.ID] = struct{}{}
171-
count++
172-
fmt.Fprintf(&sb, "%s %s %s\n", n.QualifiedName, n.Kind, n.FilePath)
173-
if repoRoot != "" && n.FilePath != "" && n.StartLine > 0 {
174-
code := readLines(filepath.Join(repoRoot, n.FilePath), n.StartLine, n.EndLine)
175-
sb.WriteString(code)
176-
sb.WriteByte('\n')
142+
for _, n := range found {
143+
if n.ID > 0 {
144+
if _, dup := seen[n.ID]; dup {
145+
continue
146+
}
147+
seen[n.ID] = struct{}{}
177148
}
149+
writeNode(n)
178150
if expander == nil {
179151
continue
180152
}
@@ -197,22 +169,71 @@ func graphTokensMulti(ctx context.Context, db *gorm.DB, backend SearchBackend, e
197169
}
198170
for _, nb := range neighbors {
199171
seen[nb.ID] = struct{}{}
200-
fmt.Fprintf(&sb, "%s %s %s\n", nb.QualifiedName, nb.Kind, nb.FilePath)
201-
if repoRoot != "" && nb.FilePath != "" && nb.StartLine > 0 {
202-
code := readLines(filepath.Join(repoRoot, nb.FilePath), nb.StartLine, nb.EndLine)
203-
sb.WriteString(code)
204-
sb.WriteByte('\n')
205-
}
172+
writeNode(nb)
206173
}
207174
if ann, aerr := expander.GetAnnotation(ctx, n.ID); aerr == nil && ann != nil {
208175
fmt.Fprintf(&sb, "annotation: %s\n", ann.RawText)
209176
}
210177
}
211178
}
212-
return EstimateTokens(sb.String()), totalElapsed, count, nil
179+
return nodes, sb.String(), elapsedMs, nil
213180
}
214181

215-
// RunTokenBench는 corpus의 각 쿼리에 대해 naive/graph 토큰을 비교한다.
182+
// GraphTokens는 단일 쿼리로 검색해 토큰 수, 경과 시간(ms), 결과 수를 반환한다.
183+
func GraphTokens(ctx context.Context, db *gorm.DB, backend SearchBackend, expander NodeExpander, query, repoRoot string, limit int) (tokens int, elapsedMs int64, count int, err error) {
184+
nodes, text, elapsedMs, err := searchAndCollect(ctx, db, backend, expander, query, repoRoot, limit, nil)
185+
if err != nil {
186+
return 0, elapsedMs, 0, err
187+
}
188+
return EstimateTokens(text), elapsedMs, len(nodes), nil
189+
}
190+
191+
// countFilesHit는 nodes 중 expectedFiles에 해당하는 FilePath를 가진 노드 수를 반환한다.
192+
func countFilesHit(nodes []model.Node, expectedFiles []string) int {
193+
if len(expectedFiles) == 0 {
194+
return 0
195+
}
196+
found := make(map[string]struct{}, len(nodes))
197+
for _, n := range nodes {
198+
found[n.FilePath] = struct{}{}
199+
}
200+
hit := 0
201+
for _, f := range expectedFiles {
202+
if _, ok := found[f]; ok {
203+
hit++
204+
}
205+
}
206+
return hit
207+
}
208+
209+
// countSymbolsHit는 nodes 중 QualifiedName에 expectedSymbols 심볼명이 포함된 노드 수를 반환한다.
210+
func countSymbolsHit(nodes []model.Node, expectedSymbols []string) int {
211+
if len(expectedSymbols) == 0 {
212+
return 0
213+
}
214+
hit := 0
215+
for _, sym := range expectedSymbols {
216+
for _, n := range nodes {
217+
if strings.Contains(n.QualifiedName, sym) {
218+
hit++
219+
break
220+
}
221+
}
222+
}
223+
return hit
224+
}
225+
226+
// computeRecall은 파일/심볼 히트율을 0~1로 반환한다.
227+
func computeRecall(filesHit, filesTotal, symbolsHit, symbolsTotal int) float64 {
228+
total := filesTotal + symbolsTotal
229+
if total == 0 {
230+
return 0
231+
}
232+
return float64(filesHit+symbolsHit) / float64(total)
233+
}
234+
235+
// RunTokenBench는 corpus의 각 쿼리에 대해 naive/graph 토큰과 recall을 비교한다.
236+
// 검색은 항상 Description을 사용하며, expected_symbols/files는 정답 매칭에만 사용한다.
216237
func RunTokenBench(ctx context.Context, db *gorm.DB, backend SearchBackend, expander NodeExpander, corpus *Corpus, repoRoot string, exts []string) ([]TokenBenchResult, error) {
217238
naive, err := NaiveTokens(repoRoot, exts)
218239
if err != nil {
@@ -221,19 +242,17 @@ func RunTokenBench(ctx context.Context, db *gorm.DB, backend SearchBackend, expa
221242

222243
results := make([]TokenBenchResult, 0, len(corpus.Queries))
223244
for _, q := range corpus.Queries {
224-
var tokens int
225-
var elapsed int64
226-
var count int
227-
var qerr error
228-
if len(q.ExpectedSymbols) > 0 {
229-
// 심볼별로 개별 검색 후 중복 제거 합산
230-
tokens, elapsed, count, qerr = graphTokensMulti(ctx, db, backend, expander, q.ExpectedSymbols, repoRoot, 5)
231-
} else {
232-
tokens, elapsed, count, qerr = GraphTokens(ctx, db, backend, expander, q.Description, repoRoot, 10)
233-
}
245+
nodes, text, elapsed, qerr := searchAndCollect(ctx, db, backend, expander, q.Description, repoRoot, 10, nil)
234246
if qerr != nil {
235247
return nil, qerr
236248
}
249+
tokens := EstimateTokens(text)
250+
251+
filesHit := countFilesHit(nodes, q.ExpectedFiles)
252+
symbolsHit := countSymbolsHit(nodes, q.ExpectedSymbols)
253+
filesTotal := len(q.ExpectedFiles)
254+
symbolsTotal := len(q.ExpectedSymbols)
255+
237256
var ratio float64
238257
if tokens > 0 {
239258
ratio = float64(naive) / float64(tokens)
@@ -244,7 +263,12 @@ func RunTokenBench(ctx context.Context, db *gorm.DB, backend SearchBackend, expa
244263
GraphTokens: tokens,
245264
Ratio: ratio,
246265
SearchElapsedMs: elapsed,
247-
ResultCount: count,
266+
ResultCount: len(nodes),
267+
FilesHit: filesHit,
268+
FilesTotal: filesTotal,
269+
SymbolsHit: symbolsHit,
270+
SymbolsTotal: symbolsTotal,
271+
Recall: computeRecall(filesHit, filesTotal, symbolsHit, symbolsTotal),
248272
})
249273
}
250274
return results, nil

internal/cli/benchmark.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/tae2089/trace"
1515

1616
"github.com/tae2089/code-context-graph/internal/benchmark"
17+
"github.com/tae2089/code-context-graph/internal/store/gormstore"
1718
storesearch "github.com/tae2089/code-context-graph/internal/store/search"
1819
)
1920

@@ -266,7 +267,7 @@ func newBenchmarkTokenBenchCmd(deps *Deps) *cobra.Command {
266267
return trace.Wrap(err, "load corpus")
267268
}
268269
backend := storesearch.NewSQLiteBackend()
269-
results, err := benchmark.RunTokenBench(cmd.Context(), deps.DB, backend, nil, corpus, repoRoot, exts)
270+
results, err := benchmark.RunTokenBench(cmd.Context(), deps.DB, backend, gormstore.New(deps.DB), corpus, repoRoot, exts)
270271
if err != nil {
271272
return trace.Wrap(err, "run token bench")
272273
}

0 commit comments

Comments
 (0)