# Diff návrh: Vektorový index pro knowledge plane

## Souhrn

Přidat embedding pipeline (`qwen3-embedding:0.6b`) a LanceDB uložiště do `knowledge_plane`. Změny jsou doplňkové — stávající rozhraní `knowledge_plane` zůstává nezměněné.

## Konstanty

```python
EMBEDDING_MODEL = "qwen3-embedding:0.6b"
VECTOR_DIM = 1024
MAX_CONTEXT = 32_000
OLLAMA_BASE_URL = "http://127.0.0.1:11434"
LANCEDB_DIRECTORY = "./lancedb_data"
FILE_EXTENSIONS = (".txt", ".md", ".py", ".js", ".ts", ".json", ".yaml", ".yml", ".toml", ".rst", ".csv")
CHUNK_SIZE = 512
CHUNK_OVERLAP = 100
```

## Nové soubory

### `src/cyberbrain26/knowledge_plane/embedding_config.py`

```python
"""Konfigurace embedding providera pro knowledge plane."""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path


@dataclass(frozen=True)
class EmbeddingConfig:
    """Nastavení pro embedding model a vector store."""

    model: str = "qwen3-embedding:0.6b"
    vector_dim: int = 1024
    max_context: int = 32_000
    ollama_base_url: str = "http://127.0.0.1:11434"
    lancedb_directory: Path = field(default_factory=lambda: Path("./lancedb_data"))
    file_extensions: tuple[str, ...] = (
        ".txt",
        ".md",
        ".py",
        ".js",
        ".ts",
        ".json",
        ".yaml",
        ".yml",
        ".toml",
        ".rst",
        ".csv",
    )
    chunk_size_tokens: int = 512
    chunk_overlap_tokens: int = 100

    @property
    def effective_dim(self) -> int:
        """Dimenze vektoru po případném ořezu MRL."""
        return self.vector_dim
```

### `src/cyberbrain26/knowledge_plane/embedding_service.py`

```python
"""Služba pro generování embeddingů přes Ollama."""

from __future__ import annotations

import logging
from pathlib import Path

import httpx

from cyberbrain26.knowledge_plane.embedding_config import EmbeddingConfig

logger = logging.getLogger(__name__)


class EmbeddingService:
    """Wrapper kolem Ollama /api/embed pro knowledge plane."""

    def __init__(self, config: EmbeddingConfig | None = None) -> None:
        self.config = config or EmbeddingConfig()
        self._client = httpx.Client(
            base_url=self.config.ollama_base_url,
            timeout=60.0,
        )

    def _request(self, payload: dict[str, object]) -> dict[str, object]:
        response = self._client.post("/api/embed", json=payload)
        response.raise_for_status()
        return response.json()

    def embed_text(self, text: str, *, num_ctx: int | None = None) -> list[float]:
        """Vrátí embedding pro jeden text."""
        ctx = num_ctx or self.config.max_context
        payload: dict[str, object] = {
            "model": self.config.model,
            "prompt": text,
            "options": {"num_ctx": ctx},
        }
        data = self._request(payload)
        embeddings = data.get("embeddings", [])
        if not embeddings:
            raise RuntimeError("Ollama nevrátil žádný embedding")
        vector = embeddings[0]
        if len(vector) != self.config.vector_dim:
            raise RuntimeError(
                f"Očekávaná dimenze {self.config.vector_dim}, "
                f"dostal jsem {len(vector)}"
            )
        return vector

    def embed_texts(self, texts: list[str], *, num_ctx: int | None = None) -> list[list[float]]:
        """Batch embedding — Ollama podporuje pole promptů."""
        ctx = num_ctx or self.config.max_context
        payload: dict[str, object] = {
            "model": self.config.model,
            "prompt": texts,
            "options": {"num_ctx": ctx},
        }
        data = self._request(payload)
        embeddings = data.get("embeddings", [])
        if len(embeddings) != len(texts):
            raise RuntimeError(
                f"Očekávaných {len(texts)} vektorů, dostal jsem {len(embeddings)}"
            )
        for vector in embeddings:
            if len(vector) != self.config.vector_dim:
                raise RuntimeError(
                    f"Očekávaná dimenze {self.config.vector_dim}, "
                    f"dostal jsem {len(vector)}"
                )
        return embeddings

    def health_check(self) -> dict[str, object]:
        """Rychlá kontrola dostupnosti providera."""
        try:
            response = self._client.get("/")
            ok = response.status_code == 200
        except Exception as exc:  # noqa: BLE001
            return {"healthy": False, "error": str(exc)}
        return {
            "healthy": ok,
            "provider": "ollama",
            "model": self.config.model,
            "vector_dim": self.config.vector_dim,
        }

    def close(self) -> None:
        self._client.close()

    def __enter__(self) -> EmbeddingService:
        return self

    def __exit__(self, *_exc: object) -> None:
        self.close()
```

### `src/cyberbrain26/knowledge_plane/chunker.py`

