Skip to content

Commit edbdfbf

Browse files
authored
fix(search): match non-contiguous underscore segments (#1291)
* Fix underscore segment tool search * Address underscore search review feedback
1 parent 8acb506 commit edbdfbf

2 files changed

Lines changed: 194 additions & 0 deletions

File tree

‎internal/index/bleve.go‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/blevesearch/bleve/v2"
1010
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
1111
"github.com/blevesearch/bleve/v2/analysis/analyzer/standard"
12+
"github.com/blevesearch/bleve/v2/search/query"
1213
"go.uber.org/zap"
1314

1415
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
@@ -20,6 +21,13 @@ import (
2021
// needed, so total coverage is never bounded by this value (MCP-3319).
2122
const defaultSearchPageSize = 10000
2223

24+
const (
25+
// maxUnderscoreSearchSegments bounds the extra wildcard clauses generated for
26+
// identifier-style queries. Typical MCP tool names stay well below this cap.
27+
maxUnderscoreSearchSegments = 16
28+
underscoreSegmentBoost = 5.0
29+
)
30+
2331
// BleveIndex wraps Bleve index operations
2432
type BleveIndex struct {
2533
index bleve.Index
@@ -350,6 +358,29 @@ func (b *BleveIndex) SearchTools(queryStr string, limit int) ([]*config.SearchRe
350358
return nil, fmt.Errorf("search failed: %w", err)
351359
}
352360

361+
// Identifier queries often include only the meaningful segments of a
362+
// longer tool name. If the legacy query did not find a canonical exact
363+
// match, repeat it with an additional segment-aware signal. This preserves
364+
// exact-name scores while letting boundary matches outrank substring hits.
365+
segmentQuery := underscoreSegmentQuery(queryStr)
366+
if segmentQuery != nil {
367+
hasExactToolName := false
368+
for _, hit := range searchResult.Hits {
369+
if fieldsContainExactToolName(hit.Fields, queryStr) {
370+
hasExactToolName = true
371+
break
372+
}
373+
}
374+
375+
if !hasExactToolName {
376+
boolQuery.AddShould(segmentQuery)
377+
searchResult, err = b.index.Search(searchReq)
378+
if err != nil {
379+
return nil, fmt.Errorf("underscore segment search failed: %w", err)
380+
}
381+
}
382+
}
383+
353384
// Convert results
354385
var results []*config.SearchResult
355386
for _, hit := range searchResult.Hits {
@@ -363,6 +394,64 @@ func (b *BleveIndex) SearchTools(queryStr string, limit int) ([]*config.SearchRe
363394
return results, nil
364395
}
365396

397+
func fieldsContainExactToolName(fields map[string]interface{}, queryStr string) bool {
398+
for _, field := range []string{"tool_name", "full_tool_name"} {
399+
if value, ok := fields[field].(string); ok && value == queryStr {
400+
return true
401+
}
402+
}
403+
return false
404+
}
405+
406+
// underscoreSegmentQuery matches each underscore-delimited query segment at a
407+
// complete segment boundary in the keyword-indexed tool_name field. Segment
408+
// order is intentionally irrelevant, but every segment is required.
409+
func underscoreSegmentQuery(queryStr string) query.Query {
410+
segments := strings.Split(queryStr, "_")
411+
if len(segments) < 2 || len(segments) > maxUnderscoreSearchSegments {
412+
return nil
413+
}
414+
415+
segmentQueries := make([]query.Query, 0, len(segments))
416+
for _, segment := range segments {
417+
if !isASCIIAlphanumeric(segment) {
418+
return nil
419+
}
420+
421+
exact := bleve.NewTermQuery(segment)
422+
exact.SetField("tool_name")
423+
exact.SetBoost(underscoreSegmentBoost)
424+
425+
prefix := bleve.NewPrefixQuery(segment + "_")
426+
prefix.SetField("tool_name")
427+
prefix.SetBoost(underscoreSegmentBoost)
428+
429+
middle := bleve.NewWildcardQuery("*_" + segment + "_*")
430+
middle.SetField("tool_name")
431+
middle.SetBoost(underscoreSegmentBoost)
432+
433+
suffix := bleve.NewWildcardQuery("*_" + segment)
434+
suffix.SetField("tool_name")
435+
suffix.SetBoost(underscoreSegmentBoost)
436+
437+
segmentQueries = append(segmentQueries, bleve.NewDisjunctionQuery(exact, prefix, middle, suffix))
438+
}
439+
440+
return bleve.NewConjunctionQuery(segmentQueries...)
441+
}
442+
443+
func isASCIIAlphanumeric(value string) bool {
444+
if value == "" {
445+
return false
446+
}
447+
for _, r := range value {
448+
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') {
449+
return false
450+
}
451+
}
452+
return true
453+
}
454+
366455
// GetDocumentCount returns the number of documents in the index
367456
func (b *BleveIndex) GetDocumentCount() (uint64, error) {
368457
return b.index.DocCount()

‎internal/index/bleve_test.go‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,111 @@ func TestBleveIndex_SearchTokenization(t *testing.T) {
217217
}
218218
}
219219

220+
func TestBleveIndex_SearchUnderscoreSegments(t *testing.T) {
221+
idx, err := NewBleveIndex(t.TempDir(), zap.NewNop())
222+
require.NoError(t, err)
223+
defer idx.Close()
224+
225+
require.NoError(t, idx.BatchIndex([]*config.ToolMetadata{
226+
{
227+
Name: "work_start_task_attachment_upload",
228+
ServerName: "fixture",
229+
Description: "Create a signed request for a local file.",
230+
ParamsJSON: `{"type":"object","properties":{}}`,
231+
Hash: "target",
232+
},
233+
{
234+
Name: "network_upload_attachment",
235+
ServerName: "fixture",
236+
Description: "Create a signed request for a remote file.",
237+
ParamsJSON: `{"type":"object","properties":{}}`,
238+
Hash: "substring-decoy",
239+
},
240+
}))
241+
242+
t.Run("matches non-contiguous segments in any order", func(t *testing.T) {
243+
for _, query := range []string{
244+
"work_upload_attachment",
245+
"attachment_work_upload",
246+
"work_start_task_attachment_upload",
247+
} {
248+
results, err := idx.SearchTools(query, 10)
249+
require.NoError(t, err)
250+
require.NotEmpty(t, results, query)
251+
assert.Equal(t, "fixture:work_start_task_attachment_upload", results[0].Tool.Name, query)
252+
}
253+
})
254+
255+
t.Run("requires segment boundaries and every query segment", func(t *testing.T) {
256+
for _, query := range []string{"network_work_upload_attachment", "work_missing_attachment"} {
257+
results, err := idx.SearchTools(query, 10)
258+
require.NoError(t, err)
259+
assert.Empty(t, results, query)
260+
}
261+
})
262+
263+
t.Run("preserves a legacy prefix match ahead of an added segment match", func(t *testing.T) {
264+
prefixIdx, err := NewBleveIndex(t.TempDir(), zap.NewNop())
265+
require.NoError(t, err)
266+
defer prefixIdx.Close()
267+
268+
require.NoError(t, prefixIdx.BatchIndex([]*config.ToolMetadata{
269+
{
270+
Name: "work_upload_attachment_preview",
271+
ServerName: "fixture",
272+
Description: "Preview an attachment upload for a Work task.",
273+
Hash: "legacy-prefix",
274+
},
275+
{
276+
Name: "work_start_task_attachment_upload",
277+
ServerName: "fixture",
278+
Description: "Create a signed request for a local file.",
279+
Hash: "segment-match",
280+
},
281+
}))
282+
283+
results, err := prefixIdx.SearchTools("work_upload_attachment", 10)
284+
require.NoError(t, err)
285+
require.Len(t, results, 2)
286+
assert.Equal(t, "fixture:work_upload_attachment_preview", results[0].Tool.Name)
287+
assert.Equal(t, "fixture:work_start_task_attachment_upload", results[1].Tool.Name)
288+
})
289+
}
290+
291+
func TestFieldsContainExactToolName(t *testing.T) {
292+
tests := []struct {
293+
name string
294+
fields map[string]interface{}
295+
query string
296+
want bool
297+
}{
298+
{
299+
name: "tool name",
300+
fields: map[string]interface{}{"tool_name": "work_upload_attachment", "full_tool_name": "fixture:work_upload_attachment"},
301+
query: "work_upload_attachment",
302+
want: true,
303+
},
304+
{
305+
name: "full tool name",
306+
fields: map[string]interface{}{"tool_name": "work_upload_attachment", "full_tool_name": "fixture:work_upload_attachment"},
307+
query: "fixture:work_upload_attachment",
308+
want: true,
309+
},
310+
{
311+
name: "no exact name",
312+
fields: map[string]interface{}{"tool_name": "work_start_task_attachment_upload", "full_tool_name": "fixture:work_start_task_attachment_upload"},
313+
query: "work_upload_attachment",
314+
want: false,
315+
},
316+
}
317+
318+
for _, tt := range tests {
319+
t.Run(tt.name, func(t *testing.T) {
320+
assert.Equal(t, tt.want, fieldsContainExactToolName(tt.fields, tt.query))
321+
})
322+
}
323+
}
324+
220325
func TestBleveIndex_FieldMapping(t *testing.T) {
221326
// Test that all fields are properly indexed and searchable
222327
tool := &config.ToolMetadata{

0 commit comments

Comments
 (0)