mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-14 08:58:27 +08:00
132 lines
5.2 KiB
Python
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)
|