Skip to content

Commit 7143684

Browse files
feat(recall): add opt-in BM25 query term cap (#2567)
* Add opt-in BM25 query term cap * docs(config): document HINDSIGHT_API_BM25_MAX_QUERY_TERMS --------- Co-authored-by: r266-tech <r266-tech@users.noreply.github.com> Co-authored-by: Ben <ben.bartholomew@vectorize.io>
1 parent 11da432 commit 7143684

10 files changed

Lines changed: 145 additions & 3 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ HINDSIGHT_API_LOG_LEVEL=info
115115
# chinese_lindera/lindera(chinese), japanese_lindera/lindera(japanese),
116116
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
117117
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
118+
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
119+
# Long queries OR-join every normalized token, which can match too much of a
120+
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
121+
# value bounds only the native backend (other BM25 backends get the raw query).
122+
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
118123

119124
# File Parser (Optional - uses markitdown by default)
120125
# HINDSIGHT_API_FILE_PARSER=markitdown

hindsight-api-slim/hindsight_api/config.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,7 @@ def _resolve_operation_temperature(operation_env: str, default: float) -> float
638638

639639
# Recall candidate gating (per-source cap + BM25 score floor)
640640
ENV_BM25_MIN_SCORE = "HINDSIGHT_API_BM25_MIN_SCORE"
641+
ENV_BM25_MAX_QUERY_TERMS = "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
641642
ENV_RECALL_MAX_CANDIDATES_PER_SOURCE = "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE"
642643
# Per-strategy recall boost. Prioritises specific retrieval arms (semantic,
643644
# bm25, graph, temporal) on recall via a human priority level — e.g.
@@ -789,6 +790,9 @@ def _resolve_operation_temperature(operation_env: str, default: float) -> float
789790
# zero-score (non-matching) rows on backends — notably VectorChord — whose
790791
# operator ranks every document rather than pre-filtering to term matches.
791792
DEFAULT_BM25_MIN_SCORE = 0.0
793+
# Native tsvector BM25 can optionally cap the OR tsquery built from normalized
794+
# query tokens. 0 preserves the historical uncapped behavior.
795+
DEFAULT_BM25_MAX_QUERY_TERMS = 0
792796
# Per-source candidate cap applied to each retrieval arm (semantic, BM25, graph,
793797
# temporal) before RRF, so a single over-expanding backend cannot fill the
794798
# reranker's global candidate budget on its own. 0 disables the cap.
@@ -1209,6 +1213,19 @@ def _parse_positive_int(name: str, raw: str | None, default: int) -> int:
12091213
return parsed
12101214

12111215

1216+
def _parse_non_negative_int(name: str, raw: str | None, default: int) -> int:
1217+
"""Parse an env var that must be an integer >= 0."""
1218+
if raw is None or raw == "":
1219+
return default
1220+
try:
1221+
parsed = int(raw)
1222+
except ValueError as e:
1223+
raise ValueError(f"{name} must be an integer, got {raw!r}") from e
1224+
if parsed < 0:
1225+
raise ValueError(f"{name} must be >= 0, got {parsed}")
1226+
return parsed
1227+
1228+
12121229
def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
12131230
"""Parse an optional env var that must be a positive integer when set."""
12141231
if raw is None or raw == "":
@@ -1979,6 +1996,7 @@ class HindsightConfig:
19791996
reflect_llm_strategy: LLMStrategyConfig | None = None
19801997
consolidation_llm_members: list[LLMMemberConfig] = field(default_factory=list)
19811998
consolidation_llm_strategy: LLMStrategyConfig | None = None
1999+
bm25_max_query_terms: int = DEFAULT_BM25_MAX_QUERY_TERMS
19822000

19832001
# Class-level sets for configuration categorization
19842002

@@ -2179,6 +2197,9 @@ def validate(self) -> None:
21792197
f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0"
21802198
)
21812199

