Skip to content

Commit 30b57de

Browse files
tae2089claude
andauthored
camelCase-aware search tokenization (#13)
* Extract identtoken package from searchrank Move the identifier splitter (camelCase / separator / letter-digit boundaries) out of searchrank into a leaf internal/identtoken package so search indexing can share it. Pure structural change: searchrank behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Index camelCase identifier sub-tokens for search buildSearchContent emitted node names verbatim, so unicode61 FTS treated a camelCase name as one token: inner-word queries ('limit', 'user', 'suspects') and any query whose term was not a name prefix returned nothing, starving the reranker of candidates. Emit deduplicated camelCase / separator / letter-digit sub-tokens of the name and qualified name alongside the verbatim name (which still serves whole-name and prefix matching). Content is backend-agnostic, so SQLite FTS5 and Postgres tsvector both benefit; ccg build rebuilds the index, so no migration is needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Document camelCase search sub-tokens and rebuild-after-upgrade Note that search content now indexes identifier sub-tokens and that an existing graph needs one full `ccg build` after upgrading, since a pure incremental workflow only re-indexes changed nodes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Split camelCase query terms into sub-token OR groups Query sanitizers expanded each term to a single prefix, so a camelCase query term only matched a whole indexed token. Expand a camelCase term to "(whole OR (sub1 AND sub2 ...))" for both FTS5 and Postgres tsquery, mirroring the sub-tokens indexed at build time, via a shared buildPrefixQuery helper. Add sanitizeRawTokens to keep original case for splitting; sanitizeTokens now derives from it, so exact-name promotion is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c6c9300 commit 30b57de

11 files changed

Lines changed: 271 additions & 87 deletions

File tree

guide/architecture.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ Per-database full-text search backends:
4141
- **SQLite**: FTS5
4242
- **PostgreSQL**: tsvector + GIN index
4343

44+
Search content indexes camelCase identifier sub-tokens (`getUserById` also
45+
indexes `get`, `user`, `by`, `id`), so inner words are searchable. Because this
46+
content is generated at index time, an existing graph only gains sub-tokens for
47+
nodes that are re-indexed. Run a full `ccg build` once after upgrading — a
48+
pure incremental-update workflow only refreshes changed nodes, leaving
49+
untouched nodes without sub-tokens until they change.
50+
4451
Full builds and explicit postprocess runs rebuild namespace search state.
4552
Incremental updates refresh only affected search documents and FTS rows, while
4653
community postprocessing can still be namespace-wide. Persisted stored-flow

guide/ko/architecture.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ GORM ORM 기반 저장소입니다. SQLite 및 PostgreSQL과 호환됩니다.
4343
- **SQLite**: FTS5
4444
- **PostgreSQL**: tsvector + GIN 인덱스
4545

46+
검색 콘텐츠는 camelCase 식별자의 서브토큰을 색인합니다(`getUserById``get`,
47+
`user`, `by`, `id`도 색인) — 내부 단어로도 검색됩니다. 이 콘텐츠는 색인 시점에
48+
생성되므로, 기존 그래프는 재색인된 노드에 대해서만 서브토큰을 갖습니다. 업그레이드
49+
`ccg build`로 한 번 전체 재빌드하십시오 — 순수 증분 업데이트만 반복하면 변경된
50+
노드만 갱신되어, 건드리지 않은 노드는 변경 전까지 서브토큰이 없습니다.
51+
4652
전체 빌드 및 명시적인 사후 처리 실행은 네임스페이스 검색 상태를 재생성합니다. 증분 업데이트는 영향을 받는 검색 문서와 FTS 행만 갱신하는 반면, 커뮤니티 사후 처리는 여전히 네임스페이스 전체에 걸쳐 이루어질 수 있습니다. 영구 저장된 흐름(stored-flow)의 재생성은 전체 사후 처리 실행 및 명시적인 `run_postprocess(flows=true)` 호출 시 구현되어 있으며, 진입점별 흐름 쿼리에는 `trace_flow`를 사용하십시오.
4753

4854
### 분석 (Analysis) (`internal/analysis/`)

internal/identtoken/identtoken.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Package identtoken splits source identifiers into lowercased sub-tokens on
2+
// separators, camelCase boundaries, and letter/digit transitions. It is a leaf
3+
// utility shared by search indexing (content generation) and search reranking.
4+
package identtoken
5+
6+
import (
7+
"strings"
8+
"unicode"
9+
)
10+
11+
// Split breaks an identifier into lowercased sub-tokens on separators, camelCase
12+
// boundaries, and letter/digit transitions ("getUserById" -> get, user, by, id;
13+
// "HTTPServer" -> http, server; "parseHTML5" -> parse, html, 5).
14+
func Split(s string) []string {
15+
runes := []rune(s)
16+
var tokens []string
17+
var cur []rune
18+
flush := func() {
19+
if len(cur) > 0 {
20+
tokens = append(tokens, strings.ToLower(string(cur)))
21+
cur = cur[:0]
22+
}
23+
}
24+
for i, r := range runes {
25+
if !isAlnum(r) {
26+
flush()
27+
continue
28+
}
29+
if len(cur) > 0 {
30+
prev := cur[len(cur)-1]
31+
switch {
32+
case unicode.IsUpper(r) && (unicode.IsLower(prev) || unicode.IsDigit(prev)):
33+
flush() // lower/digit -> Upper: new word
34+
case unicode.IsUpper(r) && unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]):
35+
flush() // acronym tail begins a new word (HTTPServer -> http, server)
36+
case unicode.IsDigit(r) != unicode.IsDigit(prev):
37+
flush() // letter/digit transition
38+
}
39+
}
40+
cur = append(cur, r)
41+
}
42+
flush()
43+
return tokens
44+
}
45+
46+
func isAlnum(r rune) bool {
47+
return unicode.IsLetter(r) || unicode.IsDigit(r)
48+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package identtoken
2+
3+
import "testing"
4+
5+
func TestSplit(t *testing.T) {
6+
cases := []struct {
7+
in string
8+
want []string
9+
}{
10+
{"getUserById", []string{"get", "user", "by", "id"}},
11+
{"HTTPServer", []string{"http", "server"}},
12+
{"user_id", []string{"user", "id"}},
13+
{"parseHTML5", []string{"parse", "html", "5"}},
14+
{"", nil},
15+
{"lower", []string{"lower"}},
16+
}
17+
for _, c := range cases {
18+
got := Split(c.in)
19+
if len(got) != len(c.want) {
20+
t.Errorf("Split(%q)=%v, want %v", c.in, got, c.want)
21+
continue
22+
}
23+
for i := range c.want {
24+
if got[i] != c.want[i] {
25+
t.Errorf("Split(%q)=%v, want %v", c.in, got, c.want)
26+
break
27+
}
28+
}
29+
}
30+
}

internal/searchrank/searchrank.go

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99
"unicode"
1010

11+
"github.com/tae2089/code-context-graph/internal/identtoken"
1112
"github.com/tae2089/code-context-graph/internal/model"
1213
)
1314

