Skip to content

Commit 4719a85

Browse files
committed
refactor(config): unify pageindex_max_concurrency + compile_concurrency into concurrency
PageIndex indexing and OpenKB's own concept/entity compilation never run concurrently with each other for the same document (they're sequential phases of one add), and both knobs exist for the exact same reason — the user hit a provider rate limit or an fd ceiling. Splitting them by internal subsystem leaked OpenKB's architecture into the config surface for no practical benefit, since a user tuning one for a rate limit would set the other to the same value anyway. - Single `concurrency` config key (default null = each stage keeps its own built-in default). - config.resolve_concurrency(config) -> int | None: validates (rejects bool and non-positive values with a warning), returns None on unset/invalid — no default substitution inside; callers decide what None means for them. - cli.py's compile call sites: `resolve_concurrency(config) or DEFAULT_COMPILE_CONCURRENCY`. - indexer.py's _build_index_config now routes through resolve_concurrency (openkb validates before forwarding to PageIndex, rather than relying solely on PageIndex's own future validator) instead of raw config.get. - This also removes the prior dual-literal-default drift risk entirely: there's no second numeric default to keep in sync, since an unset `concurrency` simply forwards nothing to PageIndex and lets the compiler use its own DEFAULT_COMPILE_CONCURRENCY. - Updated config.yaml.example and examples/configuration/README.md (kept in sync) to the single key.
1 parent 577956f commit 4719a85

8 files changed

Lines changed: 93 additions & 108 deletions

File tree

config.yaml.example

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ model: gpt-5.4 # LLM model (any LiteLLM-supported provider)
22
language: en # Wiki output language
33
pageindex_threshold: 20 # PDF pages threshold for PageIndex
44

5-
# Optional: cap concurrent LLM calls to avoid provider rate limits or "too many
6-
# open files" on large PDFs. Omit either to use the default.
7-
# pageindex_max_concurrency: 10 # PageIndex indexing concurrency (null = PageIndex's own default)
8-
# compile_concurrency: 5 # concept/entity page generation concurrency
5+
# Optional: cap concurrent LLM calls during ingest (PageIndex indexing and
6+
# concept/entity compilation — they never overlap, so one setting covers
7+
# both). Lower it if you hit provider rate limits or "too many open files" on
8+
# large PDFs. Omit to let each stage apply its own default.
9+
# concurrency: 5
910

1011
# Optional: override the entity-type vocabulary used for entity pages.
1112
# Omit this key to use the default 7 types

examples/configuration/README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,11 @@ model: gpt-5.4 # LLM model (any LiteLLM-supported provider)
7070
language: en # Wiki output language
7171
pageindex_threshold: 20 # PDF pages threshold for PageIndex
7272

73-
# Optional: cap concurrent LLM calls to avoid provider rate limits or "too many
74-
# open files" on large PDFs. Omit either to use the default.
75-
# pageindex_max_concurrency: 10 # PageIndex indexing concurrency (null = PageIndex's own default)
76-
# compile_concurrency: 5 # concept/entity page generation concurrency
73+
# Optional: cap concurrent LLM calls during ingest (PageIndex indexing and
74+
# concept/entity compilation — they never overlap, so one setting covers
75+
# both). Lower it if you hit provider rate limits or "too many open files" on
76+
# large PDFs. Omit to let each stage apply its own default.
77+
# concurrency: 5
7778

7879
# Optional: override the entity-type vocabulary used for entity pages.
7980
# Omit this key to use the default 7 types
@@ -100,8 +101,7 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
100101
| `model` | `gpt-5.4` | LLM used for all compile/query/chat work. |
101102
| `language` | `en` | Language the wiki is written in. |
102103
| `pageindex_threshold` | `20` | PDFs with this many pages **or more** take the long-doc (PageIndex) path; shorter ones go through the short-doc path. See [`pageindex-cloud/`](../pageindex-cloud/). |
103-
| `pageindex_max_concurrency` | `null` | Caps concurrent indexing LLM calls PageIndex makes for a single long document. Lower it if you hit provider rate limits or "too many open files" on large PDFs. `null` lets PageIndex apply its own default. |
104-
| `compile_concurrency` | `5` | Caps concurrent LLM calls OpenKB makes while generating concept/entity pages. Lower it if your LLM provider rate-limits. |
104+
| `concurrency` | `null` | Caps concurrent LLM calls OpenKB makes during ingest — both PageIndex's indexing of a long document and OpenKB's own concept/entity compilation. The two never run at once for the same document, so one setting covers both. Lower it if you hit provider rate limits or "too many open files" on large PDFs. `null` lets each stage apply its own default. |
105105
| `entity_types` | 7 defaults | Custom vocabulary for entity pages. `other` is always kept. |
106106
| `litellm:` || A pass-through block for LiteLLM. See below. |
107107

