$ cat node-template.py

K

KB Semantic Search

// Semantic (vector) search over the knowledge base's document chunks. Returns the ranked chunks, synthesizer-ready chunk texts, and the deduped document ids. Wire chunkTexts into Knowledge Synthesizer's context and documentIds into Ontology From Documents.

Input
Data
#knowledge#semantic-search#rag#chunks#retrieval#read
template.py
1"""kb-semantic-search — semantic (vector) search over the KB's document chunks.23The thematic entry of the unified-retrieval flow: embeds ``query`` and runs a4chunk kNN over the Milvus-backed knowledge index via the gais.knowledge SDK5(``Gais.knowledge.search``), which carries the per-execution USER_TOKEN and6talks to the permissioned www-emblema v2 knowledge API — this node imports no7HTTP client. Permissions are enforced SERVER-SIDE: the endpoint derives the8accessible scope from the caller's own drive permissions and only ever9narrows it.1011Scoping (1.3.0): ``driveItemIds`` is the ONLY scope input — Drive item ids,12folders and/or files (folders expand server-side to their whole subtree,13files to their document). "KB folder id" was the legacy pre-Drive concept;14a folder's drive-item id IS the kb id. Absent-vs-explicit-empty is15preserved: an unwired input means "no narrowing", a wired input that16produced ZERO ids scopes to nothing (clean empty output) — it never17silently broadens. Any input shape is accepted (id string, DriveItem18object, lists of either, JSON-encoded forms) — a scope input never crashes19on shape.2021Outputs (flat):22- ``chunks``      — the raw ranked chunk dicts for structured downstream use;23- ``chunkTexts``  — self-describing texts (provenance header + body) shaped24  for Knowledge Synthesizer's ``context`` port;25- ``documentIds`` — deduped, order-preserving document ids for the26  Ontology From Documents bridge node.2728An empty result set is a CLEAN empty output (three empty lists), never an29error — downstream nodes degrade gracefully.30"""3132from __future__ import annotations3334import json35import re36import sys37import traceback38from typing import Any3940from gais import Gais4142# 1.4.0: fetch WIDE by default (k=100) and let the server-side cross-encoder43# rerank + threshold decide what survives — the reranker, not k, is the44# precision instrument.45MAX_K = 10046DEFAULT_K = 100474849def _parse_drive_item_ids(raw: Any) -> list[str] | None:50    """Parse the driveItemIds scope input, accepting every shape the canvas51    actually delivers, while PRESERVING absent-vs-explicit-empty.5253    Investigate 2026-07-22: a Folder picker's single DriveItem output wired54    into the DriveItem[] port arrives as a BARE id string — the JSON-only55    parser crashed the node. A scope input must never crash on shape; it56    normalizes:57    - None / ""                -> None: "no narrowing" (input not wired).58    - JSON string              -> parsed, then normalized as below.59    - bare string              -> the id itself (comma/whitespace-separated60                                  strings contribute one id each).61    - dict (a DriveItem object) -> its "id".62    - list/tuple               -> per item: strings kept, dicts contribute63                                  their "id"; blanks/junk dropped.64    - [] / "[]" / only-blanks  -> []: an EXPLICIT empty scope ("these ZERO65      items") — the wired-input-found-nothing case must scope to nothing,66      never broaden (N1, eng review 2026-07-22).67    """68    if raw is None or raw == "":69        return None70    if isinstance(raw, str):71        try:72            raw = json.loads(raw)73        except json.JSONDecodeError:74            # Not JSON: a bare id (or several, separator-joined) from a75            # single-DriveItem edge. Never a crash.76            return [tok for tok in re.split(r"[,\s]+", raw.strip()) if tok]77    if isinstance(raw, str):78        # A JSON-encoded bare string ('"<id>"').79        return [raw.strip()] if raw.strip() else []80    if isinstance(raw, dict):81        raw = [raw]82    if not isinstance(raw, (list, tuple)):83        raise ValueError(84            "'driveItemIds' must be ids (a string, a list, or DriveItem objects)"85        )86    ids: list[str] = []87    for item in raw:88        if isinstance(item, str) and item.strip():89            ids.append(item.strip())90        elif isinstance(item, dict):91            item_id = item.get("id")92            if isinstance(item_id, str) and item_id.strip():93                ids.append(item_id.strip())94    return ids959697def _chunk_text(index: int, chunk: dict) -> str:98    """One self-describing context entry: provenance header + chunk body."""99    document_id = chunk.get("documentId") or ""100    try:101        score = round(float(chunk.get("score") or 0.0), 4)102    except (TypeError, ValueError):103        score = 0.0104    text = chunk.get("text") or ""105    return (106        f"[Chunk {index} | Document ID: {document_id} | Relevance: {score}]\n{text}"107    )108109110def process(inputs: dict[str, Any]) -> dict[str, Any]:111    query = inputs.get("query")112    if not isinstance(query, str) or not query.strip():113        raise ValueError("Required input 'query' not provided")114    query = query.strip()115116    # Clamp k to the server's cap (100). Default = fetch wide, rerank filters.117    try:118        k = int(inputs.get("k") or DEFAULT_K)119    except (TypeError, ValueError):120        k = DEFAULT_K121    k = max(1, min(k, MAX_K))122123    # Rerank threshold (0-1): forwarded to the server's cross-encoder filter;124    # absent/junk -> None (the server applies its own default, 0.01).125    rerank_threshold: float | None126    try:127        raw_threshold = inputs.get("rerankThreshold")128        rerank_threshold = None if raw_threshold in (None, "") else float(raw_threshold)129    except (TypeError, ValueError):130        rerank_threshold = None131    if rerank_threshold is not None:132        rerank_threshold = max(0.0, min(rerank_threshold, 1.0))133134    # 1.3.0: driveItemIds is the ONLY scope input. "KB folder id" was the135    # legacy pre-Drive concept — a folder's drive-item id IS the kb id (Milvus136    # kb_id = document.parentId), and the drive path additionally expands137    # folder SUBTREES, which the legacy kbIds path never did. A stale `kbIds`138    # value from an old canvas is ignored.139    drive_item_ids = _parse_drive_item_ids(inputs.get("driveItemIds"))140141    # Explicitly scoped to zero items -> clean empty output WITHOUT an API142    # round-trip (the server would answer the same uniform empty).143    if drive_item_ids is not None and len(drive_item_ids) == 0:144        print(145            "[kb-semantic-search] explicit empty scope — clean empty output",146            file=sys.stderr,147        )148        return {"chunks": [], "chunkTexts": [], "documentIds": []}149150    chunks = Gais.knowledge.search(151        query=query,152        k=k,153        drive_item_ids=drive_item_ids,154        rerank_threshold=rerank_threshold,155    )156    if not isinstance(chunks, list):157        chunks = []158    chunks = [chunk for chunk in chunks if isinstance(chunk, dict)]159160    chunk_texts = [_chunk_text(i, chunk) for i, chunk in enumerate(chunks, 1)]161162    # Deduped, order-preserving document ids (dict preserves insertion order).163    document_ids = list(164        dict.fromkeys(165            chunk["documentId"]166            for chunk in chunks167            if isinstance(chunk.get("documentId"), str) and chunk["documentId"]168        )169    )170171    print(172        f"[kb-semantic-search] k={k} "173        f"driveItemIds={len(drive_item_ids) if drive_item_ids else 0} "174        f"chunks={len(chunks)} documents={len(document_ids)}",175        file=sys.stderr,176    )177178    return {179        "chunks": chunks,180        "chunkTexts": chunk_texts,181        "documentIds": document_ids,182    }183184185def main() -> None:186    try:187        envelope = json.loads(sys.stdin.read() or "{}")188        if not isinstance(envelope, dict):189            envelope = {}190        inputs = envelope.get("inputs", {}) or {}191        json.dump(process(inputs), sys.stdout)192    except Exception as e:193        print(194            json.dumps({195                "error": str(e),196                "errorType": type(e).__name__,197                "traceback": traceback.format_exc(),198            }),199            file=sys.stderr,200        )201        sys.exit(1)202203204if __name__ == "__main__":205    main()

$ git log --oneline

v1.4.0
HEAD
2026-08-18
v1.3.02026-07-22

1.3.0: driveItemIds is the ONLY scope input — kbIds removed (legacy pre-Drive concept; a folder's drive-item id IS the kb id, and the drive path expands subtrees). The input accepts any edge shape (bare id string from a single-DriveItem wire, DriveItem objects, lists, JSON strings) — the 1.2.0 JSON-only parser crashed on a Folder-picker edge.

v1.2.02026-07-22

1.2.0 (N1): driveItemIds input (folders expand to their subtree, files to their document; union with kbIds); absent-vs-explicit-empty preserved — a wired input with zero ids scopes to NOTHING (clean empty output) instead of silently broadening to everything accessible; response rows now carry vectorScore/rerankScore/rerankApplied.

v1.1.02026-07-19
v1.0.02026-07-19