Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/en/guides/01-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,7 @@ Reranking model for search result refinement. Supports VikingDB (Volcengine), Co
| `model` | str | Model name (for `openai` providers) |
| `timeout` | float | HTTP request timeout in seconds for OpenAI-compatible providers. Increase for slow or cold-starting local rerank servers. Default: `30.0` |
| `threshold` | float | Score threshold between `0.0` and `1.0`; results below this are filtered out. Default: `0.1` |
| `max_chars_per_doc` | int | Truncate each document to at most N characters before reranking; `0` disables truncation (default). Bounds reranker input so one oversized abstract cannot overflow the model and fail the whole batch. Truncates the model input only — stored and returned abstracts are unchanged. Recommended when enabled: `2000` for 512-token rerankers (BGE/MiniLM/VikingDB). Note for CJK text: characters are not tokens, so a char cap can still overflow a token-limited reranker. Default: `0` |
| `extra_headers` | object | Custom HTTP headers (for OpenAI-compatible providers, optional) |

**Supported providers:**
Expand Down
1 change: 1 addition & 0 deletions docs/zh/guides/01-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,7 @@ AST 提取支持:Python、JavaScript/TypeScript、Rust、Go、Java、C/C++。
| `model` | str | 模型名称(用于 `openai` 提供方) |
| `timeout` | float | OpenAI 兼容 provider 的 HTTP 请求超时时间,单位为秒。对于较慢或冷启动的本地 rerank 服务可适当增大。默认:`30.0` |
| `threshold` | float | 分数阈值,范围为 `0.0` 到 `1.0`。低于此值的结果会被过滤。默认:`0.1` |
| `max_chars_per_doc` | int | 在精排前将每篇文档截断为至多 N 个字符;`0` 表示不截断(默认)。用于限制精排模型的输入长度,避免单篇超长摘要撑爆模型导致整批精排失败。仅截断送入模型的输入,存储与返回的摘要保持不变。启用时推荐值:512-token 精排模型(BGE/MiniLM/VikingDB)取 `2000`。CJK 文本注意:字符数不等于 token 数,字符上限仍可能撑爆 token 受限的精排模型。默认:`0` |
| `extra_headers` | object | 自定义 HTTP 请求头(OpenAI 兼容 provider 可用,可选) |