2200+
if self.bm25_max_query_terms < 0:
2201+
raise ValueError(f"Invalid bm25_max_query_terms: {self.bm25_max_query_terms}. Must be >= 0")
2202+
21822203
# Validate bedrock_service_tier
21832204
valid_bedrock_tiers = (None, "flex", "priority", "reserved")
21842205
if self.llm_bedrock_service_tier not in valid_bedrock_tiers:
@@ -2608,6 +2629,11 @@ def from_env(cls) -> "HindsightConfig":
26082629
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
26092630
semantic_min_similarity=float(os.getenv(ENV_SEMANTIC_MIN_SIMILARITY, str(DEFAULT_SEMANTIC_MIN_SIMILARITY))),
26102631
bm25_min_score=float(os.getenv(ENV_BM25_MIN_SCORE, str(DEFAULT_BM25_MIN_SCORE))),
2632+
bm25_max_query_terms=_parse_non_negative_int(
2633+
ENV_BM25_MAX_QUERY_TERMS,
2634+
os.getenv(ENV_BM25_MAX_QUERY_TERMS),
2635+
DEFAULT_BM25_MAX_QUERY_TERMS,
2636+
),
26112637
recall_max_candidates_per_source=int(
26122638
os.getenv(ENV_RECALL_MAX_CANDIDATES_PER_SOURCE, str(DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE))
26132639
),

hindsight-api-slim/hindsight_api/engine/search/retrieval.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from datetime import UTC, datetime
1616
from typing import TYPE_CHECKING, Any, Optional
1717

