Skip to content

Navigation Menu

Sign in
Appearance settings
Sign up
Appearance settings

Commit 6532806

Browse filesBrowse the repository at this point in the historyBrowse files
GoodbyePlanetclaude
andcommitted
feat: expose chunk_tier as a search filter
chunk_tier is computed for every indexed symbol and registered as a Qdrant keyword payload index, but no code path applied it as a filter, so clients couldn't scope a query to just classes or just methods. Adds chunk_tier to QdrantStore.search() / find_by_name() as a FieldCondition, and threads it through the search_code and find_symbol MCP tools. Closes #95 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 65eea62 commit 6532806
Copy full SHA for 6532806

6 files changed

+122-14Lines changed: 122 additions & 14 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎README.md‎

Copy file name to clipboardExpand all lines: README.md
+3-1Lines changed: 3 additions & 1 deletion
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,9 @@ in `.env`, then start without the `jina` profile (`docker-compose up` / `make do
360360

361361
`search_code` queries both via a Qdrant `query_points` call with `FusionQuery(fusion=RRF)`. Indexed
362362
payload fields (`language`, `service`, `symbol_type`, `chunk_tier`, `parent_name`, `file_path`) are
363-
usable as filters. The full payload also includes `signature`, `docstring`, `annotations`, `package`,
363+
usable as filters. `search_code` and `find_symbol` expose `chunk_tier` (`"method"` or `"class"`)
364+
directly, so a query can be scoped to just classes or just methods. The full payload also includes
365+
`signature`, `docstring`, `annotations`, `package`,
364366
`start_line`, `end_line`, `file_hash`, `indexed_at`, and language-specific extras (`http_method`,
365367
`http_route`, `spring_stereotype`, `lombok_annotations`, `is_async`, `uses_memo`, …).
366368

Collapse file

‎docs/retrieval-rrf.md‎

Copy file name to clipboardExpand all lines: docs/retrieval-rrf.md
+4-4Lines changed: 4 additions & 4 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -100,24 +100,24 @@ semcode exposes four search tools to AI clients via the MCP protocol (`server/to
100100
### `search_code`
101101

102102
```
103-
search_code(query: str, service: str | None, limit: int = 10) -> str
103+
search_code(query: str, service: str | None, chunk_tier: str | None, limit: int = 10) -> str
104104
```
105105

106106
The primary semantic search tool. At query time:
107107

108108
1. Embeds the query string with both the dense provider (`embed_query`) and the sparse provider (`embed_query`)
109-
2. Calls `store.search()` with both vectors → RRF fusion
109+
2. Calls `store.search()` with both vectors → RRF fusion, optionally scoped to `chunk_tier` (`"method"` or `"class"`)
110110
3. Returns a formatted Markdown string with up to `limit` results
111111

112112
Each result includes: symbol name and type, RRF score, file location (path + line range), service, language, annotations, HTTP route (if present), and the symbol's signature or source (first 500 characters from the payload).
113113

114114
### `find_symbol`
115115

116116
```
117-
find_symbol(name: str, symbol_type: str | None, service: str | None, exact: bool = False) -> str
117+
find_symbol(name: str, symbol_type: str | None, service: str | None, chunk_tier: str | None, exact: bool = False) -> str
118118
```
119119

120-
Name-based lookup via `store.find_by_name()`. Does not use vectors or RRF. Returns up to 20 (exact) or 50 (substring) matches. Each result includes: name, type, location, package, parent class, and source (first 800 characters).
120+
Name-based lookup via `store.find_by_name()`. Does not use vectors or RRF. Supports filtering by `chunk_tier` (`"method"` or `"class"`) in addition to `symbol_type` and `service`. Returns up to 20 (exact) or 50 (substring) matches. Each result includes: name, type, location, package, parent class, and source (first 800 characters).
121121

122122
### `find_usages`
123123

Collapse file

‎server/store/qdrant.py‎

Copy file name to clipboardExpand all lines: server/store/qdrant.py
+13-6Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,16 @@ async def search(
216216
sparse_vector: SparseVector,
217217
limit: int = 10,
218218
service: str | None = None,
219+
chunk_tier: str | None = None,
219220
) -> list[ScoredPoint]:
220-
query_filter = (
221-
Filter(
222-
must=[FieldCondition(key="service", match=MatchValue(value=service))]
221+
must = []
222+
if service:
223+
must.append(FieldCondition(key="service", match=MatchValue(value=service)))
224+
if chunk_tier:
225+
must.append(
226+
FieldCondition(key="chunk_tier", match=MatchValue(value=chunk_tier))
223227
)
224-
if service
225-
else None
226-
)
228+
query_filter = Filter(must=must) if must else None
227229

228230
result = await self._client.query_points(
229231
collection_name=self._collection,
@@ -252,6 +254,7 @@ async def find_by_name(
252254
name: str,
253255
symbol_type: str | None = None,
254256
service: str | None = None,
257+
chunk_tier: str | None = None,
255258
exact: bool = False,
256259
) -> list[ScoredPoint]:
257260
must = []
@@ -263,6 +266,10 @@ async def find_by_name(
263266
)
264267
if service:
265268
must.append(FieldCondition(key="service", match=MatchValue(value=service)))
269+
if chunk_tier:
270+
must.append(
271+
FieldCondition(key="chunk_tier", match=MatchValue(value=chunk_tier))
272+
)
266273

267274
base_filter = Filter(must=must) if must else None
268275

Collapse file

‎server/tools/search.py‎

Copy file name to clipboardExpand all lines: server/tools/search.py
+10-1Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ def register_search_tools(mcp: FastMCP) -> None:
1919
async def search_code(
2020
query: str,
2121
service: str | None = None,
22+
chunk_tier: str | None = None,
2223
limit: int = 10,
2324
) -> str:
2425
"""Semantically search code across indexed services using natural language.
2526
2627
Args:
2728
query: Natural language description of what you're looking for.
2829
service: Filter by service name
30+
chunk_tier: Filter by chunk tier: "method" or "class"
2931
limit: Maximum number of results (default 10)
3032
"""
3133
embedder = get_embedding_provider()
@@ -39,6 +41,7 @@ async def search_code(
3941
sparse_vector=sparse_vector,
4042
limit=limit,
4143
service=service,
44+
chunk_tier=chunk_tier,
4245
)
4346

4447
if not results:
@@ -74,6 +77,7 @@ async def find_symbol(
7477
name: str,
7578
symbol_type: str | None = None,
7679
service: str | None = None,
80+
chunk_tier: str | None = None,
7781
exact: bool = False,
7882
) -> str:
7983
"""Find a class, method, interface, or function by name.
@@ -82,11 +86,16 @@ async def find_symbol(
8286
name: Symbol name to search for
8387
symbol_type: Optional type filter: class, method, interface, enum, record, function, etc.
8488
service: Optional service filter
89+
chunk_tier: Optional chunk tier filter: "method" or "class"
8590
exact: If true, only exact name matches. If false (default), partial/fuzzy matching.
8691
"""
8792
store = get_store()
8893
results = await store.find_by_name(
89-
name=name, symbol_type=symbol_type, service=service, exact=exact
94+
name=name,
95+
symbol_type=symbol_type,
96+
service=service,
97+
chunk_tier=chunk_tier,
98+
exact=exact,
9099
)
91100

92101
if not results:
Collapse file

‎tests/test_store.py‎

Copy file name to clipboardExpand all lines: tests/test_store.py
+47-1Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from types import SimpleNamespace
55
from unittest.mock import AsyncMock, MagicMock
66

7-
from qdrant_client.models import Fusion, FusionQuery, SparseVector
7+
from qdrant_client.models import FieldCondition, Fusion, FusionQuery, SparseVector
88

99
from server.store.qdrant import QdrantStore
1010

@@ -99,3 +99,49 @@ async def test_search_uses_prefetch_and_rrf() -> None:
9999

100100
assert isinstance(kwargs["query"], FusionQuery)
101101
assert kwargs["query"].fusion == Fusion.RRF
102+
103+
104+
async def test_search_filters_by_chunk_tier() -> None:
105+
store = QdrantStore.__new__(QdrantStore)
106+
store._collection = "test"
107+
108+
fake_result = MagicMock()
109+
fake_result.points = []
110+
store._client = MagicMock()
111+
store._client.query_points = AsyncMock(return_value=fake_result)
112+
113+
dense = [0.1] * 768
114+
sparse = SparseVector(indices=[1, 2], values=[0.5, 0.3])
115+
116+
await store.search(
117+
dense_vector=dense, sparse_vector=sparse, limit=5, chunk_tier="method"
118+
)
119+
120+
kwargs = store._client.query_points.call_args.kwargs
121+
for prefetch in kwargs["prefetch"]:
122+
conditions = prefetch.filter.must
123+
assert any(
124+
isinstance(c, FieldCondition)
125+
and c.key == "chunk_tier"
126+
and c.match.value == "method"
127+
for c in conditions
128+
)
129+
130+
131+
async def test_find_by_name_filters_by_chunk_tier() -> None:
132+
store = QdrantStore.__new__(QdrantStore)
133+
store._collection = "test"
134+
135+
record = _make_record("MyService")
136+
store._client = MagicMock()
137+
store._client.scroll = AsyncMock(return_value=([record], None))
138+
139+
await store.find_by_name("MyService", exact=True, chunk_tier="class")
140+
141+
scroll_filter = store._client.scroll.call_args.kwargs["scroll_filter"]
142+
assert any(
143+
isinstance(c, FieldCondition)
144+
and c.key == "chunk_tier"
145+
and c.match.value == "class"
146+
for c in scroll_filter.must
147+
)
Collapse file

‎tests/tools/test_search.py‎

Copy file name to clipboardExpand all lines: tests/tools/test_search.py
+45-1Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,46 @@ async def test_search_code_formats_hits() -> None:
6767
assert "0.870" in result
6868

6969

70+
async def test_search_code_passes_chunk_tier_to_store() -> None:
71+
search_code = _tool("search_code")
72+
store = AsyncMock()
73+
store.search.return_value = []
74+
75+
with (
76+
patch("server.tools.search.get_embedding_provider") as mock_embedder,
77+
patch("server.tools.search.get_sparse_provider") as mock_sparse,
78+
patch("server.tools.search.get_store", return_value=store),
79+
):
80+
mock_embedder.return_value.embed_query = AsyncMock(return_value=[0.1])
81+
mock_sparse.return_value.embed_query = AsyncMock(return_value={})
82+
await search_code("find the order service", chunk_tier="method")
83+
84+
store.search.assert_awaited_once_with(
85+
dense_vector=[0.1],
86+
sparse_vector={},
87+
limit=10,
88+
service=None,
89+
chunk_tier="method",
90+
)
91+
92+
93+
async def test_find_symbol_passes_chunk_tier_to_store() -> None:
94+
find_symbol = _tool("find_symbol")
95+
store = AsyncMock()
96+
store.find_by_name.return_value = []
97+
98+
with patch("server.tools.search.get_store", return_value=store):
99+
await find_symbol("OrderService", chunk_tier="class")
100+
101+
store.find_by_name.assert_awaited_once_with(
102+
name="OrderService",
103+
symbol_type=None,
104+
service=None,
105+
chunk_tier="class",
106+
exact=False,
107+
)
108+
109+
70110
async def test_find_symbol_reports_no_match() -> None:
71111
find_symbol = _tool("find_symbol")
72112
store = AsyncMock()
@@ -103,7 +143,11 @@ async def test_find_symbol_formats_match_with_parent() -> None:
103143
assert "`placeOrder`" in result
104144
assert "**Parent**: `OrderService`" in result
105145
store.find_by_name.assert_awaited_once_with(
106-
name="placeOrder", symbol_type=None, service=None, exact=True
146+
name="placeOrder",
147+
symbol_type=None,
148+
service=None,
149+
chunk_tier=None,
150+
exact=True,
107151
)
108152

109153

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.