```python
"""Chunking strategie pro indexing textových souborů."""

from __future__ import annotations

import hashlib
import logging
from dataclasses import dataclass
from pathlib import Path

from cyberbrain26.knowledge_plane.embedding_config import EmbeddingConfig

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class Chunk:
    """Jeden chunk textu."""

    text: str
    chunk_index: int
    source_path: str
    mtime: float
    sha256: str


class TextChunker:
    """Dělí text na chunky podle velikosti a překryvu."""

    def __init__(self, config: EmbeddingConfig | None = None) -> None:
        self.config = config or EmbeddingConfig()

    @staticmethod
    def _file_sha256(path: Path) -> str:
        """Vrátí SHA256 souboru."""
        h = hashlib.sha256()
        with path.open("rb") as f:
            for chunk in iter(lambda: f.read(65536), b""):
                h.update(chunk)
        return h.hexdigest()

    def chunk_file(self, path: Path) -> list[Chunk]:
        """Načte soubor a rozdelí ho na chunky."""
        text = path.read_text(encoding="utf-8", errors="replace")
        mtime = path.stat().st_mtime
        sha256 = self._file_sha256(path)
        tokens = text.split()
        size = self.config.chunk_size_tokens
        overlap = self.config.chunk_overlap_tokens
        chunks: list[Chunk] = []
        index = 0
        start = 0
        while start < len(tokens):
            end = start + size
            chunk_tokens = tokens[start:end]
            chunk_text = " ".join(chunk_tokens)
            chunks.append(
                Chunk(
                    text=chunk_text,
                    chunk_index=index,
                    source_path=str(path),
                    mtime=mtime,
                    sha256=sha256,
                )
            )
            index += 1
            start += size - overlap
        logger.info("Rozděleno %s na %d chunů", path, len(chunks))
        return chunks
```

### `src/cyberbrain26/knowledge_plane/vector_store.py`

```python
"""LanceDB vector store pro knowledge plane."""

from __future__ import annotations

import hashlib
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import lancedb
from lancedb import Connection

from cyberbrain26.knowledge_plane.embedding_config import EmbeddingConfig

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class VectorDocument:
    """Jeden záznam ve vector store."""

    id: str
    vector: list[float]
    text: str
    source_id: str
    path: str
    mtime: float
    sha256: str
    chunk_index: int
    metadata: dict[str, Any] = field(default_factory=dict)


class KnowledgeVectorStore:
    """Uložiště vektorů nad LanceDB pro knowledge plane."""

    def __init__(
        self,
        config: EmbeddingConfig | None = None,
        connection: Connection | None = None,
    ) -> None:
        self.config = config or EmbeddingConfig()
        self.config.lancedb_directory.mkdir(parents=True, exist_ok=True)
        self._connection = connection or lancedb.connect(
            str(self.config.lancedb_directory)
        )
        self._table_name = "knowledge_documents"
        self._table = self._get_or_create_table()

    def _get_or_create_table(self) -> Any:
        """Vrátí existující tabulku nebo vytvoří novou."""
        if self._table_name in self._connection.table_names():
            return self._connection.open_table(self._table_name)
        return self._connection.create_table(
            self._table_name,
            schema=[
                {"name": "id", "type": "string"},
                {"name": "vector", "type": "float32", "dim": self.config.vector_dim},
                {"name": "text", "type": "string"},
                {"name": "source_id", "type": "string"},
                {"name": "path", "type": "string"},
                {"name": "mtime", "type": "float"},
                {"name": "sha256", "type": "string"},
                {"name": "chunk_index", "type": "int64"},
                {"name": "metadata", "type": "string"},
            ],
            mode="overwrite",
        )

    @staticmethod
    def _chunk_id(path: str, chunk_index: int) -> str:
        """Stabilní ID pro chunk."""
        raw = f"{path}:{chunk_index}"
        return hashlib.sha256(raw.encode()).hexdigest()

    def add_documents(self, documents: list[VectorDocument]) -> None:
        """Přidá dokumenty do vector store."""
        if not documents:
            return
        rows: list[dict[str, Any]] = []
        for doc in documents:
            rows.append(
                {
                    "id": doc.id,
                    "vector": doc.vector,
                    "text": doc.text,
                    "source_id": doc.source_id,
                    "path": doc.path,
                    "mtime": doc.mtime,
                    "sha256": doc.sha256,
                    "chunk_index": doc.chunk_index,
                    "metadata": str(doc.metadata),
                }
            )
        self._table.add(rows)
        logger.info("Přidáno %d vektorů do %s", len(rows), self._table_name)

    def search(self, query_vector: list[float], limit: int = 10) -> list[dict[str, Any]]:
        """Sémantické vyhledání podle vektoru."""
        if len(query_vector) != self.config.vector_dim:
            raise ValueError(
                f"Query vektor má {len(query_vector)} dimenzí, "
                f"očekávám {self.config.vector_dim}"
            )
        results = (
            self._table.search(query_vector)
            .limit(limit)
            .to_pandas()
            .to_dict("records")
        )
        return results

    def count(self) -> int:
        """Počet vektorů v tabulce."""
        return self._table.count_rows()

    def delete_by_path(self, path: str) -> None:
        """Smaže všechny chunky pro danou cestu."""
        self._table.delete(f'path = "{path}"')

    def close(self) -> None:
        """Uzavře připojení k LanceDB."""
        try:
            self._connection.close()
        except Exception:  # noqa: BLE001
            pass
```

