Skip to content

Commit 00efa41

Browse files
committed
Improve DB-backed retrieve scoring
1 parent ee6a1b9 commit 00efa41

14 files changed

Lines changed: 405 additions & 84 deletions

File tree

guide/annotations.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,40 @@ Use `ccg://{namespace}/{path}#{symbol}` in `@see` when a behavior depends on cod
6363

6464
The path and symbol are optional, so `ccg://auth-svc/internal/auth` can point at a package/path scope and `ccg://auth-svc/` can point at a whole namespace.
6565

66+
## Retrieval Quality
67+
68+
Annotations are retrieval features. `retrieve_docs` and generated docs rank
69+
file-level evidence from structured buckets such as `@index`, `@intent`,
70+
`@domainRule`, `@sideEffect`, `@requires`, `@ensures`, and `@see`. Better
71+
annotations make natural-language retrieval more precise, but only when the
72+
tags describe real behavior.
73+
74+
Use tags by need:
75+
76+
| Situation | Preferred tags |
77+
|-----------|----------------|
78+
| File/package should be discoverable as a unit | `@index` |
79+
| Public function, handler, CLI command, service method, or UI workflow has a clear purpose | `@intent` |
80+
| Policy, constraint, false-positive/false-negative criterion, or operational rule matters | `@domainRule` |
81+
| Code writes DB/files, calls network, changes cache, logs important events, or starts processes | `@sideEffect` |
82+
| Receiver or input state is changed | `@mutates` |
83+
| Input contract or output guarantee matters to callers | `@requires`, `@ensures` |
84+
| Understanding requires jumping to another implementation or namespace | `@see` |
85+
86+
Good retrieval annotations use the words an engineer or LLM would naturally ask
87+
for, while staying faithful to the code. For example, if a UI graph component
88+
focuses a resolved `ccg://` node, the annotation should say so. Do not add
89+
unrelated keywords just to increase score; broad or false terms make unrelated
90+
files outrank the real implementation.
91+
92+
Avoid over-annotation:
93+
94+
- Skip trivial getters, setters, tiny wrappers, and obvious one-line helpers.
95+
- Do not repeat the same keyword across many tags unless each tag adds distinct
96+
evidence.
97+
- Prefer one accurate `@domainRule` over several vague `@intent` lines.
98+
- Keep `@see` for real navigation edges, especially cross-namespace references.
99+
66100
## AI-Driven Annotation
67101

68102
Coding agents with the `/ccg-annotate` skill can analyze your codebase and

