diff --git a/project-docs/V2-ai-platform-embedding-pgvector-2026-06-22.md b/project-docs/V2-ai-platform-embedding-pgvector-2026-06-22.md index cb63a3bd..2dff35ff 100644 --- a/project-docs/V2-ai-platform-embedding-pgvector-2026-06-22.md +++ b/project-docs/V2-ai-platform-embedding-pgvector-2026-06-22.md @@ -44,6 +44,13 @@ - PostgreSQL + pgvector 环境会重新写入 `knowledge_chunks.embedding`。 - Python SDK 新增 `reindex_knowledge_documents(...)`。 +7. 检索 explain 闭环。 + - 新增 `GET /api/v1/knowledge-chunks/search/explain`。 + - 返回 normalized terms、候选 chunk 数、命中数、keyword/vector/hybrid 分数。 + - 返回每个候选项的 `included`、`rank`、`filter_reason`。 + - Python SDK 新增 `explain_knowledge_search(...)`。 + - JSON fallback 的向量分改为 cosine 口径,便于和 pgvector cosine 距离保持一致。 + ## 生产配置建议 ```powershell @@ -73,3 +80,4 @@ python -m alembic upgrade head - provider 不可用且允许 fallback 时:chunk 仍可写入,检索仍可用,但 provider 显示 `local-hash`。 - pgvector 可用时:优先使用数据库向量距离排序。 - pgvector 不可用时:回退 JSON embedding,不影响法律问答主链路。 +- explain 接口可说明命中依据和过滤原因,用于法律问答可信度展示和检索调参。 diff --git a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml index 80a7831e..ff5ed664 100644 --- a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml +++ b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml @@ -373,6 +373,52 @@ paths: type: array items: $ref: "#/components/schemas/KnowledgeSearchResult" + /api/v1/knowledge-chunks/search/explain: + get: + operationId: explainKnowledgeSearch + summary: Explain knowledge chunk retrieval scoring and filtering + parameters: + - name: keyword + in: query + required: false + schema: + type: string + default: "" + - name: knowledge_base_id + in: query + required: false + schema: + type: string + - name: limit + in: query + required: false + schema: + type: integer + default: 5 + minimum: 1 + maximum: 20 + - name: candidate_limit + in: query + required: false + schema: + type: integer + default: 20 + minimum: 1 + maximum: 100 + - name: strategy + in: query + required: false + schema: + type: string + enum: [keyword, vector, hybrid] + default: hybrid + responses: + "200": + description: Retrieval explain result + content: + application/json: + schema: + $ref: "#/components/schemas/KnowledgeSearchExplain" /api/v1/audit/ai-runs: get: operationId: listAiRuns @@ -969,6 +1015,95 @@ components: type: array items: type: string + KnowledgeSearchExplainItem: + type: object + required: + - chunk + - included + properties: + chunk: + $ref: "#/components/schemas/KnowledgeChunk" + score: + type: number + format: float + keyword_score: + type: number + format: float + vector_score: + type: number + format: float + hybrid_score: + type: number + format: float + retrieval_strategy: + type: string + enum: [keyword, vector, hybrid] + matched_terms: + type: array + items: + type: string + included: + type: boolean + rank: + type: integer + nullable: true + filter_reason: + type: string + nullable: true + KnowledgeSearchExplain: + type: object + required: + - keyword + - retrieval_strategy + - embedding_provider + - embedding_model + - results + properties: + keyword: + type: string + normalized_terms: + type: array + items: + type: string + retrieval_strategy: + type: string + enum: [keyword, vector, hybrid] + knowledge_base_ids: + type: array + items: + type: string + limit: + type: integer + candidate_limit: + type: integer + candidate_count: + type: integer + included_count: + type: integer + retrieval_backend: + type: string + embedding_provider: + type: string + embedding_model: + type: string + query_embedding_dimension: + type: integer + keyword_threshold: + type: number + format: float + vector_threshold: + type: number + format: float + hybrid_keyword_weight: + type: number + format: float + hybrid_vector_weight: + type: number + format: float + results: + type: array + items: + $ref: "#/components/schemas/KnowledgeSearchExplainItem" RetrievalSource: type: object required: diff --git a/yuqei-ai-platform/services/ai-platform-api/README.md b/yuqei-ai-platform/services/ai-platform-api/README.md index 75102d6b..1be64740 100644 --- a/yuqei-ai-platform/services/ai-platform-api/README.md +++ b/yuqei-ai-platform/services/ai-platform-api/README.md @@ -71,6 +71,7 @@ Core MVP endpoints: - `GET /api/v1/knowledge-documents/{document_id}/chunks` - `GET /api/v1/knowledge-documents/search` - `GET /api/v1/knowledge-chunks/search?strategy=keyword|vector|hybrid` +- `GET /api/v1/knowledge-chunks/search/explain?strategy=keyword|vector|hybrid` - `POST /api/v1/legal/qa` - `GET /api/v1/audit/ai-runs` @@ -78,6 +79,10 @@ Knowledge chunk search returns keyword, vector, and hybrid scores. Legal QA uses hybrid retrieval by default and records retrieved chunk ids plus structured source metadata in AI run audits. +Use `GET /api/v1/knowledge-chunks/search/explain` to inspect normalized query +terms, keyword/vector/hybrid scores, inclusion rank, and filter reasons for the +candidate chunks considered by retrieval. + After switching from `local-hash-v1` to a real embedding model, call `POST /api/v1/knowledge-documents/reindex` with a `document_id`, `knowledge_base_id`, or `import_batch_id` filter to rebuild historical chunks diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py index 2a11d425..d32f3333 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py @@ -21,6 +21,7 @@ from yuqei_ai_platform_api.repository import ( KnowledgeImportBatchCreate, KnowledgeReindexRequest, KnowledgeReindexResult, + KnowledgeSearchExplain, KnowledgeSearchResult, PromptTemplate, PromptTemplateCreate, @@ -180,6 +181,26 @@ def create_app( strategy=strategy, ) + @app.get( + f"{resolved_settings.api_prefix}/knowledge-chunks/search/explain", + response_model=KnowledgeSearchExplain, + tags=["knowledge"], + ) + def explain_knowledge_search( + keyword: str = "", + knowledge_base_id: str | None = None, + limit: int = Query(5, ge=1, le=20), + candidate_limit: int = Query(20, ge=1, le=100), + strategy: str = Query("hybrid", pattern="^(keyword|vector|hybrid)$"), + ) -> KnowledgeSearchExplain: + return store.explain_knowledge_search( + keyword, + knowledge_base_ids=[knowledge_base_id] if knowledge_base_id else None, + limit=limit, + candidate_limit=candidate_limit, + strategy=strategy, + ) + @app.get( f"{resolved_settings.api_prefix}/audit/ai-runs", response_model=list[AiRunAudit], diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py index 11f9eccc..291f707f 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math from dataclasses import dataclass, field from datetime import UTC, datetime from threading import Lock @@ -124,6 +125,39 @@ class KnowledgeSearchResult(BaseModel): matched_terms: list[str] = Field(default_factory=list) +class KnowledgeSearchExplainItem(BaseModel): + chunk: KnowledgeChunk + score: float = 0.0 + keyword_score: float = 0.0 + vector_score: float = 0.0 + hybrid_score: float = 0.0 + retrieval_strategy: str = "hybrid" + matched_terms: list[str] = Field(default_factory=list) + included: bool = False + rank: int | None = None + filter_reason: str | None = None + + +class KnowledgeSearchExplain(BaseModel): + keyword: str + normalized_terms: list[str] = Field(default_factory=list) + retrieval_strategy: str = "hybrid" + knowledge_base_ids: list[str] = Field(default_factory=list) + limit: int = 5 + candidate_limit: int = 20 + candidate_count: int = 0 + included_count: int = 0 + retrieval_backend: str = "json-explain" + embedding_provider: str + embedding_model: str + query_embedding_dimension: int = 0 + keyword_threshold: float = 0.0 + vector_threshold: float = MIN_VECTOR_RECALL_SCORE + hybrid_keyword_weight: float = 0.55 + hybrid_vector_weight: float = 0.45 + results: list[KnowledgeSearchExplainItem] = Field(default_factory=list) + + class KnowledgeReindexRequest(BaseModel): document_id: str | None = None knowledge_base_id: str | None = None @@ -216,6 +250,16 @@ class AiPlatformRepository(Protocol): strategy: str = "hybrid", ) -> list[KnowledgeSearchResult]: ... + def explain_knowledge_search( + self, + keyword: str, + *, + knowledge_base_ids: list[str] | None = None, + limit: int = 5, + candidate_limit: int = 20, + strategy: str = "hybrid", + ) -> KnowledgeSearchExplain: ... + def reindex_knowledge_documents(self, payload: KnowledgeReindexRequest) -> KnowledgeReindexResult: ... def add_ai_run_audit(self, audit: AiRunAudit) -> AiRunAudit: ... @@ -397,6 +441,27 @@ class InMemoryAiPlatformRepository: embedding_provider=self.embedding_provider, ) + def explain_knowledge_search( + self, + keyword: str, + *, + knowledge_base_ids: list[str] | None = None, + limit: int = 5, + candidate_limit: int = 20, + strategy: str = "hybrid", + ) -> KnowledgeSearchExplain: + with self._lock: + chunks = list(self.knowledge_chunks.values()) + return _explain_knowledge_chunks( + chunks, + keyword, + knowledge_base_ids=knowledge_base_ids, + limit=limit, + candidate_limit=candidate_limit, + strategy=strategy, + embedding_provider=self.embedding_provider, + ) + def reindex_knowledge_documents(self, payload: KnowledgeReindexRequest) -> KnowledgeReindexResult: with self._lock: documents = [ @@ -683,6 +748,30 @@ class SqlAlchemyAiPlatformRepository: embedding_provider=self._embedding_provider, ) + def explain_knowledge_search( + self, + keyword: str, + *, + knowledge_base_ids: list[str] | None = None, + limit: int = 5, + candidate_limit: int = 20, + strategy: str = "hybrid", + ) -> KnowledgeSearchExplain: + with self._session_factory() as session: + statement = select(KnowledgeChunkModel).order_by(KnowledgeChunkModel.created_at.desc()) + if knowledge_base_ids: + statement = statement.where(KnowledgeChunkModel.knowledge_base_id.in_(knowledge_base_ids)) + rows = session.scalars(statement.limit(candidate_limit)).all() + return _explain_knowledge_chunks( + [_knowledge_chunk_from_model(row) for row in rows], + keyword, + knowledge_base_ids=knowledge_base_ids, + limit=limit, + candidate_limit=candidate_limit, + strategy=strategy, + embedding_provider=self._embedding_provider, + ) + def reindex_knowledge_documents(self, payload: KnowledgeReindexRequest) -> KnowledgeReindexResult: with self._session_factory.begin() as session: statement = select(KnowledgeDocumentModel).order_by(KnowledgeDocumentModel.created_at.desc()) @@ -1207,8 +1296,7 @@ def _rank_knowledge_chunks( for chunk in chunks: if allowed_base_ids and chunk.knowledge_base_id not in allowed_base_ids: continue - haystack = f"{chunk.title}\n{chunk.reference}\n{chunk.content}".lower() - matched_terms = [term for term in terms if term in haystack] + matched_terms = _matched_terms(chunk, terms) keyword_score = _score_chunk(chunk, matched_terms, terms_count=len(terms)) vector_score = _vector_score(query_vector, _chunk_embedding(chunk)) if terms and not _passes_retrieval_threshold( @@ -1217,13 +1305,13 @@ def _rank_knowledge_chunks( strategy=retrieval_strategy, ): continue - hybrid_score = round(keyword_score * 0.55 + vector_score * 0.45, 4) - if retrieval_strategy == "keyword": - score = keyword_score - elif retrieval_strategy == "vector": - score = vector_score - else: - score = hybrid_score + hybrid_score = _hybrid_score(keyword_score=keyword_score, vector_score=vector_score) + score = _retrieval_score( + keyword_score=keyword_score, + vector_score=vector_score, + hybrid_score=hybrid_score, + strategy=retrieval_strategy, + ) if terms and score <= 0: continue results.append( @@ -1240,10 +1328,112 @@ def _rank_knowledge_chunks( return sorted(results, key=lambda item: (item.score, item.chunk.created_at), reverse=True)[:limit] +def _explain_knowledge_chunks( + chunks: list[KnowledgeChunk], + keyword: str, + *, + knowledge_base_ids: list[str] | None = None, + limit: int = 5, + candidate_limit: int = 20, + strategy: str = "hybrid", + embedding_provider: EmbeddingProvider | None = None, +) -> KnowledgeSearchExplain: + allowed_base_ids = set(knowledge_base_ids or []) + retrieval_strategy = strategy if strategy in RETRIEVAL_STRATEGIES else "hybrid" + terms = _tokenize(keyword) + provider = embedding_provider or LocalHashEmbeddingProvider() + query_vector = _embed_query(keyword, provider) + candidates = sorted(chunks, key=lambda item: item.created_at, reverse=True)[:candidate_limit] + items: list[KnowledgeSearchExplainItem] = [] + + for chunk in candidates: + base_filtered = bool(allowed_base_ids and chunk.knowledge_base_id not in allowed_base_ids) + matched_terms = _matched_terms(chunk, terms) if not base_filtered else [] + keyword_score = 0.0 if base_filtered else _score_chunk(chunk, matched_terms, terms_count=len(terms)) + vector_score = 0.0 if base_filtered else _vector_score(query_vector, _chunk_embedding(chunk)) + hybrid_score = _hybrid_score(keyword_score=keyword_score, vector_score=vector_score) + score = _retrieval_score( + keyword_score=keyword_score, + vector_score=vector_score, + hybrid_score=hybrid_score, + strategy=retrieval_strategy, + ) + included = not base_filtered and _passes_explain_threshold( + terms=terms, + score=score, + keyword_score=keyword_score, + vector_score=vector_score, + strategy=retrieval_strategy, + ) + items.append( + KnowledgeSearchExplainItem( + chunk=chunk, + score=score, + keyword_score=keyword_score, + vector_score=vector_score, + hybrid_score=hybrid_score, + retrieval_strategy=retrieval_strategy, + matched_terms=matched_terms, + included=included, + filter_reason=_explain_filter_reason( + base_filtered=base_filtered, + terms=terms, + score=score, + keyword_score=keyword_score, + vector_score=vector_score, + strategy=retrieval_strategy, + ), + ) + ) + + included_items = sorted( + [item for item in items if item.included], + key=lambda item: (item.score, item.chunk.created_at), + reverse=True, + ) + included_ids: set[str] = set() + for rank, item in enumerate(included_items[:limit], start=1): + item.rank = rank + included_ids.add(item.chunk.id) + for item in items: + if item.included and item.chunk.id not in included_ids: + item.included = False + item.filter_reason = "below_limit" + + ordered_items = sorted( + items, + key=lambda item: ( + 1 if item.included else 0, + item.score, + item.chunk.created_at, + ), + reverse=True, + ) + return KnowledgeSearchExplain( + keyword=keyword, + normalized_terms=terms, + retrieval_strategy=retrieval_strategy, + knowledge_base_ids=knowledge_base_ids or [], + limit=limit, + candidate_limit=candidate_limit, + candidate_count=len(candidates), + included_count=len(included_ids), + embedding_provider=provider.provider_name, + embedding_model=provider.model, + query_embedding_dimension=len(query_vector), + results=ordered_items, + ) + + def _tokenize(keyword: str) -> list[str]: return tokenize_embedding_text(keyword) +def _matched_terms(chunk: KnowledgeChunk, terms: list[str]) -> list[str]: + haystack = f"{chunk.title}\n{chunk.reference}\n{chunk.content}".lower() + return [term for term in terms if term in haystack] + + def _score_chunk(chunk: KnowledgeChunk, matched_terms: list[str], *, terms_count: int) -> float: if terms_count == 0: return 0.1 @@ -1256,6 +1446,24 @@ def _score_chunk(chunk: KnowledgeChunk, matched_terms: list[str], *, terms_count return round(min(coverage * 0.65 + density * 0.2 + title_bonus + reference_bonus, 1.0), 4) +def _hybrid_score(*, keyword_score: float, vector_score: float) -> float: + return round(keyword_score * 0.55 + vector_score * 0.45, 4) + + +def _retrieval_score( + *, + keyword_score: float, + vector_score: float, + hybrid_score: float, + strategy: str, +) -> float: + if strategy == "keyword": + return keyword_score + if strategy == "vector": + return vector_score + return hybrid_score + + def _passes_retrieval_threshold( *, keyword_score: float, @@ -1269,6 +1477,49 @@ def _passes_retrieval_threshold( return keyword_score > 0 or vector_score >= MIN_VECTOR_RECALL_SCORE +def _passes_explain_threshold( + *, + terms: list[str], + score: float, + keyword_score: float, + vector_score: float, + strategy: str, +) -> bool: + if terms and score <= 0: + return False + if not terms: + return score > 0 + return _passes_retrieval_threshold( + keyword_score=keyword_score, + vector_score=vector_score, + strategy=strategy, + ) + + +def _explain_filter_reason( + *, + base_filtered: bool, + terms: list[str], + score: float, + keyword_score: float, + vector_score: float, + strategy: str, +) -> str | None: + if base_filtered: + return "knowledge_base_filtered" + if not terms: + return None if score > 0 else "empty_query_no_score" + if strategy == "keyword" and keyword_score <= 0: + return "keyword_score_zero" + if strategy == "vector" and vector_score < MIN_VECTOR_RECALL_SCORE: + return "vector_below_threshold" + if strategy == "hybrid" and keyword_score <= 0 and vector_score < MIN_VECTOR_RECALL_SCORE: + return "keyword_and_vector_below_threshold" + if score <= 0: + return "score_zero" + return None + + def _embed_query(text: str, embedding_provider: EmbeddingProvider) -> list[float]: embeddings = embedding_provider.embed_texts([text]) return embeddings[0].vector if embeddings else [] @@ -1285,7 +1536,11 @@ def _vector_score(query_vector: list[float], chunk_vector: list[float]) -> float return 0.0 length = min(len(query_vector), len(chunk_vector)) dot = sum(query_vector[index] * chunk_vector[index] for index in range(length)) - return round(max(dot, 0.0), 4) + query_norm = math.sqrt(sum(value * value for value in query_vector[:length])) + chunk_norm = math.sqrt(sum(value * value for value in chunk_vector[:length])) + if query_norm <= 0 or chunk_norm <= 0: + return 0.0 + return round(max(dot / (query_norm * chunk_norm), 0.0), 4) def _encode_embedding(vector: list[float]) -> str | None: diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py index 4eed926b..d82d7b9b 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py @@ -194,6 +194,59 @@ def test_repository_uses_injected_embedding_provider_for_chunks_and_search() -> assert result.vector_score >= 0.35 +def test_knowledge_search_explain_returns_scores_rank_and_filter_reason() -> None: + repository = InMemoryAiPlatformRepository(embedding_provider=TestEmbeddingProvider()) + repository.add_knowledge_document( + KnowledgeDocumentCreate( + knowledge_base_id="laws-cn", + title="Lease Policy", + source_type="policy", + reference="Lease-001", + content="Deposit refund must be clearly written.", + ) + ) + repository.add_knowledge_document( + KnowledgeDocumentCreate( + knowledge_base_id="enterprise", + title="Employment Policy", + source_type="policy", + reference="EMP-001", + content="Probation period must be approved by HR.", + ) + ) + app = create_app(AiPlatformSettings(environment="test"), repository=repository) + client = TestClient(app) + + response = client.get( + "/api/v1/knowledge-chunks/search/explain", + params={ + "keyword": "deposit refund", + "knowledge_base_id": "laws-cn", + "strategy": "hybrid", + "limit": 1, + "candidate_limit": 10, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["keyword"] == "deposit refund" + assert payload["retrieval_strategy"] == "hybrid" + assert payload["embedding_provider"] == "test-embedding" + assert payload["embedding_model"] == "test-embedding-v1" + assert payload["query_embedding_dimension"] == 3 + assert payload["candidate_count"] == 2 + assert payload["included_count"] == 1 + included = next(item for item in payload["results"] if item["included"]) + filtered = next(item for item in payload["results"] if not item["included"]) + assert included["rank"] == 1 + assert included["keyword_score"] > 0 + assert included["vector_score"] >= 0.35 + assert included["matched_terms"] + assert included["filter_reason"] is None + assert filtered["filter_reason"] == "knowledge_base_filtered" + + def test_knowledge_reindex_rebuilds_chunks_with_current_embedding_provider() -> None: repository = InMemoryAiPlatformRepository() app = create_app(AiPlatformSettings(environment="test"), repository=repository) diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py b/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py index 5f4416f7..5c97e0a5 100644 --- a/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py +++ b/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py @@ -8,6 +8,8 @@ from yuqei_sdk.ai_platform import ( KnowledgeImportBatchCreate, KnowledgeReindexRequest, KnowledgeReindexResult, + KnowledgeSearchExplain, + KnowledgeSearchExplainItem, KnowledgeSearchResult, LegalCitation, LegalQaRequest, @@ -30,6 +32,8 @@ __all__ = [ "KnowledgeImportBatchCreate", "KnowledgeReindexRequest", "KnowledgeReindexResult", + "KnowledgeSearchExplain", + "KnowledgeSearchExplainItem", "KnowledgeSearchResult", "LegalCitation", "LegalQaRequest", diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py index 4d48dffb..1931dfe2 100644 --- a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py +++ b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py @@ -123,6 +123,39 @@ class KnowledgeSearchResult(BaseModel): matched_terms: list[str] = Field(default_factory=list) +class KnowledgeSearchExplainItem(BaseModel): + chunk: KnowledgeChunk + score: float = 0.0 + keyword_score: float = 0.0 + vector_score: float = 0.0 + hybrid_score: float = 0.0 + retrieval_strategy: str = "hybrid" + matched_terms: list[str] = Field(default_factory=list) + included: bool = False + rank: int | None = None + filter_reason: str | None = None + + +class KnowledgeSearchExplain(BaseModel): + keyword: str + normalized_terms: list[str] = Field(default_factory=list) + retrieval_strategy: str = "hybrid" + knowledge_base_ids: list[str] = Field(default_factory=list) + limit: int = 5 + candidate_limit: int = 20 + candidate_count: int = 0 + included_count: int = 0 + retrieval_backend: str = "json-explain" + embedding_provider: str + embedding_model: str + query_embedding_dimension: int = 0 + keyword_threshold: float = 0.0 + vector_threshold: float = 0.35 + hybrid_keyword_weight: float = 0.55 + hybrid_vector_weight: float = 0.45 + results: list[KnowledgeSearchExplainItem] = Field(default_factory=list) + + class KnowledgeReindexRequest(BaseModel): document_id: str | None = None knowledge_base_id: str | None = None @@ -368,6 +401,32 @@ class AiPlatformClient: response.raise_for_status() return [KnowledgeSearchResult.model_validate(item) for item in response.json()] + def explain_knowledge_search( + self, + keyword: str = "", + *, + knowledge_base_id: str | None = None, + limit: int = 5, + candidate_limit: int = 20, + strategy: str = "hybrid", + context: RequestContext | None = None, + ) -> KnowledgeSearchExplain: + params: dict[str, Any] = { + "keyword": keyword, + "limit": limit, + "candidate_limit": candidate_limit, + "strategy": strategy, + } + if knowledge_base_id: + params["knowledge_base_id"] = knowledge_base_id + response = self._client.get( + f"{self._api_prefix}/knowledge-chunks/search/explain", + params=params, + headers=(context or RequestContext()).to_headers(), + ) + response.raise_for_status() + return KnowledgeSearchExplain.model_validate(response.json()) + def list_ai_run_audits( self, *, diff --git a/yuqei-ai-sdk-python/tests/test_ai_platform.py b/yuqei-ai-sdk-python/tests/test_ai_platform.py index 408dfce9..57251fce 100644 --- a/yuqei-ai-sdk-python/tests/test_ai_platform.py +++ b/yuqei-ai-sdk-python/tests/test_ai_platform.py @@ -284,6 +284,62 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None: } ], ) + if request.url.path == "/api/v1/knowledge-chunks/search/explain": + query = parse_qs(request.url.query.decode()) + assert query["keyword"] == ["lease"] + assert query["limit"] == ["3"] + assert query["candidate_limit"] == ["9"] + assert query["strategy"] == ["hybrid"] + return httpx.Response( + 200, + json={ + "keyword": "lease", + "normalized_terms": ["lease"], + "retrieval_strategy": "hybrid", + "knowledge_base_ids": [], + "limit": 3, + "candidate_limit": 9, + "candidate_count": 1, + "included_count": 1, + "retrieval_backend": "json-explain", + "embedding_provider": "local-hash", + "embedding_model": "local-hash-v1", + "query_embedding_dimension": 64, + "keyword_threshold": 0.0, + "vector_threshold": 0.35, + "hybrid_keyword_weight": 0.55, + "hybrid_vector_weight": 0.45, + "results": [ + { + "chunk": { + "id": "chunk-1", + "document_id": "doc-1", + "import_batch_id": "kb-batch-1", + "knowledge_base_id": "laws-cn", + "title": "Civil Code", + "source_type": "law", + "reference": "Article 703", + "content": "A lease contract defines rent and use of leased property.", + "chunk_index": 1, + "locator": "chunk-1", + "embedding_provider": "local-hash", + "embedding_model": "local-hash-v1", + "embedding_dimension": 64, + "created_at": "2026-06-22T00:00:00Z", + }, + "score": 0.82, + "keyword_score": 0.76, + "vector_score": 0.89, + "hybrid_score": 0.82, + "retrieval_strategy": "hybrid", + "matched_terms": ["lease"], + "included": True, + "rank": 1, + "filter_reason": None, + } + ], + }, + ) if request.url.path == "/api/v1/audit/ai-runs": return httpx.Response( 200, @@ -369,6 +425,10 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None: chunk_result = client.search_knowledge_chunks("lease", limit=3) assert chunk_result[0].score == 0.82 assert chunk_result[0].hybrid_score == 0.82 + explain = client.explain_knowledge_search("lease", limit=3, candidate_limit=9) + assert explain.embedding_provider == "local-hash" + assert explain.results[0].included is True + assert explain.results[0].rank == 1 audit = client.list_ai_run_audits(limit=5)[0] assert audit.operation == "legal_qa" assert audit.retrieved_chunk_ids == ["chunk-1"]