@@ -117,7 +118,7 @@ func nameSim(qTokens []string, node model.Node) float64 {
117118
continue
118119
}
119120
lower := strings.ToLower(raw)
120-
subs := splitIdentifier(raw) // original case: camelCase boundaries matter
121+
subs := identtoken.Split(raw) // original case: camelCase boundaries matter
121122
sum := 0.0
122123
for _, tok := range qTokens {
123124
b := normLevSim(tok, lower)
@@ -132,45 +133,6 @@ func nameSim(qTokens []string, node model.Node) float64 {
132133
return best
133134
}
134135

135-
// splitIdentifier breaks an identifier into lowercased sub-tokens on separators,
136-
// camelCase boundaries, and letter/digit transitions ("getUserById" -> get,
137-
// user, by, id; "HTTPServer" -> http, server; "parseHTML5" -> parse, html, 5).
138-
func splitIdentifier(s string) []string {
139-
runes := []rune(s)
140-
var tokens []string
141-
var cur []rune
142-
flush := func() {
143-
if len(cur) > 0 {
144-
tokens = append(tokens, strings.ToLower(string(cur)))
145-
cur = cur[:0]
146-
}
147-
}
148-
for i, r := range runes {
149-
if !isAlnum(r) {
150-
flush()
151-
continue
152-
}
153-
if len(cur) > 0 {
154-
prev := cur[len(cur)-1]
155-
switch {
156-
case unicode.IsUpper(r) && (unicode.IsLower(prev) || unicode.IsDigit(prev)):
157-
flush() // lower/digit -> Upper: new word
158-
case unicode.IsUpper(r) && unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]):
159-
flush() // acronym tail begins a new word (HTTPServer -> http, server)
160-
case unicode.IsDigit(r) != unicode.IsDigit(prev):
161-
flush() // letter/digit transition
162-
}
163-
}
164-
cur = append(cur, r)
165-
}
166-
flush()
167-
return tokens
168-
}
169-
170-
func isAlnum(r rune) bool {
171-
return unicode.IsLetter(r) || unicode.IsDigit(r)
172-
}
173-
174136
// pathScore is the fraction of query tokens that appear as file-path segments.
175137
func pathScore(qTokens []string, node model.Node) float64 {
176138
segs := map[string]struct{}{}

internal/searchrank/searchrank_test.go

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -131,31 +131,6 @@ func TestFetchLimit(t *testing.T) {
131131
}
132132
}
133133