guide/ko/annotations.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,39 @@ func AuthenticateUser(username, password string) (string, error) {
5959

6060
path와 symbol은 선택 사항입니다. `ccg://auth-svc/internal/auth`는 패키지/경로 범위, `ccg://auth-svc/`는 네임스페이스 전체를 가리킬 수 있습니다.
6161

62+
## Retrieval 품질 (Retrieval Quality)
63+
64+
어노테이션은 retrieval feature입니다. `retrieve_docs`와 생성 문서는
65+
`@index`, `@intent`, `@domainRule`, `@sideEffect`, `@requires`, `@ensures`,
66+
`@see` 같은 구조화된 bucket을 파일 단위 근거로 점수화합니다. 자연어 검색
67+
품질은 좋은 어노테이션으로 올라가지만, 태그가 실제 동작을 설명할 때만
68+
효과가 있습니다.
69+
70+
필요에 맞게 태그를 선택하십시오:
71+
72+
| 상황 | 권장 태그 |
73+
|------|-----------|
74+
| 파일/패키지가 하나의 검색 단위로 발견되어야 함 | `@index` |
75+
| public 함수, handler, CLI command, service method, UI workflow의 목적이 중요함 | `@intent` |
76+
| 정책, 제약, false-positive/false-negative 기준, 운영 규칙이 중요함 | `@domainRule` |
77+
| DB/파일 write, 네트워크 호출, 캐시 변경, 중요한 로그, 프로세스 실행이 있음 | `@sideEffect` |
78+
| receiver 또는 입력 상태를 변경함 | `@mutates` |
79+
| 입력 계약이나 결과 보장이 호출자에게 중요함 | `@requires`, `@ensures` |
80+
| 다른 구현이나 namespace로 이동해야 이해됨 | `@see` |
81+
82+
좋은 retrieval 어노테이션은 엔지니어나 LLM이 자연스럽게 물어볼 표현을
83+
사용하되, 코드의 실제 역할을 벗어나지 않습니다. 예를 들어 UI graph
84+
component가 resolved `ccg://` node를 focus한다면 어노테이션에도 그 사실이
85+
드러나야 합니다. 점수를 올리기 위해 관련 없는 키워드를 넣지 마십시오.
86+
넓거나 거짓인 표현은 실제 구현이 아닌 파일을 위로 올립니다.
87+
88+
과한 어노테이션은 피하십시오:
89+
90+
- 단순 getter/setter, 작은 wrapper, 자명한 한 줄 helper는 건너뜁니다.
91+
- 각 태그가 별도 근거를 추가하지 않는다면 같은 키워드를 반복하지 않습니다.
92+
- 모호한 `@intent` 여러 줄보다 정확한 `@domainRule` 하나가 낫습니다.
93+
- `@see`는 실제 이동 가치가 있는 연결, 특히 cross-namespace ref에 사용합니다.
94+
6295
## AI 기반 어노테이션 (AI-Driven Annotation)
6396

6497
`/ccg-annotate` 스킬을 사용할 수 있는 코딩 에이전트는 코드베이스를 분석하여

internal/ctxns/namespace.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// @index context.Context에 namespace를 전파하는 유틸리티 패키지.
1+
// @index context.Context에 namespace를 전파하고 namespace-scoped DB/search/query 호출의 기본값을 정규화하는 유틸리티 패키지.
22
package ctxns
33

44
import "context"
@@ -10,7 +10,7 @@ type ctxKey struct{}
1010
const DefaultNamespace = "default"
1111

1212
// Normalize replaces an empty namespace with DefaultNamespace, leaving other values unchanged.
13-
// @intent guarantee callers always observe a non-empty namespace string for store and query layers.
13+
// @intent normalize namespace query parameter values so store, retrieve_docs, and DB search layers always observe a non-empty namespace string.
1414
func Normalize(ns string) string {
1515
if ns == "" {
1616
return DefaultNamespace

internal/db/db.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// @index Database connection and search-backend selection helpers for SQLite and PostgreSQL.
1+
// @index Database connection, SQLite WAL/busy_timeout, connection pool, and search-backend selection helpers for SQLite and PostgreSQL.
22
package db
33

44
import (
@@ -69,7 +69,7 @@ func Open(driver, dsn string) (*gorm.DB, error) {
6969

7070
// ConfigurePool applies database-specific connection pool settings.
7171
// @intent apply connection-pool limits that match each database driver's concurrency model.
72-
// @domainRule sqlite is pinned to MaxOpenConns=1 because it supports only one writer at a time.
72+
// @domainRule sqlite is pinned to MaxOpenConns=1 because it supports only one writer at a time and FTS query workloads share the same DB file.
7373
// @mutates sqlDB connection-pool settings.
7474
func ConfigurePool(sqlDB SQLDBPool, driver string) {
7575
if driver == "sqlite" {

internal/mcp/handler_docs_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,8 +1127,8 @@ func TestRetrieveDocs_DBPrimaryTakesPrecedenceOverDocIndex(t *testing.T) {
11271127
}
11281128

11291129
response := decodeRetrieveDocsResponse(t, result)
1130-
if len(response.Results) != 1 {
1131-
t.Fatalf("results = %d, want 1: %#v", len(response.Results), response.Results)
1130+
if len(response.Results) == 0 {
1131+
t.Fatalf("results = 0, want DB primary result")
11321132
}
11331133
if !strings.Contains(response.Results[0].Content, "DB fallback content") || !strings.Contains(response.Results[0].DocPath, "internal/db/only.go") {
11341134
t.Fatalf("expected DB primary result, got %#v", response.Results[0])

internal/mcp/tools_docs.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func docsTools(h *handlers) []server.ServerTool {
2525
mcp.WithString("node_id", mcp.Description("Tree node ID as shown in search/retrieve/tree results (e.g. 'community:internal/analysis', 'package:internal/core', or 'file:internal/core/runtime.go'). Omit only for a bounded overview with depth set.")),
2626
mcp.WithString("community_id", mcp.Description("Deprecated alias for node_id; accepts any tree node ID for compatibility.")),
2727
mcp.WithNumber("depth", mcp.Description("Maximum tree depth to return (1=top-level communities/packages, 2=plus child packages/files). Recommended when node_id is omitted. Default: 0 (unlimited, only for small namespaces).")),
28-
mcp.WithString("namespace", mcp.Description("Namespace. When set, reads that namespace's DB tree or wiki-index.json fallback.")),
28+
mcp.WithString("namespace", mcp.Description("Namespace. When set, reads that namespace's DB-backed tree.")),
2929
),
3030
Handler: h.getRagTree,
3131
},
@@ -39,20 +39,20 @@ func docsTools(h *handlers) []server.ServerTool {
3939
},
4040
{
4141
Tool: mcp.NewTool("search_docs",
42-
mcp.WithDescription("Search the RAG document tree by keyword. Matches against node labels and summaries. Returns breadcrumb paths to matching nodes."),
42+
mcp.WithDescription("Search DB-backed documentation candidates by keyword. Matches graph nodes and annotation evidence, then returns breadcrumb paths to matching files."),
4343
mcp.WithString("query", mcp.Description("Search keyword (case-insensitive)"), mcp.Required()),
4444
mcp.WithNumber("limit", mcp.Description("Maximum number of results (default: 10, max: 500)")),
45-
mcp.WithString("namespace", mcp.Description("Namespace. When set, searches the namespace-specific doc-index.json.")),
45+
mcp.WithString("namespace", mcp.Description("Namespace. When set, searches that namespace's DB-backed documentation candidates.")),
4646
),
4747
Handler: h.searchDocs,
4848
},
4949
{
5050
Tool: mcp.NewTool("retrieve_docs",
51-
mcp.WithDescription("Retrieve relevant generated docs for a natural-language or multi-keyword query. Uses DB-backed graph evidence first, falls back to doc-index.json when DB retrieval is unavailable, and returns matched evidence plus bounded document content."),
51+
mcp.WithDescription("Retrieve relevant generated docs for a natural-language or multi-keyword query. Uses DB-backed graph evidence and returns matched evidence plus bounded document content."),
5252
mcp.WithString("query", mcp.Description("Natural-language or multi-keyword retrieval query"), mcp.Required()),
5353
mcp.WithNumber("limit", mcp.Description("Maximum number of document results (default: 5, max: 50)")),
5454
mcp.WithNumber("content_limit", mcp.Description("Maximum bytes of Markdown content per result (default: 4000, max: 20000; use 0 to omit content)")),
55-
mcp.WithString("namespace", mcp.Description("Namespace. When set, retrieves from that namespace's DB rows or namespace-specific doc-index.json fallback.")),
55+
mcp.WithString("namespace", mcp.Description("Namespace. When set, retrieves from that namespace's DB rows and generated docs.")),
5656
mcp.WithBoolean("explain", mcp.Description("When true, include available per-result diagnostics such as field_scores, literal_score, expanded_terms, and expansion_score (default: false).")),
5757
),
5858
Handler: h.retrieveDocs,

internal/retrieval/helpers.go

Lines changed: 87 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func IsRetrievableNodeKind(kind model.NodeKind) bool {
2525
}
2626

2727
// DBFileGroup은 파일 경로별로 묶인 DB 후보 노드 그룹이다.
28-
// @intent retrieve_docs DB 폴백이 파일 단위 결과 세분성을 유지하도록 후보를 그룹화한다.
28+
// @intent retrieve_docs DB-backed 결과가 파일 단위 세분성을 유지하도록 후보를 그룹화한다.
2929
type DBFileGroup struct {
3030
FilePath string
3131
Nodes []model.Node
@@ -39,39 +39,102 @@ func DBCandidateLimit(limit int) int {
3939
}
4040

4141
// MatchedTerms는 쿼리 문자열을 소문자 토큰으로 분리하고 중복을 제거한다.
42-
// @intent DB 폴백 응답 증거를 위해 검색 쿼리를 결정론적 소문자 용어로 토큰화한다.
42+
// @intent DB-backed 응답 증거를 위해 검색 쿼리를 결정론적 소문자 용어로 토큰화하고 코드 식별자 alias를 보강한다.
4343
func MatchedTerms(query string) []string {
4444
seen := map[string]struct{}{}
4545
terms := make([]string, 0)
46-
for term := range strings.FieldsSeq(strings.ToLower(query)) {
47-
term = strings.Trim(term, `"'()[]{}.,:;!?`)
46+
addTerm := func(term string) {
47+
term = strings.TrimSpace(term)
4848
if term == "" {
49-
continue
49+
return
5050
}
5151
if _, ok := seen[term]; ok {
52-
continue
52+
return
5353
}
5454
seen[term] = struct{}{}
5555
terms = append(terms, term)
5656
}
57+
for term := range strings.FieldsSeq(strings.ToLower(query)) {
58+
term = strings.Trim(term, `"'()[]{}.,:;!?`)
59+
addTerm(term)
60+
}
61+
baseLen := len(terms)
62+
for i := 0; i+1 < baseLen; i++ {
63+
left, right := terms[i], terms[i+1]
64+
if left == "" || right == "" || strings.Contains(left, "_") || strings.Contains(right, "_") {
65+
continue
66+
}
67+
for _, alias := range []string{left + "_" + right, left + right} {
68+
addTerm(alias)
69+
}
70+
}
71+
for _, term := range terms[:baseLen] {
72+
for _, alias := range retrievalTermAliases(term) {
73+
addTerm(alias)
74+
}
75+
}
5776
return terms
5877
}
5978

79+
// @intent expand common natural-language retrieval terms into code identifier variants without calling an LLM reranker.
80+
func retrievalTermAliases(term string) []string {
81+
switch term {
82+
case "reference":
83+
return []string{"ref", "refs"}
84+
case "references":
85+
return []string{"reference", "ref", "refs"}
86+
case "ref":
87+
return []string{"reference", "refs"}
88+
default:
89+
return nil
90+
}
91+
}
92+
6093
// TextContainsAnyTerm은 텍스트가 주어진 용어 중 하나라도 포함하는지 대소문자 무시로 확인한다.
61-
// @intent DB 폴백 매칭 필드 진단을 위한 단순 대소문자 무시 용어 포함 검사를 수행한다.
94+
// @intent DB-backed 매칭 필드 진단을 위한 단순 대소문자 무시 용어 포함 검사를 수행한다.
6295
func TextContainsAnyTerm(text string, terms []string) bool {
6396
if text == "" || len(terms) == 0 {
6497
return false
6598
}
6699
lower := strings.ToLower(text)
67100
for _, term := range terms {
68-
if term != "" && strings.Contains(lower, term) {
101+
if containsRetrievalTerm(lower, term) {
69102
return true
70103
}
71104
}
72105
return false
73106
}
74107

108+
// @intent match natural-language terms against code identifiers and hyphenated prose using the same collapsed form.
109+
func containsRetrievalTerm(lowerText, term string) bool {
110+
if term == "" {
111+
return false
112+
}
113+
if strings.Contains(lowerText, term) {
114+
return true
115+
}
116+
if !strings.ContainsAny(lowerText, "_-/.:#") {
117+
return false
118+
}
119+
collapsedTerm := collapseRetrievalText(term)
120+
if collapsedTerm == term || len(collapsedTerm) < 3 {
121+
return false
122+
}
123+
return strings.Contains(collapseRetrievalText(lowerText), collapsedTerm)
124+
}
125+
126+
// @intent collapse separators so cross_namespace, cross-namespace, and cross namespace can match the same evidence.
127+
func collapseRetrievalText(text string) string {
128+
var b strings.Builder
129+
b.Grow(len(text))
130+
for _, r := range text {
131+
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
132+
b.WriteRune(r)
133+
}
134+
}
135+
return b.String()
136+
}
137+
75138
// NodeMatchesTerms는 노드 또는 어노테이션 텍스트가 검색 용어 중 하나라도 일치하는지 확인한다.
76139
// @intent 저장된 노드 또는 어노테이션 텍스트가 검색 용어와 일치하는지 감지한다.
77140
func NodeMatchesTerms(node model.Node, terms []string) bool {
@@ -133,7 +196,7 @@ func BuildDBResult(group DBFileGroup, annotations map[uint]*model.Annotation, te
133196
}
134197

135198
// BuildDBResultWithOptions derives one file-level retrieve_docs candidate and optional diagnostics.
136-
// @intent score DB-backed retrieve_docs candidates from structured annotation buckets while keeping FTS order as a deterministic tie-breaker.
199+
// @intent score DB-backed retrieve_docs candidates from structured annotation buckets while preserving backend rank in response order.
137200
func BuildDBResultWithOptions(group DBFileGroup, annotations map[uint]*model.Annotation, terms []string, index int, opts Options) ragindex.RetrieveResult {
138201
score, fieldScores, matchedTerms := ScoreDBFields(group.Nodes, annotations, terms)
139202
fields := sortedFieldNames(fieldScores)
@@ -151,7 +214,7 @@ func BuildDBResultWithOptions(group DBFileGroup, annotations map[uint]*model.Ann
151214
Summary: summary,
152215
DocPath: filepath.ToSlash(filepath.Join("docs", filepath.FromSlash(group.FilePath)+".md")),
153216
Path: DBPath(group.FilePath),
154-
Score: score*1000 + orderScore(index),
217+
Score: score,
155218
MatchedTerms: matchedTerms,
156219
MatchedFields: fields,
157220
}
@@ -162,8 +225,8 @@ func BuildDBResultWithOptions(group DBFileGroup, annotations map[uint]*model.Ann
162225
return result
163226
}
164227

165-
// MatchedFields는 DB 폴백 파일 히트를 설명하는 노드 메타데이터 또는 어노테이션 버킷을 보고한다.
166-
// @intent DB 폴백 매칭 필드 진단을 위해 어떤 노드 메타데이터 또는 어노테이션 버킷이 파일 히트를 설명하는지 보고한다.
228+
// MatchedFields는 DB-backed 파일 히트를 설명하는 노드 메타데이터 또는 어노테이션 버킷을 보고한다.
229+
// @intent DB-backed 매칭 필드 진단을 위해 어떤 노드 메타데이터 또는 어노테이션 버킷이 파일 히트를 설명하는지 보고한다.
167230
func MatchedFields(nodes []model.Node, annotations map[uint]*model.Annotation, terms []string) []string {
168231
return sortedFieldNames(ScoreDBFieldBreakdown(nodes, annotations, terms))
169232
}
@@ -217,26 +280,26 @@ func scoreDBFields(nodes []model.Node, annotations map[uint]*model.Annotation, t
217280
}
218281
if lowerName == term {
219282
addOnce("label", 12, term)
220-
} else if strings.Contains(lowerName, term) {
283+
} else if containsRetrievalTerm(lowerName, term) {
221284
addOnce("label_contains", 7, term)
222285
}
223-
if strings.Contains(lowerQN, term) {
286+
if containsRetrievalTerm(lowerQN, term) {
224287
addOnce("qualified_name", 4, term)
225288
}
226-
if strings.Contains(lowerPath, term) {
289+
if containsRetrievalTerm(lowerPath, term) {
227290
addOnce("path", 2, term)
228291
}
229292
if ann == nil {
230293
continue
231294
}
232-
if strings.Contains(strings.ToLower(ann.Summary), term) || strings.Contains(strings.ToLower(ann.Context), term) {
295+
if containsRetrievalTerm(strings.ToLower(ann.Summary), term) || containsRetrievalTerm(strings.ToLower(ann.Context), term) {
233296
addOnce("annotation_text", 2, term)
234297
}
235-
if strings.Contains(strings.ToLower(ann.RawText), term) {
298+
if containsRetrievalTerm(strings.ToLower(ann.RawText), term) {
236299
addOnce("generic", 1, term)
237300
}
238301
for _, tag := range ann.Tags {
239-
if !strings.Contains(strings.ToLower(tag.Value), term) && !strings.Contains(strings.ToLower(tag.Name), term) {
302+
if !containsRetrievalTerm(strings.ToLower(tag.Value), term) && !containsRetrievalTerm(strings.ToLower(tag.Name), term) {
240303
continue
241304
}
242305
addOnce(dbFieldName(tag.Kind), dbFieldWeight(tag.Kind), term)
@@ -297,17 +360,8 @@ func sortedTerms(terms map[string]struct{}) []string {
297360
return out
298361
}
299362

300-
// @intent keep DB backend candidate order as a small deterministic tie-breaker after structured scoring.
301-
func orderScore(index int) int {
302-
score := dbCandidateCap - index
303-
if score < 0 {
304-
return 0
305-
}
306-
return score
307-
}
308-
309-
// DBSummary는 그룹화된 DB 폴백 노드에서 첫 번째 사용 가능한 어노테이션 요약/태그 값을 선택한다.
310-
// @intent 첫 번째 사용 가능한 어노테이션 요약 또는 태그 값을 간결한 파일 수준 DB 폴백 요약으로 선택한다.
363+
// DBSummary는 그룹화된 DB-backed 노드에서 첫 번째 사용 가능한 어노테이션 요약/태그 값을 선택한다.
364+
// @intent 첫 번째 사용 가능한 어노테이션 요약 또는 태그 값을 간결한 파일 수준 DB-backed 요약으로 선택한다.
311365
func DBSummary(nodes []model.Node, annotations map[uint]*model.Annotation) string {
312366
for _, node := range nodes {
313367
ann := annotations[node.ID]
@@ -326,8 +380,8 @@ func DBSummary(nodes []model.Node, annotations map[uint]*model.Annotation) strin
326380
return ""
327381
}
328382

329-
// DBPath는 정규화된 파일 경로 세그먼트로부터 DB 폴백 결과의 비어있지 않은 경로 조각을 생성한다.
330-
// @intent 정규화된 파일 경로 세그먼트로부터 DB 폴백 결과의 비어있지 않은 경로 조각을 생성한다.
383+
// DBPath는 정규화된 파일 경로 세그먼트로부터 DB-backed 결과의 비어있지 않은 경로 조각을 생성한다.
384+
// @intent 정규화된 파일 경로 세그먼트로부터 DB-backed 결과의 비어있지 않은 경로 조각을 생성한다.
331385
func DBPath(filePath string) []string {
332386
parts := strings.Split(filepath.ToSlash(filePath), "/")
333387
path := make([]string, 0, len(parts)+1)
@@ -340,8 +394,8 @@ func DBPath(filePath string) []string {
340394
return path
341395
}
342396

343-
// DBMatches는 그룹화된 DB 폴백 노드에 대한 간결한 검색 스타일 증거 항목을 생성한다.
344-
// @intent 그룹화된 DB 폴백 노드에 대한 간결한 검색 스타일 증거 항목을 생성한다.
397+
// DBMatches는 그룹화된 DB-backed 노드에 대한 간결한 검색 스타일 증거 항목을 생성한다.
398+
// @intent 그룹화된 DB-backed 노드에 대한 간결한 검색 스타일 증거 항목을 생성한다.
345399
func DBMatches(nodes []model.Node, annotations map[uint]*model.Annotation) []ragindex.SearchResult {
346400
if len(nodes) == 0 {
347401
return nil

0 commit comments

Comments
 (0)