Skip to content

Commit 112359b

Browse files
committed
feat(python): add streaming query functionality with real-time events
Add streaming query support that yields real-time retrieval events as dicts with type information. This includes a new StreamingQuery async iterator that bridges Rust's mpsc::Receiver<RetrieveEvent> to Python. - Introduce PyStreamingQuery wrapper for async iteration over events - Add query_stream method to PyEngine with proper event conversion - Bridge RetrieveEvent variants to Python dictionaries with "type" keys - Provide proper terminal event handling (completed/error) - Update Session.query_stream to use real Rust-side streaming - Add StreamingQueryResult to consume and process streaming events refactor(docs): update navbar styling and disable blog link Replace hardcoded colors with CSS variables for theme consistency. Apply subtle gradient background pattern to navbar. Clean up redundant styles and remove dark mode specific overrides. - Replace hardcoded hex colors with --text, --text-light variables - Add grid-like gradient background pattern using linear gradients - Remove duplicate border/box-shadow declarations - Hide navbar pseudo-elements for cleaner appearance feat(python): introduce SyncSession for synchronous operations Provide synchronous Vectorless API that works in scripts and Jupyter notebooks without requiring async/await syntax. Uses background event loop management for compatibility across different environments. - Create SyncSession wrapping Session with sync methods - Implement run_async utility for event loop bridging - Add synchronous versions of all core operations (index, ask, etc.) - Support both new and existing event loop contexts refactor(python): enhance LangChain and LlamaIndex compatibility Update LangChain and LlamaIndex integrations to support passing Session instances for engine reuse. Replace asyncio.run usage with robust async-to-sync bridging utility. - Add session parameter to VectorlessRetriever constructors - Implement lazy session creation with caching - Use run_async instead of asyncio.run for better compatibility - Maintain backward compatibility with existing API patterns
1 parent 3a760af commit 112359b

16 files changed

Lines changed: 551 additions & 91 deletions

File tree