18-
from ...config import get_config
18+
from ...config import DEFAULT_BM25_MAX_QUERY_TERMS, get_config
1919
from ..db_utils import acquire_with_retry
2020
from ..memory_engine import fq_table
2121
from ..sql import create_sql_dialect
@@ -222,7 +222,12 @@ async def retrieve_semantic_bm25_combined(
222222
# --- BM25 UNION ALL arms (one per fact_type, only when tokens present) ---
223223
if _include_bm25:
224224
text_ext = config.text_search_extension
225-
bm25_text_param: str = dialect.prepare_bm25_text(tokens, query_text, text_search_extension=text_ext)
225+
bm25_text_param: str = dialect.prepare_bm25_text(
226+
tokens,
227+
query_text,
228+
text_search_extension=text_ext,
229+
max_query_terms=getattr(config, "bm25_max_query_terms", DEFAULT_BM25_MAX_QUERY_TERMS),
230+
)
226231
for i, ft in enumerate(fact_types):
227232
arms.append(
228233
dialect.build_bm25_arm(

hindsight-api-slim/hindsight_api/engine/sql/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ def prepare_bm25_text(
449449
query_text: str,
450450
*,
451451
text_search_extension: str = "native",
452+
max_query_terms: int | None = None,
452453
) -> str:
453454
"""Prepare the text parameter value for BM25 search.
454455
@@ -459,6 +460,8 @@ def prepare_bm25_text(
459460
tokens: Tokenized query words.
460461
query_text: Original query text.
461462
text_search_extension: Full-text search backend variant.
463+
max_query_terms: Optional backend-specific token cap. 0 or None
464+
leaves query terms uncapped.
462465
463466
Returns:
464467
Prepared text string to bind as the BM25 text parameter.

hindsight-api-slim/hindsight_api/engine/sql/oracle.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ def prepare_bm25_text(
303303
query_text: str,
304304
*,
305305
text_search_extension: str = "native",
306+
max_query_terms: int | None = None,
306307
) -> str:
307308
# Oracle Text: filter tokens with special chars, escape reserved words
308309
# with curly braces (e.g. "about" → "{about}"), and join with OR.

hindsight-api-slim/hindsight_api/engine/sql/postgresql.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,11 @@ def prepare_bm25_text(
254254
query_text: str,
255255
*,
256256
text_search_extension: str = "native",
257+
max_query_terms: int | None = None,
257258
) -> str:
258259
if text_search_extension in ("vchord", "pg_textsearch", "pgroonga", "pg_search"):
259260
return query_text
261+
if max_query_terms is not None and max_query_terms > 0:
262+
tokens = tokens[:max_query_terms]
260263
# native tsvector: join tokens with OR operator
261264
return " | ".join(tokens)

hindsight-api-slim/tests/test_multilingual_bm25.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@
1010
from __future__ import annotations
1111

1212
from pathlib import Path
13+
from types import SimpleNamespace
1314
from unittest.mock import MagicMock
1415

16+
import pytest
17+
1518
from hindsight_api.engine.consolidation.prompts import build_batch_consolidation_prompt
1619
from hindsight_api.engine.prompt_utils import output_language_directive
1720
from hindsight_api.engine.reflect.prompts import build_final_system_prompt
1821
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
22+
from hindsight_api.engine.search import retrieval as retrieval_mod
23+
from hindsight_api.engine.search.retrieval import tokenize_query
24+
from hindsight_api.engine.sql.postgresql import PostgreSQLDialect
1925

2026

2127
def _baseline_config() -> MagicMock:
@@ -165,3 +171,75 @@ def test_configurable_bm25_language_migration_chains_off_head():
165171
src = target.read_text()
166172
assert 'revision: str = "p4q5r6s7t8u9"' in src
167173
assert 'down_revision: str | Sequence[str] | None = "86f7a033d372"' in src
174+
175+
176+
# ---------------------------------------------------------------------------
177+
# BM25 query term cap
178+
# ---------------------------------------------------------------------------
179+
180+
181+
def test_postgresql_native_bm25_caps_raw_terms_preserving_order():
182+
query = "Alpha beta alpha, gamma delta beta epsilon"
183+
tokens = tokenize_query(query)
184+
185+
assert PostgreSQLDialect().prepare_bm25_text(tokens, query, max_query_terms=3) == "alpha | beta | alpha"
186+
187+
188+
def test_postgresql_native_bm25_zero_cap_keeps_existing_unlimited_behavior():
189+
query = "Alpha beta alpha"
190+
tokens = tokenize_query(query)
191+
192+
assert PostgreSQLDialect().prepare_bm25_text(tokens, query, max_query_terms=0) == "alpha | beta | alpha"
193+
194+
195+
def test_postgresql_extension_bm25_keeps_raw_query_text():
196+
query = "Alpha beta alpha, gamma delta beta epsilon"
197+
tokens = tokenize_query(query)
198+
199+
assert (
200+
PostgreSQLDialect().prepare_bm25_text(tokens, query, text_search_extension="vchord", max_query_terms=3) == query
201+
)
202+
203+
204+
@pytest.mark.asyncio
205+
async def test_combined_retrieval_uses_default_bm25_cap_for_legacy_config(monkeypatch):
206+
class FakeDialect:
207+
max_query_terms: int | None = None
208+
209+
def build_semantic_arm(self, **kwargs):
210+
return "SELECT 'semantic' AS source"
211+
212+
def build_bm25_arm(self, **kwargs):
213+
return "SELECT 'bm25' AS source"
214+
215+
def prepare_bm25_text(self, tokens, query_text, *, text_search_extension="native", max_query_terms=None):
216+
self.max_query_terms = max_query_terms
217+
return " | ".join(tokens)
218+
219+
class FakeConn:
220+
backend_type = "postgresql"
221+
222+
async def fetch(self, query, *params):
223+
return []
224+
225+
fake_dialect = FakeDialect()
226+
legacy_config = SimpleNamespace(
227+
semantic_min_similarity=0.0,
228+
bm25_min_score=0.0,
229+
text_search_extension="native",
230+
text_search_extension_native_language="english",
231+
)
232+
monkeypatch.setattr(retrieval_mod, "get_config", lambda: legacy_config)
233+
monkeypatch.setattr(retrieval_mod, "create_sql_dialect", lambda backend: fake_dialect)
234+
235+
result = await retrieval_mod.retrieve_semantic_bm25_combined(
236+
FakeConn(),
237+
"[0.0]",
238+
"alpha beta",
239+
"bank-1",
240+
["observation"],
241+
5,
242+
)
243+
244+
assert result == {"observation": ([], [])}
245+
assert fake_dialect.max_query_terms == 0

hindsight-api-slim/tests/test_recall_config.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,14 @@ class TestRecallConfigFields:
6565
"""Hierarchical config fields for internal recall."""
6666

6767
def test_fields_exist_on_dataclass(self):
68-
from hindsight_api.config import HindsightConfig
68+
from hindsight_api.config import DEFAULT_BM25_MAX_QUERY_TERMS, HindsightConfig
6969

7070
names = {f.name for f in dataclasses.fields(HindsightConfig)}
7171
assert "recall_include_chunks" in names
7272
assert "recall_max_tokens" in names
7373
assert "recall_chunks_max_tokens" in names
74+
assert "bm25_max_query_terms" in names
75+
assert HindsightConfig.__dataclass_fields__["bm25_max_query_terms"].default == DEFAULT_BM25_MAX_QUERY_TERMS
7476

7577
def test_fields_are_configurable(self):
7678
from hindsight_api.config import HindsightConfig
@@ -82,6 +84,7 @@ def test_fields_are_configurable(self):
8284

8385
def test_default_values(self):
8486
from hindsight_api.config import (
87+
DEFAULT_BM25_MAX_QUERY_TERMS,
8588
DEFAULT_RECALL_CHUNKS_MAX_TOKENS,
8689
DEFAULT_RECALL_INCLUDE_CHUNKS,
8790
DEFAULT_RECALL_MAX_TOKENS,
@@ -90,9 +93,11 @@ def test_default_values(self):
9093
assert DEFAULT_RECALL_INCLUDE_CHUNKS is True
9194
assert DEFAULT_RECALL_MAX_TOKENS == 2048
9295
assert DEFAULT_RECALL_CHUNKS_MAX_TOKENS == 1000
96+
assert DEFAULT_BM25_MAX_QUERY_TERMS == 0
9397

9498
def test_env_var_constants(self):
9599
from hindsight_api.config import (
100+
ENV_BM25_MAX_QUERY_TERMS,
96101
ENV_RECALL_CHUNKS_MAX_TOKENS,
97102
ENV_RECALL_INCLUDE_CHUNKS,
98103
ENV_RECALL_MAX_TOKENS,
@@ -101,13 +106,15 @@ def test_env_var_constants(self):
101106
assert ENV_RECALL_INCLUDE_CHUNKS == "HINDSIGHT_API_RECALL_INCLUDE_CHUNKS"
102107
assert ENV_RECALL_MAX_TOKENS == "HINDSIGHT_API_RECALL_MAX_TOKENS"
103108
assert ENV_RECALL_CHUNKS_MAX_TOKENS == "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS"
109+
assert ENV_BM25_MAX_QUERY_TERMS == "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
104110

105111
@patch.dict(
106112
"os.environ",
107113
{
108114
"HINDSIGHT_API_RECALL_INCLUDE_CHUNKS": "false",
109115
"HINDSIGHT_API_RECALL_MAX_TOKENS": "777",
110116
"HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS": "333",
117+
"HINDSIGHT_API_BM25_MAX_QUERY_TERMS": "24",
111118
},
112119
)
113120
def test_from_env_reads_overrides(self):
@@ -117,6 +124,14 @@ def test_from_env_reads_overrides(self):
117124
assert config.recall_include_chunks is False
118125
assert config.recall_max_tokens == 777
119126
assert config.recall_chunks_max_tokens == 333
127+
assert config.bm25_max_query_terms == 24
128+
129+
@patch.dict("os.environ", {"HINDSIGHT_API_BM25_MAX_QUERY_TERMS": "-1"})
130+
def test_from_env_rejects_negative_bm25_max_query_terms(self):
131+
from hindsight_api.config import HindsightConfig
132+
133+
with pytest.raises(ValueError, match="HINDSIGHT_API_BM25_MAX_QUERY_TERMS must be >= 0"):
134+
HindsightConfig.from_env()
120135

121136

122137
class TestMentalModelTriggerRecallFields:

hindsight-docs/docs/developer/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ If you need to switch from one extension to another:
145145
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION` | Text search backend: `native`, `vchord`, `pg_textsearch`, `pgroonga`, or `pg_search` | `native` |
146146
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE` | PostgreSQL text search dictionary used by the `native` backend (e.g. `english`, `french`, `simple`, `zhparser`) | `english` |
147147
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER` | ParadeDB `pg_search` tokenizer used when creating BM25 indexes. Empty uses ParadeDB's default tokenizer (`unicode_words`). | unset |
148+
| `HINDSIGHT_API_BM25_MAX_QUERY_TERMS` | Optional cap on the number of terms in the native PostgreSQL BM25 `tsquery`. Long queries OR-join every normalized token, which can match too much of a large bank. `0` keeps the historical uncapped behavior; a positive value bounds only the `native` backend (other BM25 backends receive the raw query). | `0` |
148149
| `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE` | When set, forces every LLM-generated artifact (retain facts, consolidation observations, reflect responses) into this language. Free-form (e.g. `Spanish`, `Japanese`). | unset |
149150

150151
Hindsight supports five backends for BM25 keyword retrieval:

hindsight-embed/hindsight_embed/env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ HINDSIGHT_API_LOG_LEVEL=info
115115
# chinese_lindera/lindera(chinese), japanese_lindera/lindera(japanese),
116116
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
117117
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
118+
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
119+
# Long queries OR-join every normalized token, which can match too much of a
120+
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
121+
# value bounds only the native backend (other BM25 backends get the raw query).
122+
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
118123

119124
# File Parser (Optional - uses markitdown by default)
120125
# HINDSIGHT_API_FILE_PARSER=markitdown

0 commit comments

Comments
 (0)