Skip to content

Commit bd9fe39

Browse files
feat: make indexing and compile concurrency configurable (#174)
* feat(indexer): expose pageindex_max_concurrency to cap indexing concurrency PageIndex fans out one indexing LLM call per structure node; on a large document that can open a socket per node and exhaust the process file-descriptor limit ("[Errno 24] Too many open files"). This wires an OpenKB config knob through to PageIndex's IndexConfig.max_concurrency cap. - New `pageindex_max_concurrency` KB config key (default None = let PageIndex apply its own default). - indexer forwards it via `_build_index_config` only when set AND the installed PageIndex's IndexConfig declares the field, so OpenKB keeps working against a pinned PageIndex that predates it (IndexConfig forbids unknown kwargs). * feat(config): make compile concurrency configurable (closes #173) Concept/entity generation ran at a hardcoded concurrency of 5 (DEFAULT_COMPILE_CONCURRENCY) with no way to lower it when the LLM provider rate-limits. Add a `compile_concurrency` config key (default 5) and thread it through every add / recompile / cloud-import compile call via a shared `_compile_concurrency` resolver (null / non-positive → the compiler default). Pairs with `pageindex_max_concurrency` (indexing side) from this PR — both concurrency knobs are now KB-configurable. * fix(config): address xhigh code-review findings on the concurrency knobs - resolve_compile_concurrency moves to config.py (matching resolve_timeout's convention) and now rejects bool (bool is an int subclass, so `compile_concurrency: true` previously silently became concurrency=1) and logs a warning on any malformed value, same as the other resolvers. - _build_index_config now warns when pageindex_max_concurrency is configured but the installed PageIndex doesn't support the field yet, instead of silently dropping it with no signal to the user. - Replaced the tautological test_forwards_max_concurrency_when_supported (which branched on the same runtime condition as the code under test, so it never exercised the forwarding assertion under CI's pinned PageIndex) with fake IndexConfig doubles that make both branches deterministic regardless of the installed pageindex version. - Added a cross-check test pinning DEFAULT_CONFIG["compile_concurrency"] against the compiler's own DEFAULT_COMPILE_CONCURRENCY so the two literals can't silently drift apart. - Added an end-to-end test proving index_long_document's own loaded config (not just a hand-built dict) reaches PageIndexClient's IndexConfig. - Hoisted the compile-concurrency resolution out of `recompile --all`'s per-document loop (was recomputed every iteration despite being loop-invariant). - Documented both new config.yaml keys in config.yaml.example and examples/configuration/README.md (kept in sync). * 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. * build(deps): bump pageindex to 0.3.0.dev2 (ships IndexConfig.max_concurrency) 0.3.0.dev2 is the first published release that declares IndexConfig.max_concurrency, so `concurrency` in config.yaml now takes effect for the indexing stage in a normal `pip install` / `uv sync`, not just against a local editable checkout. Vetted: downloaded the wheel and confirmed its sha256 matches PyPI (b0cb1f6e…) and that IndexConfig declares `max_concurrency: int | None` with a positivity field_validator. uv.lock regenerated via `uv lock --upgrade-package pageindex`. The runtime `"max_concurrency" in IndexConfig.model_fields` guard in indexer._build_index_config is now always-true under the pinned dependency, but is kept as graceful degradation for mismatched local installs. * refactor(config): keep concurrency out of DEFAULT_CONFIG (align with other optional knobs) Every other optional tuning knob (timeout, extra_headers, litellm, entity_types, parallel_tool_calls) is resolved via its resolver's .get() and lives only in config.yaml.example, not DEFAULT_CONFIG — which holds just the core keys openkb init writes (model, language, pageindex_threshold). concurrency was the lone exception; resolve_concurrency already reads it via .get(), so the DEFAULT_CONFIG entry was redundant. Remove it for consistency. * build(deps): bump pageindex 0.3.0.dev2 -> 0.3.0.dev3 Vetted: identical Requires-Dist/Requires-Python to dev2; IndexConfig, PageIndexClient, and IndexConfig.max_concurrency (used by #174) all still present; wheel/sdist hashes verified against PyPI. uv.lock regenerated via `uv lock` (change scoped to the pageindex entry). Also aligns the stale dev1 reference in the config README. * feat(cli): add --version flag The CLI group had no way to report its version (`openkb version` / `openkb -v` both fail). Add `@click.version_option(package_name="openkb")` so `openkb --version` prints "openkb <version>", read from installed package metadata (tracks the hatch-vcs-derived version, no hardcoded string).
1 parent 6774c59 commit bd9fe39

11 files changed

Lines changed: 306 additions & 17 deletions

File tree

config.yaml.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ 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 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
10+
511
# Optional: whether the LLM agents (query, chat, lint, skill) may call tools
612
# in parallel. Leave it UNSET (commented out) to keep OpenKB's per-agent
713
# defaults. Setting it applies the SAME value to every agent:

examples/configuration/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ pip install openkb
1212
```
1313

1414
OpenKB pins a **pre-release** of its PageIndex dependency
15-
(`pageindex==0.3.0.dev1`), which some installers skip by default. If an install
15+
(`pageindex==0.3.0.dev3`), which some installers skip by default. If an install
1616
can't resolve `pageindex`, allow pre-releases:
1717

1818
```bash
@@ -70,6 +70,12 @@ 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 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
78+
7379
# Optional: whether the LLM agents (query, chat, lint, skill) may call tools
7480
# in parallel. Leave it UNSET (commented out) to keep OpenKB's per-agent
7581
# defaults. Setting it applies the SAME value to every agent:
@@ -105,6 +111,7 @@ pageindex_threshold: 20 # PDF pages threshold for PageIndex
105111
| `model` | `gpt-5.4` | LLM used for all compile/query/chat work. |
106112
| `language` | `en` | Language the wiki is written in. |
107113
| `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/). |
114+
| `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. |
108115
| `parallel_tool_calls` | unset | Whether the LLM agents (query, chat, lint, skill) may call tools in parallel. Unset keeps OpenKB's per-agent defaults; `true`/`false` force allow/sequential for every agent; `null` omits the setting (provider default). **Amazon Bedrock needs `null`** (see below). |
109116
| `entity_types` | 7 defaults | Custom vocabulary for entity pages. `other` is always kept. |
110117
| `litellm:` || A pass-through block for LiteLLM. See below. |

openkb/cli.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +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_concurrency,
5758
resolve_extra_headers,
5859
set_extra_headers,
5960
resolve_parallel_tool_calls,
@@ -561,6 +562,7 @@ def commit_body(snapshot) -> None:
561562
kb_dir,
562563
model,
563564
doc_description=index_result.description,
565+
max_concurrency=resolve_concurrency(config) or DEFAULT_COMPILE_CONCURRENCY,
564566
),
565567
label=f"Compiling long doc (doc_id={index_result.doc_id})",
566568
)
@@ -569,7 +571,13 @@ def commit_body(snapshot) -> None:
569571
raise RuntimeError(f"Converted document has no source artifact: {file_path.name}")
570572
source_path = result.source_path
571573
_run_compile_with_retry(
572-
lambda: compile_short_doc(doc_name, source_path, kb_dir, model),
574+
lambda: compile_short_doc(
575+
doc_name,
576+
source_path,
577+
kb_dir,
578+
model,
579+
max_concurrency=resolve_concurrency(config) or DEFAULT_COMPILE_CONCURRENCY,
580+
),
573581
label="Compiling short doc",
574582
)
575583