134-
func TestSplitIdentifier(t *testing.T) {
135-
cases := []struct {
136-
in string
137-
want []string
138-
}{
139-
{"getUserById", []string{"get", "user", "by", "id"}},
140-
{"HTTPServer", []string{"http", "server"}},
141-
{"user_id", []string{"user", "id"}},
142-
{"parseHTML5", []string{"parse", "html", "5"}},
143-
}
144-
for _, c := range cases {
145-
got := splitIdentifier(c.in)
146-
if len(got) != len(c.want) {
147-
t.Errorf("splitIdentifier(%q)=%v, want %v", c.in, got, c.want)
148-
continue
149-
}
150-
for i := range c.want {
151-
if got[i] != c.want[i] {
152-
t.Errorf("splitIdentifier(%q)=%v, want %v", c.in, got, c.want)
153-
break
154-
}
155-
}
156-
}
157-
}
158-
159134
func TestLevenshtein(t *testing.T) {
160135
cases := []struct {
161136
a, b string

internal/service/indexer_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,47 @@ func TestBuildSearchDocuments_IndexesFileBaseAndLanguageTokens(t *testing.T) {
117117
}
118118
}
119119

120+
// camelCase 식별자를 서브토큰으로도 색인해, 내부 단어(user, id 등)로 검색 가능하게 한다.
121+
func TestBuildSearchContent_EmitsIdentifierSubtokens(t *testing.T) {
122+
tests := []struct {
123+
name string
124+
node model.Node
125+
contains []string
126+
}{
127+
{
128+
name: "camelCase name and qualified name split",
129+
node: model.Node{Name: "getUserById", QualifiedName: "svc.getUserById", Kind: model.NodeKindFunction, FilePath: "svc/user.go", Language: "go"},
130+
contains: []string{"getuserbyid", "get", "user", "by", "id"},
131+
},
132+
{
133+
name: "PascalCase class split",
134+
node: model.Node{Name: "UserService", QualifiedName: "pkg.UserService", Kind: model.NodeKindClass, FilePath: "pkg/svc.go", Language: "go"},
135+
contains: []string{"userservice", "user", "service"},
136+
},
137+
}
138+
for _, tt := range tests {
139+
t.Run(tt.name, func(t *testing.T) {
140+
content := strings.ToLower(buildSearchContent(tt.node, nil))
141+
for _, want := range tt.contains {
142+
if !containsToken(content, want) {
143+
t.Fatalf("content %q missing subtoken %q", content, want)
144+
}
145+
}
146+
})
147+
}
148+
}
149+
150+
// containsToken checks for a whitespace-delimited token, not a substring, so
151+
// "id" must appear as its own token rather than inside "identifier".
152+
func containsToken(content, tok string) bool {
153+
for _, f := range strings.Fields(content) {
154+
if f == tok {
155+
return true
156+
}
157+
}
158+
return false
159+
}
160+
120161
type recordingGraphStore struct {
121162
t *testing.T
122163
ops []string

internal/service/search.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/tae2089/trace"
1313

1414
"github.com/tae2089/code-context-graph/internal/ctxns"
15+
"github.com/tae2089/code-context-graph/internal/identtoken"
1516
"github.com/tae2089/code-context-graph/internal/model"
1617
)
1718

@@ -201,6 +202,13 @@ func buildSearchContent(n model.Node, annByNode map[uint]*model.Annotation) stri
201202
sb.WriteString(n.QualifiedName)
202203
sb.WriteByte(' ')
203204
sb.WriteString(string(n.Kind))
205+
// Emit identifier sub-tokens so camelCase names are searchable by their
206+
// inner words (getUserById -> get, user, by, id). The verbatim name above
207+
// preserves existing whole-name and prefix matching.
208+
for _, token := range identSubtokens(n.Name, n.QualifiedName) {
209+
sb.WriteByte(' ')
210+
sb.WriteString(token)
211+
}
204212
for _, token := range searchPathTokens(n.FilePath) {
205213
sb.WriteByte(' ')
206214
sb.WriteString(token)
@@ -222,6 +230,24 @@ func buildSearchContent(n model.Node, annByNode map[uint]*model.Annotation) stri
222230
return sb.String()
223231
}
224232

233+
// identSubtokens returns the deduplicated camelCase/separator sub-tokens of a
234+
// node's name and qualified name. Dedup keeps a token's term frequency from
235+
// being inflated just because it appears in both the name and the qualified name.
236+
func identSubtokens(name, qualifiedName string) []string {
237+
seen := map[string]struct{}{}
238+
var tokens []string
239+
for _, raw := range []string{name, qualifiedName} {
240+
for _, tok := range identtoken.Split(raw) {
241+
if _, ok := seen[tok]; ok {
242+
continue
243+
}
244+
seen[tok] = struct{}{}
245+
tokens = append(tokens, tok)
246+
}
247+
}
248+
return tokens
249+
}
250+
225251
// searchPathTokens derives lowercase filename tokens and optional language aliases for search indexing.
226252
// @intent 파일 경로 자체도 검색 힌트가 되도록 basename과 언어 별칭을 토큰화한다.
227253
func searchPathTokens(filePath string) []string {

0 commit comments

Comments
 (0)