Skip to content

Commit 21b00f0

Browse files
earayuclaude
andauthored
feat(phase9 #96 D10.d): search primitives split + omnibus deprecation (#1713)
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 a652cb7 commit 21b00f0

7 files changed

Lines changed: 843 additions & 113 deletions

File tree

aperag/mcp/server.py

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

2323
# Import view models for type safety
2424
from aperag.domains.retrieval.schemas import SearchResult
25-
from aperag.domains.web_access.schemas import WebReadResponse, WebSearchResponse
25+
from aperag.domains.web_access.schemas import WebReadResponse
2626
from aperag.mcp.tools import (
2727
ByteRange,
2828
)
@@ -236,7 +236,15 @@ async def search_collection(
236236
topk: int = 5,
237237
query_keywords: list[str] = None,
238238
) -> Dict[str, Any]:
239-
"""Search a persistent knowledge base for evidence relevant to the current request.
239+
"""[DEPRECATED] Search a persistent knowledge base for evidence relevant to the current request.
240+
241+
[DEPRECATED] Phase 9 D10.d (#96, ``docs/modularization/d10-design-pack.md``
242+
§B.5 / §H.1): use the discrete split tools instead —
243+
``vector_search`` / ``graph_search`` / ``fulltext_search``. This
244+
omnibus tool is preserved as a deprecated alias for backward
245+
compatibility during the D10 migration window and will be removed in
246+
D11 once telemetry confirms no remaining external callers (D10.h
247+
cutover lane). Implementation is intentionally untouched.
240248
241249
Use this when:
242250
- You already know which collection should be searched.
@@ -397,7 +405,14 @@ async def search_chat_files(
397405
rerank: bool = True,
398406
topk: int = 5,
399407
) -> Dict[str, Any]:
400-
"""Search files uploaded in the current chat for evidence relevant to this turn.
408+
"""[DEPRECATED] Search files uploaded in the current chat for evidence relevant to this turn.
409+
410+
[DEPRECATED] Phase 9 D10.d (#96, ``docs/modularization/d10-design-pack.md``
411+
§H.2): chat-scoped omnibus search shares the deprecation timeline of
412+
``search_collection``. The split tool surface (``vector_search`` /
413+
``graph_search`` / ``fulltext_search``) is collection-scoped today;
414+
a chat-scoped equivalent will be sequenced in the D10.h cutover
415+
lane. Implementation is intentionally untouched.
401416
402417
Use this when:
403418
- The user refers to files shared in this chat session.
@@ -499,116 +514,12 @@ async def search_chat_files(
499514
return {"error": str(e)}
500515

501516

502-
@mcp_server.tool
503-
async def web_search(
504-
query: str = "",
505-
max_results: int = 5,
506-
timeout: int = 30,
507-
locale: str = "en-US",
508-
source: str = "",
509-
) -> Dict[str, Any]:
510-
"""Search the web for current or missing information.
511-
512-
Use this when:
513-
- The current turn allows web access.
514-
- You need current information, external verification, or gap-filling beyond ApeRAG collections.
515-
516-
Do not use this when:
517-
- The current turn disables web access.
518-
- Collection or chat-file evidence is already sufficient for the requested step.
519-
520-
What success means:
521-
- You received candidate web results with titles, snippets, and URLs.
522-
523-
What an empty result means:
524-
- No strong web results were found for this query and scope.
525-
- Use `meta.search_status` to distinguish a genuine empty result from `unavailable` or `disabled`.
526-
527-
What failure may mean:
528-
- network / timeout: external search could not complete.
529-
- upstream search provider issue: the search backend could not return usable results.
530-
531-
How to explain this step to the user:
532-
- While running: "Searching the web for current or missing information."
533-
- After completion: "Checked web sources for supporting information."
534-
535-
Args:
536-
query: Search query for web search. Optional when using source-only site browsing.
537-
max_results: Maximum number of results to return (default: 5)
538-
timeout: Request timeout in seconds (default: 30)
539-
locale: Browser locale (default: en-US)
540-
source: Optional domain or URL for site-specific filtering. When provided with query,
541-
limits search results to this domain (e.g., 'site:vercel.com query').
542-
543-
Returns:
544-
Web search results with URLs, titles, snippets, and metadata
545-
546-
Note:
547-
Uses JINA first when configured, otherwise falls back to DuckDuckGo.
548-
Search failures are soft-failed into empty result sets with lightweight `meta` diagnostics so
549-
downstream workflows stay stable while still distinguishing `ok`, `empty`, `unavailable`, and `disabled`.
550-
"""
551-
try:
552-
api_key = get_api_key()
553-
logger.info(
554-
"MCP web_search request query=%s source=%s max_results=%s timeout=%s locale=%s",
555-
query.strip() if query else "",
556-
source.strip() if source else "",
557-
max_results,
558-
timeout,
559-
locale,
560-
)
561-
562-
# Build search request
563-
search_data = {
564-
"max_results": max_results,
565-
"timeout": timeout,
566-
"locale": locale,
567-
}
568-
569-
# Only include non-empty optional parameters
570-
if query and query.strip():
571-
search_data["query"] = query.strip()
572-
573-
if source and source.strip():
574-
search_data["source"] = source.strip()
575-
576-
# Use longer timeout for web search operations
577-
async with httpx.AsyncClient(timeout=90.0) as client:
578-
response = await client.post(
579-
f"{API_BASE_URL}/api/v2/web/search",
580-
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
581-
json=search_data,
582-
)
583-
if response.status_code == 200:
584-
try:
585-
# Parse response using view model for type safety
586-
search_response = WebSearchResponse.model_validate(response.json())
587-
logger.info(
588-
"MCP web_search completed query=%s source=%s status=%s results=%s providers=%s backends=%s fallback=%s",
589-
query.strip() if query else "",
590-
source.strip() if source else "",
591-
search_response.meta.search_status if search_response.meta else "unknown",
592-
len(search_response.results),
593-
search_response.meta.provider_used if search_response.meta else [],
594-
search_response.meta.backend_used if search_response.meta else [],
595-
search_response.meta.fallback_used if search_response.meta else False,
596-
)
597-
return search_response.model_dump()
598-
except Exception as e:
599-
logger.error(f"Failed to parse web search response: {e}")
600-
return {"error": "Failed to parse web search response", "details": str(e)}
601-
else:
602-
logger.warning(
603-
"MCP web_search failed status=%s query=%s source=%s body=%s",
604-
response.status_code,
605-
query.strip() if query else "",
606-
source.strip() if source else "",
607-
response.text,
608-
)
609-
return {"error": f"Web search failed: {response.status_code}", "details": response.text}
610-
except ValueError as e:
611-
return {"error": str(e)}
517+
# NOTE(D10.d #96 §B.4): the ``web_search`` tool implementation moved to
518+
# ``aperag.mcp.tools.search_web`` so all D10 search tools live in the
519+
# ``aperag/mcp/tools/`` subpackage. Wire signature is preserved (no
520+
# breaking change for external MCP callers); §B.4 spec parameter
521+
# canonicalization (``top_k`` / kw-only / ``source: str | None``) is
522+
# deferred to the D10.h cutover lane.
612523

613524

614525
@mcp_server.tool
@@ -976,5 +887,23 @@ def get_api_key() -> str:
976887
)
977888

978889

890+
# Phase 9 D10.d (#96, ``docs/modularization/d10-design-pack.md`` §B):
891+
# import the split search tool functions so their ``@mcp_server.tool``
892+
# decorators register the new surface (``vector_search`` /
893+
# ``graph_search`` / ``fulltext_search`` / ``web_search``). The imports
894+
# happen at the bottom of this module — after ``mcp_server``,
895+
# ``API_BASE_URL``, and ``get_api_key`` are defined — to break the
896+
# circular import cycle (``aperag.mcp.tools.search_*`` import from
897+
# ``aperag.mcp.server``).
898+
#
899+
# Re-exporting the function symbols at module level preserves the
900+
# existing ``aperag.mcp.server.web_search`` access path for backward
901+
# compatibility with callers (e.g. ``tests/unit_test/test_mcp_server.py``)
902+
# that read attributes off the server module directly.
903+
from aperag.mcp.tools.search_fulltext import fulltext_search # noqa: E402, F401
904+
from aperag.mcp.tools.search_graph import graph_search # noqa: E402, F401
905+
from aperag.mcp.tools.search_vector import vector_search # noqa: E402, F401
906+
from aperag.mcp.tools.search_web import web_search # noqa: E402, F401
907+
979908
# Export the server instance
980909
__all__ = ["mcp_server"]

aperag/mcp/tools/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@
4949
OutlineHeading,
5050
)
5151

52+
# NOTE(D10.d #96): the D10.d split search tool modules
53+
# (``aperag.mcp.tools.search_{vector,graph,fulltext,web}``) are
54+
# intentionally **not** re-exported from this package's ``__init__``
55+
# because they ``from aperag.mcp.server import mcp_server, ...`` at
56+
# module load time. After D10.c implementation (#1714 / #95) added
57+
# top-level ``from aperag.mcp.tools import ...`` to ``aperag/mcp/server.py``,
58+
# importing the search modules from here would create a partial-load
59+
# cycle: server.py top imports trigger this ``__init__`` → search_*
60+
# modules try to import from ``aperag.mcp.server`` → server.py is still
61+
# in mid-execution and ``mcp_server`` / ``API_BASE_URL`` /
62+
# ``get_api_key`` are not yet defined. Registration therefore happens
63+
# only via the bottom-of-server.py import block (after the symbols
64+
# exist). Consumers that need the function symbols can import directly
65+
# from the per-tool module path or via the ``aperag.mcp.server`` module
66+
# attribute (which re-exports them at module level for backward compat).
67+
5268
__all__ = [
5369
# Stable handle types (LOCKED per §A.9)
5470
"ChunkId",
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)