diff --git a/backend/app/search_client.py b/backend/app/search_client.py
new file mode 100644
index 0000000..bfa9650
--- /dev/null
+++ b/backend/app/search_client.py
@@ -0,0 +1,43 @@
+import base64
+
+
+def _encode_key(key: str) -> str:
+ return base64.urlsafe_b64encode(key.encode("utf-8")).decode("ascii").rstrip("=")
+
+
+class SearchClient:
+ def __init__(self, client, index_name: str):
+ self._client = client
+ self._index_name = index_name
+
+ def index_document(self, key: str, path: str, title: str, content: str) -> None:
+ index = self._client.index(self._index_name)
+ index.add_documents([{
+ "id": _encode_key(key),
+ "key": key,
+ "path": path,
+ "title": title,
+ "content": content,
+ }])
+
+ def delete_document(self, key: str) -> None:
+ index = self._client.index(self._index_name)
+ index.delete_document(_encode_key(key))
+
+ def search(self, query: str) -> list[dict]:
+ index = self._client.index(self._index_name)
+ response = index.search(query, {
+ "attributesToHighlight": ["content"],
+ "highlightPreTag": "",
+ "highlightPostTag": "",
+ })
+ results = []
+ for hit in response.get("hits", []):
+ formatted = hit.get("_formatted", {})
+ results.append({
+ "key": hit["key"],
+ "title": hit["title"],
+ "path": hit["path"],
+ "snippet": formatted.get("content", hit.get("content", "")),
+ })
+ return results
diff --git a/backend/tests/test_search_client.py b/backend/tests/test_search_client.py
new file mode 100644
index 0000000..6d75f63
--- /dev/null
+++ b/backend/tests/test_search_client.py
@@ -0,0 +1,80 @@
+from app.search_client import SearchClient
+
+
+class FakeIndex:
+ def __init__(self):
+ self.documents = {}
+ self.deleted_ids = []
+
+ def add_documents(self, docs):
+ for doc in docs:
+ self.documents[doc["id"]] = doc
+
+ def delete_document(self, doc_id):
+ self.documents.pop(doc_id, None)
+ self.deleted_ids.append(doc_id)
+
+ def search(self, query, params=None):
+ hits = []
+ for doc in self.documents.values():
+ if query in doc["content"] or query in doc["title"]:
+ formatted = dict(doc)
+ if query in doc["content"]:
+ formatted["content"] = doc["content"].replace(query, f"{query}")
+ hits.append({**doc, "_formatted": formatted})
+ return {"hits": hits}
+
+
+class FakeMeilisearchSDK:
+ def __init__(self):
+ self.indexes = {}
+
+ def index(self, name):
+ if name not in self.indexes:
+ self.indexes[name] = FakeIndex()
+ return self.indexes[name]
+
+
+def test_index_document_adds_document_with_encoded_id():
+ sdk = FakeMeilisearchSDK()
+ client = SearchClient(sdk, index_name="documents")
+
+ client.index_document(
+ "产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 MinIO 存储文档"
+ )
+
+ index = sdk.indexes["documents"]
+ assert len(index.documents) == 1
+ stored = list(index.documents.values())[0]
+ assert stored["key"] == "产品文档/架构设计.md"
+ assert stored["path"] == "产品文档"
+ assert stored["title"] == "架构设计.md"
+ assert stored["content"] == "DocHub 使用 MinIO 存储文档"
+
+
+def test_delete_document_removes_document():
+ sdk = FakeMeilisearchSDK()
+ client = SearchClient(sdk, index_name="documents")
+ client.index_document("产品文档/旧文档.md", "产品文档", "旧文档.md", "旧内容")
+
+ client.delete_document("产品文档/旧文档.md")
+
+ index = sdk.indexes["documents"]
+ assert index.documents == {}
+ assert len(index.deleted_ids) == 1
+
+
+def test_search_returns_matching_documents_with_highlighted_snippet():
+ sdk = FakeMeilisearchSDK()
+ client = SearchClient(sdk, index_name="documents")
+ client.index_document(
+ "产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 支持全文检索功能"
+ )
+
+ results = client.search("检索")
+
+ assert len(results) == 1
+ assert results[0]["key"] == "产品文档/架构设计.md"
+ assert results[0]["title"] == "架构设计.md"
+ assert results[0]["path"] == "产品文档"
+ assert "检索" in results[0]["snippet"]