retrieval_evaluation.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. import asyncio
  2. import hashlib
  3. import json
  4. from pathlib import Path
  5. from time import perf_counter
  6. from uuid import UUID, uuid4
  7. from test_after_sales import scenario # noqa: F401
  8. from zhifu.modules.identity.access_models import StoreGrant
  9. from zhifu.modules.knowledge.config import KnowledgeSettings
  10. from zhifu.modules.knowledge.vector import connection, search
  11. async def publish_dataset(context):
  12. dataset = json.loads(
  13. await asyncio.to_thread(Path("evaluations/retrieval.json").read_text, encoding="utf-8")
  14. )
  15. async with context["app"].state.sessions.begin() as session:
  16. session.add(
  17. StoreGrant(
  18. tenant_id=context["tenant"].bytes,
  19. principal_id=context["accounts"]["agent"].bytes,
  20. store_id=context["store"].bytes,
  21. permission="knowledge:write",
  22. )
  23. )
  24. identifiers = {}
  25. for document in dataset["documents"]:
  26. identifier = str(uuid4())
  27. identifiers[identifier] = document["key"]
  28. path = f"/admin/v1/stores/{context['store']}/knowledge/{identifier}"
  29. response = await context["call"](
  30. "PUT",
  31. path,
  32. "agent",
  33. {
  34. "expected_version": 0,
  35. "title": document["title"],
  36. "text": document["text"],
  37. },
  38. )
  39. assert response.status_code == 200, response.text
  40. response = await context["call"](
  41. "POST", path + "/publish", "agent", {"expected_version": 1}
  42. )
  43. assert response.status_code == 200, response.text
  44. return dataset, identifiers
  45. async def test_retrieval_quality(scenario): # noqa: F811
  46. try:
  47. dataset, identifiers = await publish_dataset(scenario)
  48. settings = KnowledgeSettings()
  49. results = []
  50. async with asyncio.timeout(180):
  51. await evaluate_queries(scenario, dataset, identifiers, results)
  52. report = {
  53. "dataset": dataset["version"],
  54. "dataset_sha256": hashlib.sha256(await asyncio.to_thread(
  55. Path("evaluations/retrieval.json").read_bytes)).hexdigest(),
  56. "origin": "local_regression",
  57. "production_acceptance": "NOT_RUN",
  58. "embedding_model": settings.embedding_model,
  59. "embedding_revision": settings.embedding_revision,
  60. "generation_attempts": 0,
  61. "request_limit": 51,
  62. "query_timeout_seconds": 180,
  63. "documents": len(identifiers),
  64. "queries": len(results),
  65. "recall": {
  66. str(k): sum(row["rank"] is not None and row["rank"] <= k for row in results)
  67. / len(results)
  68. for k in (1, 3, 5, 10)
  69. },
  70. "mrr_at_10": sum(1 / row["rank"] if row["rank"] else 0 for row in results)
  71. / len(results),
  72. "results": results,
  73. }
  74. await asyncio.to_thread(
  75. Path("docs/verification/retrieval-at10-results.json").write_text,
  76. json.dumps(report, ensure_ascii=False, indent=2),
  77. encoding="utf-8",
  78. )
  79. assert report["recall"]["10"] >= 0.9, report
  80. assert report["mrr_at_10"] >= 0.8, report
  81. finally:
  82. with connection() as (client, collection):
  83. client.delete(collection, filter=f'tenant == "{scenario["tenant"].hex}"')
  84. async def evaluate_queries(scenario, dataset, identifiers, results): # noqa: F811
  85. path = f"/openapi/v1/stores/{scenario['store']}/knowledge/search"
  86. for limit in (0, 11):
  87. response = await scenario["call"](
  88. "POST", path, payload={"query": "换货", "limit": limit})
  89. assert response.status_code == 422
  90. default = await scenario["call"]("POST", path, payload={"query": "换货"})
  91. assert default.status_code == 200
  92. assert len(default.json()["items"]) == 5
  93. for question in dataset["queries"]:
  94. started = perf_counter()
  95. response = await scenario["call"](
  96. "POST", path, payload={"query": question["query"], "limit": 10})
  97. elapsed = perf_counter() - started
  98. assert response.status_code == 200, response.text
  99. keys = list(dict.fromkeys(
  100. identifiers[item["document_id"]] for item in response.json()["items"]))
  101. rank = keys.index(question["expected"]) + 1 if question["expected"] in keys else None
  102. reference = await search(
  103. scenario["tenant"].bytes, scenario["store"].bytes, question["query"])
  104. reference_keys = list(dict.fromkeys(
  105. identifiers[str(UUID(hex=item["entity"]["document"]))] for item in reference[:10]))
  106. assert keys == reference_keys, {
  107. "query": question["query"], "pooled": keys, "reference": reference_keys}
  108. results.append({**question, "retrieved": keys, "rank": rank, "seconds": elapsed,
  109. "reference_retrieved": reference_keys})