Merge feat/qdrant-local-vector-backend
This commit is contained in:
+40
-4
@@ -1,14 +1,50 @@
|
|||||||
# 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=
|
||||||
|
|
||||||
# Database/vector paths are auto-detected for macOS, Windows, and Linux.
|
# AI profiles are selected during setup because embedding dimensions shape
|
||||||
# Override only if you intentionally want a custom location.
|
# the sqlite-vec tables. Use one:
|
||||||
|
#
|
||||||
|
# npm run setup:local -- --profile openai
|
||||||
|
# npm run setup:local -- --profile qwen-local
|
||||||
|
#
|
||||||
|
# OpenAI profile:
|
||||||
|
# LLM_PROFILE=openai
|
||||||
|
# LLM_MODEL=gpt-4o-mini
|
||||||
|
# EMBEDDING_PROFILE=openai
|
||||||
|
# EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
# EMBEDDING_DIMENSIONS=1536
|
||||||
|
# VECTOR_BACKEND=sqlite-vec
|
||||||
|
|
||||||
|
# Supported local profile: point RA-H at OpenAI-compatible local endpoints.
|
||||||
|
# Example Ollama:
|
||||||
|
# LLM_PROFILE=openai-compatible
|
||||||
|
# LLM_BASE_URL=http://127.0.0.1:11434/v1
|
||||||
|
# LLM_MODEL=qwen3:4b
|
||||||
|
# EMBEDDING_PROFILE=openai-compatible
|
||||||
|
# EMBEDDING_BASE_URL=http://127.0.0.1:11434/v1
|
||||||
|
# EMBEDDING_MODEL=qwen3-embedding:0.6b
|
||||||
|
# EMBEDDING_DIMENSIONS=1024
|
||||||
|
#
|
||||||
|
# Example Qdrant sidecar, only needed when sqlite-vec is unavailable or unreliable:
|
||||||
|
# VECTOR_BACKEND=qdrant
|
||||||
|
# QDRANT_URL=http://localhost:6333
|
||||||
|
|
||||||
|
# Database path defaults to the operating system app-data folder:
|
||||||
|
# macOS: ~/Library/Application Support/RA-H/db/rah.sqlite
|
||||||
|
# Linux: ~/.local/share/RA-H/db/rah.sqlite
|
||||||
|
# Windows: %APPDATA%/RA-H/db/rah.sqlite
|
||||||
|
#
|
||||||
|
# Set SQLITE_DB_PATH before setup if you intentionally want a repo-local DB,
|
||||||
|
# demo DB, or any other separate location.
|
||||||
# SQLITE_DB_PATH=/absolute/path/to/rah.sqlite
|
# SQLITE_DB_PATH=/absolute/path/to/rah.sqlite
|
||||||
|
|
||||||
|
# sqlite-vec extension path is auto-detected for macOS, Windows, and Linux.
|
||||||
|
# Override only if you intentionally want a custom extension location.
|
||||||
# SQLITE_VEC_EXTENSION_PATH=/absolute/path/to/vec0.<dylib|dll|so>
|
# SQLITE_VEC_EXTENSION_PATH=/absolute/path/to/vec0.<dylib|dll|so>
|
||||||
|
|
||||||
# App config (no changes needed)
|
# App config (no changes needed)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
|
.ra-h/
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
|
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
|
||||||
```
|
```
|
||||||
|
|
||||||
**TL;DR:** Use the MCP quick install if you want Claude Code, Cursor, Codex, or another agent to read and write your local graph. Clone this repository only if you also want the local browser UI.
|
**TL;DR:** Use the MCP quick install if you want Claude Code, Cursor, Codex, or another agent to read and write your local graph. Clone this repository only if you also want the local browser UI. If you clone the repo, choose **OpenAI** or **local Qwen** before setup creates the vector tables.
|
||||||
|
|
||||||
[](https://youtu.be/YyUCGigZIZE?si=USYgvmwtdGpgGdwu)
|
[](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)
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
2. **Provides a UI** — Browse, search, and organize your nodes at `localhost:3000`
|
2. **Provides a UI** — Browse, search, and organize your nodes at `localhost:3000`
|
||||||
3. **Exposes an MCP server** — Claude Code and other MCP clients can query and add to your knowledge base
|
3. **Exposes an MCP server** — Claude Code and other MCP clients can query and add to your knowledge base
|
||||||
|
|
||||||
Your data stays on your machine. Nothing is sent anywhere unless you configure an API key.
|
Your database stays on your machine. With the `openai` profile, model requests go to OpenAI after you add an API key. With the `qwen-local` profile, model requests go to your local Ollama/OpenAI-compatible endpoint.
|
||||||
|
|
||||||
Current contract:
|
Current contract:
|
||||||
- no runtime `dimensions`
|
- no runtime `dimensions`
|
||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,7 +53,8 @@ Current contract:
|
|||||||
| You want... | Use this path |
|
| You want... | Use this path |
|
||||||
|-------------|---------------|
|
|-------------|---------------|
|
||||||
| Your AI coding agent can read/write your RA-H graph | **Option A: MCP-only quick install** |
|
| Your AI coding agent can read/write your RA-H graph | **Option A: MCP-only quick install** |
|
||||||
| A browser UI at `localhost:3000` | **Option B: Full local app** |
|
| A browser UI at `localhost:3000` with OpenAI models | **Option B1: Full local app with OpenAI** |
|
||||||
|
| A browser UI at `localhost:3000` with local Qwen models | **Option B2: Full local app with local Qwen** |
|
||||||
| A clean demo that does not touch your real graph | **Demo-safe isolated install** |
|
| A clean demo that does not touch your real graph | **Demo-safe isolated install** |
|
||||||
|
|
||||||
### Option A: MCP-only quick install
|
### Option A: MCP-only quick install
|
||||||
@@ -104,32 +106,97 @@ Notes:
|
|||||||
npx -y ra-h-mcp-server@latest setup --client claude-code --yes --pin current
|
npx -y ra-h-mcp-server@latest setup --client claude-code --yes --pin current
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option B: Full local app
|
### Option B1: Full local app with OpenAI
|
||||||
|
|
||||||
Use this if you want the browser UI at `localhost:3000`.
|
Use this if you want the browser UI at `localhost:3000` and want RA-H to use OpenAI for descriptions, embeddings, and semantic search.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/bradwmorris/ra-h_os.git
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
cd ra-h_os
|
cd ra-h_os
|
||||||
npm install
|
npm install
|
||||||
npm run setup:local
|
npm run setup:local -- --profile openai
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [localhost:3000](http://localhost:3000). Done.
|
Open [localhost:3000](http://localhost:3000). Add your OpenAI API key when the app prompts you, or later in Settings -> API Keys.
|
||||||
|
|
||||||
If you also want your coding agent connected to the same database, run Option A after the app setup.
|
### Option B2: Full local app with local Qwen
|
||||||
|
|
||||||
|
Use this if you want the browser UI at `localhost:3000` and want RA-H to call local OpenAI-compatible Ollama endpoints instead of OpenAI.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Ollama is installed and running.
|
||||||
|
- These models are pulled before setup.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
|
cd ra-h_os
|
||||||
|
npm install
|
||||||
|
ollama pull qwen3:4b
|
||||||
|
ollama pull qwen3-embedding:0.6b
|
||||||
|
npm run setup:local -- --profile qwen-local
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [localhost:3000](http://localhost:3000). Settings -> API Keys will show the active local model profile and disable OpenAI key entry.
|
||||||
|
|
||||||
|
If you also want your coding agent connected to the same default database, run Option A after the app setup. If you override `SQLITE_DB_PATH`, pass the same path to the MCP installer with `--db`.
|
||||||
|
|
||||||
Full install details:
|
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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First-Time Setup Rules
|
||||||
|
|
||||||
|
Pick the embedding profile before the database is created.
|
||||||
|
|
||||||
|
This is not cosmetic. The readable `nodes` and `chunks` tables are normal SQLite tables, but the derived vector tables are created with a fixed embedding width:
|
||||||
|
|
||||||
|
| Setup profile | Utility model | Embedding model | Vector width |
|
||||||
|
|---------------|---------------|-----------------|--------------|
|
||||||
|
| `openai` | `gpt-4o-mini` | `text-embedding-3-small` | `1536` |
|
||||||
|
| `qwen-local` | `qwen3:4b` through Ollama | `qwen3-embedding:0.6b` through Ollama | `1024` |
|
||||||
|
|
||||||
|
Setup requires one of these commands on a fresh install.
|
||||||
|
|
||||||
|
OpenAI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run setup:local -- --profile openai
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Local Qwen with Ollama:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
ollama pull qwen3:4b
|
||||||
|
ollama pull qwen3-embedding:0.6b
|
||||||
|
npm run setup:local -- --profile qwen-local
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
If you run setup without a profile and `.env.local` does not already select one, setup stops before creating vector tables and prints the two supported commands.
|
||||||
|
|
||||||
|
If you change embedding provider, model, dimensions, or vector backend after data exists, your source data stays intact but derived embeddings must be rebuilt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run rebuild:embeddings
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OpenAI API Key
|
## OpenAI API Key
|
||||||
|
|
||||||
**Optional but recommended.** Without a key, you can still create and organize nodes manually.
|
Only applies to the `openai` setup profile.
|
||||||
|
|
||||||
|
Without a key, you can still create and organize nodes manually.
|
||||||
|
|
||||||
With a key, you get:
|
With a key, you get:
|
||||||
- Auto-generated descriptions when you add nodes
|
- Auto-generated descriptions when you add nodes
|
||||||
@@ -138,25 +205,113 @@ With a key, you get:
|
|||||||
|
|
||||||
**Cost:** Less than $0.10/day for heavy use. Most users spend $1-2/month.
|
**Cost:** Less than $0.10/day for heavy use. Most users spend $1-2/month.
|
||||||
|
|
||||||
**Setup:** The app will prompt you on first launch, or go to Settings → API Keys.
|
**Setup:** The app will prompt you on first launch, or go to Settings -> API Keys.
|
||||||
|
|
||||||
Get a key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
|
Get a key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
|
||||||
|
|
||||||
|
If you selected `qwen-local`, do not add an OpenAI key in the UI. Settings -> API Keys shows the active local model profile and disables OpenAI key entry so the install stays aligned with the setup profile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Model Profile
|
||||||
|
|
||||||
|
Use the `qwen-local` setup profile if you want local utility LLM calls and local embeddings.
|
||||||
|
|
||||||
|
RA-H does not bundle model weights. It calls a local OpenAI-compatible HTTP endpoint. The tested local path is Ollama with Qwen.
|
||||||
|
|
||||||
|
Supported local contract:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LLM_PROFILE=openai-compatible
|
||||||
|
LLM_BASE_URL=http://127.0.0.1:11434/v1
|
||||||
|
LLM_MODEL=qwen3:4b
|
||||||
|
|
||||||
|
EMBEDDING_PROFILE=openai-compatible
|
||||||
|
EMBEDDING_BASE_URL=http://127.0.0.1:11434/v1
|
||||||
|
EMBEDDING_MODEL=qwen3-embedding:0.6b
|
||||||
|
EMBEDDING_DIMENSIONS=1024
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime guides:
|
||||||
|
|
||||||
|
- [Ollama local profile](./OLLAMA-LOCAL-PROFILE.md)
|
||||||
|
- [llama.cpp local profile](./LLAMA-CPP-LOCAL-PROFILE.md)
|
||||||
|
|
||||||
|
Validate local AI and vector configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run doctor:local-ai
|
||||||
|
```
|
||||||
|
|
||||||
|
If you change embedding provider, model, dimensions, or vector backend after data exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run rebuild:embeddings
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom model/provider overrides are advanced and not a broad support guarantee. They may work, but the tested product surface is OpenAI plus the narrow local Qwen profile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vector Backends
|
||||||
|
|
||||||
|
Default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VECTOR_BACKEND=sqlite-vec
|
||||||
|
```
|
||||||
|
|
||||||
|
Use Qdrant when sqlite-vec is unavailable or unreliable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d qdrant
|
||||||
|
VECTOR_BACKEND=qdrant
|
||||||
|
QDRANT_URL=http://localhost:6333
|
||||||
|
```
|
||||||
|
|
||||||
|
SQLite remains the source-of-truth database. Qdrant stores only derived vector indexes.
|
||||||
|
|
||||||
|
Qdrant does not change your model choice. OpenAI vs local Qwen is controlled by `LLM_PROFILE` and `EMBEDDING_PROFILE`. sqlite-vec vs Qdrant is controlled by `VECTOR_BACKEND`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Where Your Data Lives
|
## Where Your Data Lives
|
||||||
|
|
||||||
|
By default, setup creates and seeds the SQLite database in your operating system's app-data folder, not inside the cloned repo:
|
||||||
|
|
||||||
```
|
```
|
||||||
~/Library/Application Support/RA-H/db/rah.sqlite # macOS
|
~/Library/Application Support/RA-H/db/rah.sqlite # macOS
|
||||||
~/.local/share/RA-H/db/rah.sqlite # Linux
|
~/.local/share/RA-H/db/rah.sqlite # Linux
|
||||||
%APPDATA%/RA-H/db/rah.sqlite # Windows
|
%APPDATA%/RA-H/db/rah.sqlite # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This default applies to both app profiles:
|
||||||
|
- `npm run setup:local -- --profile openai`
|
||||||
|
- `npm run setup:local -- --profile qwen-local`
|
||||||
|
|
||||||
This is a standard SQLite file. You can:
|
This is a standard SQLite file. You can:
|
||||||
- Back it up by copying the file
|
- Back it up by copying the file
|
||||||
- Query it directly with `sqlite3` or any SQLite tool
|
- Query it directly with `sqlite3` or any SQLite tool
|
||||||
- Move it between machines
|
- Move it between machines
|
||||||
|
|
||||||
|
You can put the database somewhere else by setting `SQLITE_DB_PATH` before setup. Use this when you want a repo-local DB, a demo DB, or any other separate database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SQLITE_DB_PATH="$HOME/Desktop/ra-h_os-demo-data/rah.sqlite" npm run setup:local -- --profile qwen-local
|
||||||
|
```
|
||||||
|
|
||||||
|
To put it directly inside your cloned repo, use a gitignored local folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SQLITE_DB_PATH="$PWD/.ra-h/db/rah.sqlite" npm run setup:local -- --profile qwen-local
|
||||||
|
```
|
||||||
|
|
||||||
|
If MCP should use that same non-default database, pass the same path to the MCP installer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y ra-h-mcp-server@latest setup --client claude-code,codex --yes --db "$HOME/Desktop/ra-h_os-demo-data/rah.sqlite"
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Demo-safe isolated install
|
## Demo-safe isolated install
|
||||||
@@ -167,7 +322,7 @@ If you need a clean demo without touching your normal RA-H database:
|
|||||||
git clone https://github.com/bradwmorris/ra-h_os.git ~/Desktop/ra-h_os-demo
|
git clone https://github.com/bradwmorris/ra-h_os.git ~/Desktop/ra-h_os-demo
|
||||||
cd ~/Desktop/ra-h_os-demo
|
cd ~/Desktop/ra-h_os-demo
|
||||||
npm install
|
npm install
|
||||||
SQLITE_DB_PATH="$HOME/Desktop/ra-h_os-demo-data/rah.sqlite" npm run setup:local
|
SQLITE_DB_PATH="$HOME/Desktop/ra-h_os-demo-data/rah.sqlite" npm run setup:local -- --profile qwen-local
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
npx -y ra-h-mcp-server@latest setup \
|
npx -y ra-h-mcp-server@latest setup \
|
||||||
@@ -300,8 +455,11 @@ See [docs/2_schema.md](./docs/2_schema.md) and [docs/8_mcp.md](./docs/8_mcp.md)
|
|||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
|---------|--------------|
|
|---------|--------------|
|
||||||
| `npm run setup:local` | Rebuild native modules, create `.env.local`, create the SQLite DB, and seed the base schema |
|
| `npm run setup:local -- --profile openai` | Rebuild native modules, create `.env.local`, create the SQLite DB, and seed OpenAI-width vector tables |
|
||||||
|
| `npm run setup:local -- --profile qwen-local` | Rebuild native modules, create `.env.local`, create the SQLite DB, and seed Qwen-width vector tables |
|
||||||
|
| `npm run setup:local` | Only valid if `.env.local` already selects an embedding profile; otherwise it stops before DB/vector setup |
|
||||||
| `npm run bootstrap:local` | Lower-level helper used by `setup:local`; most users should not run this directly |
|
| `npm run bootstrap:local` | Lower-level helper used by `setup:local`; most users should not run this directly |
|
||||||
|
| `npm run rebuild:embeddings` | Recreate derived embeddings after changing embedding provider, model, dimensions, or vector backend |
|
||||||
| `npm run dev` | Start the app at localhost:3000 |
|
| `npm run dev` | Start the app at localhost:3000 |
|
||||||
| `npm run dev:local` | Alias for `npm run dev` |
|
| `npm run dev:local` | Alias for `npm run dev` |
|
||||||
| `npm run build` | Production build |
|
| `npm run build` | Production build |
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,10 +11,23 @@ export const runtime = 'nodejs';
|
|||||||
|
|
||||||
function buildResponse(key: string | null) {
|
function buildResponse(key: string | null) {
|
||||||
const configured = isValidOpenAiKey(key);
|
const configured = isValidOpenAiKey(key);
|
||||||
|
const llmProfile = process.env.LLM_PROFILE || 'openai';
|
||||||
|
const embeddingProfile = process.env.EMBEDDING_PROFILE || 'openai';
|
||||||
|
const openAiKeyWritable = llmProfile === 'openai' || embeddingProfile === 'openai';
|
||||||
return {
|
return {
|
||||||
configured,
|
configured,
|
||||||
maskedKey: configured ? maskOpenAiKey(key) : null,
|
maskedKey: configured ? maskOpenAiKey(key) : null,
|
||||||
envPath: getEnvLocalPath(),
|
envPath: getEnvLocalPath(),
|
||||||
|
openAiKeyWritable,
|
||||||
|
activeProfile: {
|
||||||
|
llmProfile,
|
||||||
|
llmModel: process.env.LLM_MODEL || (llmProfile === 'openai' ? 'gpt-4o-mini' : null),
|
||||||
|
llmBaseUrl: process.env.LLM_BASE_URL || null,
|
||||||
|
embeddingProfile,
|
||||||
|
embeddingModel: process.env.EMBEDDING_MODEL || (embeddingProfile === 'openai' ? 'text-embedding-3-small' : null),
|
||||||
|
embeddingBaseUrl: process.env.EMBEDDING_BASE_URL || process.env.LLM_BASE_URL || null,
|
||||||
|
embeddingDimensions: process.env.EMBEDDING_DIMENSIONS || (embeddingProfile === 'openai' ? '1536' : '1024'),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +50,17 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const key = typeof body?.key === 'string' ? body.key.trim() : '';
|
const key = typeof body?.key === 'string' ? body.key.trim() : '';
|
||||||
|
const currentState = buildResponse(await readStoredOpenAiKey());
|
||||||
|
|
||||||
|
if (!currentState.openAiKeyWritable) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'OpenAI key entry is disabled because this install is using a local model profile. Change .env.local and rebuild embeddings to switch providers.',
|
||||||
|
...currentState,
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isValidOpenAiKey(key)) {
|
if (!isValidOpenAiKey(key)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+36
-6
@@ -21,14 +21,16 @@
|
|||||||
| [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
|
||||||
|
|
||||||
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 OpenAI local app quick start if you want the browser UI with OpenAI models.
|
||||||
3. Read [Full Local](./10_full-local.md) if you want a more local-first or community setup.
|
3. Use the local Qwen quick start if you want the browser UI with local Ollama models.
|
||||||
|
|
||||||
## MCP Quick Install
|
## MCP Quick Install
|
||||||
|
|
||||||
@@ -42,17 +44,35 @@ Run `doctor` after setup or whenever MCP feels stale:
|
|||||||
npx -y ra-h-mcp-server@latest doctor
|
npx -y ra-h-mcp-server@latest doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local App Quick Start
|
## Local App Quick Start: OpenAI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/bradwmorris/ra-h_os.git
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
cd ra-h_os
|
cd ra-h_os
|
||||||
npm install
|
npm install
|
||||||
npm run setup:local
|
npm run setup:local -- --profile openai
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:3000
|
Open http://localhost:3000 and add your OpenAI API key when prompted, or later in Settings -> API Keys.
|
||||||
|
|
||||||
|
## Local App Quick Start: Local Qwen
|
||||||
|
|
||||||
|
Requires Ollama to be installed and running.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/bradwmorris/ra-h_os.git
|
||||||
|
cd ra-h_os
|
||||||
|
npm install
|
||||||
|
ollama pull qwen3:4b
|
||||||
|
ollama pull qwen3-embedding:0.6b
|
||||||
|
npm run setup:local -- --profile qwen-local
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000. Settings -> API Keys shows the active local model profile and disables OpenAI key entry.
|
||||||
|
|
||||||
|
Fresh app setup must choose `--profile openai` or `--profile qwen-local` before vector tables are created. OpenAI embeddings use width `1536`; the supported Qwen embedding profile uses width `1024`.
|
||||||
|
|
||||||
## MCP Integration
|
## MCP Integration
|
||||||
|
|
||||||
@@ -71,7 +91,17 @@ The recommended MCP setup is the CLI command above. Manual config is only for tr
|
|||||||
|
|
||||||
If you need a frozen version for release/debug work, pin it intentionally and restart the client.
|
If you need a frozen version for release/debug work, pin it intentionally and restart the client.
|
||||||
|
|
||||||
The setup command creates the default database if it does not exist. The standalone MCP server can write nodes without the app running, but the app owns chunking and embedding from node source: readable `chunks`, full-text indexes, `vec_nodes`, and `vec_chunks`. See [MCP docs](./8_mcp.md) for the full install, verify, memory-file, and troubleshooting path.
|
The selected setup profile creates the default database if it does not exist. By default, that database is in the operating system's app-data folder, not inside the cloned repo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Library/Application Support/RA-H/db/rah.sqlite # macOS
|
||||||
|
~/.local/share/RA-H/db/rah.sqlite # Linux
|
||||||
|
%APPDATA%/RA-H/db/rah.sqlite # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `SQLITE_DB_PATH` before setup if you want a repo-local DB, demo DB, or any other separate location. If MCP should use that same non-default database, pass the same path to the MCP installer with `--db`.
|
||||||
|
|
||||||
|
The standalone MCP server can write nodes without the app running, but the app owns chunking and embedding from node source: readable `chunks`, full-text indexes, `vec_nodes`, and `vec_chunks`. See [MCP docs](./8_mcp.md) for the full install, verify, memory-file, and troubleshooting path.
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Database from 'better-sqlite3';
|
|||||||
const repoDir = process.cwd();
|
const repoDir = process.cwd();
|
||||||
const envTemplate = path.join(repoDir, '.env.example');
|
const envTemplate = path.join(repoDir, '.env.example');
|
||||||
const targetEnv = path.join(repoDir, '.env.local');
|
const targetEnv = path.join(repoDir, '.env.local');
|
||||||
|
const supportedProfiles = new Set(['openai', 'qwen-local']);
|
||||||
|
|
||||||
function log(message) {
|
function log(message) {
|
||||||
console.log(`[bootstrap-local] ${message}`);
|
console.log(`[bootstrap-local] ${message}`);
|
||||||
@@ -89,10 +90,12 @@ function ensureEnvValue(key, value) {
|
|||||||
const nextLines = lines.map((line) => {
|
const nextLines = lines.map((line) => {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (trimmed.startsWith(`${key}=`)) {
|
if (trimmed.startsWith(`${key}=`)) {
|
||||||
|
if (found) return line;
|
||||||
found = true;
|
found = true;
|
||||||
return `${key}=${value}`;
|
return `${key}=${value}`;
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith(`# ${key}=`) || trimmed.startsWith(`#${key}=`)) {
|
if (trimmed.startsWith(`# ${key}=`) || trimmed.startsWith(`#${key}=`)) {
|
||||||
|
if (found) return line;
|
||||||
found = true;
|
found = true;
|
||||||
return `${key}=${value}`;
|
return `${key}=${value}`;
|
||||||
}
|
}
|
||||||
@@ -110,6 +113,74 @@ function ensureEnvValue(key, value) {
|
|||||||
log(`Set ${key} in .env.local`);
|
log(`Set ${key} in .env.local`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { profile: undefined };
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === '--profile') {
|
||||||
|
args.profile = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else if (arg.startsWith('--profile=')) {
|
||||||
|
args.profile = arg.slice('--profile='.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSetupProfile(rawProfile) {
|
||||||
|
if (!rawProfile) return undefined;
|
||||||
|
if (rawProfile === 'qwen' || rawProfile === 'local' || rawProfile === 'ollama') {
|
||||||
|
return 'qwen-local';
|
||||||
|
}
|
||||||
|
return rawProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySetupProfile(profile) {
|
||||||
|
if (!profile) return;
|
||||||
|
if (!supportedProfiles.has(profile)) {
|
||||||
|
throw new Error(`Unsupported setup profile "${profile}". Use "openai" or "qwen-local".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile === 'openai') {
|
||||||
|
ensureEnvValue('LLM_PROFILE', 'openai');
|
||||||
|
ensureEnvValue('EMBEDDING_PROFILE', 'openai');
|
||||||
|
ensureEnvValue('EMBEDDING_MODEL', 'text-embedding-3-small');
|
||||||
|
ensureEnvValue('EMBEDDING_DIMENSIONS', '1536');
|
||||||
|
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureEnvValue('LLM_PROFILE', 'openai-compatible');
|
||||||
|
ensureEnvValue('LLM_BASE_URL', 'http://127.0.0.1:11434/v1');
|
||||||
|
ensureEnvValue('LLM_MODEL', 'qwen3:4b');
|
||||||
|
ensureEnvValue('EMBEDDING_PROFILE', 'openai-compatible');
|
||||||
|
ensureEnvValue('EMBEDDING_BASE_URL', 'http://127.0.0.1:11434/v1');
|
||||||
|
ensureEnvValue('EMBEDDING_MODEL', 'qwen3-embedding:0.6b');
|
||||||
|
ensureEnvValue('EMBEDDING_DIMENSIONS', '1024');
|
||||||
|
ensureEnvValue('VECTOR_BACKEND', 'sqlite-vec');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEmbeddingProfileSelected(env) {
|
||||||
|
if (env.EMBEDDING_PROFILE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error([
|
||||||
|
'Choose an embedding profile before database setup.',
|
||||||
|
'',
|
||||||
|
'The selected embedding model controls sqlite-vec table dimensions:',
|
||||||
|
' OpenAI text-embedding-3-small -> 1536',
|
||||||
|
' Local Qwen qwen3-embedding:0.6b -> 1024',
|
||||||
|
'',
|
||||||
|
'Run one of:',
|
||||||
|
' npm run setup:local -- --profile openai',
|
||||||
|
' npm run setup:local -- --profile qwen-local',
|
||||||
|
'',
|
||||||
|
'If you change embedding provider later, run:',
|
||||||
|
' npm run rebuild:embeddings',
|
||||||
|
].join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
function ensureCoreSchema(db) {
|
function ensureCoreSchema(db) {
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
db.exec(`
|
db.exec(`
|
||||||
@@ -551,20 +622,31 @@ function ensureCoreSchema(db) {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryInitVectorTables(db, dbPath) {
|
function getEmbeddingDimensions(env) {
|
||||||
|
const defaultDimensions = env.EMBEDDING_PROFILE === 'openai-compatible' || env.EMBEDDING_PROFILE === 'custom'
|
||||||
|
? '1024'
|
||||||
|
: '1536';
|
||||||
|
return Number(env.EMBEDDING_DIMENSIONS || defaultDimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryInitVectorTables(db, dbPath, env) {
|
||||||
const extension = process.platform === 'darwin' ? 'dylib' : process.platform === 'win32' ? 'dll' : 'so';
|
const 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 = getEmbeddingDimensions(env);
|
||||||
|
if (!Number.isInteger(dimensions) || dimensions <= 0) {
|
||||||
|
throw new Error(`Invalid EMBEDDING_DIMENSIONS="${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}`);
|
||||||
@@ -579,13 +661,19 @@ function main() {
|
|||||||
throw new Error(`Node.js 20+ required (found ${process.version})`);
|
throw new Error(`Node.js 20+ required (found ${process.version})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureEnvFile();
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const setupProfile = normalizeSetupProfile(args.profile);
|
||||||
|
|
||||||
const env = parseEnvFile(targetEnv);
|
ensureEnvFile();
|
||||||
|
applySetupProfile(setupProfile);
|
||||||
|
|
||||||
|
const env = { ...parseEnvFile(targetEnv), ...process.env };
|
||||||
|
assertEmbeddingProfileSelected(env);
|
||||||
if (process.env.SQLITE_DB_PATH) {
|
if (process.env.SQLITE_DB_PATH) {
|
||||||
ensureEnvValue('SQLITE_DB_PATH', process.env.SQLITE_DB_PATH);
|
ensureEnvValue('SQLITE_DB_PATH', process.env.SQLITE_DB_PATH);
|
||||||
|
env.SQLITE_DB_PATH = process.env.SQLITE_DB_PATH;
|
||||||
}
|
}
|
||||||
const dbPath = expandPath(process.env.SQLITE_DB_PATH || env.SQLITE_DB_PATH || getDefaultDbPath());
|
const dbPath = expandPath(env.SQLITE_DB_PATH || getDefaultDbPath());
|
||||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
if (!fs.existsSync(dbPath)) {
|
if (!fs.existsSync(dbPath)) {
|
||||||
fs.closeSync(fs.openSync(dbPath, 'w'));
|
fs.closeSync(fs.openSync(dbPath, 'w'));
|
||||||
@@ -594,7 +682,7 @@ function main() {
|
|||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
try {
|
try {
|
||||||
ensureCoreSchema(db);
|
ensureCoreSchema(db);
|
||||||
tryInitVectorTables(db, dbPath);
|
tryInitVectorTables(db, dbPath, env);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ console.log('[setup-local] Rebuilding better-sqlite3 native bindings');
|
|||||||
run('npm', ['rebuild', 'better-sqlite3']);
|
run('npm', ['rebuild', 'better-sqlite3']);
|
||||||
|
|
||||||
console.log('[setup-local] Bootstrapping local RA-H database');
|
console.log('[setup-local] Bootstrapping local RA-H database');
|
||||||
run('npm', ['run', 'bootstrap:local']);
|
run('npm', ['run', 'bootstrap:local', '--', ...process.argv.slice(2)]);
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('[setup-local] Local app setup complete.');
|
console.log('[setup-local] Local app setup complete.');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||||
|
import { NodeEmbedder } from '@/services/typescript/embed-nodes';
|
||||||
|
import { UniversalEmbedder } from '@/services/typescript/embed-universal';
|
||||||
|
import { getVectorBackendType } from '@/services/vectorBackend';
|
||||||
|
|
||||||
|
async function maybeRecreateQdrantCollections() {
|
||||||
|
if (process.env.VECTOR_BACKEND !== 'qdrant' || process.env.QDRANT_RECREATE_COLLECTIONS !== 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = (process.env.QDRANT_URL || 'http://localhost:6333').replace(/\/+$/, '');
|
||||||
|
const headers = process.env.QDRANT_API_KEY ? { 'api-key': process.env.QDRANT_API_KEY } : undefined;
|
||||||
|
const collections = [
|
||||||
|
process.env.QDRANT_CHUNKS_COLLECTION || 'rah_chunks',
|
||||||
|
process.env.QDRANT_NODES_COLLECTION || 'rah_nodes',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const collection of collections) {
|
||||||
|
const response = await fetch(`${baseUrl}/collections/${encodeURIComponent(collection)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
const detail = await response.text().catch(() => response.statusText);
|
||||||
|
throw new Error(`Failed to delete Qdrant collection ${collection}: ${detail}`);
|
||||||
|
}
|
||||||
|
console.log(`[rebuild-embeddings] Recreated Qdrant collection on next upsert: ${collection}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
await maybeRecreateQdrantCollections();
|
||||||
|
if (getVectorBackendType() === 'sqlite-vec') {
|
||||||
|
sqlite.recreateVectorTables();
|
||||||
|
console.log('[rebuild-embeddings] Recreated sqlite-vec tables for the active embedding dimensions.');
|
||||||
|
}
|
||||||
|
const nodeRows = sqlite.query<{ id: number; source?: string | null }>(`
|
||||||
|
SELECT id, source
|
||||||
|
FROM nodes
|
||||||
|
ORDER BY id
|
||||||
|
`).rows;
|
||||||
|
|
||||||
|
console.log(`[rebuild-embeddings] Rebuilding node embeddings for ${nodeRows.length} nodes`);
|
||||||
|
const nodeEmbedder = new NodeEmbedder();
|
||||||
|
try {
|
||||||
|
await nodeEmbedder.embedNodes({ forceReEmbed: true, verbose: true });
|
||||||
|
} finally {
|
||||||
|
nodeEmbedder.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRows = nodeRows.filter((node) => typeof node.source === 'string' && node.source.trim().length > 0);
|
||||||
|
console.log(`[rebuild-embeddings] Rebuilding chunk embeddings for ${sourceRows.length} nodes with source text`);
|
||||||
|
|
||||||
|
const chunkEmbedder = new UniversalEmbedder();
|
||||||
|
try {
|
||||||
|
let processed = 0;
|
||||||
|
for (const node of sourceRows) {
|
||||||
|
await chunkEmbedder.processNode({ nodeId: node.id, verbose: false });
|
||||||
|
processed += 1;
|
||||||
|
if (processed % 10 === 0 || processed === sourceRows.length) {
|
||||||
|
console.log(`[rebuild-embeddings] Chunked ${processed}/${sourceRows.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
chunkEmbedder.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite.markEmbeddingProfileCurrent();
|
||||||
|
console.log('[rebuild-embeddings] Active embedding/vector profile recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -8,13 +8,18 @@ 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');
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function FirstRunModal() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const dismissed = window.localStorage.getItem(FIRST_RUN_DISMISSED_KEY) === 'true';
|
const dismissed = window.localStorage.getItem(FIRST_RUN_DISMISSED_KEY) === 'true';
|
||||||
setIsOpen(!data.configured && !dismissed);
|
setIsOpen(data.openAiKeyWritable !== false && !data.configured && !dismissed);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const dismissed = window.localStorage.getItem(FIRST_RUN_DISMISSED_KEY) === 'true';
|
const dismissed = window.localStorage.getItem(FIRST_RUN_DISMISSED_KEY) === 'true';
|
||||||
|
|||||||
@@ -3,11 +3,23 @@
|
|||||||
import { useState, useEffect, type CSSProperties } from 'react';
|
import { useState, useEffect, type CSSProperties } from 'react';
|
||||||
import { openExternalUrl } from '@/utils/openExternalUrl';
|
import { openExternalUrl } from '@/utils/openExternalUrl';
|
||||||
|
|
||||||
|
type ActiveProfile = {
|
||||||
|
llmProfile: string;
|
||||||
|
llmModel: string | null;
|
||||||
|
llmBaseUrl: string | null;
|
||||||
|
embeddingProfile: string;
|
||||||
|
embeddingModel: string | null;
|
||||||
|
embeddingBaseUrl: string | null;
|
||||||
|
embeddingDimensions: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ApiKeysViewer() {
|
export default function ApiKeysViewer() {
|
||||||
const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking');
|
const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking');
|
||||||
const [keyInput, setKeyInput] = useState('');
|
const [keyInput, setKeyInput] = useState('');
|
||||||
const [maskedKey, setMaskedKey] = useState<string | null>(null);
|
const [maskedKey, setMaskedKey] = useState<string | null>(null);
|
||||||
const [envPath, setEnvPath] = useState<string>('.env.local');
|
const [envPath, setEnvPath] = useState<string>('.env.local');
|
||||||
|
const [openAiKeyWritable, setOpenAiKeyWritable] = useState(true);
|
||||||
|
const [activeProfile, setActiveProfile] = useState<ActiveProfile | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -19,6 +31,8 @@ export default function ApiKeysViewer() {
|
|||||||
setStatus(data.configured ? 'configured' : 'not-set');
|
setStatus(data.configured ? 'configured' : 'not-set');
|
||||||
setMaskedKey(data.maskedKey ?? null);
|
setMaskedKey(data.maskedKey ?? null);
|
||||||
setEnvPath(data.envPath ?? '.env.local');
|
setEnvPath(data.envPath ?? '.env.local');
|
||||||
|
setOpenAiKeyWritable(data.openAiKeyWritable !== false);
|
||||||
|
setActiveProfile(data.activeProfile ?? null);
|
||||||
})
|
})
|
||||||
.catch(() => setStatus('not-set'));
|
.catch(() => setStatus('not-set'));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -44,6 +58,8 @@ export default function ApiKeysViewer() {
|
|||||||
setStatus('configured');
|
setStatus('configured');
|
||||||
setMaskedKey(payload.maskedKey ?? null);
|
setMaskedKey(payload.maskedKey ?? null);
|
||||||
setEnvPath(payload.envPath ?? envPath);
|
setEnvPath(payload.envPath ?? envPath);
|
||||||
|
setOpenAiKeyWritable(payload.openAiKeyWritable !== false);
|
||||||
|
setActiveProfile(payload.activeProfile ?? activeProfile);
|
||||||
setKeyInput('');
|
setKeyInput('');
|
||||||
setMessage('Saved to .env.local and updated the running app.');
|
setMessage('Saved to .env.local and updated the running app.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -71,6 +87,8 @@ export default function ApiKeysViewer() {
|
|||||||
setMaskedKey(null);
|
setMaskedKey(null);
|
||||||
setKeyInput('');
|
setKeyInput('');
|
||||||
setEnvPath(payload.envPath ?? envPath);
|
setEnvPath(payload.envPath ?? envPath);
|
||||||
|
setOpenAiKeyWritable(payload.openAiKeyWritable !== false);
|
||||||
|
setActiveProfile(payload.activeProfile ?? activeProfile);
|
||||||
setMessage('Removed from .env.local and cleared the running app key.');
|
setMessage('Removed from .env.local and cleared the running app key.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to remove API key');
|
setError(err instanceof Error ? err.message : 'Failed to remove API key');
|
||||||
@@ -81,8 +99,38 @@ export default function ApiKeysViewer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
|
{activeProfile && (
|
||||||
|
<div style={profileBoxStyle}>
|
||||||
|
<div style={featuresHeaderStyle}>
|
||||||
|
{openAiKeyWritable ? 'OpenAI profile active' : 'Local model profile active'}
|
||||||
|
</div>
|
||||||
|
<div style={profileGridStyle}>
|
||||||
|
<div>
|
||||||
|
<div style={profileLabelStyle}>Utility LLM</div>
|
||||||
|
<code style={codeInlineStyle}>{activeProfile.llmProfile}</code>
|
||||||
|
{activeProfile.llmModel && <span style={profileValueStyle}> {activeProfile.llmModel}</span>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={profileLabelStyle}>Embeddings</div>
|
||||||
|
<code style={codeInlineStyle}>{activeProfile.embeddingProfile}</code>
|
||||||
|
{activeProfile.embeddingModel && <span style={profileValueStyle}> {activeProfile.embeddingModel}</span>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={profileLabelStyle}>Embedding width</div>
|
||||||
|
<span style={profileValueStyle}>{activeProfile.embeddingDimensions}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!openAiKeyWritable && (
|
||||||
|
<p style={profileNoteStyle}>
|
||||||
|
This install is already configured for local models. OpenAI key entry is disabled here so the app does not drift away from the setup profile. To switch providers, edit <code style={codeInlineStyle}>{envPath}</code> and run <code style={codeInlineStyle}>npm run rebuild:embeddings</code>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Features explanation */}
|
{/* Features explanation */}
|
||||||
<div style={featuresBoxStyle}>
|
{openAiKeyWritable && (
|
||||||
|
<div style={featuresBoxStyle}>
|
||||||
<div style={featuresHeaderStyle}>OpenAI API Key enables:</div>
|
<div style={featuresHeaderStyle}>OpenAI API Key enables:</div>
|
||||||
<ul style={featuresListStyle}>
|
<ul style={featuresListStyle}>
|
||||||
<li>Auto-generated descriptions for new nodes</li>
|
<li>Auto-generated descriptions for new nodes</li>
|
||||||
@@ -93,9 +141,11 @@ export default function ApiKeysViewer() {
|
|||||||
Without a key, you can still create and organise nodes manually.
|
Without a key, you can still create and organise nodes manually.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div style={cardStyle}>
|
{openAiKeyWritable ? (
|
||||||
|
<div style={cardStyle}>
|
||||||
<div style={cardHeaderStyle}>
|
<div style={cardHeaderStyle}>
|
||||||
<span style={cardTitleStyle}>OpenAI API Key</span>
|
<span style={cardTitleStyle}>OpenAI API Key</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -178,9 +228,28 @@ export default function ApiKeysViewer() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={cardHeaderStyle}>
|
||||||
|
<span style={cardTitleStyle}>OpenAI API Key</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--settings-muted)' }}>Disabled</span>
|
||||||
|
</div>
|
||||||
|
<div style={instructionsStyle}>
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
This workspace is using the local model profile selected during setup. The app will call the configured OpenAI-compatible local endpoints for descriptions and embeddings instead of using an OpenAI API key.
|
||||||
|
</p>
|
||||||
|
{activeProfile?.llmBaseUrl && (
|
||||||
|
<p style={{ margin: '10px 0 0', fontSize: 12, color: 'var(--settings-muted)' }}>
|
||||||
|
Local endpoint: <code style={codeInlineStyle}>{activeProfile.llmBaseUrl}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Get key link */}
|
{/* Get key link */}
|
||||||
<div style={helpStyle}>
|
{openAiKeyWritable && (
|
||||||
|
<div style={helpStyle}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -194,6 +263,7 @@ export default function ApiKeysViewer() {
|
|||||||
Get your API key from OpenAI →
|
Get your API key from OpenAI →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -212,6 +282,38 @@ const featuresBoxStyle: CSSProperties = {
|
|||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const profileBoxStyle: CSSProperties = {
|
||||||
|
background: 'var(--settings-card-bg)',
|
||||||
|
border: '1px solid var(--settings-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileGridStyle: CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileLabelStyle: CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--settings-muted)',
|
||||||
|
marginBottom: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileValueStyle: CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--settings-subtext)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileNoteStyle: CSSProperties = {
|
||||||
|
margin: '12px 0 0',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--settings-muted)',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
};
|
||||||
|
|
||||||
const featuresHeaderStyle: CSSProperties = {
|
const featuresHeaderStyle: CSSProperties = {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>(`
|
||||||
|
|||||||
@@ -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}"`);
|
||||||
|
|
||||||
|
|||||||
@@ -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 = (() => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -345,6 +346,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]);
|
||||||
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -16,6 +18,7 @@ export interface SQLiteQueryResult<T = any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FtsSurfaceName = 'nodes' | 'chunks';
|
type FtsSurfaceName = 'nodes' | 'chunks';
|
||||||
|
type VectorTableName = 'vec_nodes' | 'vec_chunks';
|
||||||
|
|
||||||
interface IntegrityProbeResult {
|
interface IntegrityProbeResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -43,6 +46,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,33 +283,83 @@ class SQLiteClient {
|
|||||||
|
|
||||||
public ensureVectorExtensions(): void {
|
public ensureVectorExtensions(): void {
|
||||||
try {
|
try {
|
||||||
// Test for vec_nodes and vec_chunks; create them if missing
|
const dimensions = getEmbeddingProviderInfo().dimensions;
|
||||||
const hasVecNodes = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_nodes');
|
this.ensureVectorTable('vec_nodes', dimensions);
|
||||||
if (!hasVecNodes) {
|
this.ensureVectorTable('vec_chunks', dimensions);
|
||||||
this.db.exec(`
|
|
||||||
CREATE VIRTUAL TABLE vec_nodes USING vec0(
|
|
||||||
node_id INTEGER PRIMARY KEY,
|
|
||||||
embedding FLOAT[1536]
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
console.log('Created vec_nodes virtual table');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasVecChunks = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_chunks');
|
|
||||||
if (!hasVecChunks) {
|
|
||||||
this.db.exec(`
|
|
||||||
CREATE VIRTUAL TABLE vec_chunks USING vec0(
|
|
||||||
chunk_id INTEGER PRIMARY KEY,
|
|
||||||
embedding FLOAT[1536]
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
console.log('Created vec_chunks virtual table');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Vector extension not available:', error);
|
console.warn('Vector extension not available:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getVectorTableSql(tableName: VectorTableName): string | null {
|
||||||
|
const row = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name=?")
|
||||||
|
.get(tableName) as { sql?: string } | undefined;
|
||||||
|
return row?.sql || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getVectorTableDimensions(): Record<'nodes' | 'chunks', number | null> {
|
||||||
|
return {
|
||||||
|
nodes: this.getVectorTableDimension('vec_nodes'),
|
||||||
|
chunks: this.getVectorTableDimension('vec_chunks'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVectorTableDimension(tableName: VectorTableName): number | null {
|
||||||
|
const sql = this.getVectorTableSql(tableName);
|
||||||
|
const match = sql?.match(/embedding\s+FLOAT\[(\d+)\]/i);
|
||||||
|
return match ? Number(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createVectorTable(tableName: VectorTableName, dimensions: number): void {
|
||||||
|
const idColumn = tableName === 'vec_nodes' ? 'node_id' : 'chunk_id';
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE VIRTUAL TABLE ${tableName} USING vec0(
|
||||||
|
${idColumn} INTEGER PRIMARY KEY,
|
||||||
|
embedding FLOAT[${dimensions}]
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureVectorTable(tableName: VectorTableName, dimensions: number): void {
|
||||||
|
const existingSql = this.getVectorTableSql(tableName);
|
||||||
|
if (!existingSql) {
|
||||||
|
this.createVectorTable(tableName, dimensions);
|
||||||
|
console.log(`Created ${tableName} virtual table`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingDimensions = this.getVectorTableDimension(tableName);
|
||||||
|
if (existingDimensions === dimensions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowCount = this.countVectorRows(tableName);
|
||||||
|
if (rowCount === 0 || this.maintenanceMode) {
|
||||||
|
this.db.exec(`DROP TABLE IF EXISTS ${tableName};`);
|
||||||
|
this.createVectorTable(tableName, dimensions);
|
||||||
|
console.warn(`Recreated ${tableName} virtual table for ${dimensions} dimensions (was ${existingDimensions ?? 'unknown'}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`${tableName} uses ${existingDimensions ?? 'unknown'} dimensions, but the active embedding profile requires ${dimensions}. ` +
|
||||||
|
'Run npm run rebuild:embeddings to recreate derived vectors.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public recreateVectorTables(): void {
|
||||||
|
if (this.readOnly) {
|
||||||
|
throw new Error('Cannot recreate vector tables while SQLITE_READONLY=true.');
|
||||||
|
}
|
||||||
|
const dimensions = getEmbeddingProviderInfo().dimensions;
|
||||||
|
this.db.exec(`
|
||||||
|
DROP TABLE IF EXISTS vec_nodes;
|
||||||
|
DROP TABLE IF EXISTS vec_chunks;
|
||||||
|
`);
|
||||||
|
this.createVectorTable('vec_nodes', dimensions);
|
||||||
|
this.createVectorTable('vec_chunks', dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
private ensureVectorTables(): void {
|
private ensureVectorTables(): void {
|
||||||
if (this.readOnly) {
|
if (this.readOnly) {
|
||||||
return;
|
return;
|
||||||
@@ -353,6 +428,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 +1037,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 +1314,86 @@ class SQLiteClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getEmbeddingProfileStatus(): EmbeddingProfileStatus {
|
||||||
|
const embedding = getEmbeddingProviderInfo();
|
||||||
|
const active = {
|
||||||
|
profile: embedding.profile,
|
||||||
|
model: embedding.model,
|
||||||
|
dimensions: embedding.dimensions,
|
||||||
|
vector_backend: getVectorBackendType(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stored = this.db.prepare(`
|
||||||
|
SELECT profile, model, dimensions, vector_backend, updated_at
|
||||||
|
FROM embedding_profile_state
|
||||||
|
WHERE id = 1
|
||||||
|
`).get() as EmbeddingProfileStatus['stored'] | undefined;
|
||||||
|
|
||||||
|
const nodes = this.countVectorRows('vec_nodes');
|
||||||
|
const chunks = this.countVectorRows('vec_chunks');
|
||||||
|
const hasVectors = nodes > 0 || chunks > 0;
|
||||||
|
|
||||||
|
let rebuildRequired = false;
|
||||||
|
let reason: string | undefined;
|
||||||
|
|
||||||
|
if (!stored && hasVectors) {
|
||||||
|
rebuildRequired = true;
|
||||||
|
reason = 'Existing vectors do not have recorded provider/model/dimension metadata.';
|
||||||
|
} else if (stored) {
|
||||||
|
const mismatches = [
|
||||||
|
stored.profile !== active.profile ? `profile ${stored.profile} -> ${active.profile}` : '',
|
||||||
|
stored.model !== active.model ? `model ${stored.model} -> ${active.model}` : '',
|
||||||
|
Number(stored.dimensions) !== active.dimensions ? `dimensions ${stored.dimensions} -> ${active.dimensions}` : '',
|
||||||
|
stored.vector_backend !== active.vector_backend ? `backend ${stored.vector_backend} -> ${active.vector_backend}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (mismatches.length > 0) {
|
||||||
|
rebuildRequired = hasVectors;
|
||||||
|
reason = `Embedding/vector profile changed (${mismatches.join(', ')}). Rebuild embeddings before semantic search.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
stored: stored || null,
|
||||||
|
vectors: { nodes, chunks },
|
||||||
|
rebuild_required: rebuildRequired,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public markEmbeddingProfileCurrent(): void {
|
||||||
|
if (this.readOnly) return;
|
||||||
|
const embedding = getEmbeddingProviderInfo();
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO embedding_profile_state (id, profile, model, dimensions, vector_backend, updated_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
profile = excluded.profile,
|
||||||
|
model = excluded.model,
|
||||||
|
dimensions = excluded.dimensions,
|
||||||
|
vector_backend = excluded.vector_backend,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run(
|
||||||
|
embedding.profile,
|
||||||
|
embedding.model,
|
||||||
|
embedding.dimensions,
|
||||||
|
getVectorBackendType(),
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private countVectorRows(tableName: VectorTableName): number {
|
||||||
|
try {
|
||||||
|
const exists = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(tableName);
|
||||||
|
if (!exists) return 0;
|
||||||
|
const row = this.db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get() as { count?: number } | undefined;
|
||||||
|
return Number(row?.count ?? 0);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public getIntegrityReport(forceRefresh = false): DatabaseIntegrityReport {
|
public getIntegrityReport(forceRefresh = false): DatabaseIntegrityReport {
|
||||||
if (!this.integrityReport || forceRefresh) {
|
if (!this.integrityReport || forceRefresh) {
|
||||||
this.integrityReport = this.inspectIntegrity();
|
this.integrityReport = this.inspectIntegrity();
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
try {
|
||||||
const chunkIds = this.db.prepare('SELECT id FROM chunks WHERE node_id = ?').all(nodeId) as Array<{ id: number }>;
|
const vectorBackend = await getVectorBackend();
|
||||||
|
await vectorBackend.deleteChunksByNode(nodeId);
|
||||||
// Delete from vec_chunks first, one by one to ensure they're removed
|
} catch (error) {
|
||||||
for (const chunk of chunkIds) {
|
console.warn(`Could not delete existing chunk vectors for node ${nodeId}:`, error);
|
||||||
try {
|
|
||||||
const deleteVecStmt = this.db.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?');
|
|
||||||
deleteVecStmt.run(BigInt(chunk.id));
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Could not delete vec_chunk ${chunk.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { getEmbeddingProviderInfo } from '@/services/embedding/provider';
|
||||||
|
import type { RankedChunk, RankedNode, VectorBackend, VectorBackendHealth } from './index';
|
||||||
|
|
||||||
|
interface QdrantPoint {
|
||||||
|
id: number;
|
||||||
|
score?: number;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdrantUrl(path: string): string {
|
||||||
|
const base = (process.env.QDRANT_URL || 'http://localhost:6333').replace(/\/+$/, '');
|
||||||
|
return `${base}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKeyHeader(): Record<string, string> {
|
||||||
|
return process.env.QDRANT_API_KEY ? { 'api-key': process.env.QDRANT_API_KEY } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function qdrantFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(qdrantUrl(path), {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...getApiKeyHeader(),
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => response.statusText);
|
||||||
|
throw new Error(`Qdrant ${response.status} ${response.statusText}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadNumber(payload: Record<string, unknown> | undefined, key: string, fallback: number): number {
|
||||||
|
const value = payload?.[key];
|
||||||
|
return typeof value === 'number' ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadString(payload: Record<string, unknown> | undefined, key: string): string {
|
||||||
|
const value = payload?.[key];
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QdrantBackend implements VectorBackend {
|
||||||
|
private readonly chunksCollection = process.env.QDRANT_CHUNKS_COLLECTION || 'rah_chunks';
|
||||||
|
private readonly nodesCollection = process.env.QDRANT_NODES_COLLECTION || 'rah_nodes';
|
||||||
|
private readonly dimensions = getEmbeddingProviderInfo().dimensions;
|
||||||
|
private ensured = new Set<string>();
|
||||||
|
|
||||||
|
private async ensureCollection(name: string): Promise<void> {
|
||||||
|
if (this.ensured.has(name)) return;
|
||||||
|
|
||||||
|
const existing = await fetch(qdrantUrl(`/collections/${encodeURIComponent(name)}`), {
|
||||||
|
headers: getApiKeyHeader(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.status === 404) {
|
||||||
|
await qdrantFetch(`/collections/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
vectors: {
|
||||||
|
size: this.dimensions,
|
||||||
|
distance: 'Cosine',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
this.ensured.add(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.ok) {
|
||||||
|
const detail = await existing.text().catch(() => existing.statusText);
|
||||||
|
throw new Error(`Qdrant ${existing.status} ${existing.statusText}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await existing.json() as {
|
||||||
|
result?: { config?: { params?: { vectors?: { size?: number } | Record<string, { size?: number }> } } };
|
||||||
|
};
|
||||||
|
const vectors = data.result?.config?.params?.vectors;
|
||||||
|
const size = typeof vectors?.size === 'number'
|
||||||
|
? vectors.size
|
||||||
|
: vectors && typeof vectors === 'object'
|
||||||
|
? Object.values(vectors).find((value) => typeof value?.size === 'number')?.size
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (typeof size === 'number' && size !== this.dimensions) {
|
||||||
|
throw new Error(
|
||||||
|
`Qdrant collection ${name} has ${size} dimensions, but active embedding profile requires ${this.dimensions}. ` +
|
||||||
|
'Run the embedding rebuild script after changing embedding providers.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ensured.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertChunk(chunkId: number, nodeId: number, chunkIndex: number, text: string, embedding: number[]): Promise<void> {
|
||||||
|
await this.ensureCollection(this.chunksCollection);
|
||||||
|
await qdrantFetch(`/collections/${encodeURIComponent(this.chunksCollection)}/points?wait=true`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
points: [{
|
||||||
|
id: chunkId,
|
||||||
|
vector: embedding,
|
||||||
|
payload: { chunkId, nodeId, chunkIndex, text },
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertNode(nodeId: number, text: string, embedding: number[]): Promise<void> {
|
||||||
|
await this.ensureCollection(this.nodesCollection);
|
||||||
|
await qdrantFetch(`/collections/${encodeURIComponent(this.nodesCollection)}/points?wait=true`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
points: [{
|
||||||
|
id: nodeId,
|
||||||
|
vector: embedding,
|
||||||
|
payload: { nodeId, text },
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchChunks(
|
||||||
|
queryEmbedding: number[],
|
||||||
|
similarityThreshold: number,
|
||||||
|
limit: number,
|
||||||
|
nodeIds?: number[]
|
||||||
|
): Promise<RankedChunk[]> {
|
||||||
|
await this.ensureCollection(this.chunksCollection);
|
||||||
|
const filter = nodeIds && nodeIds.length > 0
|
||||||
|
? {
|
||||||
|
must: [{
|
||||||
|
key: 'nodeId',
|
||||||
|
match: { any: nodeIds },
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const response = await qdrantFetch<{ result: QdrantPoint[] }>(
|
||||||
|
`/collections/${encodeURIComponent(this.chunksCollection)}/points/search`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
vector: queryEmbedding,
|
||||||
|
limit,
|
||||||
|
score_threshold: similarityThreshold,
|
||||||
|
with_payload: true,
|
||||||
|
filter,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.result.map((point) => {
|
||||||
|
const payload = point.payload;
|
||||||
|
return {
|
||||||
|
id: payloadNumber(payload, 'chunkId', Number(point.id)),
|
||||||
|
node_id: payloadNumber(payload, 'nodeId', 0),
|
||||||
|
chunk_idx: payloadNumber(payload, 'chunkIndex', 0),
|
||||||
|
text: payloadString(payload, 'text'),
|
||||||
|
embedding_type: getEmbeddingProviderInfo().model,
|
||||||
|
created_at: '',
|
||||||
|
similarity: Number(point.score ?? 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchNodes(queryEmbedding: number[], limit: number): Promise<RankedNode[]> {
|
||||||
|
await this.ensureCollection(this.nodesCollection);
|
||||||
|
const response = await qdrantFetch<{ result: QdrantPoint[] }>(
|
||||||
|
`/collections/${encodeURIComponent(this.nodesCollection)}/points/search`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
vector: queryEmbedding,
|
||||||
|
limit,
|
||||||
|
with_payload: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.result.map((point) => ({
|
||||||
|
nodeId: payloadNumber(point.payload, 'nodeId', Number(point.id)),
|
||||||
|
score: Number(point.score ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChunksByNode(nodeId: number): Promise<void> {
|
||||||
|
await this.ensureCollection(this.chunksCollection);
|
||||||
|
await qdrantFetch(`/collections/${encodeURIComponent(this.chunksCollection)}/points/delete?wait=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
filter: {
|
||||||
|
must: [{ key: 'nodeId', match: { value: nodeId } }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNode(nodeId: number): Promise<void> {
|
||||||
|
await this.deleteChunksByNode(nodeId);
|
||||||
|
await this.ensureCollection(this.nodesCollection);
|
||||||
|
await qdrantFetch(`/collections/${encodeURIComponent(this.nodesCollection)}/points/delete?wait=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
points: [nodeId],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<VectorBackendHealth> {
|
||||||
|
try {
|
||||||
|
await qdrantFetch('/collections', { method: 'GET' });
|
||||||
|
await this.ensureCollection(this.chunksCollection);
|
||||||
|
await this.ensureCollection(this.nodesCollection);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
backend: 'qdrant',
|
||||||
|
dimensions: this.dimensions,
|
||||||
|
detail: `Qdrant reachable at ${process.env.QDRANT_URL || 'http://localhost:6333'}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
backend: 'qdrant',
|
||||||
|
dimensions: this.dimensions,
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||||
|
import { getEmbeddingProviderInfo } from '@/services/embedding/provider';
|
||||||
|
import type { Chunk } from '@/types/database';
|
||||||
|
import type { RankedChunk, RankedNode, VectorBackend, VectorBackendHealth } from './index';
|
||||||
|
|
||||||
|
function vectorLiteral(embedding: number[]): string {
|
||||||
|
return `[${embedding.join(',')}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SqliteVecBackend implements VectorBackend {
|
||||||
|
async upsertChunk(chunkId: number, _nodeId: number, _chunkIndex: number, _text: string, embedding: number[]): Promise<void> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
sqlite.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?').run(BigInt(chunkId));
|
||||||
|
sqlite.prepare('INSERT OR REPLACE INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)').run(BigInt(chunkId), vectorLiteral(embedding));
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertNode(nodeId: number, _text: string, embedding: number[]): Promise<void> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
sqlite.prepare('DELETE FROM vec_nodes WHERE node_id = ?').run(BigInt(nodeId));
|
||||||
|
sqlite.prepare('INSERT OR REPLACE INTO vec_nodes (node_id, embedding) VALUES (?, ?)').run(BigInt(nodeId), vectorLiteral(embedding));
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchChunks(
|
||||||
|
queryEmbedding: number[],
|
||||||
|
similarityThreshold: number,
|
||||||
|
limit: number,
|
||||||
|
nodeIds?: number[]
|
||||||
|
): Promise<RankedChunk[]> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
const vectorLimit = Math.max(limit * 10, 50);
|
||||||
|
|
||||||
|
if (nodeIds && nodeIds.length > 0) {
|
||||||
|
const result = sqlite.query<RankedChunk>(`
|
||||||
|
SELECT c.*, (1.0 / (1.0 + v.distance)) AS similarity
|
||||||
|
FROM (
|
||||||
|
SELECT chunk_id, distance
|
||||||
|
FROM vec_chunks
|
||||||
|
WHERE embedding MATCH ?
|
||||||
|
ORDER BY distance
|
||||||
|
LIMIT ?
|
||||||
|
) v
|
||||||
|
JOIN chunks c ON c.id = v.chunk_id
|
||||||
|
WHERE c.node_id IN (${nodeIds.map(() => '?').join(',')})
|
||||||
|
AND (1.0 / (1.0 + v.distance)) >= ?
|
||||||
|
ORDER BY similarity DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, [vectorLiteral(queryEmbedding), vectorLimit, ...nodeIds, similarityThreshold, limit]);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = sqlite.query<RankedChunk>(`
|
||||||
|
WITH vector_results AS (
|
||||||
|
SELECT chunk_id, distance
|
||||||
|
FROM vec_chunks
|
||||||
|
WHERE embedding MATCH ?
|
||||||
|
ORDER BY distance
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
SELECT c.*, (1.0 / (1.0 + vr.distance)) AS similarity
|
||||||
|
FROM vector_results vr
|
||||||
|
JOIN chunks c ON c.id = vr.chunk_id
|
||||||
|
WHERE (1.0 / (1.0 + vr.distance)) >= ?
|
||||||
|
ORDER BY similarity DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, [vectorLiteral(queryEmbedding), vectorLimit, similarityThreshold, limit]);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchNodes(queryEmbedding: number[], limit: number): Promise<RankedNode[]> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
const result = sqlite.query<{ node_id: number; distance: number }>(`
|
||||||
|
SELECT node_id, distance
|
||||||
|
FROM vec_nodes
|
||||||
|
WHERE embedding MATCH ?
|
||||||
|
ORDER BY distance
|
||||||
|
LIMIT ?
|
||||||
|
`, [vectorLiteral(queryEmbedding), Math.max(limit * 2, 50)]);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
nodeId: Number(row.node_id),
|
||||||
|
score: 1.0 / (1.0 + Number(row.distance)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChunksByNode(nodeId: number): Promise<void> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
const chunks = sqlite.query<Pick<Chunk, 'id'>>('SELECT id FROM chunks WHERE node_id = ?', [nodeId]).rows;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
sqlite.prepare('DELETE FROM vec_chunks WHERE chunk_id = ?').run(BigInt(chunk.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNode(nodeId: number): Promise<void> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
await this.deleteChunksByNode(nodeId);
|
||||||
|
sqlite.prepare('DELETE FROM vec_nodes WHERE node_id = ?').run(BigInt(nodeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<VectorBackendHealth> {
|
||||||
|
const sqlite = getSQLiteClient();
|
||||||
|
const ok = await sqlite.checkVectorExtension();
|
||||||
|
const expectedDimensions = getEmbeddingProviderInfo().dimensions;
|
||||||
|
const tableDimensions = sqlite.getVectorTableDimensions();
|
||||||
|
const mismatchedTables = Object.entries(tableDimensions)
|
||||||
|
.filter(([, dimensions]) => dimensions !== null && dimensions !== expectedDimensions)
|
||||||
|
.map(([table, dimensions]) => `${table}=${dimensions}`);
|
||||||
|
|
||||||
|
if (mismatchedTables.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
backend: 'sqlite-vec',
|
||||||
|
dimensions: expectedDimensions,
|
||||||
|
detail: `sqlite-vec table dimensions mismatch (${mismatchedTables.join(', ')}), expected ${expectedDimensions}. Run npm run rebuild:embeddings.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
backend: 'sqlite-vec',
|
||||||
|
dimensions: expectedDimensions,
|
||||||
|
detail: ok ? 'sqlite-vec extension loaded' : 'sqlite-vec extension unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user