Merge feat/qdrant-local-vector-backend

This commit is contained in:
“BeeRad”
2026-05-02 13:00:46 +10:00
42 changed files with 1909 additions and 370 deletions
+40 -4
View File
@@ -1,14 +1,50 @@
# RA-H OS Configuration
# Copy to .env.local: cp .env.example .env.local
# OpenAI API Key (optional)
# Enables: auto-descriptions, smart tagging, semantic search
# OpenAI API Key (optional, default supported AI path)
# Enables: auto-descriptions, extraction summaries, edge inference, embeddings, and semantic search
# Get one at: https://platform.openai.com/api-keys
OPENAI_API_KEY=
# Database/vector paths are auto-detected for macOS, Windows, and Linux.
# Override only if you intentionally want a custom location.
# AI profiles are selected during setup because embedding dimensions shape
# the sqlite-vec tables. Use one:
#
# npm run setup:local -- --profile openai
# npm run setup:local -- --profile qwen-local
#
# OpenAI profile:
# LLM_PROFILE=openai
# LLM_MODEL=gpt-4o-mini
# EMBEDDING_PROFILE=openai
# EMBEDDING_MODEL=text-embedding-3-small
# EMBEDDING_DIMENSIONS=1536
# VECTOR_BACKEND=sqlite-vec
# Supported local profile: point RA-H at OpenAI-compatible local endpoints.
# Example Ollama:
# LLM_PROFILE=openai-compatible
# LLM_BASE_URL=http://127.0.0.1:11434/v1
# LLM_MODEL=qwen3:4b
# EMBEDDING_PROFILE=openai-compatible
# EMBEDDING_BASE_URL=http://127.0.0.1:11434/v1
# EMBEDDING_MODEL=qwen3-embedding:0.6b
# EMBEDDING_DIMENSIONS=1024
#
# Example Qdrant sidecar, only needed when sqlite-vec is unavailable or unreliable:
# VECTOR_BACKEND=qdrant
# QDRANT_URL=http://localhost:6333
# Database path defaults to the operating system app-data folder:
# macOS: ~/Library/Application Support/RA-H/db/rah.sqlite
# Linux: ~/.local/share/RA-H/db/rah.sqlite
# Windows: %APPDATA%/RA-H/db/rah.sqlite
#
# Set SQLITE_DB_PATH before setup if you intentionally want a repo-local DB,
# demo DB, or any other separate location.
# SQLITE_DB_PATH=/absolute/path/to/rah.sqlite
# sqlite-vec extension path is auto-detected for macOS, Windows, and Linux.
# Override only if you intentionally want a custom extension location.
# SQLITE_VEC_EXTENSION_PATH=/absolute/path/to/vec0.<dylib|dll|so>
# App config (no changes needed)
+1
View File
@@ -38,6 +38,7 @@ yarn-error.log*
next-env.d.ts
# Database
.ra-h/
*.db
*.sqlite
*.sqlite3
+31
View File
@@ -0,0 +1,31 @@
# llama.cpp Local Profile
llama.cpp is the direct runtime path for users who want explicit model file and server control.
Run separate servers for chat and embeddings:
```bash
llama-server -m /models/qwen3-4b.gguf --port 8080
llama-server -m /models/qwen3-embedding-0.6b.gguf --embedding --port 8081
```
Configure RA-H:
```bash
LLM_PROFILE=openai-compatible
LLM_BASE_URL=http://127.0.0.1:8080/v1
LLM_MODEL=qwen3-4b
EMBEDDING_PROFILE=openai-compatible
EMBEDDING_BASE_URL=http://127.0.0.1:8081/v1
EMBEDDING_MODEL=qwen3-embedding-0.6b
EMBEDDING_DIMENSIONS=1024
```
Validate:
```bash
npm run doctor:local-ai
```
RA-H does not download GGUF files for you. Install llama.cpp, place model files on disk, start the server processes, then point RA-H at the `/v1` endpoints.
+45
View File
@@ -0,0 +1,45 @@
# Local Models
RA-H does not run model weights directly. You run a local OpenAI-compatible model server, then RA-H calls that server over HTTP.
The supported local profile is intentionally narrow:
- Utility LLM: Qwen3 4B or the closest tested Qwen3 4B runtime equivalent
- Embeddings: Qwen3 Embedding 0.6B
- Embedding dimensions: `1024`
- Runtime options behind the same contract: Ollama or llama.cpp
OpenAI remains the default supported path. Local mode is for users who are comfortable installing a model runtime, pulling model weights, starting local server processes, and managing rebuilds when embedding settings change.
## Core Env
```bash
LLM_PROFILE=openai-compatible
LLM_BASE_URL=http://127.0.0.1:11434/v1
LLM_MODEL=qwen3:4b
EMBEDDING_PROFILE=openai-compatible
EMBEDDING_BASE_URL=http://127.0.0.1:11434/v1
EMBEDDING_MODEL=qwen3-embedding:0.6b
EMBEDDING_DIMENSIONS=1024
```
Run:
```bash
npm run doctor:local-ai
```
If you change embedding provider, model, dimensions, or vector backend after data exists, run:
```bash
npm run rebuild:embeddings
```
## Vector Storage
Qwen3 creates vectors. sqlite-vec or Qdrant stores and searches those vectors.
You do not need Qdrant just because models are local. Use Qdrant when sqlite-vec is unavailable or unreliable on your platform, especially Alpine/musl Docker images, Windows ARM64, or other native-extension-hostile environments.
SQLite remains the source-of-truth database. Qdrant stores only derived vector indexes.
+38
View File
@@ -0,0 +1,38 @@
# Ollama Local Profile
Ollama is the convenience runtime path for the supported local profile.
Start Ollama and pull the supported model pair:
```bash
ollama serve
ollama pull qwen3:4b
ollama pull qwen3-embedding:0.6b
```
Configure RA-H:
```bash
LLM_PROFILE=openai-compatible
LLM_BASE_URL=http://127.0.0.1:11434/v1
LLM_MODEL=qwen3:4b
EMBEDDING_PROFILE=openai-compatible
EMBEDDING_BASE_URL=http://127.0.0.1:11434/v1
EMBEDDING_MODEL=qwen3-embedding:0.6b
EMBEDDING_DIMENSIONS=1024
```
Validate:
```bash
npm run doctor:local-ai
```
Changing `EMBEDDING_MODEL`, `EMBEDDING_DIMENSIONS`, `EMBEDDING_PROFILE`, or `VECTOR_BACKEND` requires a vector rebuild:
```bash
npm run rebuild:embeddings
```
Local utility LLM quality can affect descriptions, extraction summaries, transcript summaries, and edge inference. Keep custom model overrides experimental until they pass your own workflow checks.
+47
View File
@@ -0,0 +1,47 @@
# Qdrant Deployment
Qdrant is the optional vector-search sidecar for environments where sqlite-vec is unavailable or unreliable.
Use Qdrant for:
- Alpine/musl Docker images
- Windows ARM64
- Linux ARM64 setups where sqlite-vec has not been validated
- any setup where sqlite-vec cannot be installed or loaded reliably
Qdrant is not required for local embeddings. The embedding model creates vectors; the vector backend stores and searches them.
## Start Qdrant
```bash
docker compose up -d qdrant
```
Configure RA-H:
```bash
VECTOR_BACKEND=qdrant
QDRANT_URL=http://localhost:6333
```
Validate:
```bash
npm run doctor:local-ai
```
## Rebuild
Switching from sqlite-vec to Qdrant, or changing embedding provider/model/dimensions, requires rebuilding vector indexes:
```bash
npm run rebuild:embeddings
```
If existing Qdrant collections have the wrong dimensions and you intentionally want to recreate them from SQLite source data:
```bash
QDRANT_RECREATE_COLLECTIONS=true npm run rebuild:embeddings
```
Nodes, edges, source text, chunks, and metadata remain in SQLite. Qdrant can be deleted and rebuilt from SQLite-derived source data.
+172 -14
View File
@@ -9,11 +9,11 @@
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
```
**TL;DR:** Use the MCP quick install if you want Claude Code, Cursor, Codex, or another agent to read and write your local graph. Clone this repository only if you also want the local browser UI.
**TL;DR:** Use the MCP quick install if you want Claude Code, Cursor, Codex, or another agent to read and write your local graph. Clone this repository only if you also want the local browser UI. If you clone the repo, choose **OpenAI** or **local Qwen** before setup creates the vector tables.
[![Watch the setup walkthrough](https://img.youtube.com/vi/YyUCGigZIZE/hqdefault.jpg)](https://youtu.be/YyUCGigZIZE?si=USYgvmwtdGpgGdwu)
> **Cross-platform local runtime:** macOS works out of the box. Windows and Linux are now being hardened for the core local/web app flow, but semantic/vector search still depends on either sqlite-vec for your platform or a later Qdrant setup.
> **Cross-platform local runtime:** macOS works out of the box. OpenAI is the default AI path. A supported local model profile is available through OpenAI-compatible local endpoints, and Qdrant is available as a vector sidecar when sqlite-vec is unreliable on your platform.
**Docs start here:** [docs/README.md](./docs/README.md)
@@ -25,7 +25,7 @@
2. **Provides a UI** — Browse, search, and organize your nodes at `localhost:3000`
3. **Exposes an MCP server** — Claude Code and other MCP clients can query and add to your knowledge base
Your data stays on your machine. Nothing is sent anywhere unless you configure an API key.
Your database stays on your machine. With the `openai` profile, model requests go to OpenAI after you add an API key. With the `qwen-local` profile, model requests go to your local Ollama/OpenAI-compatible endpoint.
Current contract:
- no runtime `dimensions`
@@ -34,6 +34,7 @@ Current contract:
- direct node lookup first for specific-node intent
- `getContext` for orientation and `retrieveQueryContext` for broader current-turn grounding
- standalone MCP writes node data, but the app owns chunking and embeddings: `nodes.source` becomes readable `chunks`, node-level vectors in `vec_nodes`, and passage vectors in `vec_chunks`
- local model support uses external OpenAI-compatible model servers; RA-H does not bundle model weights
---
@@ -41,7 +42,7 @@ Current contract:
- **Node.js 20.18.1+** — [nodejs.org](https://nodejs.org/)
- **macOS** — Works out of the box
- **Windows/Linux** — Core app flow is being validated; vector search still requires sqlite-vec for your platform (see below)
- **Windows/Linux** — Core app flow is being validated; vector search requires sqlite-vec for your platform or Qdrant as the sidecar backend
---
@@ -52,7 +53,8 @@ Current contract:
| You want... | Use this path |
|-------------|---------------|
| Your AI coding agent can read/write your RA-H graph | **Option A: MCP-only quick install** |
| A browser UI at `localhost:3000` | **Option B: Full local app** |
| A browser UI at `localhost:3000` with OpenAI models | **Option B1: Full local app with OpenAI** |
| A browser UI at `localhost:3000` with local Qwen models | **Option B2: Full local app with local Qwen** |
| A clean demo that does not touch your real graph | **Demo-safe isolated install** |
### Option A: MCP-only quick install
@@ -104,32 +106,97 @@ Notes:
npx -y ra-h-mcp-server@latest setup --client claude-code --yes --pin current
```
### Option B: Full local app
### Option B1: Full local app with OpenAI
Use this if you want the browser UI at `localhost:3000`.
Use this if you want the browser UI at `localhost:3000` and want RA-H to use OpenAI for descriptions, embeddings, and semantic search.
```bash
git clone https://github.com/bradwmorris/ra-h_os.git
cd ra-h_os
npm install
npm run setup:local
npm run setup:local -- --profile openai
npm run dev
```
Open [localhost:3000](http://localhost:3000). Done.
Open [localhost:3000](http://localhost:3000). Add your OpenAI API key when the app prompts you, or later in Settings -> API Keys.
If you also want your coding agent connected to the same database, run Option A after the app setup.
### Option B2: Full local app with local Qwen
Use this if you want the browser UI at `localhost:3000` and want RA-H to call local OpenAI-compatible Ollama endpoints instead of OpenAI.
Prerequisites:
- Ollama is installed and running.
- These models are pulled before setup.
```bash
git clone https://github.com/bradwmorris/ra-h_os.git
cd ra-h_os
npm install
ollama pull qwen3:4b
ollama pull qwen3-embedding:0.6b
npm run setup:local -- --profile qwen-local
npm run dev
```
Open [localhost:3000](http://localhost:3000). Settings -> API Keys will show the active local model profile and disable OpenAI key entry.
If you also want your coding agent connected to the same default database, run Option A after the app setup. If you override `SQLITE_DB_PATH`, pass the same path to the MCP installer with `--db`.
Full install details:
- [docs/README.md](./docs/README.md)
- [docs/8_mcp.md](./docs/8_mcp.md)
- [docs/10_full-local.md](./docs/10_full-local.md)
- [LOCAL-MODELS.md](./LOCAL-MODELS.md)
- [QDRANT-DEPLOYMENT.md](./QDRANT-DEPLOYMENT.md)
---
## First-Time Setup Rules
Pick the embedding profile before the database is created.
This is not cosmetic. The readable `nodes` and `chunks` tables are normal SQLite tables, but the derived vector tables are created with a fixed embedding width:
| Setup profile | Utility model | Embedding model | Vector width |
|---------------|---------------|-----------------|--------------|
| `openai` | `gpt-4o-mini` | `text-embedding-3-small` | `1536` |
| `qwen-local` | `qwen3:4b` through Ollama | `qwen3-embedding:0.6b` through Ollama | `1024` |
Setup requires one of these commands on a fresh install.
OpenAI:
```bash
npm install
npm run setup:local -- --profile openai
npm run dev
```
Local Qwen with Ollama:
```bash
npm install
ollama pull qwen3:4b
ollama pull qwen3-embedding:0.6b
npm run setup:local -- --profile qwen-local
npm run dev
```
If you run setup without a profile and `.env.local` does not already select one, setup stops before creating vector tables and prints the two supported commands.
If you change embedding provider, model, dimensions, or vector backend after data exists, your source data stays intact but derived embeddings must be rebuilt:
```bash
npm run rebuild:embeddings
```
---
## OpenAI API Key
**Optional but recommended.** Without a key, you can still create and organize nodes manually.
Only applies to the `openai` setup profile.
Without a key, you can still create and organize nodes manually.
With a key, you get:
- Auto-generated descriptions when you add nodes
@@ -138,25 +205,113 @@ With a key, you get:
**Cost:** Less than $0.10/day for heavy use. Most users spend $1-2/month.
**Setup:** The app will prompt you on first launch, or go to Settings API Keys.
**Setup:** The app will prompt you on first launch, or go to Settings -> API Keys.
Get a key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
If you selected `qwen-local`, do not add an OpenAI key in the UI. Settings -> API Keys shows the active local model profile and disables OpenAI key entry so the install stays aligned with the setup profile.
---
## Local Model Profile
Use the `qwen-local` setup profile if you want local utility LLM calls and local embeddings.
RA-H does not bundle model weights. It calls a local OpenAI-compatible HTTP endpoint. The tested local path is Ollama with Qwen.
Supported local contract:
```bash
LLM_PROFILE=openai-compatible
LLM_BASE_URL=http://127.0.0.1:11434/v1
LLM_MODEL=qwen3:4b
EMBEDDING_PROFILE=openai-compatible
EMBEDDING_BASE_URL=http://127.0.0.1:11434/v1
EMBEDDING_MODEL=qwen3-embedding:0.6b
EMBEDDING_DIMENSIONS=1024
```
Runtime guides:
- [Ollama local profile](./OLLAMA-LOCAL-PROFILE.md)
- [llama.cpp local profile](./LLAMA-CPP-LOCAL-PROFILE.md)
Validate local AI and vector configuration:
```bash
npm run doctor:local-ai
```
If you change embedding provider, model, dimensions, or vector backend after data exists:
```bash
npm run rebuild:embeddings
```
Custom model/provider overrides are advanced and not a broad support guarantee. They may work, but the tested product surface is OpenAI plus the narrow local Qwen profile.
---
## Vector Backends
Default:
```bash
VECTOR_BACKEND=sqlite-vec
```
Use Qdrant when sqlite-vec is unavailable or unreliable:
```bash
docker compose up -d qdrant
VECTOR_BACKEND=qdrant
QDRANT_URL=http://localhost:6333
```
SQLite remains the source-of-truth database. Qdrant stores only derived vector indexes.
Qdrant does not change your model choice. OpenAI vs local Qwen is controlled by `LLM_PROFILE` and `EMBEDDING_PROFILE`. sqlite-vec vs Qdrant is controlled by `VECTOR_BACKEND`.
---
## Where Your Data Lives
By default, setup creates and seeds the SQLite database in your operating system's app-data folder, not inside the cloned repo:
```
~/Library/Application Support/RA-H/db/rah.sqlite # macOS
~/.local/share/RA-H/db/rah.sqlite # Linux
%APPDATA%/RA-H/db/rah.sqlite # Windows
```
This default applies to both app profiles:
- `npm run setup:local -- --profile openai`
- `npm run setup:local -- --profile qwen-local`
This is a standard SQLite file. You can:
- Back it up by copying the file
- Query it directly with `sqlite3` or any SQLite tool
- Move it between machines
You can put the database somewhere else by setting `SQLITE_DB_PATH` before setup. Use this when you want a repo-local DB, a demo DB, or any other separate database:
```bash
SQLITE_DB_PATH="$HOME/Desktop/ra-h_os-demo-data/rah.sqlite" npm run setup:local -- --profile qwen-local
```
To put it directly inside your cloned repo, use a gitignored local folder:
```bash
SQLITE_DB_PATH="$PWD/.ra-h/db/rah.sqlite" npm run setup:local -- --profile qwen-local
```
If MCP should use that same non-default database, pass the same path to the MCP installer:
```bash
npx -y ra-h-mcp-server@latest setup --client claude-code,codex --yes --db "$HOME/Desktop/ra-h_os-demo-data/rah.sqlite"
```
---
## Demo-safe isolated install
@@ -167,7 +322,7 @@ If you need a clean demo without touching your normal RA-H database:
git clone https://github.com/bradwmorris/ra-h_os.git ~/Desktop/ra-h_os-demo
cd ~/Desktop/ra-h_os-demo
npm install
SQLITE_DB_PATH="$HOME/Desktop/ra-h_os-demo-data/rah.sqlite" npm run setup:local
SQLITE_DB_PATH="$HOME/Desktop/ra-h_os-demo-data/rah.sqlite" npm run setup:local -- --profile qwen-local
npm run dev
npx -y ra-h-mcp-server@latest setup \
@@ -300,8 +455,11 @@ See [docs/2_schema.md](./docs/2_schema.md) and [docs/8_mcp.md](./docs/8_mcp.md)
| Command | What it does |
|---------|--------------|
| `npm run setup:local` | Rebuild native modules, create `.env.local`, create the SQLite DB, and seed the base schema |
| `npm run setup:local -- --profile openai` | Rebuild native modules, create `.env.local`, create the SQLite DB, and seed OpenAI-width vector tables |
| `npm run setup:local -- --profile qwen-local` | Rebuild native modules, create `.env.local`, create the SQLite DB, and seed Qwen-width vector tables |
| `npm run setup:local` | Only valid if `.env.local` already selects an embedding profile; otherwise it stops before DB/vector setup |
| `npm run bootstrap:local` | Lower-level helper used by `setup:local`; most users should not run this directly |
| `npm run rebuild:embeddings` | Recreate derived embeddings after changing embedding provider, model, dimensions, or vector backend |
| `npm run dev` | Start the app at localhost:3000 |
| `npm run dev:local` | Alias for `npm run dev` |
| `npm run build` | Production build |
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { createEmbeddingProvider } from '@/services/embedding/provider';
import { createUtilityLlmProvider } from '@/services/llm/provider';
import { getVectorBackend } from '@/services/vectorBackend/factory';
export async function GET() {
try {
const sqlite = getSQLiteClient();
const utility = createUtilityLlmProvider();
const embedding = createEmbeddingProvider();
const vectorBackend = await getVectorBackend();
const [utilityHealth, embeddingHealth, vectorHealth] = await Promise.all([
utility.healthCheck(),
embedding.healthCheck(),
vectorBackend.healthCheck(),
]);
return NextResponse.json({
status: utilityHealth.ok && embeddingHealth.ok && vectorHealth.ok ? 'success' : 'degraded',
data: {
utility_llm: utilityHealth,
embedding_provider: embeddingHealth,
vector_backend: vectorHealth,
embedding_profile: sqlite.getEmbeddingProfileStatus(),
},
});
} catch (error) {
return NextResponse.json({
status: 'error',
message: 'AI health check failed',
details: error instanceof Error ? error.message : String(error),
}, { status: 500 });
}
}
+63 -14
View File
@@ -1,6 +1,32 @@
import { NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { chunkService } from '@/services/database/chunks';
import { createEmbeddingProvider } from '@/services/embedding/provider';
import { createUtilityLlmProvider } from '@/services/llm/provider';
import { getVectorBackend } from '@/services/vectorBackend/factory';
import { getVectorBackendType } from '@/services/vectorBackend';
interface ChunkStats {
total_chunks: number;
vectorized_chunks: number | null;
missing_embeddings: number | null;
coverage_percentage: number | null;
}
interface VectorStats {
vec_chunks_count?: number;
matches_chunk_embeddings?: boolean;
extension_loaded?: boolean;
reason?: string;
error?: string;
suggestion?: string;
backend?: string;
status?: unknown;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function GET() {
try {
@@ -15,10 +41,16 @@ export async function GET() {
});
}
const vectorBackend = await getVectorBackend();
const vectorBackendHealth = await vectorBackend.healthCheck();
const embeddingInfo = createEmbeddingProvider().info();
const utilityInfo = createUtilityLlmProvider().info();
const profileStatus = sqlite.getEmbeddingProfileStatus();
// Check if vector extension is loaded
const vectorExtensionTest = await sqlite.checkVectorExtension();
let vectorStats = null;
let chunkStats = null;
let vectorStats: VectorStats | null = null;
let chunkStats: ChunkStats | null = null;
let vectorHealth = vectorExtensionTest ? 'healthy' : 'unavailable';
try {
@@ -30,7 +62,14 @@ export async function GET() {
coverage_percentage: null,
};
if (vectorExtensionTest) {
if (getVectorBackendType() === 'qdrant') {
vectorHealth = vectorBackendHealth.ok ? 'healthy' : 'unavailable';
vectorStats = {
backend: 'qdrant',
status: vectorBackendHealth,
matches_chunk_embeddings: !profileStatus.rebuild_required,
};
} else if (vectorExtensionTest) {
try {
const chunksWithoutEmbeddings = await chunkService.getChunksWithoutEmbeddings();
const vectorizedCount = totalChunks - chunksWithoutEmbeddings.length;
@@ -50,10 +89,10 @@ export async function GET() {
};
vectorHealth = vecCount === vectorizedCount ? 'healthy' : 'inconsistent';
} catch (vecError: any) {
} catch (vecError: unknown) {
vectorHealth = 'corrupted';
vectorStats = {
error: vecError.message,
error: errorMessage(vecError),
suggestion: 'Vector table may be corrupted and need recreation'
};
}
@@ -65,11 +104,11 @@ export async function GET() {
};
}
} catch (error: any) {
} catch (error: unknown) {
return NextResponse.json({
status: 'error',
message: 'Failed to collect vector statistics',
details: error.message
details: errorMessage(error)
});
}
@@ -79,30 +118,36 @@ export async function GET() {
database_connected: connectionTest,
vector_extension_loaded: vectorExtensionTest,
vector_capability: {
available: vectorExtensionTest,
backend: vectorExtensionTest ? 'sqlite-vec' : 'unavailable',
available: vectorBackendHealth.ok,
backend: getVectorBackendType(),
detail: vectorBackendHealth.detail,
dimensions: vectorBackendHealth.dimensions,
},
utility_llm: utilityInfo,
embedding_provider: embeddingInfo,
embedding_profile: profileStatus,
vector_health: vectorHealth,
chunk_stats: chunkStats,
vector_stats: vectorStats,
recommendations: generateRecommendations(vectorHealth, chunkStats, vectorStats)
recommendations: generateRecommendations(vectorHealth, chunkStats, vectorStats, profileStatus)
}
});
} catch (error: any) {
} catch (error: unknown) {
console.error('Vector health check failed:', error);
return NextResponse.json({
status: 'error',
message: 'Health check failed',
details: error.message
details: errorMessage(error)
});
}
}
function generateRecommendations(
vectorHealth: string,
chunkStats: any,
vectorStats: any
chunkStats: ChunkStats | null,
vectorStats: VectorStats | null,
profileStatus?: { rebuild_required?: boolean; reason?: string }
): string[] {
const recommendations: string[] = [];
@@ -122,6 +167,10 @@ function generateRecommendations(
recommendations.push('Vector count does not match chunk embeddings - database inconsistency detected');
}
if (profileStatus?.rebuild_required) {
recommendations.push(profileStatus.reason || 'Embedding profile changed - rebuild embeddings before semantic search');
}
if (recommendations.length === 0) {
recommendations.push('Vector search system is healthy');
}
+24
View File
@@ -11,10 +11,23 @@ export const runtime = 'nodejs';
function buildResponse(key: string | null) {
const configured = isValidOpenAiKey(key);
const llmProfile = process.env.LLM_PROFILE || 'openai';
const embeddingProfile = process.env.EMBEDDING_PROFILE || 'openai';
const openAiKeyWritable = llmProfile === 'openai' || embeddingProfile === 'openai';
return {
configured,
maskedKey: configured ? maskOpenAiKey(key) : null,
envPath: getEnvLocalPath(),
openAiKeyWritable,
activeProfile: {
llmProfile,
llmModel: process.env.LLM_MODEL || (llmProfile === 'openai' ? 'gpt-4o-mini' : null),
llmBaseUrl: process.env.LLM_BASE_URL || null,
embeddingProfile,
embeddingModel: process.env.EMBEDDING_MODEL || (embeddingProfile === 'openai' ? 'text-embedding-3-small' : null),
embeddingBaseUrl: process.env.EMBEDDING_BASE_URL || process.env.LLM_BASE_URL || null,
embeddingDimensions: process.env.EMBEDDING_DIMENSIONS || (embeddingProfile === 'openai' ? '1536' : '1024'),
},
};
}
@@ -37,6 +50,17 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
const key = typeof body?.key === 'string' ? body.key.trim() : '';
const currentState = buildResponse(await readStoredOpenAiKey());
if (!currentState.openAiKeyWritable) {
return NextResponse.json(
{
error: 'OpenAI key entry is disabled because this install is using a local model profile. Change .env.local and rebuild embeddings to switch providers.',
...currentState,
},
{ status: 409 }
);
}
if (!isValidOpenAiKey(key)) {
return NextResponse.json(
+11
View File
@@ -0,0 +1,11 @@
services:
qdrant:
image: qdrant/qdrant:latest
ports:
- "6333:6333"
volumes:
- qdrant_data:/qdrant/storage
restart: unless-stopped
volumes:
qdrant_data:
+29 -13
View File
@@ -19,10 +19,18 @@ Supported core path:
- local SQLite DB
- standard standalone MCP server
- documented repo install flow
- hosted model APIs if you choose them
- OpenAI model APIs by default, or the documented OpenAI-compatible local endpoint profile
This is the path the core docs and troubleshooting are written for.
Supported local model profile:
- RA-H calls a local OpenAI-compatible HTTP endpoint
- the local runtime can be Ollama or llama.cpp after you start it yourself
- the initial local model pair is Qwen3 4B plus Qwen3 Embedding 0.6B
- embedding dimensions are 1024 unless a tested runtime proves a different supported dimension is needed
Start with [Local Models](../LOCAL-MODELS.md).
## 3. Where Local-First Starts Getting Experimental
Local-first gets more experimental when you change:
@@ -34,12 +42,13 @@ Local-first gets more experimental when you change:
That does not make those setups bad. It just changes the support boundary.
## 4. Community Pattern: Local Models + RA-H MCP
## 4. Supported Local Model Profile + RA-H MCP
Reasonable community pattern:
Supported app utility/embedding pattern:
- keep RA-H OS local
- keep SQLite local
- connect a local-model-capable client to RA-H through MCP
- run a local OpenAI-compatible model server for app utility LLM calls and embeddings
- connect a local-model-capable external client to RA-H through MCP if you want local agent runtime too
Honest caveat:
- tool-calling quality depends heavily on the model/runtime
@@ -63,16 +72,19 @@ References:
- https://docs.anythingllm.com/mcp-compatibility/overview
- https://docs.anythingllm.com/agent/intelligent-tool-selection
## 6. Community Pattern: Qdrant Add-On For Vector-Heavy Or `sqlite-vec`-Hostile Environments
## 6. Qdrant Sidecar For `sqlite-vec`-Hostile Environments
Qdrant is a plausible local or self-hosted vector backend when:
- `sqlite-vec` is weak on the target platform
- storage/runtime constraints make the default vector path awkward
- you are intentionally running a more custom environment
Qdrant is a supported optional vector sidecar when:
- `sqlite-vec` is unavailable or unreliable on the target platform
- Alpine/musl, Windows ARM64, or uncertain ARM64 environments make native extensions awkward
- you want Qdrant's vector index while keeping SQLite as the source-of-truth database
Important boundary:
- this is not a bundled official RA-H core dependency
- the Nathan Maine repo is a community add-on example, not the default install story
- Qdrant is not required for local embeddings
- Qdrant does not replace SQLite as the app database
- the Nathan Maine repo is a community reference, not the current implementation contract
Start with [Qdrant Deployment](../QDRANT-DEPLOYMENT.md).
References:
- https://qdrant.tech/documentation/quickstart/
@@ -92,11 +104,15 @@ Supported core path:
- repo install flow
- SQLite
- documented standalone MCP setup
- OpenAI default AI profile
- documented OpenAI-compatible local endpoint profile
- sqlite-vec default vector backend
- Qdrant fallback backend for sqlite-vec-hostile environments
Reasonable community pattern:
- alternate local-model or alternate local chat surface that still respects the MCP contract
- alternate local chat surface that still respects the MCP contract
Experimental / user-owned:
- custom vector backend swaps
- arbitrary custom model/provider choices outside the tested local profile
- unsupported runtime targets
- heavily modified inference stacks
+3 -3
View File
@@ -83,7 +83,7 @@ Machine-readable semantic vectors for chunks.
Shape:
- `chunk_id`
- `embedding FLOAT[1536]`
- `embedding FLOAT[active embedding dimensions]`
`vec_chunks` is a separate sqlite-vec virtual table. It is table-like, but it is optimized for vector similarity search rather than normal text inspection.
@@ -100,7 +100,7 @@ Concrete live example from the April 20 audit:
- chunk `108055`: `chunk_idx = 0`
- chunk text starts with `[0.1s] Tell me about your levels.`
- `vec_chunks` has a matching row where `chunk_id = 108055`
- that row stores a 1536-number embedding for semantic comparison
- that row stores a numeric embedding for semantic comparison. OpenAI `text-embedding-3-small` defaults to 1536 dimensions; the supported local Qwen3 embedding profile uses 1024 dimensions.
### `vec_nodes`
@@ -108,7 +108,7 @@ Machine-readable semantic vectors for whole nodes.
Shape:
- `node_id`
- `embedding FLOAT[1536]`
- `embedding FLOAT[active embedding dimensions]`
The join point is:
+2
View File
@@ -45,6 +45,7 @@ Important runtime distinction:
- the standalone MCP surface talks directly to an existing SQLite DB file
- standalone MCP can read and write nodes/edges without the app running, but it does not own chunking or embedding
- if standalone MCP writes `nodes.source` while the app is closed, the app later processes that node through startup recovery
- external MCP agent model choice is separate from RA-H app utility model choice; app utility LLMs and embeddings use `LLM_PROFILE` and `EMBEDDING_PROFILE`
## WAL / Multi-Surface Safety
@@ -124,6 +125,7 @@ MCP users should understand the same retrieval split as the app:
- `vec_nodes` can find semantically similar whole nodes when node-level vectors exist
- `vec_chunks` can find semantically similar passages when chunk-level vectors exist
- standalone MCP does not generate embeddings itself
- with the local model profile, the app later calls your configured OpenAI-compatible endpoints for descriptions, summaries, and embeddings
If an external agent creates or updates a node through standalone MCP while the app is closed, the node can exist before its chunks and vectors do. The app-owned pipeline processes that later.
+36 -6
View File
@@ -21,14 +21,16 @@
| [MCP](./8_mcp.md) | Full standalone MCP install, behavior guide, and memory-file guidance |
| [Open Source](./9_open-source.md) | Scope, support boundary, contributor reality |
| [Full Local](./10_full-local.md) | Supported local path vs community patterns |
| [Local Models](../LOCAL-MODELS.md) | OpenAI-compatible local endpoint profile |
| [Qdrant](../QDRANT-DEPLOYMENT.md) | Optional vector sidecar for sqlite-vec-hostile environments |
| [Troubleshooting](./TROUBLESHOOTING.md) | Common issues and fixes |
## Start Here
If you just want RA-H OS working:
1. Use the MCP quick install below if you mainly want agent access.
2. Use the local app quick start if you also want the browser UI.
3. Read [Full Local](./10_full-local.md) if you want a more local-first or community setup.
2. Use the OpenAI local app quick start if you want the browser UI with OpenAI models.
3. Use the local Qwen quick start if you want the browser UI with local Ollama models.
## MCP Quick Install
@@ -42,17 +44,35 @@ Run `doctor` after setup or whenever MCP feels stale:
npx -y ra-h-mcp-server@latest doctor
```
## Local App Quick Start
## Local App Quick Start: OpenAI
```bash
git clone https://github.com/bradwmorris/ra-h_os.git
cd ra-h_os
npm install
npm run setup:local
npm run setup:local -- --profile openai
npm run dev
```
Open http://localhost:3000
Open http://localhost:3000 and add your OpenAI API key when prompted, or later in Settings -> API Keys.
## Local App Quick Start: Local Qwen
Requires Ollama to be installed and running.
```bash
git clone https://github.com/bradwmorris/ra-h_os.git
cd ra-h_os
npm install
ollama pull qwen3:4b
ollama pull qwen3-embedding:0.6b
npm run setup:local -- --profile qwen-local
npm run dev
```
Open http://localhost:3000. Settings -> API Keys shows the active local model profile and disables OpenAI key entry.
Fresh app setup must choose `--profile openai` or `--profile qwen-local` before vector tables are created. OpenAI embeddings use width `1536`; the supported Qwen embedding profile uses width `1024`.
## MCP Integration
@@ -71,7 +91,17 @@ The recommended MCP setup is the CLI command above. Manual config is only for tr
If you need a frozen version for release/debug work, pin it intentionally and restart the client.
The setup command creates the default database if it does not exist. The standalone MCP server can write nodes without the app running, but the app owns chunking and embedding from node source: readable `chunks`, full-text indexes, `vec_nodes`, and `vec_chunks`. See [MCP docs](./8_mcp.md) for the full install, verify, memory-file, and troubleshooting path.
The selected setup profile creates the default database if it does not exist. By default, that database is in the operating system's app-data folder, not inside the cloned repo:
```text
~/Library/Application Support/RA-H/db/rah.sqlite # macOS
~/.local/share/RA-H/db/rah.sqlite # Linux
%APPDATA%/RA-H/db/rah.sqlite # Windows
```
Set `SQLITE_DB_PATH` before setup if you want a repo-local DB, demo DB, or any other separate location. If MCP should use that same non-default database, pass the same path to the MCP installer with `--db`.
The standalone MCP server can write nodes without the app running, but the app owns chunking and embedding from node source: readable `chunks`, full-text indexes, `vec_nodes`, and `vec_chunks`. See [MCP docs](./8_mcp.md) for the full install, verify, memory-file, and troubleshooting path.
## Questions?
+2
View File
@@ -20,6 +20,8 @@
"setup": "npm install",
"sqlite:backup": "bash scripts/database/sqlite-backup.sh",
"sqlite:restore": "bash scripts/database/sqlite-restore.sh",
"doctor:local-ai": "tsx scripts/doctor-local-ai.ts",
"rebuild:embeddings": "tsx scripts/rebuild-embeddings.ts",
"test": "vitest run",
"test:watch": "vitest",
"evals": "cross-env RAH_EVALS_LOG=1 RAH_EVALS_TIMEOUT_MS=60000 tsx tests/evals/runner.ts",
+13 -3
View File
@@ -20,7 +20,17 @@ fi
DB_DIR="$(dirname "$DB_PATH")"
DB_NAME="$(basename "$DB_PATH")"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
VEC_EXTENSION_PATH="${SQLITE_VEC_EXTENSION_PATH:-$ROOT_DIR/vendor/sqlite-extensions/vec0.dylib}"
case "$(uname -s)" in
Darwin*) VEC_EXT="dylib" ;;
MINGW*|MSYS*|CYGWIN*) VEC_EXT="dll" ;;
*) VEC_EXT="so" ;;
esac
VEC_EXTENSION_PATH="${SQLITE_VEC_EXTENSION_PATH:-$ROOT_DIR/vendor/sqlite-extensions/vec0.$VEC_EXT}"
EMBEDDING_DIMENSIONS="${EMBEDDING_DIMENSIONS:-1536}"
if ! [[ "$EMBEDDING_DIMENSIONS" =~ ^[0-9]+$ ]] || [ "$EMBEDDING_DIMENSIONS" -le 0 ]; then
echo "Invalid EMBEDDING_DIMENSIONS: $EMBEDDING_DIMENSIONS" >&2
exit 1
fi
TS=$(date +"%Y%m%d_%H%M%S")
RAW_BACKUP_DIR="$DB_DIR/working/fts_repair_${TS}"
REBUILT_DB="$DB_DIR/${DB_NAME}.rebuilt.${TS}"
@@ -36,12 +46,12 @@ if [ -f "$VEC_EXTENSION_PATH" ]; then
VEC_SQL_BODY="
CREATE VIRTUAL TABLE vec_nodes USING vec0(
node_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
embedding FLOAT[$EMBEDDING_DIMENSIONS]
);
CREATE VIRTUAL TABLE vec_chunks USING vec0(
chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
embedding FLOAT[$EMBEDDING_DIMENSIONS]
);
"
+95 -7
View File
@@ -8,6 +8,7 @@ import Database from 'better-sqlite3';
const repoDir = process.cwd();
const envTemplate = path.join(repoDir, '.env.example');
const targetEnv = path.join(repoDir, '.env.local');
const supportedProfiles = new Set(['openai', 'qwen-local']);
function log(message) {
console.log(`[bootstrap-local] ${message}`);
@@ -89,10 +90,12 @@ function ensureEnvValue(key, value) {
const nextLines = lines.map((line) => {
const trimmed = line.trim();
if (trimmed.startsWith(`${key}=`)) {
if (found) return line;
found = true;
return `${key}=${value}`;
}
if (trimmed.startsWith(`# ${key}=`) || trimmed.startsWith(`#${key}=`)) {
if (found) return line;
found = true;
return `${key}=${value}`;
}
@@ -110,6 +113,74 @@ function ensureEnvValue(key, value) {
log(`Set ${key} in .env.local`);
}
function parseArgs(argv) {
const args = { profile: undefined };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--profile') {
args.profile = argv[index + 1];
index += 1;
} else if (arg.startsWith('--profile=')) {
args.profile = arg.slice('--profile='.length);
}
}
return args;
}
function normalizeSetupProfile(rawProfile) {
if (!rawProfile) return undefined;
if (rawProfile === 'qwen' || rawProfile === 'local' || rawProfile === 'ollama') {
return 'qwen-local';
}
return rawProfile;
}
function applySetupProfile(profile) {
if (!profile) return;
if (!supportedProfiles.has(profile)) {
throw new Error(`Unsupported setup profile "${profile}". Use "openai" or "qwen-local".`);
}
if (profile === 'openai') {
ensureEnvValue('LLM_PROFILE', 'openai');
ensureEnvValue('EMBEDDING_PROFILE', 'openai');
ensureEnvValue('EMBEDDING_MODEL', 'text-embedding-3-small');
ensureEnvValue('EMBEDDING_DIMENSIONS', '1536');
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
return;
}
ensureEnvValue('LLM_PROFILE', 'openai-compatible');
ensureEnvValue('LLM_BASE_URL', 'http://127.0.0.1:11434/v1');
ensureEnvValue('LLM_MODEL', 'qwen3:4b');
ensureEnvValue('EMBEDDING_PROFILE', 'openai-compatible');
ensureEnvValue('EMBEDDING_BASE_URL', 'http://127.0.0.1:11434/v1');
ensureEnvValue('EMBEDDING_MODEL', 'qwen3-embedding:0.6b');
ensureEnvValue('EMBEDDING_DIMENSIONS', '1024');
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
}
function assertEmbeddingProfileSelected(env) {
if (env.EMBEDDING_PROFILE) {
return;
}
throw new Error([
'Choose an embedding profile before database setup.',
'',
'The selected embedding model controls sqlite-vec table dimensions:',
' OpenAI text-embedding-3-small -> 1536',
' Local Qwen qwen3-embedding:0.6b -> 1024',
'',
'Run one of:',
' npm run setup:local -- --profile openai',
' npm run setup:local -- --profile qwen-local',
'',
'If you change embedding provider later, run:',
' npm run rebuild:embeddings',
].join('\n'));
}
function ensureCoreSchema(db) {
db.pragma('foreign_keys = ON');
db.exec(`
@@ -551,20 +622,31 @@ function ensureCoreSchema(db) {
`);
}
function tryInitVectorTables(db, dbPath) {
function getEmbeddingDimensions(env) {
const defaultDimensions = env.EMBEDDING_PROFILE === 'openai-compatible' || env.EMBEDDING_PROFILE === 'custom'
? '1024'
: '1536';
return Number(env.EMBEDDING_DIMENSIONS || defaultDimensions);
}
function tryInitVectorTables(db, dbPath, env) {
const extension = process.platform === 'darwin' ? 'dylib' : process.platform === 'win32' ? 'dll' : 'so';
const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH || path.join(repoDir, 'vendor', 'sqlite-extensions', `vec0.${extension}`);
const dimensions = getEmbeddingDimensions(env);
if (!Number.isInteger(dimensions) || dimensions <= 0) {
throw new Error(`Invalid EMBEDDING_DIMENSIONS="${env.EMBEDDING_DIMENSIONS}"`);
}
try {
db.loadExtension(extensionPath);
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS vec_nodes USING vec0(
node_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
embedding FLOAT[${dimensions}]
);
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(
chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
embedding FLOAT[${dimensions}]
);
`);
log(`Initialized sqlite-vec tables using ${extensionPath}`);
@@ -579,13 +661,19 @@ function main() {
throw new Error(`Node.js 20+ required (found ${process.version})`);
}
ensureEnvFile();
const args = parseArgs(process.argv.slice(2));
const setupProfile = normalizeSetupProfile(args.profile);
const env = parseEnvFile(targetEnv);
ensureEnvFile();
applySetupProfile(setupProfile);
const env = { ...parseEnvFile(targetEnv), ...process.env };
assertEmbeddingProfileSelected(env);
if (process.env.SQLITE_DB_PATH) {
ensureEnvValue('SQLITE_DB_PATH', process.env.SQLITE_DB_PATH);
env.SQLITE_DB_PATH = process.env.SQLITE_DB_PATH;
}
const dbPath = expandPath(process.env.SQLITE_DB_PATH || env.SQLITE_DB_PATH || getDefaultDbPath());
const dbPath = expandPath(env.SQLITE_DB_PATH || getDefaultDbPath());
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
if (!fs.existsSync(dbPath)) {
fs.closeSync(fs.openSync(dbPath, 'w'));
@@ -594,7 +682,7 @@ function main() {
const db = new Database(dbPath);
try {
ensureCoreSchema(db);
tryInitVectorTables(db, dbPath);
tryInitVectorTables(db, dbPath, env);
} finally {
db.close();
}
+1 -1
View File
@@ -23,7 +23,7 @@ console.log('[setup-local] Rebuilding better-sqlite3 native bindings');
run('npm', ['rebuild', 'better-sqlite3']);
console.log('[setup-local] Bootstrapping local RA-H database');
run('npm', ['run', 'bootstrap:local']);
run('npm', ['run', 'bootstrap:local', '--', ...process.argv.slice(2)]);
console.log('');
console.log('[setup-local] Local app setup complete.');
+38
View File
@@ -0,0 +1,38 @@
import { createEmbeddingProvider } from '@/services/embedding/provider';
import { createUtilityLlmProvider } from '@/services/llm/provider';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { getVectorBackend } from '@/services/vectorBackend/factory';
async function main() {
const sqlite = getSQLiteClient();
const utility = createUtilityLlmProvider();
const embedding = createEmbeddingProvider();
const vectorBackend = await getVectorBackend();
const [utilityHealth, embeddingHealth, vectorHealth] = await Promise.all([
utility.healthCheck(),
embedding.healthCheck(),
vectorBackend.healthCheck(),
]);
const profileStatus = sqlite.getEmbeddingProfileStatus();
console.log(JSON.stringify({
ok: utilityHealth.ok && embeddingHealth.ok && vectorHealth.ok && !profileStatus.rebuild_required,
utility_llm: utilityHealth,
embedding_provider: embeddingHealth,
vector_backend: vectorHealth,
embedding_profile: profileStatus,
next_action: profileStatus.rebuild_required
? 'Run npm run rebuild:embeddings after confirming your embedding provider/model/dimensions.'
: 'Local AI/vector configuration is ready.',
}, null, 2));
if (!utilityHealth.ok || !embeddingHealth.ok || !vectorHealth.ok || profileStatus.rebuild_required) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+76
View File
@@ -0,0 +1,76 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { NodeEmbedder } from '@/services/typescript/embed-nodes';
import { UniversalEmbedder } from '@/services/typescript/embed-universal';
import { getVectorBackendType } from '@/services/vectorBackend';
async function maybeRecreateQdrantCollections() {
if (process.env.VECTOR_BACKEND !== 'qdrant' || process.env.QDRANT_RECREATE_COLLECTIONS !== 'true') {
return;
}
const baseUrl = (process.env.QDRANT_URL || 'http://localhost:6333').replace(/\/+$/, '');
const headers = process.env.QDRANT_API_KEY ? { 'api-key': process.env.QDRANT_API_KEY } : undefined;
const collections = [
process.env.QDRANT_CHUNKS_COLLECTION || 'rah_chunks',
process.env.QDRANT_NODES_COLLECTION || 'rah_nodes',
];
for (const collection of collections) {
const response = await fetch(`${baseUrl}/collections/${encodeURIComponent(collection)}`, {
method: 'DELETE',
headers,
});
if (!response.ok && response.status !== 404) {
const detail = await response.text().catch(() => response.statusText);
throw new Error(`Failed to delete Qdrant collection ${collection}: ${detail}`);
}
console.log(`[rebuild-embeddings] Recreated Qdrant collection on next upsert: ${collection}`);
}
}
async function main() {
const sqlite = getSQLiteClient();
await maybeRecreateQdrantCollections();
if (getVectorBackendType() === 'sqlite-vec') {
sqlite.recreateVectorTables();
console.log('[rebuild-embeddings] Recreated sqlite-vec tables for the active embedding dimensions.');
}
const nodeRows = sqlite.query<{ id: number; source?: string | null }>(`
SELECT id, source
FROM nodes
ORDER BY id
`).rows;
console.log(`[rebuild-embeddings] Rebuilding node embeddings for ${nodeRows.length} nodes`);
const nodeEmbedder = new NodeEmbedder();
try {
await nodeEmbedder.embedNodes({ forceReEmbed: true, verbose: true });
} finally {
nodeEmbedder.close();
}
const sourceRows = nodeRows.filter((node) => typeof node.source === 'string' && node.source.trim().length > 0);
console.log(`[rebuild-embeddings] Rebuilding chunk embeddings for ${sourceRows.length} nodes with source text`);
const chunkEmbedder = new UniversalEmbedder();
try {
let processed = 0;
for (const node of sourceRows) {
await chunkEmbedder.processNode({ nodeId: node.id, verbose: false });
processed += 1;
if (processed % 10 === 0 || processed === sourceRows.length) {
console.log(`[rebuild-embeddings] Chunked ${processed}/${sourceRows.length}`);
}
}
} finally {
chunkEmbedder.close();
}
sqlite.markEmbeddingProfileCurrent();
console.log('[rebuild-embeddings] Active embedding/vector profile recorded.');
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+8 -3
View File
@@ -8,12 +8,17 @@ if (!dbPath) {
}
const db = new Database(dbPath);
const vecPath = path.join(process.cwd(), 'vendor', 'sqlite-extensions', 'vec0.dylib');
const ext = process.platform === 'darwin' ? 'dylib' : process.platform === 'win32' ? 'dll' : 'so';
const vecPath = process.env.SQLITE_VEC_EXTENSION_PATH || path.join(process.cwd(), 'vendor', 'sqlite-extensions', `vec0.${ext}`);
const dimensions = Number(process.env.EMBEDDING_DIMENSIONS || '1536');
if (!Number.isInteger(dimensions) || dimensions <= 0) {
throw new Error(`Invalid EMBEDDING_DIMENSIONS="${process.env.EMBEDDING_DIMENSIONS}"`);
}
db.loadExtension(vecPath);
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS vec_nodes USING vec0(node_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);
CREATE VIRTUAL TABLE IF NOT EXISTS vec_nodes USING vec0(node_id INTEGER PRIMARY KEY, embedding FLOAT[${dimensions}]);
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[${dimensions}]);
`);
console.log('✓ vec tables ensured');
+1 -1
View File
@@ -21,7 +21,7 @@ export default function FirstRunModal() {
if (cancelled) return;
const dismissed = window.localStorage.getItem(FIRST_RUN_DISMISSED_KEY) === 'true';
setIsOpen(!data.configured && !dismissed);
setIsOpen(data.openAiKeyWritable !== false && !data.configured && !dismissed);
} catch {
if (!cancelled) {
const dismissed = window.localStorage.getItem(FIRST_RUN_DISMISSED_KEY) === 'true';
+105 -3
View File
@@ -3,11 +3,23 @@
import { useState, useEffect, type CSSProperties } from 'react';
import { openExternalUrl } from '@/utils/openExternalUrl';
type ActiveProfile = {
llmProfile: string;
llmModel: string | null;
llmBaseUrl: string | null;
embeddingProfile: string;
embeddingModel: string | null;
embeddingBaseUrl: string | null;
embeddingDimensions: string;
};
export default function ApiKeysViewer() {
const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking');
const [keyInput, setKeyInput] = useState('');
const [maskedKey, setMaskedKey] = useState<string | null>(null);
const [envPath, setEnvPath] = useState<string>('.env.local');
const [openAiKeyWritable, setOpenAiKeyWritable] = useState(true);
const [activeProfile, setActiveProfile] = useState<ActiveProfile | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
@@ -19,6 +31,8 @@ export default function ApiKeysViewer() {
setStatus(data.configured ? 'configured' : 'not-set');
setMaskedKey(data.maskedKey ?? null);
setEnvPath(data.envPath ?? '.env.local');
setOpenAiKeyWritable(data.openAiKeyWritable !== false);
setActiveProfile(data.activeProfile ?? null);
})
.catch(() => setStatus('not-set'));
}, []);
@@ -44,6 +58,8 @@ export default function ApiKeysViewer() {
setStatus('configured');
setMaskedKey(payload.maskedKey ?? null);
setEnvPath(payload.envPath ?? envPath);
setOpenAiKeyWritable(payload.openAiKeyWritable !== false);
setActiveProfile(payload.activeProfile ?? activeProfile);
setKeyInput('');
setMessage('Saved to .env.local and updated the running app.');
} catch (err) {
@@ -71,6 +87,8 @@ export default function ApiKeysViewer() {
setMaskedKey(null);
setKeyInput('');
setEnvPath(payload.envPath ?? envPath);
setOpenAiKeyWritable(payload.openAiKeyWritable !== false);
setActiveProfile(payload.activeProfile ?? activeProfile);
setMessage('Removed from .env.local and cleared the running app key.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove API key');
@@ -81,8 +99,38 @@ export default function ApiKeysViewer() {
return (
<div style={containerStyle}>
{activeProfile && (
<div style={profileBoxStyle}>
<div style={featuresHeaderStyle}>
{openAiKeyWritable ? 'OpenAI profile active' : 'Local model profile active'}
</div>
<div style={profileGridStyle}>
<div>
<div style={profileLabelStyle}>Utility LLM</div>
<code style={codeInlineStyle}>{activeProfile.llmProfile}</code>
{activeProfile.llmModel && <span style={profileValueStyle}> {activeProfile.llmModel}</span>}
</div>
<div>
<div style={profileLabelStyle}>Embeddings</div>
<code style={codeInlineStyle}>{activeProfile.embeddingProfile}</code>
{activeProfile.embeddingModel && <span style={profileValueStyle}> {activeProfile.embeddingModel}</span>}
</div>
<div>
<div style={profileLabelStyle}>Embedding width</div>
<span style={profileValueStyle}>{activeProfile.embeddingDimensions}</span>
</div>
</div>
{!openAiKeyWritable && (
<p style={profileNoteStyle}>
This install is already configured for local models. OpenAI key entry is disabled here so the app does not drift away from the setup profile. To switch providers, edit <code style={codeInlineStyle}>{envPath}</code> and run <code style={codeInlineStyle}>npm run rebuild:embeddings</code>.
</p>
)}
</div>
)}
{/* Features explanation */}
<div style={featuresBoxStyle}>
{openAiKeyWritable && (
<div style={featuresBoxStyle}>
<div style={featuresHeaderStyle}>OpenAI API Key enables:</div>
<ul style={featuresListStyle}>
<li>Auto-generated descriptions for new nodes</li>
@@ -93,9 +141,11 @@ export default function ApiKeysViewer() {
Without a key, you can still create and organise nodes manually.
</div>
</div>
)}
{/* Status */}
<div style={cardStyle}>
{openAiKeyWritable ? (
<div style={cardStyle}>
<div style={cardHeaderStyle}>
<span style={cardTitleStyle}>OpenAI API Key</span>
<span style={{
@@ -178,9 +228,28 @@ export default function ApiKeysViewer() {
</p>
</div>
</div>
) : (
<div style={cardStyle}>
<div style={cardHeaderStyle}>
<span style={cardTitleStyle}>OpenAI API Key</span>
<span style={{ fontSize: 12, color: 'var(--settings-muted)' }}>Disabled</span>
</div>
<div style={instructionsStyle}>
<p style={{ margin: 0 }}>
This workspace is using the local model profile selected during setup. The app will call the configured OpenAI-compatible local endpoints for descriptions and embeddings instead of using an OpenAI API key.
</p>
{activeProfile?.llmBaseUrl && (
<p style={{ margin: '10px 0 0', fontSize: 12, color: 'var(--settings-muted)' }}>
Local endpoint: <code style={codeInlineStyle}>{activeProfile.llmBaseUrl}</code>
</p>
)}
</div>
</div>
)}
{/* Get key link */}
<div style={helpStyle}>
{openAiKeyWritable && (
<div style={helpStyle}>
<button
type="button"
onClick={() => {
@@ -194,6 +263,7 @@ export default function ApiKeysViewer() {
Get your API key from OpenAI
</button>
</div>
)}
</div>
);
}
@@ -212,6 +282,38 @@ const featuresBoxStyle: CSSProperties = {
marginBottom: 20,
};
const profileBoxStyle: CSSProperties = {
background: 'var(--settings-card-bg)',
border: '1px solid var(--settings-border)',
borderRadius: 8,
padding: 16,
marginBottom: 20,
};
const profileGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: 12,
};
const profileLabelStyle: CSSProperties = {
fontSize: 11,
color: 'var(--settings-muted)',
marginBottom: 6,
};
const profileValueStyle: CSSProperties = {
fontSize: 12,
color: 'var(--settings-subtext)',
};
const profileNoteStyle: CSSProperties = {
margin: '12px 0 0',
fontSize: 12,
color: 'var(--settings-muted)',
lineHeight: 1.5,
};
const featuresHeaderStyle: CSSProperties = {
fontSize: 13,
fontWeight: 500,
+5 -6
View File
@@ -1,5 +1,4 @@
import { generateText } from 'ai';
import { createLocalOpenAIProvider } from '@/services/openai/localProvider';
import { generateUtilityText } from '@/services/llm/provider';
export interface TranscriptSummaryResult {
subject?: string;
@@ -48,14 +47,14 @@ export async function summarizeTranscript(transcript: string): Promise<Transcrip
: transcript;
try {
const provider = createLocalOpenAIProvider();
const response = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt: buildPrompt(limited),
maxOutputTokens: 600,
responseFormat: 'json',
task: 'transcript_summary',
});
let content = response.text || '';
let content = text || '';
content = content.replace(/```json/gi, '').replace(/```/g, '').trim();
const parsed = JSON.parse(content);
+18 -53
View File
@@ -1,5 +1,7 @@
import { getSQLiteClient } from './sqlite-client';
import { Chunk, ChunkData } from '@/types/database';
import { getVectorBackendType } from '@/services/vectorBackend';
import { getVectorBackend } from '@/services/vectorBackend/factory';
type RankedChunk = Chunk & { similarity: number };
@@ -256,16 +258,11 @@ export class ChunkService {
matchCount = 5,
nodeIds?: number[]
): Promise<Array<Chunk & { similarity: number }>> {
const sqlite = getSQLiteClient();
const startTime = Date.now();
const vectorString = `[${queryEmbedding.join(',')}]`;
const vectorBackend = await getVectorBackend();
const vectorLimit = Math.max(matchCount * 10, 50);
// vec0 requires the knn constraint to live directly on the vec table query.
// A previous change pushed node-scoping into that WHERE clause in a way vec0 rejects,
// which made every node-scoped vector search throw and silently fall back to text.
if (nodeIds && nodeIds.length > 0) {
const sqlite = getSQLiteClient();
const chunkCountQuery = `SELECT COUNT(*) AS count FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`;
const chunkCountResult = sqlite.query<{ count: number }>(chunkCountQuery, nodeIds);
const chunkCount = Number(chunkCountResult.rows[0]?.count ?? 0);
@@ -276,62 +273,26 @@ export class ChunkService {
}
console.log(`🔍 Node-scoped search: ${chunkCount} chunks in nodes ${nodeIds.join(', ')}`);
let query = `
SELECT c.*, (1.0 / (1.0 + v.distance)) AS similarity
FROM (
SELECT chunk_id, distance
FROM vec_chunks
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
) v
JOIN chunks c ON c.id = v.chunk_id
WHERE c.node_id IN (${nodeIds.map(() => '?').join(',')})
AND (1.0 / (1.0 + v.distance)) >= ?
ORDER BY similarity DESC
LIMIT ?
`;
const params = [vectorString, vectorLimit, ...nodeIds, similarityThreshold, matchCount];
const result = sqlite.query<Chunk & { similarity: number }>(query, params);
const rows = await vectorBackend.searchChunks(queryEmbedding, similarityThreshold, matchCount, nodeIds);
const searchTime = Date.now() - startTime;
console.log(`📊 Vector search (node-scoped): ${result.rows.length} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (result.rows.length > 0) {
console.log(`🎯 Top result: chunk ${result.rows[0].id} (similarity: ${result.rows[0].similarity.toFixed(3)})`);
console.log(`📊 Vector search (${getVectorBackendType()}, node-scoped): ${rows.length} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (rows.length > 0) {
console.log(`🎯 Top result: chunk ${rows[0].id} (similarity: ${rows[0].similarity.toFixed(3)})`);
}
return result.rows;
return rows;
}
// Global search (no node filter)
const query = `
WITH vector_results AS (
SELECT chunk_id, distance
FROM vec_chunks
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
)
SELECT c.*, (1.0 / (1.0 + vr.distance)) AS similarity
FROM vector_results vr
JOIN chunks c ON c.id = vr.chunk_id
WHERE (1.0 / (1.0 + vr.distance)) >= ?
ORDER BY similarity DESC
LIMIT ?
`;
const params = [vectorString, vectorLimit, similarityThreshold, matchCount];
const result = sqlite.query<Chunk & { similarity: number }>(query, params);
const rows = await vectorBackend.searchChunks(queryEmbedding, similarityThreshold, matchCount);
const searchTime = Date.now() - startTime;
console.log(`📊 Vector search (global): ${result.rows.length}/${vectorLimit} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (result.rows.length > 0) {
console.log(`🎯 Top result: chunk ${result.rows[0].id} (similarity: ${result.rows[0].similarity.toFixed(3)})`);
console.log(`📊 Vector search (${getVectorBackendType()}, global): ${rows.length} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (rows.length > 0) {
console.log(`🎯 Top result: chunk ${rows[0].id} (similarity: ${rows[0].similarity.toFixed(3)})`);
}
return result.rows;
return rows;
}
async textSearchFallback(
@@ -495,6 +456,10 @@ export class ChunkService {
}
async getChunksWithoutEmbeddings(): Promise<Chunk[]> {
if (getVectorBackendType() === 'qdrant') {
return [];
}
// In SQLite, chunk vectors live in vec_chunks; report chunks without corresponding vector rows
const sqlite = getSQLiteClient();
const result = sqlite.query<Chunk>(`
+4 -12
View File
@@ -1,6 +1,4 @@
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { hasPreferredOpenAiKey, getPreferredOpenAiKey } from '../storage/openaiKeyServer';
import { generateUtilityText } from '@/services/llm/provider';
import type { CanonicalNodeMetadata } from '@/types/database';
export interface DescriptionInput {
@@ -23,25 +21,19 @@ export interface DescriptionInput {
* The result must cover what the artifact is, why it is in the graph, and workflow status.
*/
export async function generateDescription(input: DescriptionInput): Promise<string> {
if (!hasPreferredOpenAiKey()) {
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
}
try {
const prompt = buildDescriptionPrompt(input);
console.log(`[DescriptionService] Generating description for: "${input.title}"`);
const provider = createOpenAI({ apiKey: getPreferredOpenAiKey() });
const response = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
maxOutputTokens: 100,
temperature: 0.3,
task: 'description',
});
const description = sanitizeDescription(response.text, input);
const description = sanitizeDescription(text, input);
console.log(`[DescriptionService] Generated: "${description}"`);
+7 -22
View File
@@ -2,11 +2,9 @@ import { getSQLiteClient } from './sqlite-client';
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
import { eventBroadcaster } from '../events';
import { nodeService } from './nodes';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod';
import { validateEdgeExplanation } from './quality';
import { getPreferredOpenAiKey, hasPreferredOpenAiKey } from '../storage/openaiKeyServer';
import { generateUtilityText } from '@/services/llm/provider';
const inferredEdgeContextSchema = z.object({
type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']),
@@ -75,16 +73,12 @@ async function inferEdgeContext(params: {
].join('\n');
try {
if (!hasPreferredOpenAiKey()) {
return { type: 'related_to', confidence: 0.2, swap_direction: false };
}
const provider = createOpenAI({ apiKey: getPreferredOpenAiKey() });
const { text } = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
temperature: 0.0,
maxOutputTokens: 120,
responseFormat: 'json',
task: 'edge_inference',
});
const parsedJson = (() => {
@@ -142,21 +136,12 @@ async function autoInferEdge(params: {
].join('\n');
try {
if (!hasPreferredOpenAiKey()) {
return {
explanation: `Connection to ${toNode.title}; exact relationship uncertain.`,
type: 'related_to',
confidence: 0.2,
swap_direction: false,
};
}
const provider = createOpenAI({ apiKey: getPreferredOpenAiKey() });
const { text } = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
temperature: 0.0,
maxOutputTokens: 150,
responseFormat: 'json',
task: 'edge_inference',
});
const parsedJson = (() => {
+22 -26
View File
@@ -4,6 +4,7 @@ import { eventBroadcaster } from '../events';
import { EmbeddingService } from '@/services/embeddings';
import { getHighSignalSearchTerms, scoreNodeSearchMatch } from './searchRanking';
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
import { getVectorBackend } from '@/services/vectorBackend/factory';
type NodeRow = Node;
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
@@ -346,6 +347,13 @@ export class NodeService {
private async deleteNodeSQLite(id: number): Promise<void> {
const sqlite = getSQLiteClient();
try {
const vectorBackend = await getVectorBackend();
await vectorBackend.deleteNode(id);
} catch (error) {
console.warn(`[NodeService] Could not delete vectors for node ${id}:`, error);
}
const result = sqlite.query('DELETE FROM nodes WHERE id = ?', [id]);
if (result.changes === 0) {
@@ -627,35 +635,27 @@ export class NodeService {
return [];
}
const vecExists = sqlite.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='vec_nodes'"
).get();
if (!vecExists) return [];
const vectorString = `[${embedding.join(',')}]`;
const { clauses, params } = this.buildNodeFilterClauses(filters);
const whereClauses = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const extraClauses = clauses.length > 0 ? `AND ${clauses.join(' AND ')}` : '';
const vectorBackend = await getVectorBackend();
const matches = await vectorBackend.searchNodes(embedding, limit);
if (matches.length === 0) return [];
const matchIds = matches.map((match) => match.nodeId);
const scoreById = new Map(matches.map((match) => [match.nodeId, match.score]));
const result = sqlite.query<NodeSearchRow>(`
WITH vector_matches AS (
SELECT node_id, distance
FROM vec_nodes
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
)
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at,
(1.0 / (1.0 + vm.distance)) AS similarity
FROM vector_matches vm
JOIN nodes n ON n.id = vm.node_id
${whereClauses}
ORDER BY vm.distance
n.created_at, n.updated_at
FROM nodes n
WHERE n.id IN (${matchIds.map(() => '?').join(',')})
${extraClauses}
LIMIT ?
`, [vectorString, Math.max(limit * 2, 50), ...params, limit]);
`, [...matchIds, ...params, limit]);
return result.rows;
return result.rows
.map((row) => ({ ...row, similarity: scoreById.get(row.id) || 0 }))
.sort((a, b) => Number(b.similarity || 0) - Number(a.similarity || 0));
} catch (error) {
console.warn('[NodeSearch] Vector search unavailable, continuing without it:', error);
return [];
@@ -679,10 +679,6 @@ export class NodeService {
// PostgreSQL path removed in SQLite-only consolidation
private async bulkUpdateNodesSQLite(ids: number[], updates: Partial<Node>): Promise<Node[]> {
// For SQLite, use IN (SELECT value FROM json_each(?)) for safety
const sqlite = getSQLiteClient();
const idsJson = JSON.stringify(ids);
// For now, just update one by one - could optimize later
const updatedNodes: Node[] = [];
for (const id of ids) {
+189 -24
View File
@@ -3,6 +3,8 @@ import fs from 'fs';
import path from 'path';
import { DatabaseError } from '@/types/database';
import { getDatabasePath, getVecExtensionPath } from '@/services/database/sqlite-runtime';
import { getEmbeddingProviderInfo } from '@/services/embedding/provider';
import { getVectorBackendType } from '@/services/vectorBackend';
export interface SQLiteConfig {
dbPath: string;
@@ -16,6 +18,7 @@ export interface SQLiteQueryResult<T = any> {
}
type FtsSurfaceName = 'nodes' | 'chunks';
type VectorTableName = 'vec_nodes' | 'vec_chunks';
interface IntegrityProbeResult {
ok: boolean;
@@ -43,6 +46,28 @@ export interface DatabaseIntegrityReport {
error?: string;
}
export interface EmbeddingProfileStatus {
active: {
profile: string;
model: string;
dimensions: number;
vector_backend: string;
};
stored: {
profile: string;
model: string;
dimensions: number;
vector_backend: string;
updated_at?: string;
} | null;
vectors: {
nodes: number;
chunks: number;
};
rebuild_required: boolean;
reason?: string;
}
class SQLiteClient {
private static instance: SQLiteClient;
private db: Database.Database;
@@ -258,33 +283,83 @@ class SQLiteClient {
public ensureVectorExtensions(): void {
try {
// Test for vec_nodes and vec_chunks; create them if missing
const hasVecNodes = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_nodes');
if (!hasVecNodes) {
this.db.exec(`
CREATE VIRTUAL TABLE vec_nodes USING vec0(
node_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
);
`);
console.log('Created vec_nodes virtual table');
}
const hasVecChunks = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_chunks');
if (!hasVecChunks) {
this.db.exec(`
CREATE VIRTUAL TABLE vec_chunks USING vec0(
chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
);
`);
console.log('Created vec_chunks virtual table');
}
const dimensions = getEmbeddingProviderInfo().dimensions;
this.ensureVectorTable('vec_nodes', dimensions);
this.ensureVectorTable('vec_chunks', dimensions);
} catch (error) {
console.warn('Vector extension not available:', error);
}
}
private getVectorTableSql(tableName: VectorTableName): string | null {
const row = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name=?")
.get(tableName) as { sql?: string } | undefined;
return row?.sql || null;
}
public getVectorTableDimensions(): Record<'nodes' | 'chunks', number | null> {
return {
nodes: this.getVectorTableDimension('vec_nodes'),
chunks: this.getVectorTableDimension('vec_chunks'),
};
}
private getVectorTableDimension(tableName: VectorTableName): number | null {
const sql = this.getVectorTableSql(tableName);
const match = sql?.match(/embedding\s+FLOAT\[(\d+)\]/i);
return match ? Number(match[1]) : null;
}
private createVectorTable(tableName: VectorTableName, dimensions: number): void {
const idColumn = tableName === 'vec_nodes' ? 'node_id' : 'chunk_id';
this.db.exec(`
CREATE VIRTUAL TABLE ${tableName} USING vec0(
${idColumn} INTEGER PRIMARY KEY,
embedding FLOAT[${dimensions}]
);
`);
}
private ensureVectorTable(tableName: VectorTableName, dimensions: number): void {
const existingSql = this.getVectorTableSql(tableName);
if (!existingSql) {
this.createVectorTable(tableName, dimensions);
console.log(`Created ${tableName} virtual table`);
return;
}
const existingDimensions = this.getVectorTableDimension(tableName);
if (existingDimensions === dimensions) {
return;
}
const rowCount = this.countVectorRows(tableName);
if (rowCount === 0 || this.maintenanceMode) {
this.db.exec(`DROP TABLE IF EXISTS ${tableName};`);
this.createVectorTable(tableName, dimensions);
console.warn(`Recreated ${tableName} virtual table for ${dimensions} dimensions (was ${existingDimensions ?? 'unknown'}).`);
return;
}
console.warn(
`${tableName} uses ${existingDimensions ?? 'unknown'} dimensions, but the active embedding profile requires ${dimensions}. ` +
'Run npm run rebuild:embeddings to recreate derived vectors.'
);
}
public recreateVectorTables(): void {
if (this.readOnly) {
throw new Error('Cannot recreate vector tables while SQLITE_READONLY=true.');
}
const dimensions = getEmbeddingProviderInfo().dimensions;
this.db.exec(`
DROP TABLE IF EXISTS vec_nodes;
DROP TABLE IF EXISTS vec_chunks;
`);
this.createVectorTable('vec_nodes', dimensions);
this.createVectorTable('vec_chunks', dimensions);
}
private ensureVectorTables(): void {
if (this.readOnly) {
return;
@@ -353,6 +428,15 @@ class SQLiteClient {
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS embedding_profile_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
profile TEXT NOT NULL,
model TEXT NOT NULL,
dimensions INTEGER NOT NULL,
vector_backend TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
@@ -953,9 +1037,10 @@ class SQLiteClient {
try {
this.db.exec(`DROP TABLE IF EXISTS ${table};`);
} catch {}
const dimensions = getEmbeddingProviderInfo().dimensions;
const ddl = table === 'vec_nodes'
? `CREATE VIRTUAL TABLE vec_nodes USING vec0(node_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);`
: `CREATE VIRTUAL TABLE vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);`;
? `CREATE VIRTUAL TABLE vec_nodes USING vec0(node_id INTEGER PRIMARY KEY, embedding FLOAT[${dimensions}]);`
: `CREATE VIRTUAL TABLE vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[${dimensions}]);`;
try {
this.db.exec(ddl);
console.log(`Recreated ${table} virtual table`);
@@ -1229,6 +1314,86 @@ class SQLiteClient {
};
}
public getEmbeddingProfileStatus(): EmbeddingProfileStatus {
const embedding = getEmbeddingProviderInfo();
const active = {
profile: embedding.profile,
model: embedding.model,
dimensions: embedding.dimensions,
vector_backend: getVectorBackendType(),
};
const stored = this.db.prepare(`
SELECT profile, model, dimensions, vector_backend, updated_at
FROM embedding_profile_state
WHERE id = 1
`).get() as EmbeddingProfileStatus['stored'] | undefined;
const nodes = this.countVectorRows('vec_nodes');
const chunks = this.countVectorRows('vec_chunks');
const hasVectors = nodes > 0 || chunks > 0;
let rebuildRequired = false;
let reason: string | undefined;
if (!stored && hasVectors) {
rebuildRequired = true;
reason = 'Existing vectors do not have recorded provider/model/dimension metadata.';
} else if (stored) {
const mismatches = [
stored.profile !== active.profile ? `profile ${stored.profile} -> ${active.profile}` : '',
stored.model !== active.model ? `model ${stored.model} -> ${active.model}` : '',
Number(stored.dimensions) !== active.dimensions ? `dimensions ${stored.dimensions} -> ${active.dimensions}` : '',
stored.vector_backend !== active.vector_backend ? `backend ${stored.vector_backend} -> ${active.vector_backend}` : '',
].filter(Boolean);
if (mismatches.length > 0) {
rebuildRequired = hasVectors;
reason = `Embedding/vector profile changed (${mismatches.join(', ')}). Rebuild embeddings before semantic search.`;
}
}
return {
active,
stored: stored || null,
vectors: { nodes, chunks },
rebuild_required: rebuildRequired,
reason,
};
}
public markEmbeddingProfileCurrent(): void {
if (this.readOnly) return;
const embedding = getEmbeddingProviderInfo();
this.db.prepare(`
INSERT INTO embedding_profile_state (id, profile, model, dimensions, vector_backend, updated_at)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
profile = excluded.profile,
model = excluded.model,
dimensions = excluded.dimensions,
vector_backend = excluded.vector_backend,
updated_at = excluded.updated_at
`).run(
embedding.profile,
embedding.model,
embedding.dimensions,
getVectorBackendType(),
new Date().toISOString()
);
}
private countVectorRows(tableName: VectorTableName): number {
try {
const exists = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(tableName);
if (!exists) return 0;
const row = this.db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get() as { count?: number } | undefined;
return Number(row?.count ?? 0);
} catch {
return 0;
}
}
public getIntegrityReport(forceRefresh = false): DatabaseIntegrityReport {
if (!this.integrityReport || forceRefresh) {
this.integrityReport = this.inspectIntegrity();
+139
View File
@@ -0,0 +1,139 @@
import OpenAI from 'openai';
import { getPreferredOpenAiKey } from '@/services/storage/openaiKeyServer';
export type EmbeddingProfile = 'openai' | 'openai-compatible' | 'custom';
export interface EmbeddingProviderInfo {
profile: EmbeddingProfile;
model: string;
dimensions: number;
baseUrl?: string;
}
export interface EmbeddingHealth {
ok: boolean;
profile: EmbeddingProfile;
model: string;
dimensions: number;
detail?: string;
}
export interface EmbeddingProvider {
info(): EmbeddingProviderInfo;
generateEmbedding(text: string): Promise<number[]>;
healthCheck(): Promise<EmbeddingHealth>;
}
const DEFAULT_OPENAI_EMBEDDING_MODEL = 'text-embedding-3-small';
const DEFAULT_OPENAI_EMBEDDING_DIMENSIONS = 1536;
const DEFAULT_LOCAL_EMBEDDING_DIMENSIONS = 1024;
function normalizeProfile(raw: string | undefined): EmbeddingProfile {
if (raw === 'openai-compatible' || raw === 'custom') return raw;
return 'openai';
}
function parseDimensions(raw: string | undefined, fallback: number): number {
if (!raw) return fallback;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid EMBEDDING_DIMENSIONS="${raw}". Use a positive integer.`);
}
return parsed;
}
export function getEmbeddingProviderInfo(): EmbeddingProviderInfo {
const profile = normalizeProfile(process.env.EMBEDDING_PROFILE);
if (profile === 'openai') {
return {
profile,
model: process.env.EMBEDDING_MODEL || DEFAULT_OPENAI_EMBEDDING_MODEL,
dimensions: parseDimensions(process.env.EMBEDDING_DIMENSIONS, DEFAULT_OPENAI_EMBEDDING_DIMENSIONS),
};
}
const model = process.env.EMBEDDING_MODEL;
const baseUrl = process.env.EMBEDDING_BASE_URL || process.env.LLM_BASE_URL;
if (!model) {
throw new Error('EMBEDDING_MODEL is required when EMBEDDING_PROFILE=openai-compatible.');
}
if (!baseUrl) {
throw new Error('EMBEDDING_BASE_URL is required when EMBEDDING_PROFILE=openai-compatible.');
}
return {
profile,
model,
dimensions: parseDimensions(process.env.EMBEDDING_DIMENSIONS, DEFAULT_LOCAL_EMBEDDING_DIMENSIONS),
baseUrl,
};
}
function createOpenAiClient(info: EmbeddingProviderInfo): OpenAI {
if (info.profile === 'openai') {
const apiKey = getPreferredOpenAiKey();
if (!apiKey) {
throw new Error('OpenAI API key not configured. Add OPENAI_API_KEY to your .env.local file.');
}
return new OpenAI({ apiKey });
}
return new OpenAI({
apiKey: process.env.EMBEDDING_API_KEY || process.env.OPENAI_COMPATIBLE_API_KEY || 'local',
baseURL: info.baseUrl,
});
}
export function validateEmbeddingDimensions(embedding: number[], expectedDimensions = getEmbeddingProviderInfo().dimensions): boolean {
return Array.isArray(embedding) && embedding.length === expectedDimensions;
}
export function createEmbeddingProvider(): EmbeddingProvider {
const info = getEmbeddingProviderInfo();
return {
info: () => info,
async generateEmbedding(text: string): Promise<number[]> {
const client = createOpenAiClient(info);
const response = await client.embeddings.create({
model: info.model,
input: text.trim(),
encoding_format: 'float',
dimensions: info.dimensions,
});
const embedding = response.data?.[0]?.embedding;
if (!embedding) {
throw new Error(`No embedding returned from ${info.profile} provider.`);
}
if (!validateEmbeddingDimensions(embedding, info.dimensions)) {
throw new Error(
`Embedding dimension mismatch: expected ${info.dimensions}, got ${embedding.length}. ` +
'Run the local AI doctor and rebuild embeddings after changing providers.'
);
}
return embedding;
},
async healthCheck(): Promise<EmbeddingHealth> {
try {
const embedding = await this.generateEmbedding('RA-H embedding health check');
return {
ok: true,
profile: info.profile,
model: info.model,
dimensions: embedding.length,
detail: info.baseUrl ? `Connected to ${info.baseUrl}` : 'OpenAI embedding provider ready',
};
} catch (error) {
return {
ok: false,
profile: info.profile,
model: info.model,
dimensions: info.dimensions,
detail: error instanceof Error ? error.message : String(error),
};
}
},
};
}
+13 -26
View File
@@ -1,33 +1,16 @@
import OpenAI from 'openai';
import { getPreferredOpenAiKey } from './storage/openaiKeyServer';
function getOpenAiClient(): OpenAI {
const apiKey = getPreferredOpenAiKey();
if (!apiKey) {
throw new Error('OpenAI API key not configured. Add OPENAI_API_KEY to your .env.local file.');
}
return new OpenAI({ apiKey });
}
import {
createEmbeddingProvider,
getEmbeddingProviderInfo,
validateEmbeddingDimensions
} from '@/services/embedding/provider';
export class EmbeddingService {
/**
* Generate embedding for a search query using OpenAI's text-embedding-3-small model
* This matches the same model used in embed_universal.py for consistency
* Generate embedding for a search query using the active embedding profile.
*/
static async generateQueryEmbedding(query: string): Promise<number[]> {
try {
const openai = getOpenAiClient();
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: query.trim(),
encoding_format: "float"
});
if (!response.data?.[0]?.embedding) {
throw new Error('No embedding returned from OpenAI API');
}
return response.data[0].embedding;
return await createEmbeddingProvider().generateEmbedding(query);
} catch (error) {
console.error('Failed to generate query embedding:', error);
throw new Error(`Embedding generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -35,9 +18,13 @@ export class EmbeddingService {
}
/**
* Validate embedding dimensions match expected size (1536 for text-embedding-3-small)
* Validate embedding dimensions match the active profile.
*/
static validateEmbedding(embedding: number[]): boolean {
return Array.isArray(embedding) && embedding.length === 1536;
return validateEmbeddingDimensions(embedding);
}
static getActiveEmbeddingInfo() {
return getEmbeddingProviderInfo();
}
}
+127
View File
@@ -0,0 +1,127 @@
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { getPreferredOpenAiKey } from '@/services/storage/openaiKeyServer';
export type UtilityLlmProfile = 'openai' | 'openai-compatible' | 'custom';
export interface UtilityLlmRequest {
prompt: string;
maxOutputTokens?: number;
temperature?: number;
responseFormat?: 'text' | 'json';
task:
| 'description'
| 'edge_inference'
| 'extraction_analysis'
| 'transcript_summary'
| 'embedding_prep_analysis';
}
export interface UtilityLlmProviderInfo {
profile: UtilityLlmProfile;
model: string;
baseUrl?: string;
}
export interface UtilityLlmHealth {
ok: boolean;
profile: UtilityLlmProfile;
model: string;
detail?: string;
}
export interface UtilityLlmProvider {
info(): UtilityLlmProviderInfo;
generateText(input: UtilityLlmRequest): Promise<string>;
healthCheck(): Promise<UtilityLlmHealth>;
}
const DEFAULT_OPENAI_LLM_MODEL = 'gpt-4o-mini';
function normalizeProfile(raw: string | undefined): UtilityLlmProfile {
if (raw === 'openai-compatible' || raw === 'custom') return raw;
return 'openai';
}
export function getUtilityLlmProviderInfo(): UtilityLlmProviderInfo {
const profile = normalizeProfile(process.env.LLM_PROFILE);
if (profile === 'openai') {
return {
profile,
model: process.env.LLM_MODEL || DEFAULT_OPENAI_LLM_MODEL,
};
}
if (!process.env.LLM_MODEL) {
throw new Error('LLM_MODEL is required when LLM_PROFILE=openai-compatible.');
}
if (!process.env.LLM_BASE_URL) {
throw new Error('LLM_BASE_URL is required when LLM_PROFILE=openai-compatible.');
}
return {
profile,
model: process.env.LLM_MODEL,
baseUrl: process.env.LLM_BASE_URL,
};
}
function createProvider(info: UtilityLlmProviderInfo): ReturnType<typeof createOpenAI> {
if (info.profile === 'openai') {
const apiKey = getPreferredOpenAiKey();
if (!apiKey) {
throw new Error('OpenAI API key not configured. Add your key in Settings or .env.local.');
}
return createOpenAI({ apiKey });
}
return createOpenAI({
apiKey: process.env.LLM_API_KEY || process.env.OPENAI_COMPATIBLE_API_KEY || 'local',
baseURL: info.baseUrl,
});
}
export function createUtilityLlmProvider(): UtilityLlmProvider {
const info = getUtilityLlmProviderInfo();
return {
info: () => info,
async generateText(input: UtilityLlmRequest): Promise<string> {
const provider = createProvider(info);
const response = await generateText({
model: provider(info.model),
prompt: input.prompt,
maxOutputTokens: input.maxOutputTokens,
temperature: input.temperature,
});
return response.text;
},
async healthCheck(): Promise<UtilityLlmHealth> {
try {
await this.generateText({
prompt: 'Reply with exactly: ok',
maxOutputTokens: 8,
temperature: 0,
task: 'description',
});
return {
ok: true,
profile: info.profile,
model: info.model,
detail: info.baseUrl ? `Connected to ${info.baseUrl}` : 'OpenAI utility LLM ready',
};
} catch (error) {
return {
ok: false,
profile: info.profile,
model: info.model,
detail: error instanceof Error ? error.message : String(error),
};
}
},
};
}
export async function generateUtilityText(input: UtilityLlmRequest): Promise<string> {
return createUtilityLlmProvider().generateText(input);
}
+12 -37
View File
@@ -3,16 +3,15 @@
* Embeds node metadata (title, source, context, AI analysis) into nodes.embedding field
*/
import OpenAI from 'openai';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { getPreferredOpenAiKey } from '@/services/storage/openaiKeyServer';
import {
createDatabaseConnection,
serializeFloat32Vector,
formatEmbeddingText,
batchProcess
} from './sqlite-vec';
import { createEmbeddingProvider } from '@/services/embedding/provider';
import { generateUtilityText } from '@/services/llm/provider';
import { getVectorBackend } from '@/services/vectorBackend/factory';
interface NodeRecord {
id: number;
@@ -31,20 +30,11 @@ interface EmbedNodeOptions {
}
export class NodeEmbedder {
private openaiClient: OpenAI;
private openaiProvider: ReturnType<typeof createOpenAI>;
private db: ReturnType<typeof createDatabaseConnection>;
private processedCount: number = 0;
private failedCount: number = 0;
constructor() {
const apiKey = getPreferredOpenAiKey();
if (!apiKey) {
throw new Error('OPENAI_API_KEY environment variable is not set');
}
this.openaiClient = new OpenAI({ apiKey });
this.openaiProvider = createOpenAI({ apiKey });
this.db = createDatabaseConnection();
}
@@ -57,14 +47,14 @@ export class NodeEmbedder {
Title: ${node.title}
Source: ${node.source || 'No source'}
Focus on the main concepts, key relationships, and practical implications.`;
Focus on the main concepts, key relationships, and practical implications.`;
try {
const { text } = await generateText({
model: this.openaiProvider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
maxOutputTokens: 150,
temperature: 0.3,
task: 'embedding_prep_analysis',
});
return text;
@@ -75,15 +65,10 @@ Focus on the main concepts, key relationships, and practical implications.`;
}
/**
* Generate embedding for text using OpenAI
* Generate embedding for text using the active embedding profile.
*/
private async generateEmbedding(text: string): Promise<number[]> {
const response = await this.openaiClient.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
return createEmbeddingProvider().generateEmbedding(text);
}
/**
@@ -132,20 +117,10 @@ Focus on the main concepts, key relationships, and practical implications.`;
// Update vec_nodes virtual table
try {
// Determine correct column name for primary key (node_id vs id)
// Use declared PK column from your DB schema (confirmed: node_id)
const pkCol = 'node_id';
// Delete existing entry if any
const deleteStmt = this.db.prepare(`DELETE FROM vec_nodes WHERE ${pkCol} = ?`);
deleteStmt.run(BigInt(node.id));
// Insert new entry (use bracketed string format compatible with sqlite-vec)
const vectorString = `[${embedding.join(',')}]`;
const insertStmt = this.db.prepare(`INSERT INTO vec_nodes (${pkCol}, embedding) VALUES (?, ?)`);
insertStmt.run(BigInt(node.id), vectorString);
const vectorBackend = await getVectorBackend();
await vectorBackend.upsertNode(node.id, embeddingText, embedding);
} catch (vecError) {
console.warn(`Could not update vec_nodes for node ${node.id}:`, vecError);
console.warn(`Could not update node vector backend for node ${node.id}:`, vecError);
// Continue - main embedding is still saved
}
@@ -212,7 +187,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
async (node) => {
try {
await this.embedNode(node, forceReEmbed);
} catch (error) {
} catch {
// Error already logged in embedNode
}
},
+17 -65
View File
@@ -3,13 +3,13 @@
* Takes a node_id, reads source content from nodes table, chunks it, and stores in chunks table
*/
import OpenAI from 'openai';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { getPreferredOpenAiKey } from '@/services/storage/openaiKeyServer';
import {
createDatabaseConnection,
batchProcess
} from './sqlite-vec';
import { createEmbeddingProvider, getEmbeddingProviderInfo } from '@/services/embedding/provider';
import { getVectorBackend } from '@/services/vectorBackend/factory';
interface Node {
id: number;
@@ -18,34 +18,16 @@ interface Node {
chunk_status?: string | null;
}
interface ChunkData {
content: string;
metadata: {
node_id: number;
chunk_index: number;
start_char: number;
end_char: number;
};
}
interface EmbedUniversalOptions {
nodeId: number;
verbose?: boolean;
}
export class UniversalEmbedder {
private openaiClient: OpenAI;
private db: ReturnType<typeof createDatabaseConnection>;
private textSplitter: RecursiveCharacterTextSplitter;
private vecChunksInsertSQL: string | null = null;
constructor() {
const apiKey = getPreferredOpenAiKey();
if (!apiKey) {
throw new Error('OPENAI_API_KEY environment variable is not set');
}
this.openaiClient = new OpenAI({ apiKey });
this.db = createDatabaseConnection();
// Configure text splitter (same as old KMS system)
@@ -57,46 +39,23 @@ export class UniversalEmbedder {
}
/**
* Determine correct insert SQL for vec_chunks based on actual schema
*/
private resolveVecChunksInsertSQL(): string {
// Use declared PK column from your DB schema (confirmed: chunk_id)
if (!this.vecChunksInsertSQL) {
this.vecChunksInsertSQL = 'INSERT OR REPLACE INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)';
}
return this.vecChunksInsertSQL;
}
/**
* Generate embedding for text using OpenAI
* Generate embedding for text using the active embedding profile.
*/
private async generateEmbedding(text: string): Promise<number[]> {
const response = await this.openaiClient.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
return createEmbeddingProvider().generateEmbedding(text);
}
/**
* Delete existing chunks for a node
*/
private deleteExistingChunks(nodeId: number): void {
// First, get all chunk IDs for this node
const chunkIds = this.db.prepare('SELECT id FROM chunks WHERE node_id = ?').all(nodeId) as Array<{ id: number }>;
// Delete from vec_chunks first, one by one to ensure they're removed
for (const chunk of chunkIds) {
try {
const deleteVecStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
deleteVecStmt.run(BigInt(chunk.id));
} catch (error) {
console.warn(`Could not delete vec_chunk ${chunk.id}:`, error);
}
private async deleteExistingChunks(nodeId: number): Promise<void> {
try {
const vectorBackend = await getVectorBackend();
await vectorBackend.deleteChunksByNode(nodeId);
} catch (error) {
console.warn(`Could not delete existing chunk vectors for node ${nodeId}:`, error);
}
// Then delete from chunks table
const deleteChunksStmt = this.db.prepare('DELETE FROM chunks WHERE node_id = ?');
deleteChunksStmt.run(nodeId);
}
@@ -108,7 +67,7 @@ export class UniversalEmbedder {
nodeId: number,
chunkContent: string,
chunkIndex: number,
metadata: any
metadata: Record<string, unknown>
): Promise<void> {
// Generate embedding
const embedding = await this.generateEmbedding(chunkContent);
@@ -124,7 +83,7 @@ export class UniversalEmbedder {
nodeId,
chunkIndex,
chunkContent,
'text-embedding-3-small',
getEmbeddingProviderInfo().model,
JSON.stringify(metadata),
now
);
@@ -132,17 +91,10 @@ export class UniversalEmbedder {
const chunkId = Number(result.lastInsertRowid);
try {
const vectorString = `[${embedding.join(',')}]`;
try {
const deleteStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
deleteStmt.run(BigInt(chunkId));
} catch {}
const sql = this.resolveVecChunksInsertSQL();
const vecInsertStmt = this.db.prepare(sql);
vecInsertStmt.run(BigInt(chunkId), vectorString);
const vectorBackend = await getVectorBackend();
await vectorBackend.upsertChunk(chunkId, nodeId, chunkIndex, chunkContent, embedding);
} catch (error) {
console.warn(`Could not insert into vec_chunks for chunk ${chunkId}:`, error);
console.warn(`Could not upsert vector for chunk ${chunkId}:`, error);
}
}
@@ -173,7 +125,7 @@ export class UniversalEmbedder {
console.log(`Processing node ${nodeId}: "${node.title}"`);
// Delete existing chunks
this.deleteExistingChunks(nodeId);
await this.deleteExistingChunks(nodeId);
// Split text into chunks
const chunks = await this.textSplitter.splitText(node.source);
@@ -274,7 +226,7 @@ export async function runCLI(args: string[]): Promise<void> {
const embedder = new UniversalEmbedder();
try {
const result = await embedder.processNode({ nodeId, verbose });
await embedder.processNode({ nodeId, verbose });
if (verbose) {
const stats = embedder.getStats();
+20
View File
@@ -0,0 +1,20 @@
import { getVectorBackendType, type VectorBackend } from './index';
let instance: VectorBackend | null = null;
let instanceType: string | null = null;
export async function getVectorBackend(): Promise<VectorBackend> {
const type = getVectorBackendType();
if (instance && instanceType === type) return instance;
if (type === 'qdrant') {
const { QdrantBackend } = await import('./qdrant');
instance = new QdrantBackend();
} else {
const { SqliteVecBackend } = await import('./sqlite-vec-backend');
instance = new SqliteVecBackend();
}
instanceType = type;
return instance;
}
+38
View File
@@ -0,0 +1,38 @@
import type { Chunk } from '@/types/database';
export type VectorBackendType = 'sqlite-vec' | 'qdrant';
export interface RankedChunk extends Chunk {
similarity: number;
}
export interface RankedNode {
nodeId: number;
score: number;
}
export interface VectorBackendHealth {
ok: boolean;
backend: VectorBackendType;
detail?: string;
dimensions?: number;
}
export interface VectorBackend {
upsertChunk(chunkId: number, nodeId: number, chunkIndex: number, text: string, embedding: number[]): Promise<void>;
upsertNode(nodeId: number, text: string, embedding: number[]): Promise<void>;
searchChunks(
queryEmbedding: number[],
similarityThreshold: number,
limit: number,
nodeIds?: number[]
): Promise<RankedChunk[]>;
searchNodes(queryEmbedding: number[], limit: number): Promise<RankedNode[]>;
deleteChunksByNode(nodeId: number): Promise<void>;
deleteNode(nodeId: number): Promise<void>;
healthCheck(): Promise<VectorBackendHealth>;
}
export function getVectorBackendType(): VectorBackendType {
return process.env.VECTOR_BACKEND === 'qdrant' ? 'qdrant' : 'sqlite-vec';
}
+234
View File
@@ -0,0 +1,234 @@
import { getEmbeddingProviderInfo } from '@/services/embedding/provider';
import type { RankedChunk, RankedNode, VectorBackend, VectorBackendHealth } from './index';
interface QdrantPoint {
id: number;
score?: number;
payload?: Record<string, unknown>;
}
function qdrantUrl(path: string): string {
const base = (process.env.QDRANT_URL || 'http://localhost:6333').replace(/\/+$/, '');
return `${base}${path}`;
}
function getApiKeyHeader(): Record<string, string> {
return process.env.QDRANT_API_KEY ? { 'api-key': process.env.QDRANT_API_KEY } : {};
}
async function qdrantFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(qdrantUrl(path), {
...init,
headers: {
'Content-Type': 'application/json',
...getApiKeyHeader(),
...(init?.headers || {}),
},
});
if (!response.ok) {
const detail = await response.text().catch(() => response.statusText);
throw new Error(`Qdrant ${response.status} ${response.statusText}: ${detail}`);
}
return response.json() as Promise<T>;
}
function payloadNumber(payload: Record<string, unknown> | undefined, key: string, fallback: number): number {
const value = payload?.[key];
return typeof value === 'number' ? value : fallback;
}
function payloadString(payload: Record<string, unknown> | undefined, key: string): string {
const value = payload?.[key];
return typeof value === 'string' ? value : '';
}
export class QdrantBackend implements VectorBackend {
private readonly chunksCollection = process.env.QDRANT_CHUNKS_COLLECTION || 'rah_chunks';
private readonly nodesCollection = process.env.QDRANT_NODES_COLLECTION || 'rah_nodes';
private readonly dimensions = getEmbeddingProviderInfo().dimensions;
private ensured = new Set<string>();
private async ensureCollection(name: string): Promise<void> {
if (this.ensured.has(name)) return;
const existing = await fetch(qdrantUrl(`/collections/${encodeURIComponent(name)}`), {
headers: getApiKeyHeader(),
});
if (existing.status === 404) {
await qdrantFetch(`/collections/${encodeURIComponent(name)}`, {
method: 'PUT',
body: JSON.stringify({
vectors: {
size: this.dimensions,
distance: 'Cosine',
},
}),
});
this.ensured.add(name);
return;
}
if (!existing.ok) {
const detail = await existing.text().catch(() => existing.statusText);
throw new Error(`Qdrant ${existing.status} ${existing.statusText}: ${detail}`);
}
const data = await existing.json() as {
result?: { config?: { params?: { vectors?: { size?: number } | Record<string, { size?: number }> } } };
};
const vectors = data.result?.config?.params?.vectors;
const size = typeof vectors?.size === 'number'
? vectors.size
: vectors && typeof vectors === 'object'
? Object.values(vectors).find((value) => typeof value?.size === 'number')?.size
: undefined;
if (typeof size === 'number' && size !== this.dimensions) {
throw new Error(
`Qdrant collection ${name} has ${size} dimensions, but active embedding profile requires ${this.dimensions}. ` +
'Run the embedding rebuild script after changing embedding providers.'
);
}
this.ensured.add(name);
}
async upsertChunk(chunkId: number, nodeId: number, chunkIndex: number, text: string, embedding: number[]): Promise<void> {
await this.ensureCollection(this.chunksCollection);
await qdrantFetch(`/collections/${encodeURIComponent(this.chunksCollection)}/points?wait=true`, {
method: 'PUT',
body: JSON.stringify({
points: [{
id: chunkId,
vector: embedding,
payload: { chunkId, nodeId, chunkIndex, text },
}],
}),
});
}
async upsertNode(nodeId: number, text: string, embedding: number[]): Promise<void> {
await this.ensureCollection(this.nodesCollection);
await qdrantFetch(`/collections/${encodeURIComponent(this.nodesCollection)}/points?wait=true`, {
method: 'PUT',
body: JSON.stringify({
points: [{
id: nodeId,
vector: embedding,
payload: { nodeId, text },
}],
}),
});
}
async searchChunks(
queryEmbedding: number[],
similarityThreshold: number,
limit: number,
nodeIds?: number[]
): Promise<RankedChunk[]> {
await this.ensureCollection(this.chunksCollection);
const filter = nodeIds && nodeIds.length > 0
? {
must: [{
key: 'nodeId',
match: { any: nodeIds },
}],
}
: undefined;
const response = await qdrantFetch<{ result: QdrantPoint[] }>(
`/collections/${encodeURIComponent(this.chunksCollection)}/points/search`,
{
method: 'POST',
body: JSON.stringify({
vector: queryEmbedding,
limit,
score_threshold: similarityThreshold,
with_payload: true,
filter,
}),
}
);
return response.result.map((point) => {
const payload = point.payload;
return {
id: payloadNumber(payload, 'chunkId', Number(point.id)),
node_id: payloadNumber(payload, 'nodeId', 0),
chunk_idx: payloadNumber(payload, 'chunkIndex', 0),
text: payloadString(payload, 'text'),
embedding_type: getEmbeddingProviderInfo().model,
created_at: '',
similarity: Number(point.score ?? 0),
};
});
}
async searchNodes(queryEmbedding: number[], limit: number): Promise<RankedNode[]> {
await this.ensureCollection(this.nodesCollection);
const response = await qdrantFetch<{ result: QdrantPoint[] }>(
`/collections/${encodeURIComponent(this.nodesCollection)}/points/search`,
{
method: 'POST',
body: JSON.stringify({
vector: queryEmbedding,
limit,
with_payload: true,
}),
}
);
return response.result.map((point) => ({
nodeId: payloadNumber(point.payload, 'nodeId', Number(point.id)),
score: Number(point.score ?? 0),
}));
}
async deleteChunksByNode(nodeId: number): Promise<void> {
await this.ensureCollection(this.chunksCollection);
await qdrantFetch(`/collections/${encodeURIComponent(this.chunksCollection)}/points/delete?wait=true`, {
method: 'POST',
body: JSON.stringify({
filter: {
must: [{ key: 'nodeId', match: { value: nodeId } }],
},
}),
});
}
async deleteNode(nodeId: number): Promise<void> {
await this.deleteChunksByNode(nodeId);
await this.ensureCollection(this.nodesCollection);
await qdrantFetch(`/collections/${encodeURIComponent(this.nodesCollection)}/points/delete?wait=true`, {
method: 'POST',
body: JSON.stringify({
points: [nodeId],
}),
});
}
async healthCheck(): Promise<VectorBackendHealth> {
try {
await qdrantFetch('/collections', { method: 'GET' });
await this.ensureCollection(this.chunksCollection);
await this.ensureCollection(this.nodesCollection);
return {
ok: true,
backend: 'qdrant',
dimensions: this.dimensions,
detail: `Qdrant reachable at ${process.env.QDRANT_URL || 'http://localhost:6333'}`,
};
} catch (error) {
return {
ok: false,
backend: 'qdrant',
dimensions: this.dimensions,
detail: error instanceof Error ? error.message : String(error),
};
}
}
}
@@ -0,0 +1,124 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { getEmbeddingProviderInfo } from '@/services/embedding/provider';
import type { Chunk } from '@/types/database';
import type { RankedChunk, RankedNode, VectorBackend, VectorBackendHealth } from './index';
function vectorLiteral(embedding: number[]): string {
return `[${embedding.join(',')}]`;
}
export class SqliteVecBackend implements VectorBackend {
async upsertChunk(chunkId: number, _nodeId: number, _chunkIndex: number, _text: string, embedding: number[]): Promise<void> {
const sqlite = getSQLiteClient();
sqlite.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?').run(BigInt(chunkId));
sqlite.prepare('INSERT OR REPLACE INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)').run(BigInt(chunkId), vectorLiteral(embedding));
}
async upsertNode(nodeId: number, _text: string, embedding: number[]): Promise<void> {
const sqlite = getSQLiteClient();
sqlite.prepare('DELETE FROM vec_nodes WHERE node_id = ?').run(BigInt(nodeId));
sqlite.prepare('INSERT OR REPLACE INTO vec_nodes (node_id, embedding) VALUES (?, ?)').run(BigInt(nodeId), vectorLiteral(embedding));
}
async searchChunks(
queryEmbedding: number[],
similarityThreshold: number,
limit: number,
nodeIds?: number[]
): Promise<RankedChunk[]> {
const sqlite = getSQLiteClient();
const vectorLimit = Math.max(limit * 10, 50);
if (nodeIds && nodeIds.length > 0) {
const result = sqlite.query<RankedChunk>(`
SELECT c.*, (1.0 / (1.0 + v.distance)) AS similarity
FROM (
SELECT chunk_id, distance
FROM vec_chunks
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
) v
JOIN chunks c ON c.id = v.chunk_id
WHERE c.node_id IN (${nodeIds.map(() => '?').join(',')})
AND (1.0 / (1.0 + v.distance)) >= ?
ORDER BY similarity DESC
LIMIT ?
`, [vectorLiteral(queryEmbedding), vectorLimit, ...nodeIds, similarityThreshold, limit]);
return result.rows;
}
const result = sqlite.query<RankedChunk>(`
WITH vector_results AS (
SELECT chunk_id, distance
FROM vec_chunks
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
)
SELECT c.*, (1.0 / (1.0 + vr.distance)) AS similarity
FROM vector_results vr
JOIN chunks c ON c.id = vr.chunk_id
WHERE (1.0 / (1.0 + vr.distance)) >= ?
ORDER BY similarity DESC
LIMIT ?
`, [vectorLiteral(queryEmbedding), vectorLimit, similarityThreshold, limit]);
return result.rows;
}
async searchNodes(queryEmbedding: number[], limit: number): Promise<RankedNode[]> {
const sqlite = getSQLiteClient();
const result = sqlite.query<{ node_id: number; distance: number }>(`
SELECT node_id, distance
FROM vec_nodes
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
`, [vectorLiteral(queryEmbedding), Math.max(limit * 2, 50)]);
return result.rows.map((row) => ({
nodeId: Number(row.node_id),
score: 1.0 / (1.0 + Number(row.distance)),
}));
}
async deleteChunksByNode(nodeId: number): Promise<void> {
const sqlite = getSQLiteClient();
const chunks = sqlite.query<Pick<Chunk, 'id'>>('SELECT id FROM chunks WHERE node_id = ?', [nodeId]).rows;
for (const chunk of chunks) {
sqlite.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?').run(BigInt(chunk.id));
}
}
async deleteNode(nodeId: number): Promise<void> {
const sqlite = getSQLiteClient();
await this.deleteChunksByNode(nodeId);
sqlite.prepare('DELETE FROM vec_nodes WHERE node_id = ?').run(BigInt(nodeId));
}
async healthCheck(): Promise<VectorBackendHealth> {
const sqlite = getSQLiteClient();
const ok = await sqlite.checkVectorExtension();
const expectedDimensions = getEmbeddingProviderInfo().dimensions;
const tableDimensions = sqlite.getVectorTableDimensions();
const mismatchedTables = Object.entries(tableDimensions)
.filter(([, dimensions]) => dimensions !== null && dimensions !== expectedDimensions)
.map(([table, dimensions]) => `${table}=${dimensions}`);
if (mismatchedTables.length > 0) {
return {
ok: false,
backend: 'sqlite-vec',
dimensions: expectedDimensions,
detail: `sqlite-vec table dimensions mismatch (${mismatchedTables.join(', ')}), expected ${expectedDimensions}. Run npm run rebuild:embeddings.`,
};
}
return {
ok,
backend: 'sqlite-vec',
dimensions: expectedDimensions,
detail: ok ? 'sqlite-vec extension loaded' : 'sqlite-vec extension unavailable',
};
}
}
+6 -7
View File
@@ -1,11 +1,10 @@
import { tool } from 'ai';
import { z } from 'zod';
import { generateText } from 'ai';
import { extractPaper } from '@/services/typescript/extractors/paper';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality';
import { createLocalOpenAIProvider } from '@/services/openai/localProvider';
import { generateUtilityText } from '@/services/llm/provider';
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
const normalizedCandidate = typeof candidate === 'string'
@@ -25,7 +24,6 @@ function ensureNodeDescription(candidate: string | undefined, fallbackLead: stri
// AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try {
const provider = createLocalOpenAIProvider();
const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}"
@@ -57,13 +55,14 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
"reasoning": "Brief explanation of classification choices"
}`;
const response = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
maxOutputTokens: 800
maxOutputTokens: 800,
responseFormat: 'json',
task: 'extraction_analysis',
});
let content = response.text || '{}';
let content = text || '{}';
// Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
+6 -7
View File
@@ -1,11 +1,10 @@
import { tool } from 'ai';
import { z } from 'zod';
import { generateText } from 'ai';
import { extractWebsite } from '@/services/typescript/extractors/website';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality';
import { createLocalOpenAIProvider } from '@/services/openai/localProvider';
import { generateUtilityText } from '@/services/llm/provider';
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
const normalizedCandidate = typeof candidate === 'string'
@@ -40,7 +39,6 @@ async function analyzeContentWithAI(
contentType: string
) {
try {
const provider = createLocalOpenAIProvider();
const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}"
@@ -71,13 +69,14 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
"reasoning": "Brief explanation of classification choices"
}`;
const response = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
maxOutputTokens: 800
maxOutputTokens: 800,
responseFormat: 'json',
task: 'extraction_analysis',
});
let content = response.text || '{}';
let content = text || '{}';
// Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
+10 -12
View File
@@ -1,11 +1,10 @@
import { tool } from 'ai';
import { z } from 'zod';
import { generateText } from 'ai';
import { extractYouTube } from '@/services/typescript/extractors/youtube';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality';
import { createLocalOpenAIProvider } from '@/services/openai/localProvider';
import { generateUtilityText } from '@/services/llm/provider';
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
const normalizedCandidate = typeof candidate === 'string'
@@ -29,7 +28,6 @@ async function analyzeContentWithAI(
contentType: string
) {
try {
const provider = createLocalOpenAIProvider();
const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}"
@@ -60,13 +58,14 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
"reasoning": "Brief explanation of classification choices"
}`;
const response = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
maxOutputTokens: 800
maxOutputTokens: 800,
responseFormat: 'json',
task: 'extraction_analysis',
});
let content = response.text || '{}';
let content = text || '{}';
// Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
@@ -114,13 +113,12 @@ ${excerpt}
`;
try {
const provider = createLocalOpenAIProvider();
const response = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
maxOutputTokens: 400
maxOutputTokens: 400,
task: 'extraction_analysis',
});
return response.text?.trim() || null;
return text?.trim() || null;
} catch (error) {
console.warn('Transcript summarisation failed, falling back to AI analysis description:', error);
return null;