fix(scripts): ingest-memory.sh never actually computed embeddings

Two bugs, either one fatal:
- litellm-pgvector's embeddings endpoints take a precomputed vector per
  chunk (they don't call the embedding model themselves) — the script
  posted {content, metadata} with no embedding field, guaranteed 422.
- The batch endpoint expects {"embeddings": [...]}; the script posted a
  bare JSON array as the body.

Now embeds each file's chunks via LiteLLM's /v1/embeddings first (using
LITELLM_PGVECTOR_EMBEDDING_KEY, already minted for exactly this) before
batch-inserting. Also made both source files optional — a missing file is
skipped, not a hard failure, since neither exists in this checkout yet.
This commit is contained in:
2026-09-02 21:32:29 +00:00
parent 213550e44b
commit eaf11b6d4b
+43 -8
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Loads data/memory.md and data/claude-legacy-memory.md into the LiteLLM # Loads data/memory.md and data/claude-legacy-memory.md into the LiteLLM
# knowledgebase (the "memory-and-notes" vector store, see litellm-config.yaml) # knowledgebase (the "memory-and-notes" vector store, see litellm-config.yaml)
# via litellm-pgvector's batch-embeddings endpoint. # via litellm-pgvector's batch-embeddings endpoint. Both files are optional —
# a file that doesn't exist yet is skipped, not an error.
# #
# ponytail: one chunk per non-empty, non-heading line — both source files are # ponytail: one chunk per non-empty, non-heading line — both source files are
# already one fact/paragraph per line (no hard-wrapping), so this needs no # already one fact/paragraph per line (no hard-wrapping), so this needs no
@@ -15,34 +16,68 @@ cd "$(dirname "$0")/.."
[ -f .env ] && set -a && . ./.env && set +a [ -f .env ] && set -a && . ./.env && set +a
: "${LITELLM_PGVECTOR_API_KEY:?Set LITELLM_PGVECTOR_API_KEY in .env first}" : "${LITELLM_PGVECTOR_API_KEY:?Set LITELLM_PGVECTOR_API_KEY in .env first}"
: "${LITELLM_PGVECTOR_EMBEDDING_KEY:?Set LITELLM_PGVECTOR_EMBEDDING_KEY in .env first}"
LITELLM_PGVECTOR_URL="${LITELLM_PGVECTOR_URL:-http://localhost:8000}" LITELLM_PGVECTOR_URL="${LITELLM_PGVECTOR_URL:-http://localhost:8000}"
LITELLM_URL="${LITELLM_URL:-http://localhost:${LITELLM_PORT:-4000}}"
VECTOR_STORE_ID="memory-and-notes" VECTOR_STORE_ID="memory-and-notes"
# Must match litellm-config.yaml's vector_store_registry entry — the # Must match litellm-config.yaml's vector_store_registry entry — the
# registry just points at a store the backend must already know about. # registry just points at a store the backend must already know about.
# Ignores failure if it already exists (no documented idempotency check). # Ignores failure if it already exists (no documented idempotency check).
# id is a local addition to litellm-pgvector's create endpoint (see
# vendor/litellm-pgvector/main.py) — without it, create always minted a
# random UUID and this script's writes could never land on VECTOR_STORE_ID.
curl -sf -X POST "${LITELLM_PGVECTOR_URL}/v1/vector_stores" \ curl -sf -X POST "${LITELLM_PGVECTOR_URL}/v1/vector_stores" \
-H "Authorization: Bearer ${LITELLM_PGVECTOR_API_KEY}" \ -H "Authorization: Bearer ${LITELLM_PGVECTOR_API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"name\": \"${VECTOR_STORE_ID}\"}" > /dev/null 2>&1 || true -d "{\"id\": \"${VECTOR_STORE_ID}\", \"name\": \"${VECTOR_STORE_ID}\"}" > /dev/null 2>&1 || true
ingest_file() { ingest_file() {
local file="$1" section="" local file="$1"
local batch="[]" if [ ! -f "$file" ]; then
echo "Skipping $file (not present)."
return
fi
local section="" contents="[]" metas="[]"
while IFS= read -r line; do while IFS= read -r line; do
case "$line" in case "$line" in
"#"*) section="${line#\# }"; section="${section#\#\# }"; continue ;; "#"*) section="${line#\# }"; section="${section#\#\# }"; continue ;;
""|"---") continue ;; ""|"---") continue ;;
esac esac
batch=$(jq --arg content "$line" --arg source "$file" --arg section "$section" \ contents=$(jq --arg c "$line" '. += [$c]' <<<"$contents")
'. += [{"content": $content, "metadata": {"source": $source, "section": $section}}]' <<<"$batch") metas=$(jq --arg content "$line" --arg source "$file" --arg section "$section" \
'. += [{"content": $content, "metadata": {"source": $source, "section": $section}}]' <<<"$metas")
done < "$file" done < "$file"
echo "Ingesting $(jq 'length' <<<"$batch") chunks from $file..." local n
n=$(jq 'length' <<<"$contents")
if [ "$n" -eq 0 ]; then
echo "Nothing to ingest from $file (no fact/paragraph lines)."
return
fi
# litellm-pgvector's embeddings endpoints take a precomputed vector per
# chunk — they don't call the embedding model themselves (only query-time
# search does, via its own EMBEDDING__* config). So this has to embed
# client-side first, via the same proxy every other workload uses.
echo "Embedding $n chunks from $file via LiteLLM..."
local embeddings
embeddings=$(curl -sf "${LITELLM_URL}/v1/embeddings" \
-H "Authorization: Bearer ${LITELLM_PGVECTOR_EMBEDDING_KEY}" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson input "$contents" '{"model": "local-embedding", "input": $input}')" \
| jq '[.data[].embedding]')
local batch
batch=$(jq -n --argjson metas "$metas" --argjson embeds "$embeddings" \
'[range(0; ($metas | length)) as $i | $metas[$i] + {"embedding": $embeds[$i]}]')
echo "Ingesting $n chunks from $file..."
curl -sf -X POST "${LITELLM_PGVECTOR_URL}/v1/vector_stores/${VECTOR_STORE_ID}/embeddings/batch" \ curl -sf -X POST "${LITELLM_PGVECTOR_URL}/v1/vector_stores/${VECTOR_STORE_ID}/embeddings/batch" \
-H "Authorization: Bearer ${LITELLM_PGVECTOR_API_KEY}" \ -H "Authorization: Bearer ${LITELLM_PGVECTOR_API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$batch" > /dev/null -d "$(jq -n --argjson embeddings "$batch" '{"embeddings": $embeddings}')" > /dev/null
} }
ingest_file data/memory.md ingest_file data/memory.md