Skip to content

Commit 1954ccc

Browse files
committed
feat: sync SDK with current worker ZIP contract and agentic retrieval API
Keep raw worker metadata accessible via BaseChunk.metadata dict while removing metadata-derived fields from the flattened chunk surface. Issue #21 — Align ParseResult ZIP models with current worker output: - Add DocNav model (sections, resources) and ParseResult.doc_nav - Add HIERARCHY alias to Manifest - Parse doc_nav.json from result ZIPs, write in save() - Mark chunks_slim and hierarchy as legacy Issue #22 — Add agentic retrieval response fields: - Add answer_text and referenced_chunks to RetrievalQueryResponse - Add use_agentic parameter to sync and async retrieval.query()
1 parent 7eecbbc commit 1954ccc

8 files changed

Lines changed: 469 additions & 283 deletions

File tree

docs/usage.md

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Knowhere Python SDK — Usage Guide
22

3+
> **Recent changes:** Chunk metadata fields (`tokens`, `keywords`, `summary`,
4+
> `length`, etc.) are no longer flattened to the chunk surface. Access them
5+
> through `chunk.metadata` instead. See [Chunk Types](#chunk-types).
6+
37
Comprehensive reference for every feature, parameter, and pattern in the SDK.
48

59
## Table of Contents
@@ -219,8 +223,13 @@ result.table_chunks # List[TableChunk]
219223
# Lookup by ID
220224
chunk = result.getChunk("chunk_42")
221225

222-
# Hierarchy data (document structure tree, if available)
223-
result.hierarchy
226+
# Document navigation tree (from doc_nav.json, current worker output)
227+
result.doc_nav # DocNav | None
228+
result.doc_nav.sections # List[DocNavSection] — tree of titles/paths/levels
229+
result.doc_nav.resources # DocNavResources — image/table resource summaries
230+
231+
# Legacy hierarchy (from hierarchy.json, older worker output)
232+
result.hierarchy # Any | None
224233

225234
# Raw ZIP bytes (for archival)
226235
result.raw_zip
@@ -239,63 +248,56 @@ result.save("./output/report/")
239248

240249
## Chunk Types
241250

242-
Every chunk shares a base set of fields (`chunk_id`, `type`, `content`, `path`). Each type adds its own fields.
251+
Every chunk shares a base set of fields (`chunk_id`, `type`, `content`, `path`,
252+
`metadata`). Worker metadata is kept in the `metadata` dict — it is **not**
253+
flattened to top-level chunk properties.
243254

244-
### TextChunk
255+
### Base fields (all chunk types)
245256

246257
| Field | Type | Description |
247258
|-------|------|-------------|
248259
| `chunk_id` | `str` | Unique identifier |
249-
| `type` | `str` | Always `"text"` |
250-
| `content` | `str` | The text content |
251-
| `path` | `str \| None` | Document structure path (e.g. `"Section 1 > Subsection 2"`) |
252-
| `length` | `int` | Character count |
253-
| `tokens` | `List[str] \| None` | Tokenized words returned by the parser pipeline |
254-
| `keywords` | `List[str] \| None` | Extracted keywords (requires `summary_txt: True`) |
255-
| `summary` | `str \| None` | AI-generated summary (requires `summary_txt: True`) |
256-
| `relationships` | `List \| None` | Relationships to other chunks |
260+
| `type` | `str` | `"text"`, `"image"`, or `"table"` |
261+
| `content` | `str` | Text content or placeholder |
262+
| `path` | `str \| None` | Document structure path |
263+
| `metadata` | `dict` | Raw worker metadata (tokens, keywords, summary, length, page_nums, etc.) |
264+
265+
### TextChunk
257266

258267
```python
259268
for chunk in result.text_chunks:
260269
print(f"[{chunk.chunk_id}] {chunk.content[:60]}...")
261-
if chunk.keywords:
262-
print(f" Keywords: {', '.join(chunk.keywords)}")
263-
if chunk.summary:
264-
print(f" Summary: {chunk.summary}")
270+
# Metadata is in chunk.metadata, not flattened:
271+
keywords = chunk.metadata.get("keywords", [])
272+
summary = chunk.metadata.get("summary")
273+
if keywords:
274+
print(f" Keywords: {', '.join(keywords)}")
275+
if summary:
276+
print(f" Summary: {summary}")
265277
```
266278

267279
### ImageChunk
268280

269281
| Field | Type | Description |
270282
|-------|------|-------------|
271-
| `chunk_id` | `str` | Unique identifier |
272-
| `type` | `str` | Always `"image"` |
273-
| `content` | `str` | Text content associated with the image |
274283
| `file_path` | `str \| None` | Path within the ZIP |
275-
| `original_name` | `str \| None` | Original filename |
276-
| `summary` | `str \| None` | AI-generated image description (requires `summary_image: True`) |
277284
| `data` | `bytes` | Raw image bytes (loaded from ZIP) |
278285
| `format` | `str \| None` | Image format inferred from extension (property) |
279286

280287
```python
281288
for img in result.image_chunks:
282289
print(f"{img.file_path} ({len(img.data)} bytes, {img.format})")
283-
if img.summary:
284-
print(f" Description: {img.summary}")
290+
summary = img.metadata.get("summary")
291+
if summary:
292+
print(f" Description: {summary}")
285293
img.save("./output/images/") # writes to disk
286294
```
287295

288296
### TableChunk
289297

290298
| Field | Type | Description |
291299
|-------|------|-------------|
292-
| `chunk_id` | `str` | Unique identifier |
293-
| `type` | `str` | Always `"table"` |
294-
| `content` | `str` | Text representation of the table |
295300
| `file_path` | `str \| None` | Path within the ZIP |
296-
| `original_name` | `str \| None` | Original filename |
297-
| `table_type` | `str \| None` | Table classification |
298-
| `summary` | `str \| None` | AI-generated table summary (requires `summary_table: True`) |
299301
| `html` | `str` | Full HTML of the table (loaded from ZIP) |
300302

301303
```python
@@ -471,6 +473,19 @@ response = client.retrieval.query(
471473
top_k=5,
472474
)
473475

476+
# Agentic mode (LLM navigation + answer synthesis)
477+
response = client.retrieval.query(
478+
namespace="support-center",
479+
query="How do I pair a Bluetooth headset?",
480+
use_agentic=True,
481+
top_k=5,
482+
)
483+
print(response.answer_text) # LLM-generated natural-language answer
484+
print(response.router_used) # "workflow_single_step", "small_kb_all", etc.
485+
for ref in response.referenced_chunks:
486+
print(ref.get("chunk_id"), ref.get("asset_url"))
487+
488+
# Legacy results are always available
474489
for result in response.results:
475490
print(result.content)
476491
print(result.score)
@@ -479,6 +494,10 @@ for result in response.results:
479494
print(result.source.section_path)
480495
```
481496

497+
| Parameter | Type | Default | Description |
498+
|-----------|------|---------|-------------|
499+
| `use_agentic` | `bool \| None` | `None` | Force agentic (`True`) or legacy (`False`) retrieval. `None` uses server default. |
500+
482501
Retrieval results expose `content`, not the older parse-result `text` field.
483502
Media results may include `asset_url` when the server can sign the referenced
484503
artifact.

src/knowhere/lib/result_parser.py

Lines changed: 18 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313
from knowhere._logging import getLogger
1414
from knowhere.types.result import (
1515
Chunk,
16+
DocNav,
1617
ImageChunk,
1718
Manifest,
1819
ParseResult,
1920
SlimChunk,
2021
TableChunk,
2122
TextChunk,
22-
TextChunkTokens,
2323
)
2424

2525
_logger = getLogger()
@@ -81,38 +81,6 @@ def _extractFilePath(raw: Dict[str, Any]) -> Optional[str]:
8181
return fallback
8282

8383

84-
def _normalizeTokenList(raw_tokens: List[Any]) -> List[str]:
85-
"""Return a string-only token list with empty values removed."""
86-
normalized_tokens: List[str] = []
87-
for raw_token in raw_tokens:
88-
token_text: str = str(raw_token).strip()
89-
if token_text:
90-
normalized_tokens.append(token_text)
91-
return normalized_tokens
92-
93-
94-
def _parseTextChunkTokens(
95-
raw_tokens: Any,
96-
*,
97-
chunk_id: str,
98-
) -> Optional[TextChunkTokens]:
99-
"""Normalize text chunk tokens from the current backend payload."""
100-
if raw_tokens is None:
101-
return None
102-
if isinstance(raw_tokens, bool):
103-
raise KnowhereError(
104-
f"Invalid tokens payload for text chunk '{chunk_id}': expected list[str], got bool."
105-
)
106-
if isinstance(raw_tokens, list):
107-
return _normalizeTokenList(raw_tokens)
108-
109-
raise KnowhereError(
110-
"Invalid tokens payload for text chunk "
111-
f"'{chunk_id}': expected list[str], "
112-
f"got {type(raw_tokens).__name__}."
113-
)
114-
115-
11684
def _buildChunks(
11785
raw_chunks: List[Dict[str, Any]],
11886
zf: zipfile.ZipFile,
@@ -125,58 +93,39 @@ def _buildChunks(
12593

12694
if chunk_type == "image":
12795
image_data: bytes = b""
128-
# file_path may be at top level, inside metadata, or use path as fallback
12996
file_path: Optional[str] = _extractFilePath(raw)
13097
if file_path:
13198
image_data = _readZipBytes(zf, file_path) or b""
132-
metadata: Dict[str, Any] = raw.get("metadata", {})
13399
chunk: Chunk = ImageChunk(
134100
chunk_id=raw.get("chunk_id", ""),
135101
type="image",
136102
content=raw.get("content", ""),
137103
path=raw.get("path"),
138-
page_nums=metadata.get("page_nums", raw.get("page_nums")),
139-
length=metadata.get("length", raw.get("length", 0)),
140104
file_path=file_path,
141-
original_name=metadata.get("original_name", raw.get("original_name")),
142-
summary=metadata.get("summary", raw.get("summary")),
143105
data=image_data,
106+
metadata=raw.get("metadata", {}),
144107
)
145108
elif chunk_type == "table":
146109
table_html: str = ""
147110
file_path = _extractFilePath(raw)
148111
if file_path:
149112
table_html = _readZipText(zf, file_path) or ""
150-
metadata = raw.get("metadata", {})
151113
chunk = TableChunk(
152114
chunk_id=raw.get("chunk_id", ""),
153115
type="table",
154116
content=raw.get("content", ""),
155117
path=raw.get("path"),
156-
page_nums=metadata.get("page_nums", raw.get("page_nums")),
157-
length=metadata.get("length", raw.get("length", 0)),
158118
file_path=file_path,
159-
original_name=metadata.get("original_name", raw.get("original_name")),
160-
table_type=metadata.get("table_type", raw.get("table_type")),
161-
summary=metadata.get("summary", raw.get("summary")),
162119
html=table_html,
120+
metadata=raw.get("metadata", {}),
163121
)
164122
else:
165-
metadata = raw.get("metadata", {})
166-
chunk_id: str = raw.get("chunk_id", "")
167-
raw_tokens: Any = metadata.get("tokens", raw.get("tokens"))
168123
chunk = TextChunk(
169-
chunk_id=chunk_id,
124+
chunk_id=raw.get("chunk_id", ""),
170125
type="text",
171126
content=raw.get("content", ""),
172127
path=raw.get("path"),
173-
page_nums=metadata.get("page_nums", raw.get("page_nums")),
174-
length=metadata.get("length", raw.get("length", 0)),
175-
tokens=_parseTextChunkTokens(raw_tokens, chunk_id=chunk_id),
176-
keywords=metadata.get("keywords", raw.get("keywords")),
177-
summary=metadata.get("summary", raw.get("summary")),
178-
connect_to=metadata.get("connect_to", raw.get("connect_to")),
179-
relationships=metadata.get("relationships", raw.get("relationships")),
128+
metadata=raw.get("metadata", {}),
180129
)
181130

182131
chunks.append(chunk)
@@ -229,7 +178,15 @@ def parseResultZip(
229178
# -- Full markdown --
230179
full_markdown: str = _readZipText(zf, "full.md") or ""
231180

232-
# -- Hierarchy --
181+
# -- DocNav (current worker output) --
182+
doc_nav_text: Optional[str] = _readZipText(zf, "doc_nav.json")
183+
doc_nav: Optional[DocNav] = (
184+
DocNav.model_validate(json.loads(doc_nav_text))
185+
if doc_nav_text
186+
else None
187+
)
188+
189+
# -- Hierarchy (legacy — current worker no longer emits this) --
233190
hierarchy_text: Optional[str] = _readZipText(zf, "hierarchy.json")
234191
hierarchy: Optional[Any] = (
235192
json.loads(hierarchy_text) if hierarchy_text else None
@@ -263,11 +220,13 @@ def parseResultZip(
263220
return ParseResult(
264221
manifest=manifest,
265222
chunks=chunks,
266-
chunks_slim=chunks_slim,
267223
full_markdown=full_markdown,
224+
raw_zip=zip_bytes,
225+
doc_nav=doc_nav,
226+
# Legacy — the current worker no longer emits these files
227+
chunks_slim=chunks_slim,
268228
hierarchy=hierarchy,
269229
toc_hierarchies=toc_hierarchies,
270230
kb_csv=kb_csv,
271231
hierarchy_view_html=hierarchy_view_html,
272-
raw_zip=zip_bytes,
273232
)

src/knowhere/resources/retrieval.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def query(
2222
query: str,
2323
namespace: Optional[str] = None,
2424
top_k: Optional[int] = None,
25+
use_agentic: Optional[bool] = None,
2526
data_type: Optional[int] = None,
2627
signal_paths: Optional[list[str]] = None,
2728
filter_mode: Optional[RetrievalFilterMode] = None,
@@ -39,6 +40,8 @@ def query(
3940
body["namespace"] = namespace
4041
if top_k is not None:
4142
body["top_k"] = top_k
43+
if use_agentic is not None:
44+
body["use_agentic"] = use_agentic
4245
if data_type is not None:
4346
body["data_type"] = data_type
4447
if signal_paths is not None:
@@ -77,6 +80,7 @@ async def query(
7780
query: str,
7881
namespace: Optional[str] = None,
7982
top_k: Optional[int] = None,
83+
use_agentic: Optional[bool] = None,
8084
data_type: Optional[int] = None,
8185
signal_paths: Optional[list[str]] = None,
8286
filter_mode: Optional[RetrievalFilterMode] = None,
@@ -94,6 +98,8 @@ async def query(
9498
body["namespace"] = namespace
9599
if top_k is not None:
96100
body["top_k"] = top_k
101+
if use_agentic is not None:
102+
body["use_agentic"] = use_agentic
97103
if data_type is not None:
98104
body["data_type"] = data_type
99105
if signal_paths is not None:

0 commit comments

Comments
 (0)