astro-content-vectors 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +175 -2
- package/dist/astro.d.ts +3 -0
- package/dist/astro.js +57 -0
- package/dist/cache.d.ts +20 -0
- package/dist/cache.js +64 -0
- package/dist/embedder.d.ts +2 -0
- package/dist/embedder.js +86 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +85 -0
- package/dist/logging.d.ts +2 -0
- package/dist/logging.js +10 -0
- package/dist/provider.d.ts +4 -0
- package/dist/provider.js +51 -0
- package/dist/related-posts.d.ts +16 -0
- package/dist/related-posts.js +38 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +104 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +1 -0
- package/package.json +110 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 h93xV2
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,176 @@
|
|
|
1
|
-
#
|
|
1
|
+
# astro-content-vectors
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An Astro integration that ranks entries in a content collection by embedding similarity. It uses OpenAI when `OPENAI_API_KEY` is available and automatically falls back to a local LM Studio server when the key is missing or OpenAI is unreachable.
|
|
4
|
+
|
|
5
|
+
Requires Astro 7 or later and Node.js 20 or later for the build process.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install astro-content-vectors
|
|
11
|
+
# or: pnpm add astro-content-vectors
|
|
12
|
+
# or: bun add astro-content-vectors
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Add the integration in `astro.config.mjs`:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { defineConfig } from "astro/config";
|
|
19
|
+
import contentVectors from "astro-content-vectors";
|
|
20
|
+
|
|
21
|
+
export default defineConfig({
|
|
22
|
+
integrations: [
|
|
23
|
+
contentVectors({
|
|
24
|
+
collections: ["blog"],
|
|
25
|
+
provider: {
|
|
26
|
+
type: "openai",
|
|
27
|
+
model: "text-embedding-3-small",
|
|
28
|
+
},
|
|
29
|
+
environments: {
|
|
30
|
+
development: {
|
|
31
|
+
provider: {
|
|
32
|
+
type: "lmstudio",
|
|
33
|
+
// Match the model name loaded in LM Studio.
|
|
34
|
+
baseUrl: "http://127.0.0.1:1234/v1",
|
|
35
|
+
model: "text-embedding-nomic-embed-text-v1.5",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
production: {
|
|
39
|
+
provider: { model: "text-embedding-3-small" },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
fallback: {
|
|
43
|
+
baseUrl: "http://127.0.0.1:1234/v1",
|
|
44
|
+
model: "text-embedding-nomic-embed-text-v1.5",
|
|
45
|
+
},
|
|
46
|
+
threshold: 0.65,
|
|
47
|
+
limit: 3,
|
|
48
|
+
logLevel: "info",
|
|
49
|
+
// Set true to fail the Astro build if neither provider works.
|
|
50
|
+
failOnError: false,
|
|
51
|
+
}),
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Set `OPENAI_API_KEY` in the environment where the Astro build runs. The integration detects it automatically and never puts the key into client bundles or injected configuration. Without the key, it tries the configured LM Studio fallback. If an OpenAI request fails, it tries the fallback once. If both providers fail, it logs a warning and returns no recommendations by default; set `failOnError: true` to fail the build instead. Start LM Studio's local server and load an embedding model for the fallback to work.
|
|
57
|
+
|
|
58
|
+
Astro `dev` uses the `development` override; `astro build` uses `production`. The integration selects an override using Astro's `import.meta.env.MODE`, so custom modes such as `astro build --mode preview` work as environment keys too. Set `environment` to explicitly override the detected mode. Each environment may override the single default `provider`; any omitted settings inherit from the default. OpenAI environments use the default LM Studio fallback; configure it with the top-level `fallback` option or set `fallback: false` to disable it. An environment configured with LM Studio uses only that provider.
|
|
59
|
+
|
|
60
|
+
### Provider configuration
|
|
61
|
+
|
|
62
|
+
Both providers use the OpenAI embeddings API format. OpenAI uses `https://api.openai.com/v1` by default; LM Studio uses `http://127.0.0.1:1234/v1`. Set `baseUrl` when your server listens elsewhere, and set `model` to a model available to that provider. LM Studio defaults to `text-embedding-nomic-embed-text-v1.5`; OpenAI defaults to `text-embedding-3-small`.
|
|
63
|
+
|
|
64
|
+
| Provider setting | Description |
|
|
65
|
+
| ---------------- | ---------------------------------------------------------- |
|
|
66
|
+
| `type` | `"openai"` or `"lmstudio"`. Defaults to `"openai"`. |
|
|
67
|
+
| `baseUrl` | OpenAI-compatible API base URL. Defaults depend on `type`. |
|
|
68
|
+
| `model` | Embedding model name. Defaults depend on `type`. |
|
|
69
|
+
| `batchSize` | Content entries per embedding request. Defaults to `32`. |
|
|
70
|
+
|
|
71
|
+
The OpenAI SDK sends one request per batch, does not retry failed requests, and times out a request after 60 seconds. A failed OpenAI request can then use the configured LM Studio fallback.
|
|
72
|
+
|
|
73
|
+
### Integration options
|
|
74
|
+
|
|
75
|
+
| Option | Default | Description |
|
|
76
|
+
| ------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
77
|
+
| `collections` | all collections | Optional allowlist of Astro content collections to index. |
|
|
78
|
+
| `provider` | OpenAI | Default provider and its settings. |
|
|
79
|
+
| `environments` | `{}` | Provider overrides keyed by environment name. |
|
|
80
|
+
| `environment` | `"development"` for `astro dev`; `"production"` for `astro build` | Explicit environment key to select an override. |
|
|
81
|
+
| `fallback` | LM Studio | Fallback settings for an OpenAI provider. Set to `false` to disable fallback. |
|
|
82
|
+
| `threshold` | `0.65` | Minimum cosine similarity score for a recommendation. |
|
|
83
|
+
| `limit` | `3` | Maximum recommendations returned per entry. |
|
|
84
|
+
| `logLevel` | `"warn"` | One of `"silent"`, `"error"`, `"warn"`, `"info"`, or `"debug"`. |
|
|
85
|
+
| `failOnError` | `false` | Throw if no configured provider can generate embeddings. |
|
|
86
|
+
| `cache` | `true` | Store embeddings in SQLite during Astro builds. |
|
|
87
|
+
| `cachePath` | `.astro/embeddings.db` | SQLite file path, relative to the project root. |
|
|
88
|
+
| `outputSimilarityScores` | `false` | Write pairwise similarity score JSON files into the build output. |
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
contentVectors({
|
|
92
|
+
environment: process.env.DEPLOY_ENV ?? "preview",
|
|
93
|
+
provider: { type: "openai", model: "text-embedding-3-small" },
|
|
94
|
+
environments: {
|
|
95
|
+
preview: { provider: { model: "text-embedding-3-large" } },
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Import the Astro-aware helper from the package's `astro` entry point in an Astro component or page frontmatter. Its types are included automatically:
|
|
101
|
+
|
|
102
|
+
```astro
|
|
103
|
+
---
|
|
104
|
+
import { getCollection } from "astro:content";
|
|
105
|
+
import { getRelatedContent } from "astro-content-vectors/astro";
|
|
106
|
+
|
|
107
|
+
const post = Astro.props.post;
|
|
108
|
+
const recommendations = await getRelatedContent("blog", post.id);
|
|
109
|
+
const posts = await getCollection("blog");
|
|
110
|
+
const relatedPosts = recommendations
|
|
111
|
+
.map(({ id }) => posts.find((entry) => entry.id === id))
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
{relatedPosts.map((entry) => <a href={`/blog/${entry.id}/`}>{entry.data.title}</a>)}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Each collection is indexed once per server/build process. The integration embeds each entry's title, description (if any), and Markdown body. `getRelatedContent(collection, id)` returns an array of `{ id, score }` objects, ordered by descending cosine similarity. The current entry is excluded from its own recommendations. Entries scoring below `threshold` are omitted. Set `logLevel` to `silent` to disable logs.
|
|
119
|
+
|
|
120
|
+
By default, generated vectors are cached in `.astro/embeddings.db` using SQLite. The cache is keyed by collection, entry ID, provider, and model, and refreshes an entry when its source text changes. Set `cache: false` to disable it or `cachePath` to choose another database file.
|
|
121
|
+
|
|
122
|
+
The integration uses the OpenAI SDK for embedding requests. LM Studio works through its OpenAI-compatible API. The runtime API is also exported for custom content loaders:
|
|
123
|
+
|
|
124
|
+
The runtime helper accepts documents with an `id` and optional `title`, `text`, and `body` fields. Its configuration requires a provider `baseUrl` and `model`; SQLite caching is disabled for direct runtime calls unless `cache: true` is passed.
|
|
125
|
+
|
|
126
|
+
To inspect how your content compares while tuning `threshold`, enable the optional score report:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
contentVectors({
|
|
130
|
+
outputSimilarityScores: true,
|
|
131
|
+
threshold: 0.7,
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
During a static build, this writes one JSON file per accessed collection to `.astro/content-vector-similarities/`, alongside the default embeddings cache. Each file contains every unique pair of entry IDs and its cosine similarity score, including scores below the configured threshold. The option is off by default. The reports stay out of `dist` and are not included in the deployed site unless you copy them there yourself.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { getRelatedContent } from "astro-content-vectors/runtime";
|
|
139
|
+
|
|
140
|
+
const index = await getRelatedContent(documents, {
|
|
141
|
+
provider: {
|
|
142
|
+
baseUrl: "http://127.0.0.1:1234/v1",
|
|
143
|
+
model: "your-loaded-embedding-model",
|
|
144
|
+
},
|
|
145
|
+
cache: true,
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Static builds on Cloudflare
|
|
150
|
+
|
|
151
|
+
Cloudflare Secrets Store values cannot be retrieved through Cloudflare's management API or dashboard after they are stored. Its `get()` binding is available to a running Worker, so it cannot provide the OpenAI key to a regular static build. See [Secrets Store management](https://developers.cloudflare.com/secrets-store/manage-secrets/) and the [Workers binding guide](https://developers.cloudflare.com/secrets-store/integrations/workers/).
|
|
152
|
+
|
|
153
|
+
For a static build, configure `OPENAI_API_KEY` as a protected build environment variable instead. For example, in GitHub Actions:
|
|
154
|
+
|
|
155
|
+
```yaml
|
|
156
|
+
- name: Build Astro site
|
|
157
|
+
run: bun run build
|
|
158
|
+
env:
|
|
159
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The integration picks up this variable automatically. Keep it in the CI or hosting provider's secret settings; do not place its value in Astro config or commit it to the repository.
|
|
163
|
+
|
|
164
|
+
## Development
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
bun install
|
|
168
|
+
bun run check
|
|
169
|
+
bun run typecheck
|
|
170
|
+
bun run test
|
|
171
|
+
bun run test:package
|
|
172
|
+
bun run test:astro-install
|
|
173
|
+
bun run build
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`bun run test` runs the unit and integration suites. `bun run test:package` builds the package and checks its exports. `bun run test:astro-install` packs and installs the package into a temporary Astro 7 fixture, then runs a static build against a local mock embeddings server.
|
package/dist/astro.d.ts
ADDED
package/dist/astro.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/// <reference types="astro/client" />
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getRelatedContent as buildRelatedContent } from "./runtime.js";
|
|
6
|
+
const { environments, environment, defaultEnvironment, outputSimilarityScores, outputDirectory, } = __ASTRO_CONTENT_VECTORS_CONFIG__;
|
|
7
|
+
const viteEnvironment = import.meta.env?.MODE;
|
|
8
|
+
const activeEnvironment = environment ?? viteEnvironment ?? defaultEnvironment;
|
|
9
|
+
const selectedConfig = environments[activeEnvironment] ?? environments[defaultEnvironment];
|
|
10
|
+
if (!selectedConfig?.provider) {
|
|
11
|
+
throw new Error(`astro-content-vectors has no providers configured for environment: ${activeEnvironment}`);
|
|
12
|
+
}
|
|
13
|
+
const config = selectedConfig;
|
|
14
|
+
const indexes = new Map();
|
|
15
|
+
const optionalString = (value) => typeof value === "string" ? value : undefined;
|
|
16
|
+
/** Return ranked related entries from an Astro content collection. */
|
|
17
|
+
export async function getRelatedContent(collection, id) {
|
|
18
|
+
if (config.collections && !config.collections.includes(collection)) {
|
|
19
|
+
throw new Error(`Collection is not enabled in astro-content-vectors: ${collection}`);
|
|
20
|
+
}
|
|
21
|
+
let pending = indexes.get(collection);
|
|
22
|
+
if (!pending) {
|
|
23
|
+
const created = getCollection(collection).then((entries) => {
|
|
24
|
+
const documents = entries.map((entry) => ({
|
|
25
|
+
id: entry.id,
|
|
26
|
+
title: optionalString(entry.data.title),
|
|
27
|
+
text: optionalString(entry.data.description),
|
|
28
|
+
body: entry.body ?? "",
|
|
29
|
+
}));
|
|
30
|
+
const reportConfig = {
|
|
31
|
+
...config,
|
|
32
|
+
cacheNamespace: collection,
|
|
33
|
+
};
|
|
34
|
+
if (outputSimilarityScores) {
|
|
35
|
+
reportConfig.onSimilarityScores = async (pairs) => {
|
|
36
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
37
|
+
const file = join(outputDirectory, `${encodeURIComponent(collection)}.json`);
|
|
38
|
+
const report = {
|
|
39
|
+
collection,
|
|
40
|
+
threshold: config.threshold ?? 0.65,
|
|
41
|
+
pairs,
|
|
42
|
+
};
|
|
43
|
+
await writeFile(file, `${JSON.stringify(report, null, 2)}\n`);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return buildRelatedContent(documents, reportConfig);
|
|
47
|
+
});
|
|
48
|
+
indexes.set(collection, created);
|
|
49
|
+
void created.catch(() => indexes.delete(collection));
|
|
50
|
+
pending = created;
|
|
51
|
+
}
|
|
52
|
+
if (!pending) {
|
|
53
|
+
throw new Error("Failed to create the content-vector index.");
|
|
54
|
+
}
|
|
55
|
+
const index = await pending;
|
|
56
|
+
return index.get(id) ?? [];
|
|
57
|
+
}
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
type EmbeddingsRow = {
|
|
3
|
+
post_id: string;
|
|
4
|
+
post_hash: string;
|
|
5
|
+
embedding: string;
|
|
6
|
+
};
|
|
7
|
+
export type PostEmbeddingCacheEntry = {
|
|
8
|
+
hashedContent: string;
|
|
9
|
+
row: EmbeddingsRow | undefined;
|
|
10
|
+
vector: number[] | undefined;
|
|
11
|
+
};
|
|
12
|
+
/** Open the Astro project's persistent embedding database. */
|
|
13
|
+
export declare const getEmbeddingDatabase: (dbPath?: string) => Database.Database;
|
|
14
|
+
export declare const closeEmbeddingDatabase: (dbPath?: string) => void;
|
|
15
|
+
export declare const initializeEmbeddingTable: (db: Database.Database) => void;
|
|
16
|
+
/** Look up an embedding and compare its stored content hash. */
|
|
17
|
+
export declare const getPostEmbeddingCacheEntry: (db: Database.Database, slug: string, content: string) => PostEmbeddingCacheEntry;
|
|
18
|
+
/** Insert a new vector or replace the vector when its source text changed. */
|
|
19
|
+
export declare const savePostEmbedding: (db: Database.Database, slug: string, hashedContent: string, vector: number[], row: EmbeddingsRow | undefined) => void;
|
|
20
|
+
export {};
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
const databases = new Map();
|
|
6
|
+
/** Open the Astro project's persistent embedding database. */
|
|
7
|
+
export const getEmbeddingDatabase = (dbPath = resolve(process.cwd(), ".astro", "embeddings.db")) => {
|
|
8
|
+
const resolvedPath = resolve(dbPath);
|
|
9
|
+
const existing = databases.get(resolvedPath);
|
|
10
|
+
if (existing)
|
|
11
|
+
return existing;
|
|
12
|
+
const astroDir = dirname(resolvedPath);
|
|
13
|
+
if (!existsSync(astroDir)) {
|
|
14
|
+
mkdirSync(astroDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
const db = new Database(resolvedPath);
|
|
17
|
+
initializeEmbeddingTable(db);
|
|
18
|
+
databases.set(resolvedPath, db);
|
|
19
|
+
return db;
|
|
20
|
+
};
|
|
21
|
+
export const closeEmbeddingDatabase = (dbPath = resolve(process.cwd(), ".astro", "embeddings.db")) => {
|
|
22
|
+
const resolvedPath = resolve(dbPath);
|
|
23
|
+
const db = databases.get(resolvedPath);
|
|
24
|
+
if (db) {
|
|
25
|
+
db.close();
|
|
26
|
+
databases.delete(resolvedPath);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
export const initializeEmbeddingTable = (db) => {
|
|
30
|
+
db.exec(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
32
|
+
post_id TEXT PRIMARY KEY,
|
|
33
|
+
post_hash TEXT,
|
|
34
|
+
embedding TEXT
|
|
35
|
+
);
|
|
36
|
+
`);
|
|
37
|
+
};
|
|
38
|
+
/** Look up an embedding and compare its stored content hash. */
|
|
39
|
+
export const getPostEmbeddingCacheEntry = (db, slug, content) => {
|
|
40
|
+
const hash = createHash("sha256");
|
|
41
|
+
hash.update(content);
|
|
42
|
+
const hashedContent = hash.digest("hex");
|
|
43
|
+
const row = db
|
|
44
|
+
.prepare("SELECT * FROM embeddings WHERE post_id = ?")
|
|
45
|
+
.get(slug);
|
|
46
|
+
return {
|
|
47
|
+
hashedContent,
|
|
48
|
+
row,
|
|
49
|
+
vector: row && row.post_hash === hashedContent
|
|
50
|
+
? JSON.parse(row.embedding)
|
|
51
|
+
: undefined,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
/** Insert a new vector or replace the vector when its source text changed. */
|
|
55
|
+
export const savePostEmbedding = (db, slug, hashedContent, vector, row) => {
|
|
56
|
+
if (!row) {
|
|
57
|
+
const insert = db.prepare("INSERT INTO embeddings (post_id, post_hash, embedding) VALUES (?, ?, ?)");
|
|
58
|
+
insert.run(slug, hashedContent, JSON.stringify(vector));
|
|
59
|
+
}
|
|
60
|
+
else if (row.post_hash !== hashedContent) {
|
|
61
|
+
const update = db.prepare("UPDATE embeddings SET post_hash = ?, embedding = ? WHERE post_id = ?");
|
|
62
|
+
update.run(hashedContent, JSON.stringify(vector), slug);
|
|
63
|
+
}
|
|
64
|
+
};
|
package/dist/embedder.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { getEmbeddingDatabase, getPostEmbeddingCacheEntry, savePostEmbedding, } from "./cache.js";
|
|
2
|
+
import { requestEmbeddings } from "./provider.js";
|
|
3
|
+
const contentText = (document) => [document.title, document.text, document.body].filter(Boolean).join("\n\n");
|
|
4
|
+
const embeddingCacheId = (id, provider, namespace) => JSON.stringify([
|
|
5
|
+
provider.providerName ?? provider.baseUrl,
|
|
6
|
+
provider.baseUrl,
|
|
7
|
+
provider.model,
|
|
8
|
+
namespace ?? "",
|
|
9
|
+
id,
|
|
10
|
+
]);
|
|
11
|
+
export const embedAll = async (documents, provider, config) => {
|
|
12
|
+
const texts = documents.map(contentText);
|
|
13
|
+
const ids = documents.map(({ id }) => id);
|
|
14
|
+
const batchSize = Math.max(1, provider.batchSize ?? 32);
|
|
15
|
+
const database = config.cache === true
|
|
16
|
+
? getEmbeddingDatabase(config.cachePath)
|
|
17
|
+
: undefined;
|
|
18
|
+
const vectors = new Array(texts.length);
|
|
19
|
+
const cacheEntries = new Array(texts.length);
|
|
20
|
+
const missing = [];
|
|
21
|
+
for (let index = 0; index < texts.length; index++) {
|
|
22
|
+
const id = ids[index];
|
|
23
|
+
const text = texts[index];
|
|
24
|
+
if (id === undefined || text === undefined) {
|
|
25
|
+
throw new Error("Content IDs and text must have matching lengths.");
|
|
26
|
+
}
|
|
27
|
+
const cacheId = embeddingCacheId(id, provider, config.cacheNamespace);
|
|
28
|
+
const entry = database
|
|
29
|
+
? getPostEmbeddingCacheEntry(database, cacheId, text)
|
|
30
|
+
: undefined;
|
|
31
|
+
if (entry?.vector) {
|
|
32
|
+
vectors[index] = entry.vector;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
missing.push(index);
|
|
36
|
+
cacheEntries[index] = entry;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const updates = [];
|
|
40
|
+
for (let start = 0; start < missing.length; start += batchSize) {
|
|
41
|
+
const indices = missing.slice(start, start + batchSize);
|
|
42
|
+
const batch = indices.map((index) => {
|
|
43
|
+
const text = texts[index];
|
|
44
|
+
if (text === undefined) {
|
|
45
|
+
throw new Error("Missing content text for embedding.");
|
|
46
|
+
}
|
|
47
|
+
return text;
|
|
48
|
+
});
|
|
49
|
+
const batchVectors = await requestEmbeddings(batch, provider, config);
|
|
50
|
+
indices.forEach((index, batchIndex) => {
|
|
51
|
+
const vector = batchVectors[batchIndex];
|
|
52
|
+
if (!vector) {
|
|
53
|
+
throw new Error("Embedding provider returned fewer vectors than requested.");
|
|
54
|
+
}
|
|
55
|
+
vectors[index] = vector;
|
|
56
|
+
const entry = cacheEntries[index];
|
|
57
|
+
if (database && entry) {
|
|
58
|
+
const id = ids[index];
|
|
59
|
+
if (id === undefined) {
|
|
60
|
+
throw new Error("Missing content ID for embedding.");
|
|
61
|
+
}
|
|
62
|
+
updates.push({
|
|
63
|
+
cacheId: embeddingCacheId(id, provider, config.cacheNamespace),
|
|
64
|
+
vector,
|
|
65
|
+
entry,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const completeVectors = vectors.map((vector) => {
|
|
71
|
+
if (!vector) {
|
|
72
|
+
throw new Error("Embedding provider returned fewer vectors than documents.");
|
|
73
|
+
}
|
|
74
|
+
return vector;
|
|
75
|
+
});
|
|
76
|
+
const dimensions = completeVectors[0]?.length;
|
|
77
|
+
if (completeVectors.some((vector) => vector.length === 0 || vector.length !== dimensions)) {
|
|
78
|
+
throw new Error("Embedding provider returned vectors with incompatible dimensions.");
|
|
79
|
+
}
|
|
80
|
+
if (database) {
|
|
81
|
+
for (const { cacheId, vector, entry } of updates) {
|
|
82
|
+
savePostEmbedding(database, cacheId, entry.hashedContent, vector, entry.row);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return completeVectors;
|
|
86
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AstroIntegration } from "astro";
|
|
2
|
+
export type AstroContentVectorsOptions = {
|
|
3
|
+
/** Optional collection allowlist. Calls for other collections return no results. */
|
|
4
|
+
collections?: string[];
|
|
5
|
+
/** Default provider configuration, used unless an environment overrides it. */
|
|
6
|
+
provider?: ProviderSelection;
|
|
7
|
+
/** Select a custom environment key. Defaults to Astro's dev/build command. */
|
|
8
|
+
environment?: string;
|
|
9
|
+
environments?: Record<string, {
|
|
10
|
+
provider?: ProviderOverride;
|
|
11
|
+
}>;
|
|
12
|
+
/** LM Studio fallback used when an OpenAI provider has no key or fails. */
|
|
13
|
+
fallback?: false | ProviderOverride;
|
|
14
|
+
threshold?: number;
|
|
15
|
+
limit?: number;
|
|
16
|
+
logLevel?: "silent" | "error" | "warn" | "info" | "debug";
|
|
17
|
+
/** Throw when both OpenAI and the local fallback fail. Defaults to false. */
|
|
18
|
+
failOnError?: boolean;
|
|
19
|
+
/** Write all pairwise scores to `.astro/content-vector-similarities/`. */
|
|
20
|
+
outputSimilarityScores?: boolean;
|
|
21
|
+
/** Persist generated vectors in SQLite. Defaults to true. */
|
|
22
|
+
cache?: boolean;
|
|
23
|
+
/** SQLite file path, relative to the Astro project root. */
|
|
24
|
+
cachePath?: string;
|
|
25
|
+
};
|
|
26
|
+
export type ProviderSelection = {
|
|
27
|
+
type: "lmstudio" | "openai";
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
model?: string;
|
|
30
|
+
batchSize?: number;
|
|
31
|
+
headers?: Record<string, string>;
|
|
32
|
+
apiKey?: string;
|
|
33
|
+
};
|
|
34
|
+
export type ProviderOverride = Partial<ProviderSelection>;
|
|
35
|
+
/**
|
|
36
|
+
* Adds the Astro content-vector helper to a site. It uses OpenAI when
|
|
37
|
+
* OPENAI_API_KEY is available and falls back to the local LM Studio endpoint.
|
|
38
|
+
*/
|
|
39
|
+
export default function astroContentVectors(options?: AstroContentVectorsOptions): AstroIntegration;
|
|
40
|
+
export type { ContentDocument, ContentVectorsConfig, EmbeddingProviderConfig, RelatedContent, SimilarityScore, } from "./runtime.js";
|
|
41
|
+
export { cosineSimilarity, getPairWiseSimilarityScores, getSlugToMostRelatedPostsMap, } from "./runtime.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
const VECTORS_CONFIG_DEFINE = "__ASTRO_CONTENT_VECTORS_CONFIG__";
|
|
4
|
+
const defineProvider = (name, configured) => {
|
|
5
|
+
// API keys are resolved in the server runtime; never serialize secrets into
|
|
6
|
+
// the settings injected into Astro's Vite build.
|
|
7
|
+
const { apiKey: _apiKey, headers: _headers, ...safeOptions } = configured;
|
|
8
|
+
if (name === "lmstudio") {
|
|
9
|
+
return {
|
|
10
|
+
...safeOptions,
|
|
11
|
+
providerName: name,
|
|
12
|
+
baseUrl: safeOptions.baseUrl ?? "http://127.0.0.1:1234/v1",
|
|
13
|
+
model: safeOptions.model ?? "text-embedding-nomic-embed-text-v1.5",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
...safeOptions,
|
|
18
|
+
providerName: name,
|
|
19
|
+
baseUrl: safeOptions.baseUrl ?? "https://api.openai.com/v1",
|
|
20
|
+
model: safeOptions.model ?? "text-embedding-3-small",
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
const defineEnvironment = (name, options, projectRoot) => {
|
|
24
|
+
const selected = {
|
|
25
|
+
type: "openai",
|
|
26
|
+
...options.provider,
|
|
27
|
+
...options.environments?.[name]?.provider,
|
|
28
|
+
};
|
|
29
|
+
const { type: providerName, ...providerOptions } = selected;
|
|
30
|
+
const primaryProvider = defineProvider(providerName, providerOptions);
|
|
31
|
+
const fallbackProvider = providerName === "openai" && options.fallback !== false
|
|
32
|
+
? defineProvider("lmstudio", options.fallback ?? {})
|
|
33
|
+
: undefined;
|
|
34
|
+
return {
|
|
35
|
+
provider: primaryProvider,
|
|
36
|
+
fallbackProvider,
|
|
37
|
+
threshold: options.threshold,
|
|
38
|
+
limit: options.limit,
|
|
39
|
+
logLevel: options.logLevel,
|
|
40
|
+
failOnError: options.failOnError ?? false,
|
|
41
|
+
collections: options.collections ?? null,
|
|
42
|
+
cache: options.cache ?? true,
|
|
43
|
+
cachePath: resolve(projectRoot, options.cachePath ?? ".astro/embeddings.db"),
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Adds the Astro content-vector helper to a site. It uses OpenAI when
|
|
48
|
+
* OPENAI_API_KEY is available and falls back to the local LM Studio endpoint.
|
|
49
|
+
*/
|
|
50
|
+
export default function astroContentVectors(options = {}) {
|
|
51
|
+
return {
|
|
52
|
+
name: "astro-content-vectors",
|
|
53
|
+
hooks: {
|
|
54
|
+
"astro:config:setup": ({ command, updateConfig, config }) => {
|
|
55
|
+
const defaultEnvironment = command === "dev" ? "development" : "production";
|
|
56
|
+
const environmentNames = new Set([
|
|
57
|
+
"development",
|
|
58
|
+
"production",
|
|
59
|
+
...Object.keys(options.environments ?? {}),
|
|
60
|
+
...(options.environment ? [options.environment] : []),
|
|
61
|
+
]);
|
|
62
|
+
const projectRoot = fileURLToPath(config.root);
|
|
63
|
+
const environments = Object.fromEntries([...environmentNames].map((name) => [
|
|
64
|
+
name,
|
|
65
|
+
defineEnvironment(name, options, projectRoot),
|
|
66
|
+
]));
|
|
67
|
+
const runtimeConfig = {
|
|
68
|
+
environments,
|
|
69
|
+
environment: options.environment,
|
|
70
|
+
defaultEnvironment,
|
|
71
|
+
outputSimilarityScores: options.outputSimilarityScores ?? false,
|
|
72
|
+
outputDirectory: resolve(projectRoot, ".astro", "content-vector-similarities"),
|
|
73
|
+
};
|
|
74
|
+
updateConfig({
|
|
75
|
+
vite: {
|
|
76
|
+
define: {
|
|
77
|
+
[VECTORS_CONFIG_DEFINE]: JSON.stringify(runtimeConfig),
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export { cosineSimilarity, getPairWiseSimilarityScores, getSlugToMostRelatedPostsMap, } from "./runtime.js";
|
package/dist/logging.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const levels = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 };
|
|
2
|
+
const defaultLogger = (level, message) => {
|
|
3
|
+
const method = level === "debug" ? "log" : level;
|
|
4
|
+
console[method](`[astro-content-vectors] ${message}`);
|
|
5
|
+
};
|
|
6
|
+
export const log = (config, level, message) => {
|
|
7
|
+
if (levels[config.logLevel ?? "warn"] >= levels[level]) {
|
|
8
|
+
(config.logger ?? defaultLogger)(level, message);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ContentVectorsConfig, EmbeddingProviderConfig } from "./types.js";
|
|
2
|
+
export declare const usesOpenAI: (provider: EmbeddingProviderConfig) => boolean;
|
|
3
|
+
export declare const providerApiKey: (provider: EmbeddingProviderConfig) => any;
|
|
4
|
+
export declare const requestEmbeddings: (texts: string[], provider: EmbeddingProviderConfig, config: ContentVectorsConfig) => Promise<number[][]>;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
const environment = () => {
|
|
3
|
+
const vite = import.meta.env;
|
|
4
|
+
const node = globalThis.process?.env;
|
|
5
|
+
return { ...node, ...vite };
|
|
6
|
+
};
|
|
7
|
+
export const usesOpenAI = (provider) => provider.providerName === "openai" ||
|
|
8
|
+
provider.baseUrl.includes("api.openai.com");
|
|
9
|
+
export const providerApiKey = (provider) => {
|
|
10
|
+
if (provider.apiKey)
|
|
11
|
+
return provider.apiKey;
|
|
12
|
+
if (!usesOpenAI(provider))
|
|
13
|
+
return undefined;
|
|
14
|
+
return environment().OPENAI_API_KEY;
|
|
15
|
+
};
|
|
16
|
+
export const requestEmbeddings = async (texts, provider, config) => {
|
|
17
|
+
const apiKey = providerApiKey(provider);
|
|
18
|
+
if (usesOpenAI(provider) && !apiKey) {
|
|
19
|
+
throw new Error("OPENAI_API_KEY is required when using the OpenAI embeddings provider.");
|
|
20
|
+
}
|
|
21
|
+
const client = new OpenAI({
|
|
22
|
+
apiKey: apiKey ?? "lm-studio",
|
|
23
|
+
baseURL: provider.baseUrl,
|
|
24
|
+
defaultHeaders: provider.headers,
|
|
25
|
+
fetch: config.fetch,
|
|
26
|
+
maxRetries: 0,
|
|
27
|
+
timeout: 60_000,
|
|
28
|
+
});
|
|
29
|
+
let payload;
|
|
30
|
+
try {
|
|
31
|
+
payload = await client.embeddings.create({
|
|
32
|
+
model: provider.model,
|
|
33
|
+
input: texts,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof OpenAI.APIConnectionError &&
|
|
38
|
+
error.cause instanceof Error) {
|
|
39
|
+
throw new Error(`${error.message}: ${error.cause.message}`, {
|
|
40
|
+
cause: error,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
if (payload.data.length !== texts.length) {
|
|
46
|
+
throw new Error("Embedding provider returned an unexpected response.");
|
|
47
|
+
}
|
|
48
|
+
return [...payload.data]
|
|
49
|
+
.sort((a, b) => a.index - b.index)
|
|
50
|
+
.map(({ embedding }) => embedding);
|
|
51
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type Embedding = {
|
|
2
|
+
slug: string;
|
|
3
|
+
vector: number[];
|
|
4
|
+
};
|
|
5
|
+
declare const getCosineSimilarity: (vectorA: number[], vectorB: number[]) => number;
|
|
6
|
+
type ScoredPost = {
|
|
7
|
+
slug: string;
|
|
8
|
+
score: number;
|
|
9
|
+
};
|
|
10
|
+
declare const COSINE_THRESHOLD = 0.65;
|
|
11
|
+
declare const getSlugToMostRelatedPostsMap: (embeddings: Embedding[], options?: {
|
|
12
|
+
threshold?: number;
|
|
13
|
+
limit?: number;
|
|
14
|
+
}) => Map<string, ScoredPost[]>;
|
|
15
|
+
declare const getPairWiseSimilarityScores: (embeddings: Embedding[]) => number[];
|
|
16
|
+
export { COSINE_THRESHOLD, type Embedding, getCosineSimilarity, getPairWiseSimilarityScores, getSlugToMostRelatedPostsMap, type ScoredPost, };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const getCosineSimilarity = (vectorA, vectorB) => {
|
|
2
|
+
// See https://stackoverflow.com/a/64816824 for dot product calculation explained.
|
|
3
|
+
const dot = vectorA.reduce((sum, val, i) => sum + val * vectorB[i], 0.0);
|
|
4
|
+
const magnitudeA = Math.sqrt(vectorA.reduce((sum, val) => sum + val * val, 0.0));
|
|
5
|
+
const magnitudeB = Math.sqrt(vectorB.reduce((sum, val) => sum + val * val, 0.0));
|
|
6
|
+
const denominator = magnitudeA * magnitudeB;
|
|
7
|
+
return denominator === 0 ? 0 : dot / denominator;
|
|
8
|
+
};
|
|
9
|
+
const COSINE_THRESHOLD = 0.65;
|
|
10
|
+
const getSlugToMostRelatedPostsMap = (embeddings, options = {}) => {
|
|
11
|
+
const threshold = options.threshold ?? COSINE_THRESHOLD;
|
|
12
|
+
const limit = options.limit ?? 3;
|
|
13
|
+
const slugToMostRelatedPosts = new Map();
|
|
14
|
+
for (const a of embeddings) {
|
|
15
|
+
const sims = embeddings
|
|
16
|
+
.filter((b) => b.slug !== a.slug)
|
|
17
|
+
.map((b) => ({
|
|
18
|
+
slug: b.slug,
|
|
19
|
+
score: getCosineSimilarity(a.vector, b.vector),
|
|
20
|
+
}))
|
|
21
|
+
.filter((c) => threshold <= c.score)
|
|
22
|
+
.sort((a, b) => b.score - a.score)
|
|
23
|
+
.slice(0, limit);
|
|
24
|
+
slugToMostRelatedPosts.set(a.slug, sims);
|
|
25
|
+
}
|
|
26
|
+
return slugToMostRelatedPosts;
|
|
27
|
+
};
|
|
28
|
+
const getPairWiseSimilarityScores = (embeddings) => {
|
|
29
|
+
const pairs = [];
|
|
30
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
31
|
+
for (let j = i + 1; j < embeddings.length; j++) {
|
|
32
|
+
const sim = getCosineSimilarity(embeddings[i].vector, embeddings[j].vector);
|
|
33
|
+
pairs.push(sim);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return pairs;
|
|
37
|
+
};
|
|
38
|
+
export { COSINE_THRESHOLD, getCosineSimilarity, getPairWiseSimilarityScores, getSlugToMostRelatedPostsMap, };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getCosineSimilarity, getPairWiseSimilarityScores, getSlugToMostRelatedPostsMap } from "./related-posts.js";
|
|
2
|
+
import type { ContentDocument, ContentVectorsConfig, RelatedContent } from "./types.js";
|
|
3
|
+
/** Generate vectors and return ranked recommendations for every document. */
|
|
4
|
+
export declare function getRelatedContent(documents: ContentDocument[], config: ContentVectorsConfig): Promise<Map<string, RelatedContent[]>>;
|
|
5
|
+
export type { Embedding, ScoredPost } from "./related-posts.js";
|
|
6
|
+
export type { ContentDocument, ContentVectorsConfig, EmbeddingProviderConfig, FetchLike, RelatedContent, SimilarityScore, } from "./types.js";
|
|
7
|
+
export { getCosineSimilarity as cosineSimilarity, getPairWiseSimilarityScores, getSlugToMostRelatedPostsMap, };
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { embedAll } from "./embedder.js";
|
|
2
|
+
import { log } from "./logging.js";
|
|
3
|
+
import { providerApiKey, usesOpenAI } from "./provider.js";
|
|
4
|
+
import { getCosineSimilarity, getPairWiseSimilarityScores, getSlugToMostRelatedPostsMap, } from "./related-posts.js";
|
|
5
|
+
/** Generate vectors and return ranked recommendations for every document. */
|
|
6
|
+
export async function getRelatedContent(documents, config) {
|
|
7
|
+
const ids = new Set();
|
|
8
|
+
for (const document of documents) {
|
|
9
|
+
if (ids.has(document.id))
|
|
10
|
+
throw new Error(`Duplicate content id: ${document.id}`);
|
|
11
|
+
ids.add(document.id);
|
|
12
|
+
}
|
|
13
|
+
let vectors;
|
|
14
|
+
let primaryError;
|
|
15
|
+
const primaryApiKey = providerApiKey(config.provider);
|
|
16
|
+
const primaryHasKey = !usesOpenAI(config.provider) || Boolean(primaryApiKey);
|
|
17
|
+
if (!primaryHasKey) {
|
|
18
|
+
log(config, "info", "OPENAI_API_KEY is not set; using the local fallback provider.");
|
|
19
|
+
primaryError = new Error("OPENAI_API_KEY is required when using the OpenAI embeddings provider.");
|
|
20
|
+
if (!config.fallbackProvider)
|
|
21
|
+
log(config, "warn", String(primaryError));
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
log(config, "info", `Generating embeddings for ${documents.length} content entries with ${config.provider.model}.`);
|
|
25
|
+
try {
|
|
26
|
+
const primaryProvider = primaryApiKey
|
|
27
|
+
? { ...config.provider, apiKey: primaryApiKey }
|
|
28
|
+
: config.provider;
|
|
29
|
+
vectors = await embedAll(documents, primaryProvider, config);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
primaryError = error;
|
|
33
|
+
if (config.fallbackProvider) {
|
|
34
|
+
log(config, "warn", `Primary embedding provider failed; trying the fallback provider. ${error instanceof Error ? error.message : String(error)}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
log(config, "warn", `Embedding provider failed. ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!vectors && config.fallbackProvider) {
|
|
42
|
+
log(config, "info", `Generating embeddings with fallback model ${config.fallbackProvider.model}.`);
|
|
43
|
+
try {
|
|
44
|
+
vectors = await embedAll(documents, config.fallbackProvider, config);
|
|
45
|
+
}
|
|
46
|
+
catch (fallbackError) {
|
|
47
|
+
const message = `Fallback embedding provider failed. ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`;
|
|
48
|
+
log(config, "warn", message);
|
|
49
|
+
if (config.failOnError) {
|
|
50
|
+
throw new AggregateError([primaryError, fallbackError], message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!vectors) {
|
|
55
|
+
const message = "No embedding provider could generate vectors; returning an empty related-content index.";
|
|
56
|
+
log(config, "warn", message);
|
|
57
|
+
if (config.failOnError) {
|
|
58
|
+
throw primaryError instanceof Error
|
|
59
|
+
? primaryError
|
|
60
|
+
: new Error(message);
|
|
61
|
+
}
|
|
62
|
+
return new Map(documents.map(({ id }) => [id, []]));
|
|
63
|
+
}
|
|
64
|
+
const embeddings = documents.map((document, index) => {
|
|
65
|
+
const vector = vectors[index];
|
|
66
|
+
if (!vector) {
|
|
67
|
+
throw new Error("Embedding provider returned fewer vectors than documents.");
|
|
68
|
+
}
|
|
69
|
+
return { slug: document.id, vector };
|
|
70
|
+
});
|
|
71
|
+
if (config.onSimilarityScores) {
|
|
72
|
+
const scores = [];
|
|
73
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
74
|
+
for (let j = i + 1; j < embeddings.length; j++) {
|
|
75
|
+
const source = embeddings[i];
|
|
76
|
+
const target = embeddings[j];
|
|
77
|
+
if (!source || !target)
|
|
78
|
+
continue;
|
|
79
|
+
scores.push({
|
|
80
|
+
sourceId: source.slug,
|
|
81
|
+
targetId: target.slug,
|
|
82
|
+
score: getCosineSimilarity(source.vector, target.vector),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await config.onSimilarityScores(scores);
|
|
87
|
+
}
|
|
88
|
+
const limit = Math.max(0, config.limit ?? 3);
|
|
89
|
+
const rankedBySlug = getSlugToMostRelatedPostsMap(embeddings, {
|
|
90
|
+
threshold: config.threshold,
|
|
91
|
+
limit,
|
|
92
|
+
});
|
|
93
|
+
const related = new Map();
|
|
94
|
+
for (const document of documents) {
|
|
95
|
+
const ranked = rankedBySlug.get(document.id) ?? [];
|
|
96
|
+
related.set(document.id, ranked.slice(0, limit).map(({ slug, score }) => ({
|
|
97
|
+
id: slug,
|
|
98
|
+
score,
|
|
99
|
+
})));
|
|
100
|
+
}
|
|
101
|
+
log(config, "info", `Built recommendations for ${documents.length} entries.`);
|
|
102
|
+
return related;
|
|
103
|
+
}
|
|
104
|
+
export { getCosineSimilarity as cosineSimilarity, getPairWiseSimilarityScores, getSlugToMostRelatedPostsMap, };
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type ContentDocument = {
|
|
2
|
+
id: string;
|
|
3
|
+
title?: string;
|
|
4
|
+
body?: string;
|
|
5
|
+
/** Optional extra text such as tags or a description. */
|
|
6
|
+
text?: string;
|
|
7
|
+
};
|
|
8
|
+
export type EmbeddingProviderConfig = {
|
|
9
|
+
/** Provider identifier set by the Astro integration. */
|
|
10
|
+
providerName?: "openai" | "lmstudio";
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
model: string;
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
/** Number of documents sent in one request. Defaults to 32. */
|
|
15
|
+
batchSize?: number;
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
19
|
+
export type RelatedContent = {
|
|
20
|
+
id: string;
|
|
21
|
+
score: number;
|
|
22
|
+
};
|
|
23
|
+
export type SimilarityScore = {
|
|
24
|
+
sourceId: string;
|
|
25
|
+
targetId: string;
|
|
26
|
+
score: number;
|
|
27
|
+
};
|
|
28
|
+
export type ContentVectorsConfig = {
|
|
29
|
+
provider: EmbeddingProviderConfig;
|
|
30
|
+
/** Used if the primary provider cannot be reached or has no API key. */
|
|
31
|
+
fallbackProvider?: EmbeddingProviderConfig;
|
|
32
|
+
/** Throw if no configured provider can generate vectors. Defaults to false. */
|
|
33
|
+
failOnError?: boolean;
|
|
34
|
+
logLevel?: "silent" | "error" | "warn" | "info" | "debug";
|
|
35
|
+
threshold?: number;
|
|
36
|
+
limit?: number;
|
|
37
|
+
/** Persist vectors in SQLite. Direct runtime use defaults to false. */
|
|
38
|
+
cache?: boolean;
|
|
39
|
+
/** SQLite database file. Relative paths resolve from the Astro project root. */
|
|
40
|
+
cachePath?: string;
|
|
41
|
+
/** Prevent ID collisions between independently processed collections. */
|
|
42
|
+
cacheNamespace?: string;
|
|
43
|
+
/** Inject fetch in tests or non-standard runtimes. */
|
|
44
|
+
fetch?: FetchLike;
|
|
45
|
+
onSimilarityScores?: (scores: SimilarityScore[]) => void | Promise<void>;
|
|
46
|
+
logger?: (level: "error" | "warn" | "info" | "debug", message: string) => void;
|
|
47
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,111 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "astro-content-vectors",
|
|
3
|
+
"description": "An Astro integration for vector-based related content using local or hosted embeddings.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"author": "h93xV2",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/h93xV2/astro-content-vectors.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/h93xV2/astro-content-vectors#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/h93xV2/astro-content-vectors/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./runtime": {
|
|
27
|
+
"types": "./dist/runtime.d.ts",
|
|
28
|
+
"import": "./dist/runtime.js"
|
|
29
|
+
},
|
|
30
|
+
"./astro": {
|
|
31
|
+
"types": "./dist/astro.d.ts",
|
|
32
|
+
"import": "./dist/astro.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"astro": ">=7.0.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "npm run clean && tsc -p tsconfig.build.json",
|
|
43
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
44
|
+
"prepack": "npm run build",
|
|
45
|
+
"setup": "husky",
|
|
46
|
+
"test": "bun run test:unit && bun run test:integration",
|
|
47
|
+
"test:integration": "bun test test/integration",
|
|
48
|
+
"test:astro-install": "bun run build && bun test test/e2e/astro-install.test.ts",
|
|
49
|
+
"test:package": "bun run build && bun test test/package",
|
|
50
|
+
"test:unit": "bun test test/unit",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"format": "biome format --write .",
|
|
53
|
+
"format:check": "biome format .",
|
|
54
|
+
"lint": "biome lint .",
|
|
55
|
+
"check": "biome check ."
|
|
56
|
+
},
|
|
57
|
+
"release": {
|
|
58
|
+
"branches": [
|
|
59
|
+
"main"
|
|
60
|
+
],
|
|
61
|
+
"plugins": [
|
|
62
|
+
"@semantic-release/commit-analyzer",
|
|
63
|
+
"@semantic-release/release-notes-generator",
|
|
64
|
+
[
|
|
65
|
+
"@semantic-release/npm",
|
|
66
|
+
{
|
|
67
|
+
"npmPublish": false
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
"@semantic-release/exec",
|
|
72
|
+
{
|
|
73
|
+
"publishCmd": "npm stage publish"
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
"@semantic-release/git",
|
|
78
|
+
{
|
|
79
|
+
"assets": [
|
|
80
|
+
"package.json",
|
|
81
|
+
"bun.lock"
|
|
82
|
+
],
|
|
83
|
+
"message": "chore(release): ${nextRelease.version} [skip ci]"
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"@semantic-release/github"
|
|
87
|
+
]
|
|
88
|
+
},
|
|
89
|
+
"devDependencies": {
|
|
90
|
+
"@biomejs/biome": "2.6.3",
|
|
91
|
+
"@commitlint/cli": "21.2.4",
|
|
92
|
+
"@commitlint/config-conventional": "21.2.4",
|
|
93
|
+
"@semantic-release/commit-analyzer": "13.0.1",
|
|
94
|
+
"@semantic-release/exec": "7.1.0",
|
|
95
|
+
"@semantic-release/git": "11.0.1",
|
|
96
|
+
"@semantic-release/github": "12.0.10",
|
|
97
|
+
"@semantic-release/npm": "13.2.0",
|
|
98
|
+
"@semantic-release/release-notes-generator": "14.1.1",
|
|
99
|
+
"@types/better-sqlite3": "9.6.0",
|
|
100
|
+
"@types/bun": "1.4.4",
|
|
101
|
+
"astro": "7.0.0",
|
|
102
|
+
"husky": "9.1.7",
|
|
103
|
+
"lint-staged": "17.6.0",
|
|
104
|
+
"semantic-release": "25.0.9",
|
|
105
|
+
"typescript": "7.0.2"
|
|
106
|
+
},
|
|
107
|
+
"dependencies": {
|
|
108
|
+
"better-sqlite3": "13.0.3",
|
|
109
|
+
"openai": "7.47.0"
|
|
110
|
+
}
|
|
111
|
+
}
|