Skip to content

Commit 62dd3f1

Browse files
earayuclaude
andcommitted
feat(phase9 #96 D10.d): search primitives split + omnibus deprecation
Phase 9 D10.d (#96) per docs/modularization/d10-design-pack.md §B. Adds 4 split search MCP tools (vector_search / graph_search / fulltext_search / web_search) under aperag/mcp/tools/, marks the omnibus search_collection and search_chat_files as DEPRECATED with a docstring banner, and relocates the existing web_search implementation from server.py into the new tools subpackage so all D10 search tools live in one place. Forbidden boundaries (§G D10.d) are honored: search_collection / search_chat_files implementation bodies are intentionally untouched (deletion is D10.h territory), no read primitive tool surface is modified (D10.c territory), no aperag/service/search_service.py compat layer is created (would require [D10 spec amendment] thread). The §B canonical SearchResult / SearchResultItem shape with chunk_id / section_path / heading_anchor surfacing is intentionally deferred to a D10.d follow-up PR — current backend does not expose chunk_id in the public response shape and the propagation question warrants a [D10 spec amendment] thread before implementation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 389a059 commit 62dd3f1

7 files changed

Lines changed: 832 additions & 113 deletions

File tree

aperag/mcp/server.py

Lines changed: 42 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
# Import view models for type safety
2626
from aperag.domains.retrieval.schemas import SearchResult
27-
from aperag.domains.web_access.schemas import WebReadResponse, WebSearchResponse
27+
from aperag.domains.web_access.schemas import WebReadResponse
2828

2929
logger = logging.getLogger(__name__)
3030

@@ -104,7 +104,15 @@ async def search_collection(
104104
topk: int = 5,
105105
query_keywords: list[str] = None,
106106
) -> Dict[str, Any]:
107-
"""Search a persistent knowledge base for evidence relevant to the current request.
107+
"""[DEPRECATED] Search a persistent knowledge base for evidence relevant to the current request.
108+
109+
[DEPRECATED] Phase 9 D10.d (#96, ``docs/modularization/d10-design-pack.md``
110+
§B.5 / §H.1): use the discrete split tools instead —
111+
``vector_search`` / ``graph_search`` / ``fulltext_search``. This
112+
omnibus tool is preserved as a deprecated alias for backward
113+
compatibility during the D10 migration window and will be removed in
114+
D11 once telemetry confirms no remaining external callers (D10.h
115+
cutover lane). Implementation is intentionally untouched.
108116
109117
Use this when:
110118
- You already know which collection should be searched.
@@ -265,7 +273,14 @@ async def search_chat_files(
265273
rerank: bool = True,
266274
topk: int = 5,
267275
) -> Dict[str, Any]:
268-
"""Search files uploaded in the current chat for evidence relevant to this turn.
276+
"""[DEPRECATED] Search files uploaded in the current chat for evidence relevant to this turn.
277+
278+
[DEPRECATED] Phase 9 D10.d (#96, ``docs/modularization/d10-design-pack.md``
279+
§H.2): chat-scoped omnibus search shares the deprecation timeline of
280+
``search_collection``. The split tool surface (``vector_search`` /
281+
``graph_search`` / ``fulltext_search``) is collection-scoped today;
282+
a chat-scoped equivalent will be sequenced in the D10.h cutover
283+
lane. Implementation is intentionally untouched.
269284
270285
Use this when:
271286
- The user refers to files shared in this chat session.
@@ -367,116 +382,12 @@ async def search_chat_files(
367382
return {"error": str(e)}
368383

369384

370-
@mcp_server.tool
371-
async def web_search(
372-
query: str = "",
373-
max_results: int = 5,
374-
timeout: int = 30,
375-
locale: str = "en-US",
376-
source: str = "",
377-
) -> Dict[str, Any]:
378-
"""Search the web for current or missing information.
379-
380-
Use this when:
381-
- The current turn allows web access.
382-
- You need current information, external verification, or gap-filling beyond ApeRAG collections.
383-
384-
Do not use this when:
385-
- The current turn disables web access.
386-
- Collection or chat-file evidence is already sufficient for the requested step.
387-
388-
What success means:
389-
- You received candidate web results with titles, snippets, and URLs.
390-
391-
What an empty result means:
392-
- No strong web results were found for this query and scope.
393-
- Use `meta.search_status` to distinguish a genuine empty result from `unavailable` or `disabled`.
394-
395-
What failure may mean:
396-
- network / timeout: external search could not complete.
397-
- upstream search provider issue: the search backend could not return usable results.
398-
399-
How to explain this step to the user:
400-
- While running: "Searching the web for current or missing information."
401-
- After completion: "Checked web sources for supporting information."
402-
403-
Args:
404-
query: Search query for web search. Optional when using source-only site browsing.
405-
max_results: Maximum number of results to return (default: 5)
406-
timeout: Request timeout in seconds (default: 30)
407-
locale: Browser locale (default: en-US)
408-
source: Optional domain or URL for site-specific filtering. When provided with query,
409-
limits search results to this domain (e.g., 'site:vercel.com query').
410-
411-
Returns:
412-
Web search results with URLs, titles, snippets, and metadata
413-
414-
Note:
415-
Uses JINA first when configured, otherwise falls back to DuckDuckGo.
416-
Search failures are soft-failed into empty result sets with lightweight `meta` diagnostics so
417-
downstream workflows stay stable while still distinguishing `ok`, `empty`, `unavailable`, and `disabled`.
418-
"""
419-
try:
420-
api_key = get_api_key()
421-
logger.info(
422-
"MCP web_search request query=%s source=%s max_results=%s timeout=%s locale=%s",
423-
query.strip() if query else "",
424-
source.strip() if source else "",
425-
max_results,
426-
timeout,
427-
locale,
428-
)
429-
430-
# Build search request
431-
search_data = {
432-
"max_results": max_results,
433-
"timeout": timeout,
434-
"locale": locale,
435-
}
436-
437-
# Only include non-empty optional parameters
438-
if query and query.strip():
439-
search_data["query"] = query.strip()
440-
441-
if source and source.strip():
442-
search_data["source"] = source.strip()
443-
444-
# Use longer timeout for web search operations
445-
async with httpx.AsyncClient(timeout=90.0) as client:
446-
response = await client.post(
447-
f"{API_BASE_URL}/api/v2/web/search",
448-
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
449-
json=search_data,
450-
)
451-
if response.status_code == 200:
452-
try:
453-
# Parse response using view model for type safety
454-
search_response = WebSearchResponse.model_validate(response.json())
455-
logger.info(
456-
"MCP web_search completed query=%s source=%s status=%s results=%s providers=%s backends=%s fallback=%s",
457-
query.strip() if query else "",
458-
source.strip() if source else "",
459-
search_response.meta.search_status if search_response.meta else "unknown",
460-
len(search_response.results),
461-
search_response.meta.provider_used if search_response.meta else [],
462-
search_response.meta.backend_used if search_response.meta else [],
463-
search_response.meta.fallback_used if search_response.meta else False,
464-
)
465-
return search_response.model_dump()
466-
except Exception as e:
467-
logger.error(f"Failed to parse web search response: {e}")
468-
return {"error": "Failed to parse web search response", "details": str(e)}
469-
else:
470-
logger.warning(
471-
"MCP web_search failed status=%s query=%s source=%s body=%s",
472-
response.status_code,
473-
query.strip() if query else "",
474-
source.strip() if source else "",
475-
response.text,
476-
)
477-
return {"error": f"Web search failed: {response.status_code}", "details": response.text}
478-
except ValueError as e:
479-
return {"error": str(e)}
385+
# NOTE(D10.d #96 §B.4): the ``web_search`` tool implementation moved to
386+
# ``aperag.mcp.tools.search_web`` so all D10 search tools live in the
387+
# ``aperag/mcp/tools/`` subpackage. Wire signature is preserved (no
388+
# breaking change for external MCP callers); §B.4 spec parameter
389+
# canonicalization (``top_k`` / kw-only / ``source: str | None``) is
390+
# deferred to the D10.h cutover lane.
480391

481392

482393
@mcp_server.tool
@@ -844,5 +755,23 @@ def get_api_key() -> str:
844755
)
845756

846757

758+
# Phase 9 D10.d (#96, ``docs/modularization/d10-design-pack.md`` §B):
759+
# import the split search tool functions so their ``@mcp_server.tool``
760+
# decorators register the new surface (``vector_search`` /
761+
# ``graph_search`` / ``fulltext_search`` / ``web_search``). The imports
762+
# happen at the bottom of this module — after ``mcp_server``,
763+
# ``API_BASE_URL``, and ``get_api_key`` are defined — to break the
764+
# circular import cycle (``aperag.mcp.tools.search_*`` import from
765+
# ``aperag.mcp.server``).
766+
#
767+
# Re-exporting the function symbols at module level preserves the
768+
# existing ``aperag.mcp.server.web_search`` access path for backward
769+
# compatibility with callers (e.g. ``tests/unit_test/test_mcp_server.py``)
770+
# that read attributes off the server module directly.
771+
from aperag.mcp.tools.search_fulltext import fulltext_search # noqa: E402, F401
772+
from aperag.mcp.tools.search_graph import graph_search # noqa: E402, F401
773+
from aperag.mcp.tools.search_vector import vector_search # noqa: E402, F401
774+
from aperag.mcp.tools.search_web import web_search # noqa: E402, F401
775+
847776
# Export the server instance
848777
__all__ = ["mcp_server"]

aperag/mcp/tools/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@
4848
OutlineHeading,
4949
)
5050

51+
# Phase 9 D10.d (#96) — search primitives split (§B). Importing these
52+
# modules registers their ``@mcp_server.tool`` decorators with the
53+
# FastMCP instance owned by ``aperag.mcp.server``.
54+
from aperag.mcp.tools.search_fulltext import fulltext_search
55+
from aperag.mcp.tools.search_graph import graph_search
56+
from aperag.mcp.tools.search_vector import vector_search
57+
from aperag.mcp.tools.search_web import web_search
58+
5159
__all__ = [
5260
# Stable handle types (LOCKED per §A.9)
5361
"ChunkId",
@@ -74,4 +82,9 @@
7482
"DocumentOutline",
7583
"DocumentSection",
7684
"OutlineHeading",
85+
# Search primitive functions (per §B.1 - §B.4)
86+
"vector_search",
87+
"graph_search",
88+
"fulltext_search",
89+
"web_search",
7790
]
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright 2025 ApeCloud, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Phase 9 D10.d (#96) — fulltext_search split MCP tool (§B.3).
16+
17+
Replaces ``search_collection use_fulltext_index=True`` with a discrete
18+
tool per ``docs/modularization/d10-design-pack.md`` §B.3 (Lock #5
19+
split). ``search_collection`` itself remains as a deprecated alias
20+
(§B.5 / §H.1) until the D10.h cutover.
21+
22+
Wire shape: returns the existing ``SearchResult`` dict shape with
23+
``recall_type = "fulltext_search"`` for produced items. §B canonical
24+
shape follow-up gated on the chunk_id propagation amendment thread —
25+
see ``search_vector.py`` module docstring.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import logging
31+
from typing import Any, Dict
32+
33+
import httpx
34+
35+
from aperag.domains.retrieval.schemas import SearchResult
36+
from aperag.mcp.server import API_BASE_URL, get_api_key, mcp_server
37+
38+
logger = logging.getLogger(__name__)
39+
40+
41+
@mcp_server.tool
42+
async def fulltext_search(
43+
collection_id: str,
44+
query: str,
45+
top_k: int = 5,
46+
keywords: list[str] | None = None,
47+
) -> Dict[str, Any]:
48+
"""Full-text keyword search within a collection (§B.3).
49+
50+
Use this when:
51+
- You need exact keyword / phrase matches.
52+
- The query has identifiable proper nouns or domain-specific terms
53+
that benefit from inverted-index lookup over similarity.
54+
55+
Do not use this when:
56+
- You need semantic similarity; use ``vector_search``.
57+
- You need entity / relation traversal; use ``graph_search``.
58+
59+
What success means:
60+
- You retrieved candidate evidence ranked by full-text relevance
61+
(Elasticsearch / PostgreSQL FTS depending on collection backend).
62+
63+
What an empty result means:
64+
- No documents matched the keywords.
65+
- Consider relaxing keyword constraints, or trying ``vector_search``
66+
for fuzzier semantic match.
67+
68+
What failure may mean:
69+
- auth / permission: the current user cannot access this collection.
70+
- network / timeout: the full-text path did not complete.
71+
- bad request: the collection ID is invalid or full-text index missing.
72+
73+
Args:
74+
collection_id: The ID of the collection to search.
75+
query: The natural-language search query.
76+
top_k: Maximum number of results to return (default: 5).
77+
keywords: Optional explicit keyword list overriding the
78+
auto-extracted keywords from the query.
79+
80+
Returns:
81+
Search results with ``items`` carrying ``recall_type =
82+
"fulltext_search"``. Highlight snippets / matched terms are
83+
surfaced via ``items[*].metadata``.
84+
"""
85+
try:
86+
api_key = get_api_key()
87+
88+
fulltext_payload: Dict[str, Any] = {"topk": top_k}
89+
if keywords is not None:
90+
fulltext_payload["keywords"] = keywords
91+
92+
search_data: Dict[str, Any] = {
93+
"query": query,
94+
"fulltext_search": fulltext_payload,
95+
}
96+
97+
async with httpx.AsyncClient(timeout=120.0) as client:
98+
response = await client.post(
99+
f"{API_BASE_URL}/api/v2/collections/{collection_id}/searches",
100+
headers={
101+
"Authorization": f"Bearer {api_key}",
102+
"Content-Type": "application/json",
103+
},
104+
json=search_data,
105+
)
106+
if response.status_code in (200, 201):
107+
try:
108+
search_result = SearchResult.model_validate(response.json())
109+
if search_result.items and len(search_result.items) > top_k:
110+
search_result.items = search_result.items[:top_k]
111+
for i, item in enumerate(search_result.items):
112+
if item.rank is not None:
113+
item.rank = i + 1
114+
return search_result.model_dump()
115+
except Exception as exc:
116+
logger.error("Failed to parse fulltext_search response: %s", exc)
117+
return {
118+
"error": "Failed to parse fulltext_search response",
119+
"details": str(exc),
120+
}
121+
return {
122+
"error": f"fulltext_search failed: {response.status_code}",
123+
"details": response.text,
124+
}
125+
except ValueError as exc:
126+
return {"error": str(exc)}

0 commit comments

Comments
 (0)