openkb/cli.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ def filter(self, record: logging.LogRecord) -> bool:
4747
litellm.suppress_debug_info = True
4848
from dotenv import load_dotenv
4949

50-
from openkb.agent.compiler import compile_long_doc
50+
from openkb.agent.compiler import DEFAULT_COMPILE_CONCURRENCY, compile_long_doc
5151
from openkb.config import (
5252
DEFAULT_CONFIG,
5353
load_config,
5454
save_config,
5555
load_global_config,
5656
register_kb,
57-
resolve_compile_concurrency,
57+
resolve_concurrency,
5858
resolve_extra_headers,
5959
set_extra_headers,
6060
resolve_timeout,
@@ -556,7 +556,7 @@ def commit_body(snapshot) -> None:
556556
kb_dir,
557557
model,
558558
doc_description=index_result.description,
559-
max_concurrency=resolve_compile_concurrency(config),
559+
max_concurrency=resolve_concurrency(config) or DEFAULT_COMPILE_CONCURRENCY,
560560
),
561561
label=f"Compiling long doc (doc_id={index_result.doc_id})",
562562
)
@@ -570,7 +570,7 @@ def commit_body(snapshot) -> None:
570570
source_path,
571571
kb_dir,
572572
model,
573-
max_concurrency=resolve_compile_concurrency(config),
573+
max_concurrency=resolve_concurrency(config) or DEFAULT_COMPILE_CONCURRENCY,
574574
),
575575
label="Compiling short doc",
576576
)
@@ -692,7 +692,7 @@ def commit_body(_snapshot) -> None:
692692
kb_dir,
693693
model,
694694
doc_description=cloud.description,
695-
max_concurrency=resolve_compile_concurrency(config),
695+
max_concurrency=resolve_concurrency(config) or DEFAULT_COMPILE_CONCURRENCY,
696696
),
697697
label=f"Compiling imported doc (doc_id={doc_id})",
698698
)
@@ -1645,7 +1645,7 @@ def _classify(meta: dict) -> str:
16451645
_setup_llm_key(kb_dir)
16461646
config = load_config(openkb_dir / "config.yaml")
16471647
model: str = config.get("model", DEFAULT_CONFIG["model"])
1648-
max_concurrency = resolve_compile_concurrency(config)
1648+
max_concurrency = resolve_concurrency(config) or DEFAULT_COMPILE_CONCURRENCY
16491649

16501650
# Import lazily and reference via the module so tests can patch
16511651
# ``openkb.agent.compiler.compile_*`` and see the call.

openkb/config.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@
1717
"model": "gpt-5.4",
1818
"language": "en",
1919
"pageindex_threshold": 20,
20-
# Cap on concurrent indexing LLM calls PageIndex makes for a single long
21-
# document. None = let PageIndex apply its own default. Raise it to index
22-
# faster, lower it if you hit provider rate limits or "too many open files".
23-
"pageindex_max_concurrency": None,
24-
# Cap on concurrent compile LLM calls OpenKB makes when generating concept
25-
# and entity pages. Lower it if your LLM provider rate-limits.
26-
"compile_concurrency": 5,
20+
# Cap on concurrent LLM calls OpenKB makes during ingest — both PageIndex's
21+
# indexing of a long document and OpenKB's own concept/entity compilation.
22+
# These never run concurrently with each other for the same document, so
23+
# one knob covers both. None = each stage applies its own built-in default.
24+
# Lower it if you hit provider rate limits or "too many open files"; raise
25+
# it to go faster.
26+
"concurrency": None,
2727
}
2828

2929
# Default entity-type vocabulary. Overridable per-KB via the optional
@@ -183,24 +183,27 @@ def resolve_timeout(config: dict) -> float | None:
183183
return value
184184

185185

