Files
ragflow/test/unit_test/rag/test_search_pagination.py

132 lines
5.2 KiB
Python

#
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Unit tests for the block-based pagination window used by Dealer.retrieval.
retrieval reuses RERANK_LIMIT both as the backend block size
(req["page"] = global_offset // RERANK_LIMIT) and as the modulus for slicing a
page out of a (re)ranked block (begin = global_offset % RERANK_LIMIT). If the
window is not an exact multiple of page_size, blocks and pages drift apart, so
deep pages silently drop results and come back short. These tests pin that
invariant and verify cross-block pagination loses nothing.
"""
import math
import sys
import types
import pytest
# Stub the heavy / circular-importing dependencies before importing search,
# mirroring test_rank_feature_scores.py so the module imports in isolation.
_fake_query = types.ModuleType("rag.nlp.query")
class _DummyFulltextQueryer:
pass
_fake_query.FulltextQueryer = _DummyFulltextQueryer
sys.modules.setdefault("rag.nlp.query", _fake_query)
sys.modules.setdefault("common.settings", types.ModuleType("common.settings"))
from rag.nlp.search import Dealer # noqa: E402
_rerank_window = Dealer._rerank_window
# (page_size, top) combinations, including the common page sizes (10, 30) that
# do NOT divide 64 -- the exact case the old `min(..., 64)` clamp broke -- plus
# tiny / large / page-aligned tops.
GRID = [(page_size, top) for page_size in (1, 5, 7, 10, 30, 50, 64) for top in (0, 5, 30, 50, 55, 64, 100, 1024)]
def _paginate(total, page_size, top, rerank):
"""Replay retrieval's block-fetch + in-block slice over `total` candidates.
Returns the concatenated global positions actually surfaced across every
page, exactly as Dealer.retrieval would emit them.
"""
window = _rerank_window(page_size, top if rerank else 0)
# The backend caps the candidate pool at `top` when an external reranker is
# active; otherwise the whole result set is windowed.
cap = min(total, top) if (rerank and top > 0) else total
surfaced = []
page = 1
while (page - 1) * page_size < cap:
global_offset = (page - 1) * page_size
block_index = global_offset // window
block_start = block_index * window
block = list(range(block_start, min(block_start + window, cap)))
begin = global_offset % window
surfaced.extend(block[begin : begin + page_size])
page += 1
return window, cap, surfaced
@pytest.mark.parametrize("page_size,top", GRID)
def test_window_is_page_aligned(page_size, top):
"""The window must be a positive whole multiple of page_size."""
for rerank in (False, True):
window = _rerank_window(page_size, top if rerank else 0)
assert window >= 1
if page_size > 1:
assert window % page_size == 0, (page_size, top, rerank, window)
@pytest.mark.parametrize("page_size,top", GRID)
def test_pagination_loses_nothing(page_size, top):
"""Walking every page reconstructs the candidate pool exactly: in order,
no gaps, no duplicates, and no short interior pages."""
total = 250
for rerank in (False, True):
window, cap, surfaced = _paginate(total, page_size, top, rerank)
assert surfaced == list(range(cap)), (
f"page_size={page_size} top={top} rerank={rerank} window={window} cap={cap}: missing={sorted(set(range(cap)) - set(surfaced))[:10]} dups={len(surfaced) != len(set(surfaced))}"
)
@pytest.mark.p1
def test_reported_regression_page7_not_short():
"""The reported case: page_size=10, top=1024, reranker on. Page 7 (global
offset 60) used to return only 4 of 10 results because the window was
clamped to 64 (not a multiple of 10)."""
page_size, top = 10, 1024
window = _rerank_window(page_size, top)
assert window % page_size == 0
assert window >= 64 # still a provider-friendly ~64 candidate pool
total = 250
_, cap, surfaced = _paginate(total, page_size, top, rerank=True)
# Page 7 spans global positions 60..69 and must be full and contiguous.
assert surfaced[60:70] == list(range(60, 70))
assert len(surfaced) == cap
@pytest.mark.p1
def test_matches_legacy_window_on_non_buggy_paths():
"""Where the old formula already produced a page-aligned value, the new
window is unchanged (no behavioral regression on the non-buggy paths)."""
def legacy(page_size, top, rerank):
limit = math.ceil(64 / page_size) * page_size if page_size > 1 else 1
limit = max(30, limit)
if rerank and top > 0:
limit = min(limit, top, 64)
return limit
for page_size in (1, 5, 7, 10, 30, 50, 64):
# The non-rerank path was always page-aligned already -> must match.
assert _rerank_window(page_size, 0) == legacy(page_size, 0, False)