@@ -690,6 +698,7 @@ def commit_body(_snapshot) -> None:
690698
kb_dir,
691699
model,
692700
doc_description=cloud.description,
701+
max_concurrency=resolve_concurrency(config) or DEFAULT_COMPILE_CONCURRENCY,
693702
),
694703
label=f"Compiling imported doc (doc_id={doc_id})",
695704
)
@@ -748,6 +757,7 @@ def append_cloud_log() -> None:
748757

749758

750759
@click.group()
760+
@click.version_option(package_name="openkb", prog_name="openkb", message="%(prog)s %(version)s")
751761
@click.option("-v", "--verbose", is_flag=True, default=False, help="Enable verbose logging.")
752762
@click.option(
753763
"--kb-dir",
@@ -1642,6 +1652,7 @@ def _classify(meta: dict) -> str:
16421652
_setup_llm_key(kb_dir)
16431653
config = load_config(openkb_dir / "config.yaml")
16441654
model: str = config.get("model", DEFAULT_CONFIG["model"])
1655+
max_concurrency = resolve_concurrency(config) or DEFAULT_COMPILE_CONCURRENCY
16451656

16461657
# Import lazily and reference via the module so tests can patch
16471658
# ``openkb.agent.compiler.compile_*`` and see the call.
@@ -1677,7 +1688,16 @@ def _classify(meta: dict) -> str:
16771688
click.echo(f"[{i}/{total}] Recompiling long doc {name}...")
16781689
start = time.time()
16791690
try:
1680-
asyncio.run(compiler.compile_long_doc(name, summary_path, doc_id, kb_dir, model))
1691+
asyncio.run(
1692+
compiler.compile_long_doc(
1693+
name,
1694+
summary_path,
1695+
doc_id,
1696+
kb_dir,
1697+
model,
1698+
max_concurrency=max_concurrency,
1699+
)
1700+
)
16811701
except Exception as exc:
16821702
click.echo(f" [ERROR] Compilation failed: {exc}")
16831703
logging.getLogger(__name__).debug("Recompile traceback:", exc_info=True)
@@ -1697,7 +1717,15 @@ def _classify(meta: dict) -> str:
16971717
click.echo(f"[{i}/{total}] Recompiling short doc {name}...")
16981718
start = time.time()
16991719
try:
1700-
asyncio.run(compiler.compile_short_doc(name, source_path, kb_dir, model))
1720+
asyncio.run(
1721+
compiler.compile_short_doc(
1722+
name,
1723+
source_path,
1724+
kb_dir,
1725+
model,
1726+
max_concurrency=max_concurrency,
1727+
)
1728+
)
17011729
except Exception as exc:
17021730
click.echo(f" [ERROR] Compilation failed: {exc}")
17031731
logging.getLogger(__name__).debug("Recompile traceback:", exc_info=True)

openkb/config.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,30 @@ def resolve_timeout(config: dict) -> float | None:
200200
return value
201201

202202

203+
def resolve_concurrency(config: dict) -> int | None:
204+
"""Resolve the optional ``concurrency:`` key — the cap on concurrent LLM
205+
calls OpenKB makes during ingest (PageIndex indexing and concept/entity
206+
compilation alike; they never run at the same time for one document, so
207+
one setting covers both).
208+
209+
Returns ``None`` when absent, explicitly ``null``, or invalid (rejecting
210+
bools and non-positive values with a warning when present but unusable —
211+
an explicit ``null``/absent key is the normal "unset" case and stays
212+
silent, matching ``resolve_timeout``). Callers apply their own default (or
213+
omit the setting entirely) when this returns ``None``.
214+
"""
215+
value = config.get("concurrency")
216+
if value is None:
217+
return None
218+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
219+
logger.warning(
220+
"config: 'concurrency' must be a positive integer, got %r — ignoring it.",
221+
value,
222+
)
223+
return None
224+
return value
225+
226+
203227
def resolve_litellm_settings(config: dict) -> dict[str, Any]:
204228
"""Resolve the optional ``litellm:`` mapping of LiteLLM module settings.
205229

openkb/indexer.py

Lines changed: 29 additions & 6 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__)
@@ -153,6 +153,33 @@ def _write_long_doc_artifacts(
153153
return summary_path
154154

155155

156+
def _build_index_config(config: dict[str, Any]) -> IndexConfig:
157+
"""Build the PageIndex ``IndexConfig`` for local indexing.
158+
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).
165+
"""
166+
kwargs: dict[str, Any] = {
167+
"if_add_node_text": True,
168+
"if_add_node_summary": True,
169+
"if_add_doc_description": True,
170+
}
171+
concurrency = resolve_concurrency(config)
172+
if concurrency is not None:
173+
if "max_concurrency" in IndexConfig.model_fields:
174+
kwargs["max_concurrency"] = concurrency
175+
else:
176+
logger.warning(
177+
"config: 'concurrency' is set but the installed PageIndex "
178+
"version does not support it yet — ignoring it."
179+
)
180+
return IndexConfig(**kwargs)
181+
182+
156183
def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = None) -> IndexResult:
157184
"""Index a long PDF document using PageIndex and write wiki pages.
158185
@@ -166,11 +193,7 @@ def index_long_document(pdf_path: Path, kb_dir: Path, doc_name: str | None = Non
166193
model: str = config.get("model", "gpt-5.4")
167194
pageindex_api_key = os.environ.get("PAGEINDEX_API_KEY", "")
168195

169-
index_config = IndexConfig(
170-
if_add_node_text=True,
171-
if_add_node_summary=True,
172-
if_add_doc_description=True,
173-
)
196+
index_config = _build_index_config(config)
174197

175198
client = PageIndexClient(
176199
api_key=pageindex_api_key or None,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ keywords = ["ai", "rag", "retrieval", "knowledge-base", "llm", "pageindex", "age
3232
# returning empty Responses output (BerriAI/litellm#25429) and
3333
# auto-injects GitHub Copilot IDE-auth headers.
3434
dependencies = [
35-
"pageindex==0.3.0.dev1",
35+
"pageindex==0.3.0.dev3",
3636
"markitdown[docx,pptx,xlsx,xls]==0.1.5",
3737
"trafilatura==2.0.0",
3838
"click==8.4.0",

tests/test_add_command.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,29 @@ 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_concurrency_from_config(self, tmp_path):
101+
from unittest.mock import AsyncMock
102+
103+
from openkb.cli import add_single_file
104+
105+
kb_dir = self._setup_kb(tmp_path)
106+
(kb_dir / ".openkb" / "config.yaml").write_text(
107+
"model: gpt-4o-mini\nconcurrency: 3\n", encoding="utf-8"
108+
)
109+
doc = tmp_path / "notes.md"
110+
doc.write_text("# Notes\n\nBody", encoding="utf-8")
111+
112+
with (
113+
patch(
114+
"openkb.agent.compiler.compile_short_doc", new_callable=AsyncMock
115+
) as mock_compile,
116+
patch("openkb.cli._setup_llm_key"),
117+
):
118+
outcome = add_single_file(doc, kb_dir)
119+
120+
assert outcome == "added"
121+
assert mock_compile.call_args.kwargs["max_concurrency"] == 3
122+
100123
def test_add_single_file_uses_add_mutation_coordinator(self, tmp_path):
101124
from openkb.cli import add_single_file
102125
from openkb.converter import ConvertResult

tests/test_cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@
99
from openkb.schema import AGENTS_MD
1010

1111

12+
def test_version_flag_reports_installed_version():
13+
import importlib.metadata
14+
15+
runner = CliRunner()
16+
result = runner.invoke(cli, ["--version"])
17+
assert result.exit_code == 0
18+
# Reports the package's installed version (via importlib.metadata), so it
19+
# tracks the hatch-vcs-derived version without a hardcoded string.
20+
assert importlib.metadata.version("openkb") in result.output
21+
22+
1223
def test_init_creates_structure(tmp_path):
1324
runner = CliRunner()
1425
with runner.isolated_filesystem(temp_dir=tmp_path), patch("openkb.cli.register_kb"):

tests/test_config.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
get_parallel_tool_calls,
77
get_timeout,
88
load_config,
9+
resolve_concurrency,
910
resolve_extra_headers,
1011
resolve_litellm_settings,
1112
resolve_model_settings,
@@ -135,6 +136,57 @@ def test_default_config_values():
135136
assert DEFAULT_CONFIG["pageindex_threshold"] == 20
136137

137138

139+
def test_concurrency_not_in_default_config():
140+
# Like the other optional tuning knobs (timeout, extra_headers,
141+
# parallel_tool_calls), concurrency stays out of DEFAULT_CONFIG —
142+
# resolve_concurrency reads it via .get(), so an absent key resolves to
143+
# None without relying on load_config's merge.
144+
assert "concurrency" not in DEFAULT_CONFIG
145+
146+
147+
def test_load_concurrency_override(tmp_path):
148+
config_path = tmp_path / "config.yaml"
149+
config_path.write_text("concurrency: 12\n", encoding="utf-8")
150+
assert load_config(config_path)["concurrency"] == 12
151+
152+
153+
def test_resolve_concurrency_absent_is_none():
154+
assert resolve_concurrency({}) is None
155+
156+
157+
def test_resolve_concurrency_valid_value():
158+
assert resolve_concurrency({"concurrency": 3}) == 3
159+
160+
161+
def test_resolve_concurrency_rejects_bool(caplog):
162+
with caplog.at_level(logging.WARNING, logger="openkb.config"):
163+
result = resolve_concurrency({"concurrency": True})
164+
assert result is None
165+
assert "concurrency" in caplog.text
166+
167+
168+
def test_resolve_concurrency_rejects_non_positive(caplog):
169+
with caplog.at_level(logging.WARNING, logger="openkb.config"):
170+
assert resolve_concurrency({"concurrency": 0}) is None
171+
assert "concurrency" in caplog.text
172+
caplog.clear()
173+
with caplog.at_level(logging.WARNING, logger="openkb.config"):
174+
assert resolve_concurrency({"concurrency": -1}) is None
175+
assert "concurrency" in caplog.text
176+
177+
178+
def test_resolve_concurrency_rejects_non_int():
179+
assert resolve_concurrency({"concurrency": "3"}) is None
180+
181+
182+
def test_resolve_concurrency_none_is_silent(caplog):
183+
# Explicit null / absent is the normal "unset — caller applies its own
184+
# default, or omits the setting entirely" case — no warning.
185+
with caplog.at_level(logging.WARNING, logger="openkb.config"):
186+
assert resolve_concurrency({"concurrency": None}) is None
187+
assert caplog.text == ""
188+
189+
138190
def test_load_missing_file_returns_defaults(tmp_path):
139191
missing = tmp_path / "nonexistent" / "config.yaml"
140192
config = load_config(missing)

0 commit comments

Comments
 (0)