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는 정답 매칭에만 사용한다.
216237func 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
0 commit comments