docs/docusaurus.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const config: Config = {
7676
{to: '/docs/sdk/python', label: 'Python', position: 'left'},
7777
{to: '/docs/sdk/rust', label: 'Rust', position: 'left'},
7878
{to: '/docs/intro', label: 'Documentation', position: 'left'},
79-
{to: '/blog', label: 'Blog', position: 'left'},
79+
// {to: '/blog', label: 'Blog', position: 'left'},
8080
],
8181
},
8282
prism: {

docs/src/css/custom.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,31 @@
6666
/* ===== Navbar ===== */
6767
.navbar {
6868
background-color: var(--bg) !important;
69+
background-image:
70+
linear-gradient(rgba(175, 120, 139, 0.06) 1px, transparent 1px),
71+
linear-gradient(90deg, rgba(175, 120, 139, 0.06) 1px, transparent 1px);
72+
background-size: 48px 48px;
6973
border-bottom: none !important;
7074
box-shadow: none !important;
7175
height: 68px !important;
7276
padding: 0 !important;
7377
}
7478

79+
nav.navbar,
80+
.navbar--fixed-top {
81+
border-bottom: none !important;
82+
box-shadow: none !important;
83+
}
84+
85+
.navbar::after,
86+
.navbar::before {
87+
display: none !important;
88+
}
89+
90+
.navbar-sidebar {
91+
background-color: var(--bg) !important;
92+
}
93+
7594
.navbar__inner {
7695
height: 68px !important;
7796
max-width: 1280px;

docs/src/theme/Navbar/styles.module.css

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,10 @@
3636
font-weight: 600;
3737
font-family: 'Inter', 'Libre Franklin', -apple-system, BlinkMacSystemFont, sans-serif;
3838
letter-spacing: -0.02em;
39-
color: #111827;
39+
color: var(--text);
4040
line-height: 1;
4141
}
4242

43-
[data-theme='dark'] .logo {
44-
color: #D0D6E4;
45-
}
46-
4743
/* Center: navigation links */
4844
.navbarCenter {
4945
position: absolute;
@@ -57,19 +53,16 @@
5753
.navbarCenter :global(.navbar__link) {
5854
font-size: 0.875rem;
5955
font-weight: 400;
60-
color: #374151;
56+
color: var(--text-light);
6157
padding: 0;
6258
text-decoration: none;
6359
transition: opacity 0.15s ease;
6460
}
6561

66-
[data-theme='dark'] .navbarCenter :global(.navbar__link) {
67-
color: #C8D0E0;
68-
}
69-
7062
.navbarCenter :global(.navbar__link:hover) {
7163
opacity: 0.7;
7264
text-decoration: none;
65+
color: var(--primary);
7366
}
7467

7568
.navbarCenter :global(.navbar__link--active) {
@@ -105,7 +98,7 @@
10598
border-radius: 8px;
10699
border: 1px solid var(--border);
107100
background: transparent;
108-
color: #374151;
101+
color: var(--text-light);
109102
cursor: pointer;
110103
transition: all 0.15s;
111104
}
@@ -115,15 +108,6 @@
115108
color: var(--primary);
116109
}
117110

118-
[data-theme='dark'] .themeToggle {
119-
color: #C8D0E0;
120-
}
121-
122-
[data-theme='dark'] .themeToggle:hover {
123-
border-color: var(--primary);
124-
color: var(--primary);
125-
}
126-
127111
@media (max-width: 996px) {
128112
.navbarBrand {
129113
padding-left: 16px;

python/src/engine.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use super::error::to_py_err;
1818
use super::graph::PyDocumentGraph;
1919
use super::metrics::PyMetricsReport;
2020
use super::results::{PyIndexResult, PyQueryResult};
21+
use super::streaming::PyStreamingQuery;
2122

2223
// ============================================================
2324
// Engine async helpers (named functions to avoid FnOnce HRTB issue)
@@ -58,6 +59,11 @@ async fn run_get_graph(engine: Arc<Engine>) -> PyResult<Option<PyDocumentGraph>>
5859
Ok(graph.map(|g| PyDocumentGraph { inner: g }))
5960
}
6061

62+
async fn run_query_stream(engine: Arc<Engine>, ctx: QueryContext) -> PyResult<PyStreamingQuery> {
63+
let rx = engine.query_stream(ctx).await.map_err(to_py_err)?;
64+
Ok(PyStreamingQuery::new(rx))
65+
}
66+
6167
fn run_metrics_report(engine: Arc<Engine>) -> PyMetricsReport {
6268
PyMetricsReport {
6369
inner: engine.metrics_report(),
@@ -186,6 +192,29 @@ impl PyEngine {
186192
future_into_py(py, run_query(engine, query_ctx))
187193
}
188194

195+
/// Query documents with streaming progress events.
196+
///
197+
/// Returns a StreamingQuery async iterator that yields real-time
198+
/// retrieval events as dicts with a ``"type"`` key.
199+
///
200+
/// Args:
201+
/// ctx: QueryContext with query text and scope.
202+
///
203+
/// Returns:
204+
/// StreamingQuery async iterator.
205+
///
206+
/// Raises:
207+
/// VectorlessError: If query setup fails.
208+
fn query_stream<'py>(
209+
&self,
210+
py: Python<'py>,
211+
ctx: &PyQueryContext,
212+
) -> PyResult<Bound<'py, PyAny>> {
213+
let engine = Arc::clone(&self.inner);
214+
let query_ctx = ctx.inner.clone();
215+
future_into_py(py, run_query_stream(engine, query_ctx))
216+
}
217+
189218
/// List all indexed documents.
190219
///
191220
/// Returns:

python/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ mod error;
1313
mod graph;
1414
mod metrics;
1515
mod results;
16+
mod streaming;
1617

1718
use config::PyConfig;
1819
use context::{PyIndexContext, PyIndexOptions, PyQueryContext};
@@ -25,6 +26,7 @@ use results::{
2526
PyEvidenceItem, PyFailedItem, PyIndexItem, PyIndexMetrics, PyIndexResult, PyQueryMetrics,
2627
PyQueryResult, PyQueryResultItem,
2728
};
29+
use streaming::PyStreamingQuery;
2830

2931
/// Vectorless - Reasoning-native document intelligence engine.
3032
///
@@ -60,6 +62,7 @@ fn _vectorless(m: &Bound<'_, PyModule>) -> PyResult<()> {
6062
m.add_class::<PyRetrievalMetricsReport>()?;
6163
m.add_class::<PyMetricsReport>()?;
6264
m.add_class::<PyConfig>()?;
65+
m.add_class::<PyStreamingQuery>()?;
6366
m.add_class::<PyEngine>()?;
6467

6568
m.add("__version__", env!("CARGO_PKG_VERSION"))?;

python/src/streaming.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) 2026 vectorless developers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! PyO3 streaming query wrapper.
5+
//!
6+
//! Bridges Rust's `mpsc::Receiver<RetrieveEvent>` to a Python async iterator,
7+
//! yielding real-time retrieval progress events as dicts.
8+
9+
use pyo3::exceptions::PyStopAsyncIteration;
10+
use pyo3::prelude::*;
11+
use pyo3::types::PyDict;
12+
use pyo3_async_runtimes::tokio::future_into_py;
13+
use std::sync::Arc;
14+
use tokio::sync::{mpsc, Mutex};
15+
16+
use ::vectorless::retrieval::{RetrieveEvent, SufficiencyLevel};
17+
18+
/// Convert a `RetrieveEvent` into a Python dict with a `"type"` key.
19+
fn event_to_dict(event: RetrieveEvent, py: Python<'_>) -> PyObject {
20+
let dict = PyDict::new(py);
21+
match event {
22+
RetrieveEvent::Started { query, strategy } => {
23+
dict.set_item("type", "started").unwrap();
24+
dict.set_item("query", query).unwrap();
25+
dict.set_item("strategy", strategy).unwrap();
26+
}
27+
RetrieveEvent::StageCompleted { stage, elapsed_ms } => {
28+
dict.set_item("type", "stage_completed").unwrap();
29+
dict.set_item("stage", stage).unwrap();
30+
dict.set_item("elapsed_ms", elapsed_ms).unwrap();
31+
}
32+
RetrieveEvent::NodeVisited {
33+
node_id,
34+
title,
35+
score,
36+
} => {
37+
dict.set_item("type", "node_visited").unwrap();
38+
dict.set_item("node_id", node_id).unwrap();
39+
dict.set_item("title", title).unwrap();
40+
dict.set_item("score", score).unwrap();
41+
}
42+
RetrieveEvent::ContentFound {
43+
node_id,
44+
title,
45+
preview,
46+
score,
47+
} => {
48+
dict.set_item("type", "content_found").unwrap();
49+
dict.set_item("node_id", node_id).unwrap();
50+
dict.set_item("title", title).unwrap();
51+
dict.set_item("preview", preview).unwrap();
52+
dict.set_item("score", score).unwrap();
53+
}
54+
RetrieveEvent::Backtracking { from, to, reason } => {
55+
dict.set_item("type", "backtracking").unwrap();
56+
dict.set_item("from", from).unwrap();
57+
dict.set_item("to", to).unwrap();
58+
dict.set_item("reason", reason).unwrap();
59+
}
60+
RetrieveEvent::SufficiencyCheck { level, tokens } => {
61+
let level_str = match level {
62+
SufficiencyLevel::Sufficient => "sufficient",
63+
SufficiencyLevel::PartialSufficient => "partial_sufficient",
64+
SufficiencyLevel::Insufficient => "insufficient",
65+
};
66+
dict.set_item("type", "sufficiency_check").unwrap();
67+
dict.set_item("level", level_str).unwrap();
68+
dict.set_item("tokens", tokens).unwrap();
69+
}
70+
RetrieveEvent::Completed { response } => {
71+
dict.set_item("type", "completed").unwrap();
72+
dict.set_item("confidence", response.confidence).unwrap();
73+
dict.set_item("is_sufficient", response.is_sufficient).unwrap();
74+
dict.set_item("strategy_used", response.strategy_used).unwrap();
75+
dict.set_item("tokens_used", response.tokens_used).unwrap();
76+
dict.set_item("content", response.content).unwrap();
77+
78+
let results: Vec<PyObject> = response
79+
.results
80+
.into_iter()
81+
.map(|r| {
82+
let rd = PyDict::new(py);
83+
rd.set_item("node_id", &r.node_id).unwrap();
84+
rd.set_item("title", &r.title).unwrap();
85+
rd.set_item("content", &r.content).unwrap();
86+
rd.set_item("score", r.score).unwrap();
87+
rd.set_item("depth", r.depth).unwrap();
88+
rd.into()
89+
})
90+
.collect();
91+
dict.set_item("results", results).unwrap();
92+
}
93+
RetrieveEvent::Error { message } => {
94+
dict.set_item("type", "error").unwrap();
95+
dict.set_item("message", message).unwrap();
96+
}
97+
}
98+
dict.into()
99+
}
100+
101+
/// Python-facing async iterator over streaming retrieval events.
102+
///
103+
/// Usage::
104+
///
105+
/// stream = await engine.query_stream(ctx)
106+
/// async for event in stream:
107+
/// print(event["type"])
108+
#[pyclass(name = "StreamingQuery")]
109+
pub struct PyStreamingQuery {
110+
rx: Arc<Mutex<Option<mpsc::Receiver<RetrieveEvent>>>>,
111+
}
112+
113+
impl PyStreamingQuery {
114+
pub fn new(rx: mpsc::Receiver<RetrieveEvent>) -> Self {
115+
Self {
116+
rx: Arc::new(Mutex::new(Some(rx))),
117+
}
118+
}
119+
}
120+
121+
#[pymethods]
122+
impl PyStreamingQuery {
123+
fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
124+
slf
125+
}
126+
127+
fn __anext__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
128+
let rx = Arc::clone(&self.rx);
129+
future_into_py(py, async move {
130+
let mut guard = rx.lock().await;
131+
match guard.as_mut() {
132+
None => Err(PyStopAsyncIteration::new_err("stream exhausted")),
133+
Some(receiver) => match receiver.recv().await {
134+
Some(event) => {
135+
let is_terminal = matches!(
136+
&event,
137+
RetrieveEvent::Completed { .. } | RetrieveEvent::Error { .. }
138+
);
139+
if is_terminal {
140+
*guard = None;
141+
}
142+
// Convert to Python dict — safe because future_into_py
143+
// ensures we're on a thread that can acquire the GIL.
144+
let obj = Python::with_gil(|py| event_to_dict(event, py));
145+
Ok(obj)
146+
}
147+
None => {
148+
*guard = None;
149+
Err(PyStopAsyncIteration::new_err("stream closed"))
150+
}
151+
},
152+
}
153+
})
154+
}
155+
156+
fn __repr__(&self) -> String {
157+
"StreamingQuery(...)".to_string()
158+
}
159+
}

