Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5 e30d833624 fix(llama-embed): raise the memory limit to match its own context and batch
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#739 raised the context from 2048 to 4096 and the micro-batch from an effective
512 to 1024, both to match how Paperless chunks documents. It left memory at
2Gi/4Gi, which was sized for the smaller values.

The pod ran fine interactively and was OOMKilled (exit 137) partway through the
first sustained run, around 300 documents into an embedding pass. Nothing in the
server log marked it — the process was killed outright, so the last line is a
half-finished task and the reason is only visible in the container's
lastState.terminated.

This would have surfaced when Paperless first indexed the archive, i.e. on the
first real use rather than under test.

The weights are only ~600 MiB. What sets the footprint is four slots at 4096
context with a 1024-token micro-batch, plus Vulkan allocating the iGPU working
set from system RAM where it counts against this cgroup — the same effect that
put llama-server at 32Gi for 19 GiB of weights.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 08:14:19 +02:00
k.renner 35a8a8d975 Merge pull request 'fix(paperless): restart on paperless-config changes, not just secrets' (#741) from paperless-reloader into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#741
2026-09-02 05:20:35 +00:00
claude-botandClaude Opus 5 a003df0ef8 fix(paperless): restart on paperless-config changes, not just secrets
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The AI settings from #739 applied cleanly to git and to the ConfigMap in the
cluster, and the application never saw them: the pod was 13 minutes old with no
PAPERLESS_AI_* in its environment at all.

paperless-config is consumed with envFrom, and Kubernetes does not restart a pod
when a ConfigMap read that way changes — the container keeps the environment it
started with. The Deployment carried a Reloader annotation for paperless-secrets
only, so secret rotations restarted it but settings changes did not.

Adding the ConfigMap to Reloader closes that, and makes the annotation pair
match how the ConfigMap is actually consumed rather than only the Secret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:19:22 +02:00
k.renner f0e48b226a Merge pull request 'feat(paperless): AI suggestions against the cluster's own LLM' (#739) from paperless-llm into master
Reviewed-on: k.renner/homelab#739
2026-09-02 05:16:01 +00:00
k.renner 669ef1c5ee Merge pull request 'refactor(paperless): retire paperless-data, the CephFS predecessor volume' (#740) from close-paperless-data-migration into master
Reviewed-on: k.renner/homelab#740
2026-09-02 05:15:31 +00:00
claude-botandClaude Opus 5 68865e15c5 feat(paperless): AI suggestions against the cluster's own LLM
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Paperless 3.x has LLM suggestions built in, so this is configuration rather
than a sidecar: no extra image, no second database, no Paperless API token to
mint. Both endpoints are ClusterIP Services, so document text never leaves the
cluster and there is no key to rotate.

The make-or-break requirement was verified against the live endpoint before any
of this was written. Paperless's openai-like client calls chat_with_tools with
tool_required=True and error_on_no_tool_call=True — there is no free-text JSON
fallback, so the model must emit real tool_calls. It does: finish_reason
"tool_calls", a populated tool_calls array, empty content. A German utility
bill came back tagged Rechnung / Strom / Versorger.

That same probe showed the tags in German but document_types in English, which
is the documented weak spot and exactly what OUTPUT_LANGUAGE's second
localisation pass addresses. The value must be "de-de": it is matched against
Django's settings.LANGUAGES, where a bare "de" does not resolve.

llama-embed needed three fixes to serve as the RAG embedder:

  - --alias, so the model is not advertised as its whole --hf-repo string.
    That string would otherwise sit verbatim in this ConfigMap and break on the
    next model swap.
  - --pooling last. Qwen3-Embedding is trained for it; unspecified,
    llama-server takes the GGUF default, which is invisible from outside. Set
    now because nothing is indexed yet — changing pooling later invalidates
    every stored vector.
  - --batch-size/--ubatch-size 1024. The logs showed llama-server clamping both
    to 512 under --embeddings, below Paperless's 1024-token chunk size. 2048 is
    avoided deliberately: that is where this Vulkan stack fails with
    ErrorDeviceLost.

Both network policies are scoped to the two pods and the one port on each side,
so neither grant widens if something else is placed in either namespace.

REQUEST_TIMEOUT goes to 300s. At ~11 tok/s a classification is 2-4k prompt
tokens plus a second round trip for localisation, and the 120s default is
marginal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:11:36 +02:00
4 changed files with 107 additions and 3 deletions
+38 -3
View File
@@ -44,12 +44,37 @@ spec:
- Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0
# Serves POST /v1/embeddings.
- --embeddings
# A short, stable id for consumers. Without it the model is
# advertised as the whole --hf-repo string, which would end up
# verbatim in Paperless's config and break on the next model swap.
# One name only: llama-server keeps aliases in a sorted set and
# reports the first as the id, so a second name can steal it.
- --alias
- qwen3-embedding-0.6b
# Qwen3-Embedding is trained for last-token pooling. Left
# unspecified llama-server takes the GGUF's default, which is not
# visible from outside and would silently produce vectors the model
# was not trained to emit. Set before anything is indexed: changing
# pooling later invalidates every stored embedding.
- --pooling
- last
- --n-gpu-layers
- "99"
- --threads
- "2"
# Paperless chunks documents at 1024 tokens. The defaults are
# batch 2048 / ubatch 512, and with --embeddings llama-server
# clamps both down to 512 rather than run them mismatched — below
# the chunk size. Matching both to 1024 fits a whole chunk in one
# pass; 2048 is deliberately avoided, since that is where this
# Vulkan stack fails with ErrorDeviceLost.
- --batch-size
- "1024"
- --ubatch-size
- "1024"
# Room for a full chunk plus overhead rather than exactly one.
- --ctx-size
- "2048"
- "4096"
# No browser ever talks to this Service, so the WebUI is dead weight
# and one more unauthenticated surface.
- --no-webui
@@ -66,14 +91,24 @@ spec:
capabilities:
drop:
- ALL
# The weights are only ~600 MiB, but that is not what sets the
# footprint: four slots at 4096 context with a 1024-token micro-batch,
# and Vulkan allocating the iGPU's working set from system RAM where
# it counts against this cgroup.
#
# 4Gi was sized for the earlier 2048 context and 512 micro-batch. Both
# were raised to match Paperless's chunking without raising this, and
# the pod then survived interactive use and was OOMKilled (137) partway
# through the first sustained run — roughly 300 documents in. Indexing
# the archive would have hit it on day one.
resources:
requests:
cpu: 250m
memory: 2Gi
memory: 4Gi
gpu.intel.com/i915: "1"
limits:
cpu: "2"
memory: 4Gi
memory: 10Gi
gpu.intel.com/i915: "1"
# ~600 MiB rather than ~5 GiB, so a much shorter budget than the chat
# model needs, but still sized for a cold download.
@@ -57,6 +57,20 @@ spec:
protocol: UDP
- port: "53"
protocol: TCP
# The cluster's own LLM, for AI suggestions and RAG embeddings. Scoped to
# the two pods and the one port rather than the ai namespace as a whole, so
# this grant cannot widen if something else is ever placed there.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: llama-server
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: llama-embed
toPorts:
- ports:
- port: "8080"
protocol: TCP
# External HTTPS only — Tika model downloads, runtime image
# fetches, etc. RFC1918 + ULA are excluded from `world` via the
# CIDR-deny pattern. `world` covers public IPs / DNS-resolvable
+45
View File
@@ -46,6 +46,44 @@ data:
PAPERLESS_TIKA_ENABLED: "1"
PAPERLESS_TIKA_ENDPOINT: "http://tika:9998"
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: "http://gotenberg:3000"
# AI suggestions against the cluster's own LLM (kubernetes/apps/llama-server).
# Nothing leaves the cluster: both endpoints are ClusterIP Services, so
# document text is never sent to a third party and there is no API key to
# rotate — llama-server has no authentication, and the ai namespace's network
# policy is what restricts who may reach it.
#
# CAVEAT, and it is a real one: paperless reads these from an
# ApplicationConfiguration row in the database and falls back to the
# environment only when that row is empty. A value set in the admin UI
# therefore overrides this ConfigMap silently. Configure AI in ONE place —
# here — and leave those fields untouched in the UI.
PAPERLESS_AI_ENABLED: "true"
PAPERLESS_AI_LLM_BACKEND: "openai-like"
PAPERLESS_AI_LLM_ENDPOINT: "http://llama-server.ai.svc.cluster.local:8080/v1"
# Must match --alias in kubernetes/apps/llama-server/deployment.yaml. The
# server ignores the field on the wire, but paperless sends it and the value
# is what appears in its logs.
PAPERLESS_AI_LLM_MODEL: "qwen3.6-35b-a3b"
# ClusterIP addresses are private, and paperless blocks non-public endpoints
# unless this is set. Default is already true; explicit because the whole
# integration silently stops working if it ever flips.
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
# Under llama-server's own 16384, leaving room for the response.
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
# The 120s default is too tight at ~11 tok/s: a classification is roughly
# 2-4k prompt tokens plus a second round trip for localisation.
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "300"
# Matched against Django's settings.LANGUAGES, so the region is required —
# "de" alone does not resolve. Without it the model returns German tags but
# English document types, which was reproducible against this endpoint.
PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de-de"
# RAG embeddings from llama-embed. Its Qwen3-Embedding covers 100+ languages,
# where paperless's default all-MiniLM-L6-v2 is documented as suited to
# primarily English archives — the better fit for a German corpus.
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "http://llama-embed.ai.svc.cluster.local:8080/v1"
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "qwen3-embedding-0.6b"
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
---
apiVersion: apps/v1
kind: Deployment
@@ -58,6 +96,13 @@ metadata:
# Reloader restarts paperless when paperless-secrets changes (e.g. after a
# secret-key rotation). See kubernetes/infrastructure/openbao/rotation/paperless.yaml.
secret.reloader.stakater.com/reload: paperless-secrets
# And when paperless-config changes. The ConfigMap is consumed with
# envFrom, and Kubernetes does not restart a pod when a ConfigMap it reads
# that way is updated — the running container keeps the environment it
# started with. Without this, a settings change applies cleanly in git and
# in the cluster while the application never sees it, which is how the AI
# settings landed with no effect at all.
configmap.reloader.stakater.com/reload: paperless-config
spec:
replicas: 1
strategy:
@@ -24,6 +24,16 @@ spec:
- ports:
- port: "8080"
protocol: TCP
# Paperless calls both models for AI suggestions and RAG embeddings. The
# matching egress grant on its side is scoped to these two pods on :8080.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperless
app.kubernetes.io/name: paperless
toPorts:
- ports:
- port: "8080"
protocol: TCP
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring