test_rag.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. """
  2. Direct unit tests for `lambdagent.rag` — Paper III's headline RAG layer.
  3. audit/AUDIT_2026-06-05.md #35 baseline: 438 LOC of retrieval +
  4. RAG-tool + agentic-RAG code with ZERO direct test. The integration
  5. test paths in tests/test_extensions.py exercise Memory but not
  6. retrieval — a regression in TF-IDF scoring, in score-then-format
  7. glue, or in RAGTool's `Term` apply contract could ship to production
  8. with green CI.
  9. This file covers the dependency-free SimpleVectorStore + RAGTool +
  10. create_rag paths. ChromaDB-backed paths are excluded — they need an
  11. external service and that lifts test infra cost out of proportion;
  12. add an opt-in `tests/test_rag_chroma.py` if/when needed.
  13. """
  14. from __future__ import annotations
  15. import pytest
  16. from lambdagent.rag import (
  17. Document,
  18. SearchResult,
  19. SimpleVectorStore,
  20. RAGTool,
  21. create_rag,
  22. )
  23. # ─────────────────────────────────────────────────────────────────────────────
  24. # SimpleVectorStore — pure data layer (no LLM)
  25. # ─────────────────────────────────────────────────────────────────────────────
  26. def test_simple_vector_store_add_returns_doc_id():
  27. store = SimpleVectorStore()
  28. doc_id = store.add("Python is a programming language")
  29. assert isinstance(doc_id, str) and len(doc_id) > 0
  30. assert len(store.documents) == 1
  31. assert store.documents[0].content == "Python is a programming language"
  32. def test_simple_vector_store_search_ranks_relevant_first():
  33. """TF-IDF cosine should rank a topically-on-target document above
  34. off-target ones. We use distinctive words so the result is
  35. deterministic regardless of tokenizer details."""
  36. store = SimpleVectorStore()
  37. store.add("Lambda calculus is the foundation of computation theory")
  38. store.add("Python is a popular programming language for data science")
  39. store.add("The Eiffel Tower is in Paris and was built in 1889")
  40. results = store.search("what is lambda calculus", top_k=3)
  41. assert len(results) > 0, "no results returned at all"
  42. # The top-ranked result must be the lambda-calculus doc.
  43. assert "lambda" in results[0].document.content.lower(), (
  44. f"top result was not the lambda doc; got: "
  45. f"{results[0].document.content[:80]}"
  46. )
  47. # And it must outrank the unrelated Eiffel-Tower doc.
  48. scores_by_topic = {
  49. ("lambda" in r.document.content.lower()): r.score
  50. for r in results
  51. }
  52. if True in scores_by_topic and False in scores_by_topic:
  53. assert scores_by_topic[True] > scores_by_topic[False]
  54. def test_simple_vector_store_empty_query_returns_empty():
  55. """Defense: an empty query shouldn't blow up — it should just
  56. return nothing useful. This guards a fragile Counter() path that
  57. used to ZeroDivisionError when the query tokenized to []."""
  58. store = SimpleVectorStore()
  59. store.add("anything")
  60. results = store.search("", top_k=3)
  61. # Either an empty list or all-zero-score results are acceptable;
  62. # the contract is "no crash + nothing claimed relevant".
  63. if results:
  64. for r in results:
  65. assert r.score <= 0.0 + 1e-9
  66. def test_simple_vector_store_no_documents_returns_empty():
  67. store = SimpleVectorStore()
  68. results = store.search("anything", top_k=3)
  69. assert results == []
  70. # ─────────────────────────────────────────────────────────────────────────────
  71. # RAGTool — wraps store as a lambdagent Term
  72. # ─────────────────────────────────────────────────────────────────────────────
  73. def test_ragtool_apply_returns_formatted_string():
  74. """RAGTool must satisfy the Term apply contract:
  75. - input = query string
  76. - output = string suitable for injection into a downstream prompt
  77. The 'numbered' format produces `[Source N]` markers."""
  78. store = SimpleVectorStore()
  79. store.add("Lambda calculus underlies functional programming")
  80. store.add("Lambda functions in Python are anonymous functions")
  81. rag = RAGTool(store, top_k=2, format="numbered")
  82. out = rag.apply("lambda")
  83. assert isinstance(out, str)
  84. # `numbered` format emits `[Source 1]` or `[Source 1, score=...]`
  85. # depending on rag.py's format flag. Both forms start with the same
  86. # prefix.
  87. assert "[Source 1" in out, f"output missing [Source 1 marker: {out[:120]}"
  88. def test_ragtool_min_score_filters_low_matches():
  89. """A query that matches NOTHING in the store should yield an empty
  90. or near-empty output, not a hallucinated reference."""
  91. store = SimpleVectorStore()
  92. store.add("Cats are friendly mammals")
  93. store.add("Dogs need daily walks")
  94. # min_score forces the filter even on the top-scored irrelevant doc.
  95. rag = RAGTool(store, top_k=3, min_score=0.5)
  96. out = rag.apply("quantum field theory renormalization")
  97. # Accept either an empty string or "no results found" message —
  98. # the contract is "don't lie about relevance".
  99. if out.strip():
  100. assert "[Source 1]" not in out or "no results" in out.lower()
  101. # ─────────────────────────────────────────────────────────────────────────────
  102. # create_rag — public one-liner
  103. # ─────────────────────────────────────────────────────────────────────────────
  104. def test_create_rag_simple_backend_smoke():
  105. """The README's headline 4-line RAG usage must keep working."""
  106. rag = create_rag([
  107. "Python is a programming language",
  108. "Lambda calculus is the foundation of computation",
  109. "AI agents are autonomous task executors",
  110. ])
  111. assert isinstance(rag, RAGTool)
  112. out = rag.apply("what is lambda calculus")
  113. assert "[Source" in out, f"create_rag output missing source markers: {out[:120]}"
  114. def test_create_rag_unknown_backend_raises():
  115. """Defensive: typo in backend name should not silently fall back."""
  116. from lambdagent.core import LambdagentError
  117. with pytest.raises(LambdagentError):
  118. create_rag(["doc"], backend="not-a-real-backend")