diff --git a/docs/guides/hunyuan-pglite-local-setup.md b/docs/guides/hunyuan-pglite-local-setup.md new file mode 100644 index 000000000..b4236d9d1 --- /dev/null +++ b/docs/guides/hunyuan-pglite-local-setup.md @@ -0,0 +1,346 @@ +# Local GBrain Setup: PGLite + Hunyuan Embeddings + +## Goal + +Run GBrain locally against `PGLite` while using a Hunyuan OpenAI-compatible +embedding endpoint for semantic search. This guide documents the exact local +setup that was debugged and verified on this machine, including the two failure +modes that caused search to break: + +1. custom-endpoint embeddings coming back incorrectly through the SDK path +2. vector dimension drift between the embedding provider and the local schema + +If you want the shortest stable outcome, the validated baseline is: + +- engine: `pglite` +- embedding model: `hunyuan-embedding` +- embedding dimensions: `1024` +- local brain path: `~/.gbrain/brain.pglite` +- wiki source: `~/wiki` + +--- + +## Validated Local Baseline + +### Repos and paths + +- GBrain repo: `~/gbrain` +- Wiki repo / markdown source: `~/wiki` +- GBrain config: `~/.gbrain/config.json` +- Local DB: `~/.gbrain/brain.pglite` + +### Credential source + +This local setup reads Hunyuan credentials from shell environment exported in +`~/.zshrc`: + +```bash +export HUNYUAN_API_KEY=... +export HUNYUAN_BASE_URL=... +``` + +Those values are then copied into `~/.gbrain/config.json` as the active +embedding endpoint for GBrain. + +### Required config + +`~/.gbrain/config.json` should contain at least: + +```json +{ + "engine": "pglite", + "database_path": "/Users/wuyun/.gbrain/brain.pglite", + "openai_api_key": "", + "openai_base_url": "", + "embedding_model": "hunyuan-embedding", + "embedding_dimensions": 1024 +} +``` + +--- + +## Important Implementation Notes + +### 1. Use `hunyuan-embedding`, not `text-embedding-3-large` + +The Hunyuan endpoint used here accepts an OpenAI-compatible `/embeddings` +request shape, but it does **not** accept OpenAI embedding model names. If the +model is left as `text-embedding-3-large`, embedding generation fails. + +Use: + +```json +"embedding_model": "hunyuan-embedding" +``` + +### 2. The local vector dimension must be `1024` + +This setup was initially misconfigured with a smaller vector size. That caused: + +- failed inserts +- `NaN` query scores +- vector search returning empty or useless results + +The working local schema must match the provider's actual output dimension: + +```text +1024 +``` + +These schema files must stay aligned with the provider dimension: + +- `src/core/pglite-schema.ts` +- `src/core/schema-embedded.ts` + +### 3. Custom embedding endpoints may need a raw HTTP path + +For this local Hunyuan setup, the generic OpenAI SDK path produced broken +embeddings during debugging. The working fix was to let `src/core/embedding.ts` +use a raw HTTP `/embeddings` request for non-OpenAI base URLs. + +This matters because a broken embedding response can look superficially valid +while still destroying retrieval quality. + +### 4. Chinese search needed an explicit CJK-compatible fallback + +Default keyword search in GBrain is English full-text search. That is fine for +queries like: + +```bash +gbrain query 'Hermes memory system' +``` + +but poor for queries like: + +```bash +gbrain query 'Hermes 记忆 系统' +gbrain query '智能 收藏 回顾 系统' +``` + +The local PGLite search path was patched with a CJK-aware fallback in +`src/core/pglite-engine.ts`, so Chinese and mixed Chinese/English queries can +still recall relevant pages when English FTS alone would fail. + +--- + +## Fresh Setup / Recovery Flow + +Use this when: + +- embeddings are missing +- vector search is broken +- scores are `NaN` +- dimensions changed +- the local DB needs to be rebuilt cleanly + +### Step 1. Confirm code and config baseline + +From `~/gbrain`, verify: + +- schema vector dimension is `1024` +- config uses `hunyuan-embedding` +- config uses `embedding_dimensions = 1024` + +### Step 2. Backup the existing local brain + +Before destroying the local DB, make a directory backup: + +```bash +cp -R ~/.gbrain/brain.pglite ~/.gbrain/brain.pglite.backup +``` + +If the DB is known-bad because of a dimension mismatch, keep the backup for +forensics but do not reuse it as-is. + +### Step 3. Rebuild the local DB + +```bash +rm -rf ~/.gbrain/brain.pglite +cd ~/gbrain +gbrain init +``` + +Note: `gbrain init` may rewrite `~/.gbrain/config.json`, so restore the Hunyuan +settings afterward if needed. + +### Step 4. Re-apply the local embedding config + +Ensure `~/.gbrain/config.json` again contains: + +- `openai_api_key` +- `openai_base_url` +- `embedding_model = hunyuan-embedding` +- `embedding_dimensions = 1024` + +### Step 5. Import wiki content without inline embedding + +```bash +cd ~/gbrain +gbrain import ~/wiki --no-embed +``` + +### Step 6. Rebuild graph extras + +```bash +gbrain extract links --source db +gbrain extract timeline --source db +``` + +### Step 7. Generate embeddings + +```bash +gbrain embed --stale +``` + +--- + +## Verification Checklist + +## Current Re-validation Notes (2026-05) + +This setup was re-run on the current local machine after the original guide was +written. The observed healthy baseline was: + +- `gbrain 0.16.4` +- `gbrain stats` showed `Pages: 12`, `Chunks: 14`, `Embedded: 14` +- `gbrain query 'Hermes memory system'` returned the expected concept page +- `gbrain query 'Hermes 记忆 系统'` and `gbrain query '智能 收藏 回顾 系统'` + both returned relevant results +- `gbrain search '智能 收藏 回顾 系统'` also worked, confirming the local + CJK fallback path was active +- `gbrain extract links --source db --dry-run` and + `gbrain extract timeline --source db --dry-run` both ran successfully + +Two details are important enough to call out explicitly: + +1. A successful embedding API response is **not** sufficient proof that the + vectors are healthy. +2. `gbrain doctor --json` top-level `schema_version` is the doctor output schema + version, not the database migration version. + +### Health + +```bash +cd ~/gbrain +gbrain stats +gbrain doctor --json +``` + +Expected: + +- `Embedded` equals total chunk count +- doctor shows embeddings coverage as healthy / complete + +### English retrieval + +```bash +gbrain query 'Hermes memory system' +``` + +Expected: results include `concepts/hermes-memory-system` near the top. + +### Chinese retrieval + +```bash +gbrain query 'Hermes 记忆 系统' +gbrain query '智能 收藏 回顾 系统' +``` + +Expected: relevant concept/entity pages are returned, not `No results.` + +### Keyword-only Chinese fallback + +```bash +gbrain search '智能 收藏 回顾 系统' +``` + +Expected: Chinese pages still match through the CJK-aware local fallback. + +--- + +## Symptoms and What They Mean + +### Symptom: `gbrain embed --stale` fails with a model error + +Likely cause: +- wrong embedding model name + +Fix: +- set `embedding_model` to `hunyuan-embedding` + +### Symptom: query scores show `NaN` + +Likely cause: +- schema dimension and embedding dimension do not match + +Fix: +- update schema to `1024` +- rebuild `~/.gbrain/brain.pglite` +- re-import and re-embed + +### Symptom: vector search returns 0 rows even though embeddings exist + +Likely cause: +- broken embeddings were written +- or the custom endpoint path is not producing valid numeric vectors +- or an SDK call returned a superficially successful response whose embedding was + all zeros + +Fix: +- verify embedding output directly +- check both vector length and non-zero count, not just request success +- ensure custom endpoint goes through the validated raw HTTP path +- rebuild embeddings after the code fix + +### Symptom: `gbrain doctor --json` seems to report the wrong schema version + +Likely cause: +- the top-level `schema_version` field is being misread as the database migration + version + +Fix: +- treat top-level `schema_version` as the doctor JSON output format version +- inspect the individual `checks` entries for actual database version / migration + findings + +### Symptom: Chinese query returns `No results.` but English works + +Likely cause: +- query is falling back to English FTS only + +Fix: +- ensure the local CJK-aware `searchKeyword()` path in + `src/core/pglite-engine.ts` is present + +--- + +## Known Non-Blocking Noise + +During import, this local setup may print: + +```text +fatal: ambiguous argument 'HEAD' +``` + +This is usually because the wiki repo does not yet have a valid git `HEAD` +commit. It does **not** block: + +- import +- embedding generation +- search +- local retrieval verification + +Treat it as a repo hygiene issue, not a retrieval blocker. + +--- + +## Operational Recommendation + +This setup is now valid for local use, but if the brain grows materially larger +or reliability becomes more important than zero-setup local operation, prefer a +real Postgres + pgvector backend. PGLite is excellent for local iteration, but a +server-backed Postgres deployment remains the stronger default for long-term, +higher-scale retrieval. + +For this machine, though, the local configuration above is the verified working +baseline. \ No newline at end of file diff --git a/src/commands/embed.ts b/src/commands/embed.ts index 8813632b9..2c03d1f40 100644 --- a/src/commands/embed.ts +++ b/src/commands/embed.ts @@ -1,5 +1,5 @@ import type { BrainEngine } from '../core/engine.ts'; -import { embedBatch } from '../core/embedding.ts'; +import { embedBatch, EMBEDDING_MODEL } from '../core/embedding.ts'; import type { ChunkInput } from '../core/types.ts'; import { chunkText } from '../core/chunkers/recursive.ts'; import { createProgress, type ProgressReporter } from '../core/progress.ts'; @@ -204,6 +204,7 @@ async function embedPage( chunk_text: c.chunk_text, chunk_source: c.chunk_source, embedding: embeddingMap.get(c.chunk_index), + model: EMBEDDING_MODEL(), token_count: c.token_count || Math.ceil(c.chunk_text.length / 4), })); @@ -285,6 +286,7 @@ async function embedAll( chunk_text: c.chunk_text, chunk_source: c.chunk_source, embedding: embeddingMap.get(c.chunk_index) ?? undefined, + model: EMBEDDING_MODEL(), token_count: c.token_count || Math.ceil(c.chunk_text.length / 4), })); await engine.upsertChunks(page.slug, updated); diff --git a/src/core/config.ts b/src/core/config.ts index c0c6f09e4..de9fb8008 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -30,10 +30,13 @@ export interface GBrainConfig { database_url?: string; database_path?: string; openai_api_key?: string; + openai_base_url?: string; + /** AI gateway embedding model. Accepts provider-prefixed ids (preferred), plus legacy bare model names bridged in cli.ts. */ + embedding_model?: string; + /** Embedding vector dimensions. May arrive from env as a string before normalization. */ + embedding_dimensions?: number | string; anthropic_api_key?: string; /** AI gateway config (v0.14+). Default: "openai:text-embedding-3-large" / 1536 / "anthropic:claude-haiku-4-5-20251001". */ - embedding_model?: string; - embedding_dimensions?: number; expansion_model?: string; /** * Default chat model for `gateway.chat()` callers (v0.27+). @@ -143,9 +146,20 @@ export function loadConfig(): GBrainConfig | null { ...fileConfig, engine: inferredEngine, ...(dbUrl ? { database_url: dbUrl } : {}), - ...(process.env.OPENAI_API_KEY ? { openai_api_key: process.env.OPENAI_API_KEY } : {}), - ...(process.env.GBRAIN_EMBEDDING_MODEL ? { embedding_model: process.env.GBRAIN_EMBEDDING_MODEL } : {}), - ...(process.env.GBRAIN_EMBEDDING_DIMENSIONS ? { embedding_dimensions: parseInt(process.env.GBRAIN_EMBEDDING_DIMENSIONS, 10) } : {}), + ...(process.env.OPENAI_API_KEY ? { openai_api_key: process.env.OPENAI_API_KEY } + : process.env.HUNYUAN_API_KEY ? { openai_api_key: process.env.HUNYUAN_API_KEY } + : {}), + ...(process.env.OPENAI_BASE_URL ? { openai_base_url: process.env.OPENAI_BASE_URL } + : process.env.HUNYUAN_BASE_URL ? { openai_base_url: process.env.HUNYUAN_BASE_URL } + : {}), + ...(process.env.OPENAI_EMBEDDING_MODEL ? { embedding_model: process.env.OPENAI_EMBEDDING_MODEL } + : process.env.HUNYUAN_EMBEDDING_MODEL ? { embedding_model: process.env.HUNYUAN_EMBEDDING_MODEL } + : process.env.GBRAIN_EMBEDDING_MODEL ? { embedding_model: process.env.GBRAIN_EMBEDDING_MODEL } + : {}), + ...(process.env.OPENAI_EMBEDDING_DIMENSIONS ? { embedding_dimensions: process.env.OPENAI_EMBEDDING_DIMENSIONS } + : process.env.HUNYUAN_EMBEDDING_DIMENSIONS ? { embedding_dimensions: process.env.HUNYUAN_EMBEDDING_DIMENSIONS } + : process.env.GBRAIN_EMBEDDING_DIMENSIONS ? { embedding_dimensions: parseInt(process.env.GBRAIN_EMBEDDING_DIMENSIONS, 10) } + : {}), ...(process.env.GBRAIN_EXPANSION_MODEL ? { expansion_model: process.env.GBRAIN_EXPANSION_MODEL } : {}), ...(process.env.GBRAIN_CHAT_MODEL ? { chat_model: process.env.GBRAIN_CHAT_MODEL } : {}), ...(process.env.GBRAIN_CHAT_FALLBACK_CHAIN diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 7b3d4c066..f984d7467 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -113,6 +113,23 @@ export function computeSnapshotSchemaHash( return hash.digest('hex'); } +const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/; + +function hasCJK(text: string): boolean { + return CJK_RE.test(text); +} + +function extractSearchTokens(query: string): string[] { + const tokens = (query.toLowerCase().match(/[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]+/g) || []) + .map(t => t.trim()) + .filter(Boolean); + return Array.from(new Set(tokens)); +} + +function normalizeSearchText(text: string): string { + return text.toLowerCase().replace(/\s+/g, ''); +} + export class PGLiteEngine implements BrainEngine { readonly kind = 'pglite' as const; private _db: PGLiteDB | null = null; @@ -644,6 +661,80 @@ export class PGLiteEngine implements BrainEngine { console.warn(`[gbrain] Warning: search limit clamped from ${opts.limit} to ${MAX_SEARCH_LIMIT}`); } + // CJK path: uses fuzzy LIKE matching (not FTS) for Chinese/Japanese/Korean queries + if (hasCJK(query)) { + const tokens = extractSearchTokens(query); + const normalizedQuery = normalizeSearchText(query); + const tokenPatterns = tokens.map(t => `%${t}%`); + const tokenClauses = tokens.length > 0 + ? tokens.map((_, i) => `lower(coalesce(p.title, '') || ' ' || coalesce(p.slug, '') || ' ' || coalesce(cc.chunk_text, '') || ' ' || coalesce(p.compiled_truth, '') || ' ' || coalesce(p.timeline, '')) LIKE $${i + 2}`).join(' OR ') + : 'false'; + const params: unknown[] = [normalizedQuery, ...tokenPatterns, Math.max(limit * 10, 100), 0]; + const { rows } = await this.db.query( + `SELECT + p.slug, p.id as page_id, p.title, p.type, p.source_id, + cc.id as chunk_id, cc.chunk_index, cc.chunk_text, cc.chunk_source, + CASE WHEN p.updated_at < ( + SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id + ) THEN true ELSE false END AS stale, + lower(coalesce(p.title, '')) AS title_text, + lower(coalesce(p.slug, '')) AS slug_text, + lower(coalesce(cc.chunk_text, '')) AS chunk_text_lc, + lower(coalesce(p.compiled_truth, '')) AS compiled_truth_lc, + lower(coalesce(p.timeline, '')) AS timeline_lc, + lower(regexp_replace(coalesce(p.title, '') || ' ' || coalesce(p.slug, '') || ' ' || coalesce(cc.chunk_text, '') || ' ' || coalesce(p.compiled_truth, '') || ' ' || coalesce(p.timeline, ''), '\\s+', '', 'g')) AS normalized_text + FROM pages p + JOIN content_chunks cc ON cc.page_id = p.id + WHERE ( + lower(regexp_replace(coalesce(p.title, '') || ' ' || coalesce(p.slug, '') || ' ' || coalesce(cc.chunk_text, '') || ' ' || coalesce(p.compiled_truth, '') || ' ' || coalesce(p.timeline, ''), '\\s+', '', 'g')) LIKE '%' || $1 || '%' + OR ${tokenClauses} + ) ${detailFilter} + LIMIT $${tokens.length + 2} + OFFSET $${tokens.length + 3}`, + params + ); + + const scored = (rows as Record[]).map((row) => { + const title = String(row.title_text || ''); + const slug = String(row.slug_text || ''); + const chunk = String(row.chunk_text_lc || ''); + const compiled = String(row.compiled_truth_lc || ''); + const timeline = String(row.timeline_lc || ''); + const normalized = String(row.normalized_text || ''); + const allText = `${title} ${slug} ${chunk} ${compiled} ${timeline}`; + const matchedAllTokens = tokens.length === 0 || tokens.every(t => allText.includes(t)); + let score = 0; + if (normalizedQuery && normalized.includes(normalizedQuery)) score += 12; + for (const token of tokens) { + if (title.includes(token)) score += 4; + if (slug.includes(token)) score += 3; + if (chunk.includes(token)) score += 3; + if (compiled.includes(token)) score += 2; + if (timeline.includes(token)) score += 1; + } + return { + slug: String(row.slug), + page_id: Number(row.page_id), + title: String(row.title), + type: row.type as PageType, + chunk_text: String(row.chunk_text), + chunk_source: row.chunk_source as 'compiled_truth' | 'timeline', + chunk_id: Number(row.chunk_id), + chunk_index: Number(row.chunk_index), + score, + stale: Boolean(row.stale), + source_id: typeof row.source_id === 'string' ? row.source_id : undefined, + _matchedAllTokens: matchedAllTokens, + }; + }).filter((row) => row.score > 0 && row._matchedAllTokens) + .sort((a, b) => b.score - a.score) + .slice(offset, offset + limit) + .map(({ _matchedAllTokens, ...row }) => row as SearchResult); + + return scored; + } + + // English/non-CJK path: FTS-based keyword search with source-aware ranking // Fetch 3x to give dedup headroom, then page-dedup + re-limit. const innerLimit = Math.min(limit * 3, MAX_SEARCH_LIMIT * 3); diff --git a/src/core/schema-embedded.ts b/src/core/schema-embedded.ts index 905936657..2ce4c50c3 100644 --- a/src/core/schema-embedded.ts +++ b/src/core/schema-embedded.ts @@ -136,7 +136,7 @@ CREATE TABLE IF NOT EXISTS content_chunks ( chunk_index INTEGER NOT NULL, chunk_text TEXT NOT NULL, chunk_source TEXT NOT NULL DEFAULT 'compiled_truth', - embedding vector(1536), + embedding vector(1024), model TEXT NOT NULL DEFAULT 'text-embedding-3-large', token_count INTEGER, embedded_at TIMESTAMPTZ, @@ -372,8 +372,8 @@ CREATE TABLE IF NOT EXISTS config ( INSERT INTO config (key, value) VALUES ('version', '1'), - ('embedding_model', 'text-embedding-3-large'), - ('embedding_dimensions', '1536'), + ('embedding_model', 'hunyuan-embedding'), + ('embedding_dimensions', '1024'), ('chunk_strategy', 'semantic') ON CONFLICT (key) DO NOTHING; diff --git a/src/core/search/hybrid.ts b/src/core/search/hybrid.ts index 96e29ad7e..cb50acd5f 100644 --- a/src/core/search/hybrid.ts +++ b/src/core/search/hybrid.ts @@ -13,6 +13,7 @@ import type { BrainEngine } from '../engine.ts'; import { MAX_SEARCH_LIMIT, clampSearchLimit } from '../engine.ts'; import type { SearchResult, SearchOpts, HybridSearchMeta } from '../types.ts'; import { embed } from '../embedding.ts'; +import { loadConfig } from '../config.ts'; import { dedupResults } from './dedup.ts'; import { autoDetectDetail, classifyQuery } from './query-intent.ts'; import { expandAnchors, hydrateChunks } from './two-pass.ts'; @@ -31,6 +32,15 @@ const COMPILED_TRUTH_BOOST = 2.0; const BACKLINK_BOOST_COEF = 0.05; const DEBUG = process.env.GBRAIN_SEARCH_DEBUG === '1'; +function hasEmbeddingConfig(): boolean { + const config = loadConfig(); + return Boolean( + process.env.OPENAI_API_KEY + || process.env.HUNYUAN_API_KEY + || config?.openai_api_key, + ); +} + /** * Apply backlink boost to a result list in place. Mutates each result's score * by (1 + BACKLINK_BOOST_COEF * log(1 + count)). Pure data transform; no DB call. @@ -257,6 +267,7 @@ export async function hybridSearch( // Run keyword search (always available, no API key needed) const keywordResults = await engine.searchKeyword(query, searchOpts); + // v0.29.1: resolve salience/recency from caller (back-compat aliases for // PR #618's `recencyBoost` numeric scale) or fall back to the heuristic. // The wrapper fires from ALL THREE return paths (codex pass-1 #2 + pass-2 #4). @@ -278,6 +289,8 @@ export async function hybridSearch( // Skip vector search entirely if the gateway has no embedding provider configured (Codex C3). const { isAvailable } = await import('../ai/gateway.ts'); if (!isAvailable('embedding')) { + // Apply backlink boost in keyword-only path too. One getBacklinkCounts query + // per search request; not N+1. if (keywordResults.length > 0) { await runPostFusionStages(engine, keywordResults, postFusionOpts); keywordResults.sort((a, b) => b.score - a.score); diff --git a/test/e2e/search-quality.test.ts b/test/e2e/search-quality.test.ts index 3b2e73a77..bbf09497d 100644 --- a/test/e2e/search-quality.test.ts +++ b/test/e2e/search-quality.test.ts @@ -10,12 +10,13 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; +import { EMBEDDING_DIMENSIONS } from '../../src/core/embedding.ts'; import type { ChunkInput, SearchResult } from '../../src/core/types.ts'; let engine: PGLiteEngine; // Create a basis vector embedding: dimension `idx` is 1.0, rest are 0.0 -function basisEmbedding(idx: number, dim = 1536): Float32Array { +function basisEmbedding(idx: number, dim = EMBEDDING_DIMENSIONS()): Float32Array { const emb = new Float32Array(dim); emb[idx % dim] = 1.0; return emb; @@ -171,7 +172,7 @@ describe('getEmbeddingsByChunkIds', () => { expect(embMap.size).toBeGreaterThan(0); for (const [id, emb] of embMap) { expect(emb).toBeInstanceOf(Float32Array); - expect(emb.length).toBe(1536); + expect(emb.length).toBe(EMBEDDING_DIMENSIONS()); } }); diff --git a/test/pglite-engine.test.ts b/test/pglite-engine.test.ts index 51913a389..d99484107 100644 --- a/test/pglite-engine.test.ts +++ b/test/pglite-engine.test.ts @@ -6,6 +6,7 @@ import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { EMBEDDING_DIMENSIONS } from '../src/core/embedding.ts'; import type { BrainEngine } from '../src/core/engine.ts'; import type { PageInput, ChunkInput } from '../src/core/types.ts'; @@ -211,7 +212,7 @@ describe('PGLiteEngine: Search', () => { }); test('searchVector returns empty when no embeddings', async () => { - const fakeEmbedding = new Float32Array(1536); + const fakeEmbedding = new Float32Array(EMBEDDING_DIMENSIONS()); const results = await engine.searchVector(fakeEmbedding); expect(results.length).toBe(0); }); @@ -270,7 +271,7 @@ describe('PGLiteEngine: Chunks', () => { test('getChunksWithEmbeddings returns embedding data', async () => { await engine.putPage('test/embed', testPage); - const embedding = new Float32Array(1536).fill(0.1); + const embedding = new Float32Array(EMBEDDING_DIMENSIONS()).fill(0.1); await engine.upsertChunks('test/embed', [ { chunk_index: 0, chunk_text: 'With embedding', chunk_source: 'compiled_truth', embedding }, ]);