186-
def resolve_compile_concurrency(config: dict) -> int:
187-
"""Resolve the optional ``compile_concurrency:`` key for the compile step
188-
(concept/entity generation).
186+
def resolve_concurrency(config: dict) -> int | None:
187+
"""Resolve the optional ``concurrency:`` key — the cap on concurrent LLM
188+
calls OpenKB makes during ingest (PageIndex indexing and concept/entity
189+
compilation alike; they never run at the same time for one document, so
190+
one setting covers both).
189191
190-
Returns ``DEFAULT_CONFIG["compile_concurrency"]`` when absent, ``None``, or
191-
invalid; rejects bools and non-positive values, warning when present but
192-
unusable (an explicit ``null`` is the normal "use the default" case and
193-
warns silently, matching ``resolve_timeout``).
192+
Returns ``None`` when absent, explicitly ``null``, or invalid (rejecting
193+
bools and non-positive values with a warning when present but unusable —
194+
an explicit ``null``/absent key is the normal "unset" case and stays
195+
silent, matching ``resolve_timeout``). Callers apply their own default (or
196+
omit the setting entirely) when this returns ``None``.
194197
"""
195-
value = config.get("compile_concurrency")
198+
value = config.get("concurrency")
196199
if value is None:
197-
return DEFAULT_CONFIG["compile_concurrency"]
200+
return None
198201
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
199202
logger.warning(
200-
"config: 'compile_concurrency' must be a positive integer, got %r — using default.",
203+
"config: 'concurrency' must be a positive integer, got %r — ignoring it.",
201204
value,
202205
)
203-
return DEFAULT_CONFIG["compile_concurrency"]
206+
return None
204207
return value
205208

206209

openkb/indexer.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from pageindex import IndexConfig, PageIndexClient
1313

14-
from openkb.config import load_config
14+
from openkb.config import load_config, resolve_concurrency
1515
from openkb.tree_renderer import render_summary_md
1616

1717
logger = logging.getLogger(__name__)
@@ -156,26 +156,26 @@ def _write_long_doc_artifacts(
156156
def _build_index_config(config: dict[str, Any]) -> IndexConfig:
157157
"""Build the PageIndex ``IndexConfig`` for local indexing.
158158
159-
Forwards the optional ``pageindex_max_concurrency`` KB setting to PageIndex,
160-
which caps how many indexing LLM calls run at once (guarding against the
161-
"too many open files" fd exhaustion on large documents). The value is only
162-
passed when set *and* the installed PageIndex's ``IndexConfig`` declares the
163-
field, so OpenKB keeps working against a pinned PageIndex that predates it
164-
(``IndexConfig`` forbids unknown kwargs).
159+
Forwards the KB's ``concurrency`` setting to PageIndex, which caps how many
160+
indexing LLM calls run at once (guarding against "too many open files" fd
161+
exhaustion on large documents). The value is only passed when set *and* the
162+
installed PageIndex's ``IndexConfig`` declares the field, so OpenKB keeps
163+
working against a pinned PageIndex that predates it (``IndexConfig``
164+
forbids unknown kwargs).
165165
"""
166166
kwargs: dict[str, Any] = {
167167
"if_add_node_text": True,
168168
"if_add_node_summary": True,
169169
"if_add_doc_description": True,
170170
}
171-
max_concurrency = config.get("pageindex_max_concurrency")
172-
if max_concurrency is not None:
171+
concurrency = resolve_concurrency(config)
172+
if concurrency is not None:
173173
if "max_concurrency" in IndexConfig.model_fields:
174-
kwargs["max_concurrency"] = max_concurrency
174+
kwargs["max_concurrency"] = concurrency
175175
else:
176176
logger.warning(
177-
"config: 'pageindex_max_concurrency' is set but the installed "
178-
"PageIndex version does not support it yet — ignoring it."
177+
"config: 'concurrency' is set but the installed PageIndex "
178+
"version does not support it yet — ignoring it."
179179
)
180180
return IndexConfig(**kwargs)
181181

tests/test_add_command.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,14 @@ def test_add_single_file_compile_failure_rolls_back_converted_artifacts(self, tm
9797
assert not (kb_dir / "wiki" / "sources" / "notes.md").exists()
9898
assert HashRegistry(kb_dir / ".openkb" / "hashes.json").all_entries() == {}
9999

100-
def test_add_forwards_compile_concurrency_from_config(self, tmp_path):
100+
def test_add_forwards_concurrency_from_config(self, tmp_path):
101101
from unittest.mock import AsyncMock
102102

103103
from openkb.cli import add_single_file
104104

105105
kb_dir = self._setup_kb(tmp_path)
106106
(kb_dir / ".openkb" / "config.yaml").write_text(
107-
"model: gpt-4o-mini\ncompile_concurrency: 3\n", encoding="utf-8"
107+
"model: gpt-4o-mini\nconcurrency: 3\n", encoding="utf-8"
108108
)
109109
doc = tmp_path / "notes.md"
110110
doc.write_text("# Notes\n\nBody", encoding="utf-8")

tests/test_config.py

Lines changed: 25 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
get_extra_headers,
66
get_timeout,
77
load_config,
8-
resolve_compile_concurrency,
8+
resolve_concurrency,
99
resolve_extra_headers,
1010
resolve_litellm_settings,
1111
resolve_timeout,
@@ -27,79 +27,53 @@ def test_default_config_values():
2727
assert DEFAULT_CONFIG["pageindex_threshold"] == 20
2828

2929

30-
def test_pageindex_max_concurrency_defaults_to_none():
31-
assert DEFAULT_CONFIG["pageindex_max_concurrency"] is None
30+
def test_concurrency_defaults_to_none():
31+
assert DEFAULT_CONFIG["concurrency"] is None
3232

3333

34-
def test_load_pageindex_max_concurrency_override(tmp_path):
34+
def test_load_concurrency_override(tmp_path):
3535
config_path = tmp_path / "config.yaml"
36-
config_path.write_text("pageindex_max_concurrency: 12\n", encoding="utf-8")
37-
assert load_config(config_path)["pageindex_max_concurrency"] == 12
36+
config_path.write_text("concurrency: 12\n", encoding="utf-8")
37+
assert load_config(config_path)["concurrency"] == 12
3838

3939

40-
def test_compile_concurrency_defaults_to_5():
41-
assert DEFAULT_CONFIG["compile_concurrency"] == 5
40+
def test_resolve_concurrency_absent_is_none():
41+
assert resolve_concurrency({}) is None
4242

4343

44-
def test_load_compile_concurrency_override(tmp_path):
45-
config_path = tmp_path / "config.yaml"
46-
config_path.write_text("compile_concurrency: 3\n", encoding="utf-8")
47-
assert load_config(config_path)["compile_concurrency"] == 3
48-
49-
50-
def test_resolve_compile_concurrency_absent_uses_default():
51-
assert resolve_compile_concurrency({}) == DEFAULT_CONFIG["compile_concurrency"]
52-
44+
def test_resolve_concurrency_valid_value():
45+
assert resolve_concurrency({"concurrency": 3}) == 3
5346

54-
def test_resolve_compile_concurrency_valid_value():
55-
assert resolve_compile_concurrency({"compile_concurrency": 3}) == 3
5647

57-
58-
def test_resolve_compile_concurrency_rejects_bool(caplog):
48+
def test_resolve_concurrency_rejects_bool(caplog):
5949
with caplog.at_level(logging.WARNING, logger="openkb.config"):
60-
result = resolve_compile_concurrency({"compile_concurrency": True})
61-
assert result == DEFAULT_CONFIG["compile_concurrency"]
62-
assert "compile_concurrency" in caplog.text
50+
result = resolve_concurrency({"concurrency": True})
51+
assert result is None
52+
assert "concurrency" in caplog.text
6353

6454

65-
def test_resolve_compile_concurrency_rejects_non_positive(caplog):
55+
def test_resolve_concurrency_rejects_non_positive(caplog):
6656
with caplog.at_level(logging.WARNING, logger="openkb.config"):
67-
assert (
68-
resolve_compile_concurrency({"compile_concurrency": 0})
69-
== DEFAULT_CONFIG["compile_concurrency"]
70-
)
71-
assert "compile_concurrency" in caplog.text
57+
assert resolve_concurrency({"concurrency": 0}) is None
58+
assert "concurrency" in caplog.text
7259
caplog.clear()
7360
with caplog.at_level(logging.WARNING, logger="openkb.config"):
74-
assert (
75-
resolve_compile_concurrency({"compile_concurrency": -1})
76-
== DEFAULT_CONFIG["compile_concurrency"]
77-
)
78-
assert "compile_concurrency" in caplog.text
61+
assert resolve_concurrency({"concurrency": -1}) is None
62+
assert "concurrency" in caplog.text
7963

8064

81-
def test_resolve_compile_concurrency_rejects_non_int():
82-
assert (
83-
resolve_compile_concurrency({"compile_concurrency": "3"})
84-
== DEFAULT_CONFIG["compile_concurrency"]
85-
)
65+
def test_resolve_concurrency_rejects_non_int():
66+
assert resolve_concurrency({"concurrency": "3"}) is None
8667

8768

88-
def test_resolve_compile_concurrency_none_is_silent(caplog):
89-
# Explicit null / absent is the normal "use the default" case — no warning.
69+
def test_resolve_concurrency_none_is_silent(caplog):
70+
# Explicit null / absent is the normal "unset — caller applies its own
71+
# default, or omits the setting entirely" case — no warning.
9072
with caplog.at_level(logging.WARNING, logger="openkb.config"):
91-
resolve_compile_concurrency({"compile_concurrency": None})
73+
assert resolve_concurrency({"concurrency": None}) is None
9274
assert caplog.text == ""
9375

9476

95-
def test_compile_concurrency_defaults_match_compiler_default():
96-
"""DEFAULT_CONFIG and the compiler's own DEFAULT_COMPILE_CONCURRENCY are two
97-
independent literals; catch drift immediately if only one is ever updated."""
98-
from openkb.agent.compiler import DEFAULT_COMPILE_CONCURRENCY
99-
100-
assert DEFAULT_CONFIG["compile_concurrency"] == DEFAULT_COMPILE_CONCURRENCY
101-
102-
10377
def test_load_missing_file_returns_defaults(tmp_path):
10478
missing = tmp_path / "nonexistent" / "config.yaml"
10579
config = load_config(missing)

tests/test_indexer.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,26 +54,33 @@ def test_sets_base_flags(self):
5454
assert cfg.if_add_node_summary is True
5555
assert cfg.if_add_doc_description is True
5656

57-
def test_forwards_max_concurrency_when_supported(self, monkeypatch):
57+
def test_forwards_concurrency_when_supported(self, monkeypatch):
5858
monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency)
59-
cfg = _build_index_config({"pageindex_max_concurrency": 8})
59+
cfg = _build_index_config({"concurrency": 8})
6060
assert cfg.max_concurrency == 8
6161

6262
def test_does_not_forward_when_unsupported(self, monkeypatch):
6363
monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithoutConcurrency)
64-
cfg = _build_index_config({"pageindex_max_concurrency": 8})
64+
cfg = _build_index_config({"concurrency": 8})
6565
assert not hasattr(cfg, "max_concurrency")
6666

6767
def test_none_value_is_left_to_pageindex_default(self, monkeypatch):
6868
monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency)
69-
cfg = _build_index_config({"pageindex_max_concurrency": None})
69+
cfg = _build_index_config({"concurrency": None})
70+
assert getattr(cfg, "max_concurrency", None) is None
71+
72+
def test_invalid_value_is_left_to_pageindex_default(self, monkeypatch):
73+
# resolve_concurrency() rejects bools/non-positive values — same as an
74+
# unset key, just via the shared config-level validation.
75+
monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency)
76+
cfg = _build_index_config({"concurrency": 0})
7077
assert getattr(cfg, "max_concurrency", None) is None
7178

7279
def test_warns_when_configured_but_unsupported(self, monkeypatch, caplog):
7380
monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithoutConcurrency)
7481
with caplog.at_level(logging.WARNING, logger="openkb.indexer"):
75-
_build_index_config({"pageindex_max_concurrency": 8})
76-
assert "pageindex_max_concurrency" in caplog.text
82+
_build_index_config({"concurrency": 8})
83+
assert "concurrency" in caplog.text
7784

7885
def test_no_warning_when_unset(self, monkeypatch, caplog):
7986
monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithoutConcurrency)
@@ -84,7 +91,7 @@ def test_no_warning_when_unset(self, monkeypatch, caplog):
8491
def test_no_warning_when_supported(self, monkeypatch, caplog):
8592
monkeypatch.setattr("openkb.indexer.IndexConfig", _FakeIndexConfigWithConcurrency)
8693
with caplog.at_level(logging.WARNING, logger="openkb.indexer"):
87-
_build_index_config({"pageindex_max_concurrency": 8})
94+
_build_index_config({"concurrency": 8})
8895
assert caplog.text == ""
8996

9097

@@ -280,12 +287,12 @@ def test_localclient_called_with_index_config(self, kb_dir, sample_tree, tmp_pat
280287
assert ic.if_add_node_summary is True
281288
assert ic.if_add_doc_description is True
282289

283-
def test_pageindex_max_concurrency_flows_from_kb_config(self, kb_dir, sample_tree, tmp_path):
290+
def test_concurrency_flows_from_kb_config(self, kb_dir, sample_tree, tmp_path):
284291
"""The KB's real config.yaml, loaded by index_long_document itself, must
285292
reach the IndexConfig passed to PageIndexClient — not just the isolated
286293
_build_index_config unit tested directly with a hand-built dict."""
287294
(kb_dir / ".openkb" / "config.yaml").write_text(
288-
"model: gpt-4o-mini\npageindex_max_concurrency: 7\n", encoding="utf-8"
295+
"model: gpt-4o-mini\nconcurrency: 7\n", encoding="utf-8"
289296
)
290297

291298
doc_id = "conc-789"

0 commit comments

Comments
 (0)