Add local AI and Qdrant vector backends

This commit is contained in:
“BeeRad”
2026-05-02 09:57:57 +10:00
parent 00f0afb8f4
commit 782ace9a34
37 changed files with 1440 additions and 321 deletions
+24 -2
View File
@@ -1,11 +1,33 @@
# RA-H OS Configuration # RA-H OS Configuration
# Copy to .env.local: cp .env.example .env.local # Copy to .env.local: cp .env.example .env.local
# OpenAI API Key (optional) # OpenAI API Key (optional, default supported AI path)
# Enables: auto-descriptions, smart tagging, semantic search # Enables: auto-descriptions, extraction summaries, edge inference, embeddings, and semantic search
# Get one at: https://platform.openai.com/api-keys # Get one at: https://platform.openai.com/api-keys
OPENAI_API_KEY= OPENAI_API_KEY=
# AI profiles. Defaults keep RA-H on OpenAI plus sqlite-vec.
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/vector paths are auto-detected for macOS, Windows, and Linux. # Database/vector paths are auto-detected for macOS, Windows, and Linux.
# Override only if you intentionally want a custom location. # Override only if you intentionally want a custom location.
# SQLITE_DB_PATH=/absolute/path/to/rah.sqlite # SQLITE_DB_PATH=/absolute/path/to/rah.sqlite
+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.
+63 -2
View File
@@ -13,7 +13,7 @@
[![Watch the setup walkthrough](https://img.youtube.com/vi/YyUCGigZIZE/hqdefault.jpg)](https://youtu.be/YyUCGigZIZE?si=USYgvmwtdGpgGdwu) [![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) **Docs start here:** [docs/README.md](./docs/README.md)
@@ -34,6 +34,7 @@ Current contract:
- direct node lookup first for specific-node intent - direct node lookup first for specific-node intent
- `getContext` for orientation and `retrieveQueryContext` for broader current-turn grounding - `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` - 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/) - **Node.js 20.18.1+** — [nodejs.org](https://nodejs.org/)
- **macOS** — Works out of the box - **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
--- ---
@@ -124,6 +125,8 @@ Full install details:
- [docs/README.md](./docs/README.md) - [docs/README.md](./docs/README.md)
- [docs/8_mcp.md](./docs/8_mcp.md) - [docs/8_mcp.md](./docs/8_mcp.md)
- [docs/10_full-local.md](./docs/10_full-local.md) - [docs/10_full-local.md](./docs/10_full-local.md)
- [LOCAL-MODELS.md](./LOCAL-MODELS.md)
- [QDRANT-DEPLOYMENT.md](./QDRANT-DEPLOYMENT.md)
--- ---
@@ -144,6 +147,64 @@ Get a key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys
--- ---
## Local Model Profile
OpenAI remains the default supported path. If you want local utility LLM calls and local embeddings, run a local OpenAI-compatible model server and point RA-H at it.
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.
---
## Where Your Data Lives ## Where Your Data Lives
``` ```
+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 { NextResponse } from 'next/server';
import { getSQLiteClient } from '@/services/database/sqlite-client'; import { getSQLiteClient } from '@/services/database/sqlite-client';
import { chunkService } from '@/services/database/chunks'; 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() { export async function GET() {
try { 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 // Check if vector extension is loaded
const vectorExtensionTest = await sqlite.checkVectorExtension(); const vectorExtensionTest = await sqlite.checkVectorExtension();
let vectorStats = null; let vectorStats: VectorStats | null = null;
let chunkStats = null; let chunkStats: ChunkStats | null = null;
let vectorHealth = vectorExtensionTest ? 'healthy' : 'unavailable'; let vectorHealth = vectorExtensionTest ? 'healthy' : 'unavailable';
try { try {
@@ -30,7 +62,14 @@ export async function GET() {
coverage_percentage: null, 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 { try {
const chunksWithoutEmbeddings = await chunkService.getChunksWithoutEmbeddings(); const chunksWithoutEmbeddings = await chunkService.getChunksWithoutEmbeddings();
const vectorizedCount = totalChunks - chunksWithoutEmbeddings.length; const vectorizedCount = totalChunks - chunksWithoutEmbeddings.length;
@@ -50,10 +89,10 @@ export async function GET() {
}; };
vectorHealth = vecCount === vectorizedCount ? 'healthy' : 'inconsistent'; vectorHealth = vecCount === vectorizedCount ? 'healthy' : 'inconsistent';
} catch (vecError: any) { } catch (vecError: unknown) {
vectorHealth = 'corrupted'; vectorHealth = 'corrupted';
vectorStats = { vectorStats = {
error: vecError.message, error: errorMessage(vecError),
suggestion: 'Vector table may be corrupted and need recreation' 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({ return NextResponse.json({
status: 'error', status: 'error',
message: 'Failed to collect vector statistics', message: 'Failed to collect vector statistics',
details: error.message details: errorMessage(error)
}); });
} }
@@ -79,30 +118,36 @@ export async function GET() {
database_connected: connectionTest, database_connected: connectionTest,
vector_extension_loaded: vectorExtensionTest, vector_extension_loaded: vectorExtensionTest,
vector_capability: { vector_capability: {
available: vectorExtensionTest, available: vectorBackendHealth.ok,
backend: vectorExtensionTest ? 'sqlite-vec' : 'unavailable', backend: getVectorBackendType(),
detail: vectorBackendHealth.detail,
dimensions: vectorBackendHealth.dimensions,
}, },
utility_llm: utilityInfo,
embedding_provider: embeddingInfo,
embedding_profile: profileStatus,
vector_health: vectorHealth, vector_health: vectorHealth,
chunk_stats: chunkStats, chunk_stats: chunkStats,
vector_stats: vectorStats, 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); console.error('Vector health check failed:', error);
return NextResponse.json({ return NextResponse.json({
status: 'error', status: 'error',
message: 'Health check failed', message: 'Health check failed',
details: error.message details: errorMessage(error)
}); });
} }
} }
function generateRecommendations( function generateRecommendations(
vectorHealth: string, vectorHealth: string,
chunkStats: any, chunkStats: ChunkStats | null,
vectorStats: any vectorStats: VectorStats | null,
profileStatus?: { rebuild_required?: boolean; reason?: string }
): string[] { ): string[] {
const recommendations: string[] = []; const recommendations: string[] = [];
@@ -122,6 +167,10 @@ function generateRecommendations(
recommendations.push('Vector count does not match chunk embeddings - database inconsistency detected'); 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) { if (recommendations.length === 0) {
recommendations.push('Vector search system is healthy'); recommendations.push('Vector search system is healthy');
} }
+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 - local SQLite DB
- standard standalone MCP server - standard standalone MCP server
- documented repo install flow - 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. 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 ## 3. Where Local-First Starts Getting Experimental
Local-first gets more experimental when you change: 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. 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 RA-H OS local
- keep SQLite 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: Honest caveat:
- tool-calling quality depends heavily on the model/runtime - 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/mcp-compatibility/overview
- https://docs.anythingllm.com/agent/intelligent-tool-selection - 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: Qdrant is a supported optional vector sidecar when:
- `sqlite-vec` is weak on the target platform - `sqlite-vec` is unavailable or unreliable on the target platform
- storage/runtime constraints make the default vector path awkward - Alpine/musl, Windows ARM64, or uncertain ARM64 environments make native extensions awkward
- you are intentionally running a more custom environment - you want Qdrant's vector index while keeping SQLite as the source-of-truth database
Important boundary: Important boundary:
- this is not a bundled official RA-H core dependency - Qdrant is not required for local embeddings
- the Nathan Maine repo is a community add-on example, not the default install story - 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: References:
- https://qdrant.tech/documentation/quickstart/ - https://qdrant.tech/documentation/quickstart/
@@ -92,11 +104,15 @@ Supported core path:
- repo install flow - repo install flow
- SQLite - SQLite
- documented standalone MCP setup - 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: 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: Experimental / user-owned:
- custom vector backend swaps - arbitrary custom model/provider choices outside the tested local profile
- unsupported runtime targets - unsupported runtime targets
- heavily modified inference stacks - heavily modified inference stacks
+3 -3
View File
@@ -83,7 +83,7 @@ Machine-readable semantic vectors for chunks.
Shape: Shape:
- `chunk_id` - `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. `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 `108055`: `chunk_idx = 0`
- chunk text starts with `[0.1s] Tell me about your levels.` - chunk text starts with `[0.1s] Tell me about your levels.`
- `vec_chunks` has a matching row where `chunk_id = 108055` - `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` ### `vec_nodes`
@@ -108,7 +108,7 @@ Machine-readable semantic vectors for whole nodes.
Shape: Shape:
- `node_id` - `node_id`
- `embedding FLOAT[1536]` - `embedding FLOAT[active embedding dimensions]`
The join point is: 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 - 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 - 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 - 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 ## 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_nodes` can find semantically similar whole nodes when node-level vectors exist
- `vec_chunks` can find semantically similar passages when chunk-level vectors exist - `vec_chunks` can find semantically similar passages when chunk-level vectors exist
- standalone MCP does not generate embeddings itself - 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. 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.
+3 -1
View File
@@ -21,6 +21,8 @@
| [MCP](./8_mcp.md) | Full standalone MCP install, behavior guide, and memory-file guidance | | [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 | | [Open Source](./9_open-source.md) | Scope, support boundary, contributor reality |
| [Full Local](./10_full-local.md) | Supported local path vs community patterns | | [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 | | [Troubleshooting](./TROUBLESHOOTING.md) | Common issues and fixes |
## Start Here ## Start Here
@@ -28,7 +30,7 @@
If you just want RA-H OS working: If you just want RA-H OS working:
1. Use the MCP quick install below if you mainly want agent access. 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. 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. 3. Read [Local Models](../LOCAL-MODELS.md) and [Full Local](./10_full-local.md) if you want a more local-first setup.
## MCP Quick Install ## MCP Quick Install
+2
View File
@@ -20,6 +20,8 @@
"setup": "npm install", "setup": "npm install",
"sqlite:backup": "bash scripts/database/sqlite-backup.sh", "sqlite:backup": "bash scripts/database/sqlite-backup.sh",
"sqlite:restore": "bash scripts/database/sqlite-restore.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": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"evals": "cross-env RAH_EVALS_LOG=1 RAH_EVALS_TIMEOUT_MS=60000 tsx tests/evals/runner.ts", "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_DIR="$(dirname "$DB_PATH")"
DB_NAME="$(basename "$DB_PATH")" DB_NAME="$(basename "$DB_PATH")"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" 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") TS=$(date +"%Y%m%d_%H%M%S")
RAW_BACKUP_DIR="$DB_DIR/working/fts_repair_${TS}" RAW_BACKUP_DIR="$DB_DIR/working/fts_repair_${TS}"
REBUILT_DB="$DB_DIR/${DB_NAME}.rebuilt.${TS}" REBUILT_DB="$DB_DIR/${DB_NAME}.rebuilt.${TS}"
@@ -36,12 +46,12 @@ if [ -f "$VEC_EXTENSION_PATH" ]; then
VEC_SQL_BODY=" VEC_SQL_BODY="
CREATE VIRTUAL TABLE vec_nodes USING vec0( CREATE VIRTUAL TABLE vec_nodes USING vec0(
node_id INTEGER PRIMARY KEY, node_id INTEGER PRIMARY KEY,
embedding FLOAT[1536] embedding FLOAT[$EMBEDDING_DIMENSIONS]
); );
CREATE VIRTUAL TABLE vec_chunks USING vec0( CREATE VIRTUAL TABLE vec_chunks USING vec0(
chunk_id INTEGER PRIMARY KEY, chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[1536] embedding FLOAT[$EMBEDDING_DIMENSIONS]
); );
" "
+6 -2
View File
@@ -554,17 +554,21 @@ function ensureCoreSchema(db) {
function tryInitVectorTables(db, dbPath) { function tryInitVectorTables(db, dbPath) {
const extension = process.platform === 'darwin' ? 'dylib' : process.platform === 'win32' ? 'dll' : 'so'; 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 extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH || path.join(repoDir, 'vendor', 'sqlite-extensions', `vec0.${extension}`);
const dimensions = Number(process.env.EMBEDDING_DIMENSIONS || '1536');
if (!Number.isInteger(dimensions) || dimensions <= 0) {
throw new Error(`Invalid EMBEDDING_DIMENSIONS="${process.env.EMBEDDING_DIMENSIONS}"`);
}
try { try {
db.loadExtension(extensionPath); db.loadExtension(extensionPath);
db.exec(` db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS vec_nodes USING vec0( CREATE VIRTUAL TABLE IF NOT EXISTS vec_nodes USING vec0(
node_id INTEGER PRIMARY KEY, node_id INTEGER PRIMARY KEY,
embedding FLOAT[1536] embedding FLOAT[${dimensions}]
); );
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0( CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(
chunk_id INTEGER PRIMARY KEY, chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[1536] embedding FLOAT[${dimensions}]
); );
`); `);
log(`Initialized sqlite-vec tables using ${extensionPath}`); log(`Initialized sqlite-vec tables using ${extensionPath}`);
+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);
});
+71
View File
@@ -0,0 +1,71 @@
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { NodeEmbedder } from '@/services/typescript/embed-nodes';
import { UniversalEmbedder } from '@/services/typescript/embed-universal';
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();
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 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.loadExtension(vecPath);
db.exec(` 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_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[1536]); CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[${dimensions}]);
`); `);
console.log('✓ vec tables ensured'); console.log('✓ vec tables ensured');
+5 -6
View File
@@ -1,5 +1,4 @@
import { generateText } from 'ai'; import { generateUtilityText } from '@/services/llm/provider';
import { createLocalOpenAIProvider } from '@/services/openai/localProvider';
export interface TranscriptSummaryResult { export interface TranscriptSummaryResult {
subject?: string; subject?: string;
@@ -48,14 +47,14 @@ export async function summarizeTranscript(transcript: string): Promise<Transcrip
: transcript; : transcript;
try { try {
const provider = createLocalOpenAIProvider(); const text = await generateUtilityText({
const response = await generateText({
model: provider('gpt-4o-mini'),
prompt: buildPrompt(limited), prompt: buildPrompt(limited),
maxOutputTokens: 600, maxOutputTokens: 600,
responseFormat: 'json',
task: 'transcript_summary',
}); });
let content = response.text || ''; let content = text || '';
content = content.replace(/```json/gi, '').replace(/```/g, '').trim(); content = content.replace(/```json/gi, '').replace(/```/g, '').trim();
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
+18 -53
View File
@@ -1,5 +1,7 @@
import { getSQLiteClient } from './sqlite-client'; import { getSQLiteClient } from './sqlite-client';
import { Chunk, ChunkData } from '@/types/database'; import { Chunk, ChunkData } from '@/types/database';
import { getVectorBackendType } from '@/services/vectorBackend';
import { getVectorBackend } from '@/services/vectorBackend/factory';
type RankedChunk = Chunk & { similarity: number }; type RankedChunk = Chunk & { similarity: number };
@@ -256,16 +258,11 @@ export class ChunkService {
matchCount = 5, matchCount = 5,
nodeIds?: number[] nodeIds?: number[]
): Promise<Array<Chunk & { similarity: number }>> { ): Promise<Array<Chunk & { similarity: number }>> {
const sqlite = getSQLiteClient();
const startTime = Date.now(); 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) { if (nodeIds && nodeIds.length > 0) {
const sqlite = getSQLiteClient();
const chunkCountQuery = `SELECT COUNT(*) AS count FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`; const chunkCountQuery = `SELECT COUNT(*) AS count FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`;
const chunkCountResult = sqlite.query<{ count: number }>(chunkCountQuery, nodeIds); const chunkCountResult = sqlite.query<{ count: number }>(chunkCountQuery, nodeIds);
const chunkCount = Number(chunkCountResult.rows[0]?.count ?? 0); 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(', ')}`); console.log(`🔍 Node-scoped search: ${chunkCount} chunks in nodes ${nodeIds.join(', ')}`);
const rows = await vectorBackend.searchChunks(queryEmbedding, similarityThreshold, matchCount, nodeIds);
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 searchTime = Date.now() - startTime; const searchTime = Date.now() - startTime;
console.log(`📊 Vector search (node-scoped): ${result.rows.length} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`); console.log(`📊 Vector search (${getVectorBackendType()}, node-scoped): ${rows.length} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (result.rows.length > 0) { if (rows.length > 0) {
console.log(`🎯 Top result: chunk ${result.rows[0].id} (similarity: ${result.rows[0].similarity.toFixed(3)})`); 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 rows = await vectorBackend.searchChunks(queryEmbedding, similarityThreshold, matchCount);
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 searchTime = Date.now() - startTime; const searchTime = Date.now() - startTime;
console.log(`📊 Vector search (global): ${result.rows.length}/${vectorLimit} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`); console.log(`📊 Vector search (${getVectorBackendType()}, global): ${rows.length} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (result.rows.length > 0) { if (rows.length > 0) {
console.log(`🎯 Top result: chunk ${result.rows[0].id} (similarity: ${result.rows[0].similarity.toFixed(3)})`); console.log(`🎯 Top result: chunk ${rows[0].id} (similarity: ${rows[0].similarity.toFixed(3)})`);
} }
return result.rows; return rows;
} }
async textSearchFallback( async textSearchFallback(
@@ -495,6 +456,10 @@ export class ChunkService {
} }
async getChunksWithoutEmbeddings(): Promise<Chunk[]> { async getChunksWithoutEmbeddings(): Promise<Chunk[]> {
if (getVectorBackendType() === 'qdrant') {
return [];
}
// In SQLite, chunk vectors live in vec_chunks; report chunks without corresponding vector rows // In SQLite, chunk vectors live in vec_chunks; report chunks without corresponding vector rows
const sqlite = getSQLiteClient(); const sqlite = getSQLiteClient();
const result = sqlite.query<Chunk>(` const result = sqlite.query<Chunk>(`
+4 -12
View File
@@ -1,6 +1,4 @@
import { generateText } from 'ai'; import { generateUtilityText } from '@/services/llm/provider';
import { createOpenAI } from '@ai-sdk/openai';
import { hasPreferredOpenAiKey, getPreferredOpenAiKey } from '../storage/openaiKeyServer';
import type { CanonicalNodeMetadata } from '@/types/database'; import type { CanonicalNodeMetadata } from '@/types/database';
export interface DescriptionInput { 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. * 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> { 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 { try {
const prompt = buildDescriptionPrompt(input); const prompt = buildDescriptionPrompt(input);
console.log(`[DescriptionService] Generating description for: "${input.title}"`); console.log(`[DescriptionService] Generating description for: "${input.title}"`);
const provider = createOpenAI({ apiKey: getPreferredOpenAiKey() }); const text = await generateUtilityText({
const response = await generateText({
model: provider('gpt-4o-mini'),
prompt, prompt,
maxOutputTokens: 100, maxOutputTokens: 100,
temperature: 0.3, temperature: 0.3,
task: 'description',
}); });
const description = sanitizeDescription(response.text, input); const description = sanitizeDescription(text, input);
console.log(`[DescriptionService] Generated: "${description}"`); 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 { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
import { eventBroadcaster } from '../events'; import { eventBroadcaster } from '../events';
import { nodeService } from './nodes'; import { nodeService } from './nodes';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod'; import { z } from 'zod';
import { validateEdgeExplanation } from './quality'; import { validateEdgeExplanation } from './quality';
import { getPreferredOpenAiKey, hasPreferredOpenAiKey } from '../storage/openaiKeyServer'; import { generateUtilityText } from '@/services/llm/provider';
const inferredEdgeContextSchema = z.object({ const inferredEdgeContextSchema = z.object({
type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']), type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']),
@@ -75,16 +73,12 @@ async function inferEdgeContext(params: {
].join('\n'); ].join('\n');
try { try {
if (!hasPreferredOpenAiKey()) { const text = await generateUtilityText({
return { type: 'related_to', confidence: 0.2, swap_direction: false };
}
const provider = createOpenAI({ apiKey: getPreferredOpenAiKey() });
const { text } = await generateText({
model: provider('gpt-4o-mini'),
prompt, prompt,
temperature: 0.0, temperature: 0.0,
maxOutputTokens: 120, maxOutputTokens: 120,
responseFormat: 'json',
task: 'edge_inference',
}); });
const parsedJson = (() => { const parsedJson = (() => {
@@ -142,21 +136,12 @@ async function autoInferEdge(params: {
].join('\n'); ].join('\n');
try { try {
if (!hasPreferredOpenAiKey()) { const text = await generateUtilityText({
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'),
prompt, prompt,
temperature: 0.0, temperature: 0.0,
maxOutputTokens: 150, maxOutputTokens: 150,
responseFormat: 'json',
task: 'edge_inference',
}); });
const parsedJson = (() => { const parsedJson = (() => {
+22 -26
View File
@@ -4,6 +4,7 @@ import { eventBroadcaster } from '../events';
import { EmbeddingService } from '@/services/embeddings'; import { EmbeddingService } from '@/services/embeddings';
import { getHighSignalSearchTerms, scoreNodeSearchMatch } from './searchRanking'; import { getHighSignalSearchTerms, scoreNodeSearchMatch } from './searchRanking';
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata'; import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
import { getVectorBackend } from '@/services/vectorBackend/factory';
type NodeRow = Node; type NodeRow = Node;
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number }; type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
@@ -346,6 +347,13 @@ export class NodeService {
private async deleteNodeSQLite(id: number): Promise<void> { private async deleteNodeSQLite(id: number): Promise<void> {
const sqlite = getSQLiteClient(); 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]); const result = sqlite.query('DELETE FROM nodes WHERE id = ?', [id]);
if (result.changes === 0) { if (result.changes === 0) {
@@ -627,35 +635,27 @@ export class NodeService {
return []; 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 { 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>(` 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, 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.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at, n.created_at, n.updated_at
(1.0 / (1.0 + vm.distance)) AS similarity FROM nodes n
FROM vector_matches vm WHERE n.id IN (${matchIds.map(() => '?').join(',')})
JOIN nodes n ON n.id = vm.node_id ${extraClauses}
${whereClauses}
ORDER BY vm.distance
LIMIT ? 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) { } catch (error) {
console.warn('[NodeSearch] Vector search unavailable, continuing without it:', error); console.warn('[NodeSearch] Vector search unavailable, continuing without it:', error);
return []; return [];
@@ -679,10 +679,6 @@ export class NodeService {
// PostgreSQL path removed in SQLite-only consolidation // PostgreSQL path removed in SQLite-only consolidation
private async bulkUpdateNodesSQLite(ids: number[], updates: Partial<Node>): Promise<Node[]> { 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 // For now, just update one by one - could optimize later
const updatedNodes: Node[] = []; const updatedNodes: Node[] = [];
for (const id of ids) { for (const id of ids) {
+119 -4
View File
@@ -3,6 +3,8 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { DatabaseError } from '@/types/database'; import { DatabaseError } from '@/types/database';
import { getDatabasePath, getVecExtensionPath } from '@/services/database/sqlite-runtime'; import { getDatabasePath, getVecExtensionPath } from '@/services/database/sqlite-runtime';
import { getEmbeddingProviderInfo } from '@/services/embedding/provider';
import { getVectorBackendType } from '@/services/vectorBackend';
export interface SQLiteConfig { export interface SQLiteConfig {
dbPath: string; dbPath: string;
@@ -43,6 +45,28 @@ export interface DatabaseIntegrityReport {
error?: string; 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 { class SQLiteClient {
private static instance: SQLiteClient; private static instance: SQLiteClient;
private db: Database.Database; private db: Database.Database;
@@ -258,13 +282,14 @@ class SQLiteClient {
public ensureVectorExtensions(): void { public ensureVectorExtensions(): void {
try { try {
const dimensions = getEmbeddingProviderInfo().dimensions;
// Test for vec_nodes and vec_chunks; create them if missing // 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'); const hasVecNodes = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_nodes');
if (!hasVecNodes) { if (!hasVecNodes) {
this.db.exec(` this.db.exec(`
CREATE VIRTUAL TABLE vec_nodes USING vec0( CREATE VIRTUAL TABLE vec_nodes USING vec0(
node_id INTEGER PRIMARY KEY, node_id INTEGER PRIMARY KEY,
embedding FLOAT[1536] embedding FLOAT[${dimensions}]
); );
`); `);
console.log('Created vec_nodes virtual table'); console.log('Created vec_nodes virtual table');
@@ -275,7 +300,7 @@ class SQLiteClient {
this.db.exec(` this.db.exec(`
CREATE VIRTUAL TABLE vec_chunks USING vec0( CREATE VIRTUAL TABLE vec_chunks USING vec0(
chunk_id INTEGER PRIMARY KEY, chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[1536] embedding FLOAT[${dimensions}]
); );
`); `);
console.log('Created vec_chunks virtual table'); console.log('Created vec_chunks virtual table');
@@ -353,6 +378,15 @@ class SQLiteClient {
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE 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 ( CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
chat_type TEXT, chat_type TEXT,
@@ -953,9 +987,10 @@ class SQLiteClient {
try { try {
this.db.exec(`DROP TABLE IF EXISTS ${table};`); this.db.exec(`DROP TABLE IF EXISTS ${table};`);
} catch {} } catch {}
const dimensions = getEmbeddingProviderInfo().dimensions;
const ddl = table === 'vec_nodes' const ddl = table === 'vec_nodes'
? `CREATE VIRTUAL TABLE vec_nodes USING vec0(node_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[1536]);`; : `CREATE VIRTUAL TABLE vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[${dimensions}]);`;
try { try {
this.db.exec(ddl); this.db.exec(ddl);
console.log(`Recreated ${table} virtual table`); console.log(`Recreated ${table} virtual table`);
@@ -1229,6 +1264,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: 'vec_nodes' | 'vec_chunks'): 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 { public getIntegrityReport(forceRefresh = false): DatabaseIntegrityReport {
if (!this.integrityReport || forceRefresh) { if (!this.integrityReport || forceRefresh) {
this.integrityReport = this.inspectIntegrity(); 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 {
import { getPreferredOpenAiKey } from './storage/openaiKeyServer'; createEmbeddingProvider,
getEmbeddingProviderInfo,
function getOpenAiClient(): OpenAI { validateEmbeddingDimensions
const apiKey = getPreferredOpenAiKey(); } from '@/services/embedding/provider';
if (!apiKey) {
throw new Error('OpenAI API key not configured. Add OPENAI_API_KEY to your .env.local file.');
}
return new OpenAI({ apiKey });
}
export class EmbeddingService { export class EmbeddingService {
/** /**
* Generate embedding for a search query using OpenAI's text-embedding-3-small model * Generate embedding for a search query using the active embedding profile.
* This matches the same model used in embed_universal.py for consistency
*/ */
static async generateQueryEmbedding(query: string): Promise<number[]> { static async generateQueryEmbedding(query: string): Promise<number[]> {
try { try {
const openai = getOpenAiClient(); return await createEmbeddingProvider().generateEmbedding(query);
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;
} catch (error) { } catch (error) {
console.error('Failed to generate query embedding:', error); console.error('Failed to generate query embedding:', error);
throw new Error(`Embedding generation failed: ${error instanceof Error ? error.message : 'Unknown 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 { 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 * 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 { import {
createDatabaseConnection, createDatabaseConnection,
serializeFloat32Vector, serializeFloat32Vector,
formatEmbeddingText, formatEmbeddingText,
batchProcess batchProcess
} from './sqlite-vec'; } from './sqlite-vec';
import { createEmbeddingProvider } from '@/services/embedding/provider';
import { generateUtilityText } from '@/services/llm/provider';
import { getVectorBackend } from '@/services/vectorBackend/factory';
interface NodeRecord { interface NodeRecord {
id: number; id: number;
@@ -31,20 +30,11 @@ interface EmbedNodeOptions {
} }
export class NodeEmbedder { export class NodeEmbedder {
private openaiClient: OpenAI;
private openaiProvider: ReturnType<typeof createOpenAI>;
private db: ReturnType<typeof createDatabaseConnection>; private db: ReturnType<typeof createDatabaseConnection>;
private processedCount: number = 0; private processedCount: number = 0;
private failedCount: number = 0; private failedCount: number = 0;
constructor() { 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(); this.db = createDatabaseConnection();
} }
@@ -57,14 +47,14 @@ export class NodeEmbedder {
Title: ${node.title} Title: ${node.title}
Source: ${node.source || 'No source'} 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 { try {
const { text } = await generateText({ const text = await generateUtilityText({
model: this.openaiProvider('gpt-4o-mini'),
prompt, prompt,
maxOutputTokens: 150, maxOutputTokens: 150,
temperature: 0.3, temperature: 0.3,
task: 'embedding_prep_analysis',
}); });
return text; 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[]> { private async generateEmbedding(text: string): Promise<number[]> {
const response = await this.openaiClient.embeddings.create({ return createEmbeddingProvider().generateEmbedding(text);
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
} }
/** /**
@@ -132,20 +117,10 @@ Focus on the main concepts, key relationships, and practical implications.`;
// Update vec_nodes virtual table // Update vec_nodes virtual table
try { try {
// Determine correct column name for primary key (node_id vs id) const vectorBackend = await getVectorBackend();
// Use declared PK column from your DB schema (confirmed: node_id) await vectorBackend.upsertNode(node.id, embeddingText, embedding);
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);
} catch (vecError) { } 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 // Continue - main embedding is still saved
} }
@@ -212,7 +187,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
async (node) => { async (node) => {
try { try {
await this.embedNode(node, forceReEmbed); await this.embedNode(node, forceReEmbed);
} catch (error) { } catch {
// Error already logged in embedNode // Error already logged in embedNode
} }
}, },
+15 -63
View File
@@ -3,13 +3,13 @@
* Takes a node_id, reads source content from nodes table, chunks it, and stores in chunks table * 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 { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { getPreferredOpenAiKey } from '@/services/storage/openaiKeyServer';
import { import {
createDatabaseConnection, createDatabaseConnection,
batchProcess batchProcess
} from './sqlite-vec'; } from './sqlite-vec';
import { createEmbeddingProvider, getEmbeddingProviderInfo } from '@/services/embedding/provider';
import { getVectorBackend } from '@/services/vectorBackend/factory';
interface Node { interface Node {
id: number; id: number;
@@ -18,34 +18,16 @@ interface Node {
chunk_status?: string | null; chunk_status?: string | null;
} }
interface ChunkData {
content: string;
metadata: {
node_id: number;
chunk_index: number;
start_char: number;
end_char: number;
};
}
interface EmbedUniversalOptions { interface EmbedUniversalOptions {
nodeId: number; nodeId: number;
verbose?: boolean; verbose?: boolean;
} }
export class UniversalEmbedder { export class UniversalEmbedder {
private openaiClient: OpenAI;
private db: ReturnType<typeof createDatabaseConnection>; private db: ReturnType<typeof createDatabaseConnection>;
private textSplitter: RecursiveCharacterTextSplitter; private textSplitter: RecursiveCharacterTextSplitter;
private vecChunksInsertSQL: string | null = null;
constructor() { 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(); this.db = createDatabaseConnection();
// Configure text splitter (same as old KMS system) // 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 * Generate embedding for text using the active embedding profile.
*/
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
*/ */
private async generateEmbedding(text: string): Promise<number[]> { private async generateEmbedding(text: string): Promise<number[]> {
const response = await this.openaiClient.embeddings.create({ return createEmbeddingProvider().generateEmbedding(text);
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
} }
/** /**
* Delete existing chunks for a node * Delete existing chunks for a node
*/ */
private deleteExistingChunks(nodeId: number): void { private async deleteExistingChunks(nodeId: number): Promise<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 { try {
const deleteVecStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?'); const vectorBackend = await getVectorBackend();
deleteVecStmt.run(BigInt(chunk.id)); await vectorBackend.deleteChunksByNode(nodeId);
} catch (error) { } catch (error) {
console.warn(`Could not delete vec_chunk ${chunk.id}:`, 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 = ?'); const deleteChunksStmt = this.db.prepare('DELETE FROM chunks WHERE node_id = ?');
deleteChunksStmt.run(nodeId); deleteChunksStmt.run(nodeId);
} }
@@ -108,7 +67,7 @@ export class UniversalEmbedder {
nodeId: number, nodeId: number,
chunkContent: string, chunkContent: string,
chunkIndex: number, chunkIndex: number,
metadata: any metadata: Record<string, unknown>
): Promise<void> { ): Promise<void> {
// Generate embedding // Generate embedding
const embedding = await this.generateEmbedding(chunkContent); const embedding = await this.generateEmbedding(chunkContent);
@@ -124,7 +83,7 @@ export class UniversalEmbedder {
nodeId, nodeId,
chunkIndex, chunkIndex,
chunkContent, chunkContent,
'text-embedding-3-small', getEmbeddingProviderInfo().model,
JSON.stringify(metadata), JSON.stringify(metadata),
now now
); );
@@ -132,17 +91,10 @@ export class UniversalEmbedder {
const chunkId = Number(result.lastInsertRowid); const chunkId = Number(result.lastInsertRowid);
try { try {
const vectorString = `[${embedding.join(',')}]`; const vectorBackend = await getVectorBackend();
try { await vectorBackend.upsertChunk(chunkId, nodeId, chunkIndex, chunkContent, embedding);
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);
} catch (error) { } 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}"`); console.log(`Processing node ${nodeId}: "${node.title}"`);
// Delete existing chunks // Delete existing chunks
this.deleteExistingChunks(nodeId); await this.deleteExistingChunks(nodeId);
// Split text into chunks // Split text into chunks
const chunks = await this.textSplitter.splitText(node.source); const chunks = await this.textSplitter.splitText(node.source);
@@ -274,7 +226,7 @@ export async function runCLI(args: string[]): Promise<void> {
const embedder = new UniversalEmbedder(); const embedder = new UniversalEmbedder();
try { try {
const result = await embedder.processNode({ nodeId, verbose }); await embedder.processNode({ nodeId, verbose });
if (verbose) { if (verbose) {
const stats = embedder.getStats(); 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,109 @@
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();
return {
ok,
backend: 'sqlite-vec',
dimensions: getEmbeddingProviderInfo().dimensions,
detail: ok ? 'sqlite-vec extension loaded' : 'sqlite-vec extension unavailable',
};
}
}
+6 -7
View File
@@ -1,11 +1,10 @@
import { tool } from 'ai'; import { tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
import { generateText } from 'ai';
import { extractPaper } from '@/services/typescript/extractors/paper'; import { extractPaper } from '@/services/typescript/extractors/paper';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase'; import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter'; import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality'; 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 { function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
const normalizedCandidate = typeof candidate === 'string' const normalizedCandidate = typeof candidate === 'string'
@@ -25,7 +24,6 @@ function ensureNodeDescription(candidate: string | undefined, fallbackLead: stri
// AI-powered content analysis // AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) { async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try { try {
const provider = createLocalOpenAIProvider();
const prompt = `Analyze this ${contentType} content and provide classification. const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}" Title: "${title}"
@@ -57,13 +55,14 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
"reasoning": "Brief explanation of classification choices" "reasoning": "Brief explanation of classification choices"
}`; }`;
const response = await generateText({ const text = await generateUtilityText({
model: provider('gpt-4o-mini'),
prompt, 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 // Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
+6 -7
View File
@@ -1,11 +1,10 @@
import { tool } from 'ai'; import { tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
import { generateText } from 'ai';
import { extractWebsite } from '@/services/typescript/extractors/website'; import { extractWebsite } from '@/services/typescript/extractors/website';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase'; import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter'; import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality'; 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 { function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
const normalizedCandidate = typeof candidate === 'string' const normalizedCandidate = typeof candidate === 'string'
@@ -40,7 +39,6 @@ async function analyzeContentWithAI(
contentType: string contentType: string
) { ) {
try { try {
const provider = createLocalOpenAIProvider();
const prompt = `Analyze this ${contentType} content and provide classification. const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}" Title: "${title}"
@@ -71,13 +69,14 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
"reasoning": "Brief explanation of classification choices" "reasoning": "Brief explanation of classification choices"
}`; }`;
const response = await generateText({ const text = await generateUtilityText({
model: provider('gpt-4o-mini'),
prompt, 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 // Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
+10 -12
View File
@@ -1,11 +1,10 @@
import { tool } from 'ai'; import { tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';
import { generateText } from 'ai';
import { extractYouTube } from '@/services/typescript/extractors/youtube'; import { extractYouTube } from '@/services/typescript/extractors/youtube';
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase'; import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
import { formatNodeForChat } from '../infrastructure/nodeFormatter'; import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import { validateExplicitDescription } from '@/services/database/quality'; 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 { function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
const normalizedCandidate = typeof candidate === 'string' const normalizedCandidate = typeof candidate === 'string'
@@ -29,7 +28,6 @@ async function analyzeContentWithAI(
contentType: string contentType: string
) { ) {
try { try {
const provider = createLocalOpenAIProvider();
const prompt = `Analyze this ${contentType} content and provide classification. const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}" Title: "${title}"
@@ -60,13 +58,14 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
"reasoning": "Brief explanation of classification choices" "reasoning": "Brief explanation of classification choices"
}`; }`;
const response = await generateText({ const text = await generateUtilityText({
model: provider('gpt-4o-mini'),
prompt, 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 // Clean up the response - remove markdown code blocks if present
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
@@ -114,13 +113,12 @@ ${excerpt}
`; `;
try { try {
const provider = createLocalOpenAIProvider(); const text = await generateUtilityText({
const response = await generateText({
model: provider('gpt-4o-mini'),
prompt, prompt,
maxOutputTokens: 400 maxOutputTokens: 400,
task: 'extraction_analysis',
}); });
return response.text?.trim() || null; return text?.trim() || null;
} catch (error) { } catch (error) {
console.warn('Transcript summarisation failed, falling back to AI analysis description:', error); console.warn('Transcript summarisation failed, falling back to AI analysis description:', error);
return null; return null;