python/vectorless/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414

1515
# High-level API (recommended)
1616
from vectorless.session import Session
17+
from vectorless.sync_session import SyncSession
1718
from vectorless.config import EngineConfig, load_config, load_config_from_env, load_config_from_file
1819
from vectorless.events import EventEmitter
20+
from vectorless.streaming import StreamingQueryResult
1921
from vectorless.types import (
2022
DocumentGraphWrapper,
2123
EdgeEvidence,
@@ -38,13 +40,16 @@
3840
__all__ = [
3941
# Primary API
4042
"Session",
43+
"SyncSession",
4144
# Configuration
4245
"EngineConfig",
4346
"load_config",
4447
"load_config_from_env",
4548
"load_config_from_file",
4649
# Events
4750
"EventEmitter",
51+
# Streaming
52+
"StreamingQueryResult",
4853
# Result types
4954
"QueryResponse",
5055
"QueryResult",

python/vectorless/_async_utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Shared async utilities for sync/async context bridging."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import concurrent.futures
7+
from typing import Coroutine, TypeVar
8+
9+
T = TypeVar("T")
10+
11+
12+
def run_async(coro: Coroutine[object, object, T]) -> T:
13+
"""Run an async coroutine synchronously.
14+
15+
Handles both pure-script and Jupyter (existing event loop) contexts.
16+
17+
- No running event loop: uses ``asyncio.run()``.
18+
- Running event loop (Jupyter): runs the coroutine in a new thread
19+
with its own event loop, then waits for the result.
20+
"""
21+
try:
22+
asyncio.get_running_loop()
23+
except RuntimeError:
24+
return asyncio.run(coro)
25+
26+
# Running loop exists — offload to a new thread.
27+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
28+
future = pool.submit(asyncio.run, coro)
29+
return future.result()

0 commit comments

Comments
 (0)