**支持的提供方:**
Expand Down
9 changes: 8 additions & 1 deletion openviking/retrieve/hierarchical_retriever.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,15 @@ def _rerank_scores(
if not self._rerank_client or not documents:
return fallback_scores

# Bound reranker input width so one oversized abstract cannot overflow the
# model and fail the whole batch open. 0 = OFF (not "truncate to 0 chars").
# Model input only: scores scatter back onto the full result dict, so stored
# and returned abstracts are untouched. cap=0 is byte-identical parity.
cap = self.rerank_config.max_chars_per_doc if self.rerank_config else 0
model_inputs = [doc[:cap] for doc in documents] if cap > 0 else documents

try:
scores = self._rerank_client.rerank_batch(query, documents)
scores = self._rerank_client.rerank_batch(query, model_inputs)
except Exception as e:
logger.warning(
"[HierarchicalRetriever] Rerank failed, fallback to vector scores: %s", e
Expand Down
32 changes: 32 additions & 0 deletions openviking_cli/utils/config/rerank_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

from pydantic import BaseModel, Field, model_validator

from openviking_cli.utils.logger import get_logger

logger = get_logger(__name__)

# Below this width the reranker query budget dominates and cross-encoder scores
# become unstable; a non-zero cap under it is almost always a misconfiguration.
_MIN_SAFE_CHARS_PER_DOC = 200


class RerankConfig(BaseModel):
"""Configuration for rerank API. Supports VikingDB, Cohere, OpenAI-compatible, and LiteLLM providers."""
Expand Down Expand Up @@ -47,6 +55,18 @@ class RerankConfig(BaseModel):
default=0.1, description="Relevance threshold (score > threshold is relevant)"
)

max_chars_per_doc: int = Field(
default=0,
ge=0,
strict=True,
description=(
"Truncate each document to N characters before reranking; 0 = unbounded. "
"Bounds reranker input so one oversized abstract cannot overflow the model "
"and fail the whole batch. Truncates model input only; stored/returned "
"abstracts are untouched."
),
)

model_config = {"extra": "forbid"}

def _effective_provider(self) -> Optional[str]:
Expand Down Expand Up @@ -78,6 +98,18 @@ def validate_provider_fields(self) -> "RerankConfig":
raise ValueError("LiteLLM rerank provider requires 'model'")
return self

@model_validator(mode="after")
def warn_small_char_cap(self) -> "RerankConfig":
"""Soft-warn (never reject) when a non-zero char cap is below the stability floor."""
if 0 < self.max_chars_per_doc < _MIN_SAFE_CHARS_PER_DOC:
logger.warning(
"RerankConfig.max_chars_per_doc=%d is below the ~%d-char cross-encoder "
"stability floor; rerank scores may become unstable. Set 0 to disable truncation.",
self.max_chars_per_doc,
_MIN_SAFE_CHARS_PER_DOC,
)
return self

def is_available(self) -> bool:
"""Check if rerank is configured."""
p = self._effective_provider()
Expand Down
220 changes: 220 additions & 0 deletions tests/retrieve/test_hierarchical_retriever_rerank.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""Hierarchical retriever rerank behavior tests."""

import pytest
from pydantic import ValidationError

from openviking.retrieve.hierarchical_retriever import HierarchicalRetriever, RetrieverMode
from openviking.server.identity import RequestContext, Role
Expand Down Expand Up @@ -515,3 +516,222 @@ async def test_convert_to_matched_contexts_returns_empty_relations():
)

assert result[0].relations == []


# ---------------------------------------------------------------------------
# max_chars_per_doc — #2880 configurable rerank input truncation
# ---------------------------------------------------------------------------


def _cap_config(cap: int) -> RerankConfig:
return RerankConfig(ak="ak", sk="sk", threshold=0.1, max_chars_per_doc=cap)


def _capped_retriever(monkeypatch, fake_client, cap: int) -> HierarchicalRetriever:
"""A retriever whose rerank client is the fake, with max_chars_per_doc=cap."""
# Tiny caps in these truncation tests are intentionally below the stability
# floor; silence the sub-floor soft-warn so it does not spam CI logs (some CI
# treats warning logs as failures). The dedicated warn tests do not use this
# helper, so they still assert the warning fires.
monkeypatch.setattr(
"openviking_cli.utils.config.rerank_config.logger.warning",
lambda *a, **k: None,
)
monkeypatch.setattr(
"openviking.retrieve.hierarchical_retriever.RerankClient.from_config",
lambda config: fake_client,
)
return HierarchicalRetriever(
storage=DummyStorage(),
embedder=DummyEmbedder(),
rerank_config=_cap_config(cap),
)


def test_rerank_cap_zero_is_byte_identical_parity(monkeypatch):
fake = FakeRerankClient([0.9, 0.1])
retriever = _capped_retriever(monkeypatch, fake, 0)

docs = ["abcdefgh", "xy"]
retriever._rerank_scores("hello", docs, [0.0, 0.0])

# cap=0: docs reach rerank_batch untouched (byte-identical, no truncation).
assert fake.calls[0][1] == ["abcdefgh", "xy"]


def test_rerank_cap_truncates_each_doc(monkeypatch):
fake = FakeRerankClient([0.9, 0.1])
retriever = _capped_retriever(monkeypatch, fake, 4)

retriever._rerank_scores("hello", ["abcdefgh", "xyz"], [0.0, 0.0])

# Each doc sliced to [:cap]; a doc shorter than cap is unchanged.
assert fake.calls[0][1] == ["abcd", "xyz"]


def test_rerank_cap_does_not_truncate_the_query(monkeypatch):
fake = FakeRerankClient([0.9])
retriever = _capped_retriever(monkeypatch, fake, 4)

retriever._rerank_scores("hello world", ["abcdefgh"], [0.0])

assert fake.calls[0][0] == "hello world" # query is never truncated
assert fake.calls[0][1] == ["abcd"]


@pytest.mark.parametrize(
"cap,doc,expected",
[
(0, "abcdef", "abcdef"), # 0 = OFF, not "truncate to 0 chars"
(10, "abcdef", "abcdef"), # cap > len: unchanged
(7, "abcdef", "abcdef"), # cap == len + 1: unchanged
(6, "abcdef", "abcdef"), # cap == len: unchanged
(1, "abcdef", "a"), # cap == 1: one codepoint
],
)
def test_rerank_cap_boundaries(monkeypatch, cap, doc, expected):
fake = FakeRerankClient([0.5])
retriever = _capped_retriever(monkeypatch, fake, cap)

retriever._rerank_scores("q", [doc], [0.0])

assert fake.calls[0][1] == [expected]


@pytest.mark.parametrize(
"cap,doc,expected",
[
(2, "你好世界", "你好"), # CJK: codepoint-safe
(2, "🧑‍🚀X", "🧑‍"), # ZWJ emoji: codepoint-safe, NOT grapheme-safe
(4, "", ""), # empty abstract stays empty
],
)
def test_rerank_cap_multibyte_and_empty(monkeypatch, cap, doc, expected):
fake = FakeRerankClient([0.5])
retriever = _capped_retriever(monkeypatch, fake, cap)

retriever._rerank_scores("q", [doc], [0.0])

assert fake.calls[0][1] == [expected]


def test_rerank_cap_fail_open_when_rerank_raises(monkeypatch):
class Raiser(FakeRerankClient):
def rerank_batch(self, query, documents):
self.calls.append((query, list(documents)))
raise RuntimeError("model input overflow")

fake = Raiser([])
retriever = _capped_retriever(monkeypatch, fake, 4)

out = retriever._rerank_scores("q", ["abcdefgh", "xyz"], [0.11, 0.22])

assert out == [0.11, 0.22] # falls back to vector scores, len invariant intact
assert fake.calls[0][1] == ["abcd", "xyz"] # truncation happened before the call


def test_rerank_cap_fail_open_on_wrong_length(monkeypatch):
fake = FakeRerankClient([0.9]) # one score returned for two documents
retriever = _capped_retriever(monkeypatch, fake, 4)

out = retriever._rerank_scores("q", ["abcdefgh", "xyz"], [0.11, 0.22])

assert out == [0.11, 0.22]


@pytest.mark.asyncio
async def test_thinking_mode_truncates_docs_at_both_sites(monkeypatch):
fake = FakeRerankClient([0.95, 0.05, 0.11, 0.95])
retriever = _capped_retriever(monkeypatch, fake, 4)

await retriever.retrieve(_query(), ctx=_ctx(), limit=2, mode=RetrieverMode.THINKING)

# cap=4: DummyStorage abstracts collapse under [:4] at both rerank sites.
# Site A (global) and Site B (child recursion) both funnel through _rerank_scores.
assert fake.calls[0] == ("hello", ["root", "root"]) # "root A"[:4]/"root B"[:4] -> "root"
assert fake.calls[1] == ("hello", ["chil", "chil"]) # "child A"[:4]/"child B"[:4] -> "chil"


@pytest.mark.asyncio
async def test_truncation_does_not_alter_returned_abstract(monkeypatch):
fake = FakeRerankClient([0.95, 0.05, 0.11, 0.95])
retriever = _capped_retriever(monkeypatch, fake, 4)

result = await retriever.retrieve(_query(), ctx=_ctx(), limit=2, mode=RetrieverMode.THINKING)

abstracts = {ctx.uri: ctx.abstract for ctx in result.matched_contexts}
# Truncation is model-input-only; the returned abstract is the full original.
assert abstracts["viking://resources/file-b"] == "child B"
assert abstracts["viking://resources/file-a"] == "child A"


@pytest.mark.asyncio
async def test_quick_mode_never_reranks_even_with_cap_set(monkeypatch):
fake = FakeRerankClient([0.5, 0.5, 0.5])
monkeypatch.setattr(
"openviking_cli.utils.config.rerank_config.logger.warning",
lambda *a, **k: None,
)
monkeypatch.setattr(
"openviking.retrieve.hierarchical_retriever.RerankClient.from_config",
lambda config: fake,
)
storage = QuickSearchStorage(
[
_result("viking://resources/root", 0.95, level=0, abstract="root abstract"),
_result("viking://resources/file", 0.9, abstract="file abstract"),
]
)
retriever = HierarchicalRetriever(
storage=storage,
embedder=DummyEmbedder(),
rerank_config=_cap_config(4),
)

await retriever.retrieve(_query(), ctx=_ctx(), limit=2, mode=RetrieverMode.QUICK)

assert fake.calls == []


def test_rerank_config_default_cap_is_zero():
assert RerankConfig(ak="ak", sk="sk").max_chars_per_doc == 0


def test_rerank_config_rejects_negative_cap():
with pytest.raises(ValidationError):
RerankConfig(ak="ak", sk="sk", max_chars_per_doc=-1)


def test_rerank_config_rejects_non_int_cap_under_strict():
with pytest.raises(ValidationError):
RerankConfig(ak="ak", sk="sk", max_chars_per_doc="5")


def test_rerank_config_rejects_unknown_field():
with pytest.raises(ValidationError):
RerankConfig(ak="ak", sk="sk", max_chars_pr_doc=4) # typo, extra=forbid


def test_rerank_config_warns_on_sub_floor_cap(monkeypatch):
from openviking_cli.utils.config import rerank_config as rc_mod

warnings: list = []
monkeypatch.setattr(rc_mod.logger, "warning", lambda *a, **k: warnings.append((a, k)))

RerankConfig(ak="ak", sk="sk", max_chars_per_doc=50)

assert warnings, "expected a soft-warn for a non-zero cap below the stability floor"
# The configured cap value is surfaced in the warning so the misconfig is actionable.
assert 50 in warnings[0][0]


def test_rerank_config_does_not_warn_when_cap_disabled(monkeypatch):
from openviking_cli.utils.config import rerank_config as rc_mod

warnings: list = []
monkeypatch.setattr(rc_mod.logger, "warning", lambda *a, **k: warnings.append((a, k)))

RerankConfig(ak="ak", sk="sk", max_chars_per_doc=0)
RerankConfig(ak="ak", sk="sk", max_chars_per_doc=2000)

assert warnings == []