feat(rag): index markdown KB — chunker, embed client, delta importer, Sources page
Phase 02 (story: import documents):
- fence-aware markdown chunker (heading sections, 200-char overlap,
heading anchor on every chunk, 1200-char hard cap, fence blocks
kept atomic and split under the cap)
- LLMClient over aipi (LiteLLM) reusing the openai client's httpx
transport to send a clean {model, input} payload — the openai SDK
injects encoding_format, which aipi's openai_like group rejects;
token-budget batching + halving retry for the endpoint's
~1024-token per-request input cap
- two-phase per-file upsert importer: sha256 delta (unchanged skip),
atomic commit, A9 exclusion walk, per-source prune, per-file error
tolerance (rollback + log + continue, non-zero CLI exit), adaptive
re-chunk at half target for URL-dense files the endpoint rejects
- scripts/import_docs CLI (repeatable --source, --prune, --limit,
defaults ~/Homelab + ~/Deployments)
- GET /api/docs with per-doc chunk counts; Sources page wired to the
real endpoint (stat cards, full-width a11y table, designed empty
state, DOM-built rows — no innerHTML)
- tests: 63 passed (chunker/llm/importer units, docs API + importer
integration), story E2E 3/3 (real endpoints, in-thread import);
app/ coverage 98%
- real KB imported: 672 docs / 8969 chunks in ~3m, idempotent
re-run (672 unchanged, 0 batches)
- harness: .agent/validate.sh now gates through uv (pytest +
coverage >90% + ruff + pyright) instead of system python3
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
"""Async OpenAI-compatible client for the self-hosted aipi endpoint (PLAN A5).
|
||||
|
||||
Phase 02 adds the embeddings surface (the importer — and, from phase 03,
|
||||
retrieval — need it). Chat streaming lands in phase 03 on this same client.
|
||||
|
||||
Fail-loud rule (PLAN A6): the ``chunks.embedding`` column is fixed at 768
|
||||
dimensions when the table is created, so a model that returns a different
|
||||
dimension must abort the import with an actionable error — never store
|
||||
vectors that pgvector rejects.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger("app.llm")
|
||||
|
||||
|
||||
class EmbeddingError(RuntimeError):
|
||||
"""The embeddings endpoint failed (network, HTTP, or malformed reply)."""
|
||||
|
||||
|
||||
class EmbeddingDimensionError(EmbeddingError):
|
||||
"""Embedding dimension != BOR_EMBEDDING_DIM — import must fail loudly."""
|
||||
|
||||
|
||||
# aipi's local embedding model rejects requests over ~1024 input tokens
|
||||
# ("input is too large to process"). Batch by estimated tokens, with a
|
||||
# safety margin under that cap — code-dense text can tokenize at ~3
|
||||
# chars/token, so stay well below the ceiling. A retry that halves an
|
||||
# over-large batch (:meth:`LLMClient._embed_batch`) is the backstop.
|
||||
_TOKENS_PER_CHAR = 0.25
|
||||
_REQUEST_TOKEN_BUDGET = 700
|
||||
|
||||
|
||||
class _TooLarge(RuntimeError):
|
||||
"""Internal: the endpoint rejected the request's input size."""
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Thin async wrapper over the aipi OpenAI-compatible API."""
|
||||
|
||||
def __init__(self, settings: Settings | None = None) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
#: Number of embedding HTTP requests made so far (importer logging).
|
||||
self.embed_batches: int = 0
|
||||
self._client = AsyncOpenAI(
|
||||
base_url=self.settings.llm_base_url,
|
||||
api_key=self.settings.effective_api_key,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
async def _post_embeddings(self, texts: list[str]) -> list[list[float]]:
|
||||
"""One POST /embeddings with a minimal OpenAI-compatible payload.
|
||||
|
||||
The ``openai`` SDK (1.x and 2.x) injects ``encoding_format`` into
|
||||
every embeddings request (defaulting to ``"base64"``), and aipi's
|
||||
litellm ``openai_like`` model group rejects that parameter in any
|
||||
form — so we reuse the openai client's own httpx transport (same
|
||||
base URL, TLS, and connection pooling) and send a clean payload.
|
||||
The endpoint's default is floats, which is what pgvector needs.
|
||||
"""
|
||||
http = self._client._client # pyright: ignore[reportAttributeAccessIssue]
|
||||
resp = await http.post(
|
||||
"embeddings",
|
||||
json={"model": self.settings.llm_embed_model, "input": texts},
|
||||
headers={"Authorization": f"Bearer {self.settings.effective_api_key}"},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
if "too large" in resp.text:
|
||||
raise _TooLarge(resp.text[:300])
|
||||
raise EmbeddingError(
|
||||
f"embeddings endpoint returned HTTP {resp.status_code}: {resp.text[:300]}"
|
||||
)
|
||||
payload = resp.json()
|
||||
rows = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list) or len(rows) != len(texts):
|
||||
n = len(rows) if isinstance(rows, list) else "?"
|
||||
raise EmbeddingError(
|
||||
f"embeddings endpoint returned {n} vectors for {len(texts)} inputs — "
|
||||
"refusing to guess which is which"
|
||||
)
|
||||
ordered = sorted(rows, key=lambda r: r["index"])
|
||||
return [[float(x) for x in row["embedding"]] for row in ordered]
|
||||
|
||||
async def _embed_batch(self, chunk: list[str]) -> list[list[float]]:
|
||||
"""Embed *chunk*, halving the request if the endpoint says the input
|
||||
is too large (tokenizer estimates can be wrong for dense content).
|
||||
A single text that still fails is a hard, actionable error."""
|
||||
try:
|
||||
return await self._post_embeddings(chunk)
|
||||
except _TooLarge:
|
||||
if len(chunk) == 1:
|
||||
raise EmbeddingError(
|
||||
f"a single {len(chunk[0])}-char chunk exceeded the endpoint's "
|
||||
"per-request input token cap — lower BOR_CHUNK_TARGET_CHARS "
|
||||
"and re-import"
|
||||
) from None
|
||||
mid = len(chunk) // 2
|
||||
left = await self._embed_batch(chunk[:mid])
|
||||
right = await self._embed_batch(chunk[mid:])
|
||||
return [*left, *right]
|
||||
|
||||
def _check_dims(self, vecs: list[list[float]]) -> None:
|
||||
expected = self.settings.embedding_dim
|
||||
dims = sorted({len(v) for v in vecs})
|
||||
if dims != [expected]:
|
||||
raise EmbeddingDimensionError(
|
||||
f"embedding dimension mismatch: model '{self.settings.llm_embed_model}' "
|
||||
f"returned dims {dims} but BOR_EMBEDDING_DIM={expected}. The chunks "
|
||||
"table stores a fixed dimension — run `uv run python -m scripts.llm_probe`,"
|
||||
" update BOR_EMBEDDING_DIM, and recreate the schema (README → "
|
||||
"Troubleshooting: 'Embedding dimension mismatch')."
|
||||
)
|
||||
|
||||
def _batch_texts(self, texts: list[str]) -> list[list[str]]:
|
||||
"""Group *texts* into requests under the endpoint's token cap.
|
||||
|
||||
``BOR_EMBED_BATCH_SIZE`` stays a hard cap on *texts* per request;
|
||||
the token budget usually binds first for 2000-char chunks.
|
||||
"""
|
||||
budget_chars = int(_REQUEST_TOKEN_BUDGET / _TOKENS_PER_CHAR)
|
||||
max_texts = max(1, self.settings.embed_batch_size)
|
||||
batches: list[list[str]] = []
|
||||
cur: list[str] = []
|
||||
cur_chars = 0
|
||||
for text in texts:
|
||||
if cur and (len(cur) >= max_texts or cur_chars + len(text) > budget_chars):
|
||||
batches.append(cur)
|
||||
cur, cur_chars = [], 0
|
||||
cur.append(text)
|
||||
cur_chars += len(text)
|
||||
if cur:
|
||||
batches.append(cur)
|
||||
return batches
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Embed *texts* in token-budgeted batches (order always kept)."""
|
||||
if not texts:
|
||||
return []
|
||||
out: list[list[float]] = []
|
||||
for chunk in self._batch_texts(texts):
|
||||
try:
|
||||
vecs = await self._embed_batch(chunk)
|
||||
except EmbeddingError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
||||
raise EmbeddingError(
|
||||
f"embeddings request to {self.settings.llm_base_url} failed: {e}"
|
||||
) from e
|
||||
self.embed_batches += 1
|
||||
self._check_dims(vecs)
|
||||
out.extend(vecs)
|
||||
return out
|
||||
|
||||
async def embed_one(self, text: str) -> list[float]:
|
||||
"""Convenience: embed a single text (retrieval path, phase 03)."""
|
||||
(vec,) = await self.embed([text])
|
||||
return vec
|
||||
Reference in New Issue
Block a user