## Upravené soubory

### `src/cyberbrain26/knowledge_plane/__init__.py`

Přidat exporty:

```python
from cyberbrain26.knowledge_plane.embedding_config import EmbeddingConfig
from cyberbrain26.knowledge_plane.embedding_service import EmbeddingService
from cyberbrain26.knowledge_plane.vector_store import KnowledgeVectorStore, VectorDocument
from cyberbrain26.knowledge_plane.chunker import TextChunker, Chunk

__all__ = [
    # ... stávající ...
    "EmbeddingConfig",
    "EmbeddingService",
    "KnowledgeVectorStore",
    "VectorDocument",
    "TextChunker",
    "Chunk",
]
```

### `src/cyberbrain26/knowledge_plane/repository.py`

Přidat volitelný parametr `vector_store` do `register()`:

```python
# Přidat importy
from cyberbrain26.knowledge_plane.vector_store import KnowledgeVectorStore, VectorDocument
from cyberbrain26.knowledge_plane.chunker import TextChunker

# V register() přidat:
def register(
    self,
    registration: DocumentRegistration,
    vector_store: KnowledgeVectorStore | None = None,
    embedding_service: EmbeddingService | None = None,
) -> tuple[KnowledgeDocument, bool]:
    # ... stávající logika ...
    document, created = _existing_register_logic(self, registration)

    if created and vector_store is not None and embedding_service is not None:
        chunks = TextChunker().chunk_file(Path(registration.source.source_uri.replace("file:///", "")))
        vectors = embedding_service.embed_texts([c.text for c in chunks])
        vector_docs = [
            VectorDocument(
                id=KnowledgeVectorStore._chunk_id(c.source_path, c.chunk_index),
                vector=v,
                text=c.text,
                source_id=registration.source.source_id,
                path=c.source_path,
                mtime=c.mtime,
                sha256=c.sha256,
                chunk_index=c.chunk_index,
            )
            for c, v in zip(chunks, vectors)
        ]
        vector_store.add_documents(vector_docs)

    return document, created
```

### `pyproject.toml`

```toml
dependencies = [
    # ... stávající ...
    "lancedb>=0.13",
    "pyarrow>=19",
]
```

## Testy

### `tests/test_knowledge_embedding.py` (nový)

```python
import pytest
from pathlib import Path

from cyberbrain26.knowledge_plane.embedding_config import EmbeddingConfig
from cyberbrain26.knowledge_plane.vector_store import KnowledgeVectorStore, VectorDocument
from cyberbrain26.knowledge_plane.chunker import TextChunker


def test_embedding_config_defaults():
    config = EmbeddingConfig()
    assert config.vector_dim == 1024
    assert config.max_context == 32_000
    assert config.model == "qwen3-embedding:0.6b"


def test_vector_store_roundtrip(tmp_path: Path):
    config = EmbeddingConfig(lancedb_directory=tmp_path / "lancedb")
    store = KnowledgeVectorStore(config)
    doc = VectorDocument(
        id="test-1",
        vector=[0.1] * config.vector_dim,
        text="Hello world",
        source_id="src",
        path="/tmp/test.txt",
        mtime=0.0,
        sha256="abc123",
        chunk_index=0,
    )
    store.add_documents([doc])
    assert store.count() == 1
    results = store.search([0.1] * config.vector_dim, limit=1)
    assert len(results) == 1
    assert results[0]["text"] == "Hello world"


def test_chunker_splits_text(tmp_path: Path):
    file_path = tmp_path / "test.txt"
    file_path.write_text("word " * 20, encoding="utf-8")
    config = EmbeddingConfig(chunk_size_tokens=5, chunk_overlap_tokens=2)
    chunker = TextChunker(config)
    chunks = chunker.chunk_file(file_path)
    assert len(chunks) > 1
    assert all(isinstance(c.text, str) for c in chunks)
    assert all(c.source_path == str(file_path) for c in chunks)
```

## Rizika

- Závislost na běžícím Ollama službě (`qwen3-embedding:0.6b`)
- LanceDB zápis vyžaduje dostatek diskového prostoru
- Chunking na tokeny je zjednodušený — produkčně lze nahradit tokenizérem

## Rollback

- Odstranit `lancedb_data/` adresář
- Smazat nové soubory v `knowledge_plane/`
- Vrátit `repository.py` a `pyproject.toml` do původního stavu
- Odstranit `tests/test_knowledge_embedding.py`
