Skip to content

Commit 636978f

Browse files
authored
Merge pull request #27 from arabold/feat/26-handle-model-dimensions
feat(#26): Support custom OpenAI API endpoints and handle model dimensions
2 parents 98b0e9e + c5fbedd commit 636978f

File tree

8 files changed

+216
-20
lines changed

8 files changed

+216
-20
lines changed

.env.example

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1-
# OpenAI
1+
# OpenAI Configuration
2+
# Required: Your OpenAI API Key
23
OPENAI_API_KEY=your-key-here
34

5+
# Optional: Your OpenAI Organization ID (handled automatically by LangChain if set)
6+
OPENAI_ORG_ID=
7+
8+
# Optional: Custom base URL for OpenAI API (e.g., for Azure OpenAI or compatible APIs)
9+
OPENAI_API_BASE=
10+
11+
# Optional: Embedding model name (defaults to "text-embedding-3-small")
12+
# Must produce vectors with ≤1536 dimensions (smaller dimensions are padded with zeros)
13+
# Examples: text-embedding-3-small (1536), text-embedding-ada-002 (1536)
14+
# Note: text-embedding-3-large (3072) is not supported due to dimension limit
15+
DOCS_MCP_EMBEDDING_MODEL=
16+
417
# Optional: Specify a custom directory to store the SQLite database file (documents.db).
518
# If set, this path takes precedence over the default locations.
619
# Default behavior (if unset):

Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ COPY --from=builder /app/dist ./dist
3131
RUN ln -s /app/dist/cli.js /app/docs-cli
3232

3333
# Define the data directory environment variable and volume
34+
# Environment variables
3435
ENV DOCS_MCP_STORE_PATH=/data
36+
ENV OPENAI_API_BASE=
37+
ENV OPENAI_ORG_ID=
38+
ENV DOCS_MCP_EMBEDDING_MODEL=
39+
3540
VOLUME /data
3641

3742
# Set the command to run the application

README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ The server exposes MCP tools for:
2626
- Finding appropriate versions (`find_version`).
2727
- Removing indexed documents (`remove_docs`).
2828

29+
## Configuration
30+
31+
The following environment variables are supported to configure the OpenAI API and embedding behavior:
32+
33+
- `OPENAI_API_KEY`: **Required.** Your OpenAI API key for generating embeddings.
34+
- `OPENAI_ORG_ID`: **Optional.** Your OpenAI Organization ID (handled automatically by LangChain if set).
35+
- `OPENAI_API_BASE`: **Optional.** Custom base URL for OpenAI API (e.g., for Azure OpenAI or compatible APIs).
36+
- `DOCS_MCP_EMBEDDING_MODEL`: **Optional.** Embedding model name (defaults to "text-embedding-3-small"). Must produce vectors with ≤1536 dimensions. Smaller dimensions are automatically padded with zeros.
37+
38+
The database schema uses a fixed dimension of 1536 for embedding vectors. Models that produce larger vectors are not supported and will cause an error. Models with smaller vectors (e.g., older embedding models) are automatically padded with zeros to match the required dimension.
39+
40+
These variables can be set regardless of how you run the server (Docker, npx, or from source).
41+
2942
## Running the MCP Server
3043

3144
There are two ways to run the docs-mcp-server:
@@ -76,6 +89,17 @@ This is the recommended approach for most users. It's easy, straightforward, and
7689
- `-e OPENAI_API_KEY`: **Required.** Set your OpenAI API key.
7790
- `-v docs-mcp-data:/data`: **Required for persistence.** Mounts a Docker named volume `docs-mcp-data` to store the database. You can replace with a specific host path if preferred (e.g., `-v /path/on/host:/data`).
7891

92+
Any of the configuration environment variables (see [Configuration](#configuration) above) can be passed to the container using the `-e` flag. For example:
93+
94+
```bash
95+
docker run -i --rm \
96+
-e OPENAI_API_KEY="your-key-here" \
97+
-e DOCS_MCP_EMBEDDING_MODEL="text-embedding-3-large" \
98+
-e OPENAI_API_BASE="http://your-api-endpoint" \
99+
-v docs-mcp-data:/data \
100+
ghcr.io/arabold/docs-mcp-server:latest
101+
```
102+
79103
### Option 2: Using npx
80104

81105
This approach is recommended when you need local file access (e.g., indexing documentation from your local file system). While this can also be achieved by mounting paths into a Docker container, using npx is simpler but requires a Node.js installation.
@@ -122,7 +146,7 @@ docker run --rm \
122146
docs-cli <command> [options]
123147
```
124148

125-
Make sure to use the same volume name (`docs-mcp-data` in this example) as you did for the server.
149+
Make sure to use the same volume name (`docs-mcp-data` in this example) as you did for the server. Any of the configuration environment variables (see [Configuration](#configuration) above) can be passed using `-e` flags, just like with the server.
126150

127151
### Using npx CLI
128152

@@ -339,6 +363,16 @@ This method is useful for contributing to the project or running un-published ve
339363
# Required: Your OpenAI API key for generating embeddings.
340364
OPENAI_API_KEY=your-api-key-here
341365
366+
# Optional: Your OpenAI Organization ID (handled automatically by LangChain if set)
367+
OPENAI_ORG_ID=
368+
369+
# Optional: Custom base URL for OpenAI API (e.g., for Azure OpenAI or compatible APIs)
370+
OPENAI_API_BASE=
371+
372+
# Optional: Embedding model name (defaults to "text-embedding-3-small")
373+
# Examples: text-embedding-3-large, text-embedding-ada-002
374+
DOCS_MCP_EMBEDDING_MODEL=
375+
342376
# Optional: Specify a custom directory to store the SQLite database file (documents.db).
343377
# If set, this path takes precedence over the default locations.
344378
# Default behavior (if unset):

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
"dev:cli": "npm run build && node --enable-source-maps dist/cli.js",
2727
"server": "node --enable-source-maps --watch dist/server.js",
2828
"dev:server": "run-p \"build -- --watch\" \"server\"",
29-
"test": "vitest",
29+
"test": "vitest run",
30+
"test:watch": "vitest",
31+
"test:coverage": "vitest run --coverage",
3032
"lint": "biome check .",
3133
"format": "biome format . --write",
3234
"db:generate": "drizzle-kit generate",

src/mcp/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export async function startServer() {
9191
.default(true)
9292
.describe("Whether to follow HTTP redirects (3xx responses)"),
9393
},
94-
// Remove context as it's not used without progress reporting
94+
9595
async ({ url, library, version, maxPages, maxDepth, scope, followRedirects }) => {
9696
try {
9797
// Execute scrape tool without waiting and without progress callback

src/store/DocumentStore.test.ts

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ const mockPrepare = vi.fn().mockReturnValue(mockStatement);
2323
const mockDb = {
2424
prepare: mockPrepare,
2525
exec: vi.fn(),
26-
transaction: vi.fn((fn) => fn()),
26+
transaction: vi.fn(
27+
(fn) =>
28+
(...args: unknown[]) =>
29+
fn(...args),
30+
),
2731
close: vi.fn(),
2832
};
2933
vi.mock("better-sqlite3", () => ({
@@ -58,6 +62,9 @@ describe("DocumentStore", () => {
5862
}));
5963
mockPrepare.mockReturnValue(mockStatement); // <-- Re-configure prepare mock return value
6064

65+
// Reset embedQuery to handle initialization vector
66+
mockEmbedQuery.mockResolvedValue(new Array(1536).fill(0.1));
67+
6168
// Now create the store and initialize.
6269
// initialize() will call 'new OpenAIEmbeddings()', which uses our fresh mock implementation.
6370
documentStore = new DocumentStore(":memory:");
@@ -79,9 +86,10 @@ describe("DocumentStore", () => {
7986

8087
await documentStore.findByContent(library, version, query, limit);
8188

82-
// 1. Check if embedQuery was called
83-
expect(mockEmbedQuery).toHaveBeenCalledWith(query);
84-
expect(mockEmbedQuery).toHaveBeenCalledTimes(1);
89+
// 1. Check if embedQuery was called with correct args
90+
// Note: embedQuery is called twice - once during init and once for search
91+
const embedCalls = mockEmbedQuery.mock.calls;
92+
expect(embedCalls[embedCalls.length - 1][0]).toBe(query); // Last call should be our search
8593

8694
// 2. Check if db.prepare was called correctly during findByContent
8795
// It's called multiple times during initialize, so check the specific call
@@ -150,4 +158,77 @@ describe("DocumentStore", () => {
150158
expect(lastCallArgs?.[6]).toBe(expectedFtsQuery);
151159
});
152160
});
161+
162+
describe("Embedding Model Dimensions", () => {
163+
it("should accept a model that produces 1536-dimensional vectors", async () => {
164+
// Mock a 1536-dimensional vector
165+
mockEmbedQuery.mockResolvedValueOnce(new Array(1536).fill(0.1));
166+
documentStore = new DocumentStore(":memory:");
167+
await expect(documentStore.initialize()).resolves.not.toThrow();
168+
});
169+
170+
it("should accept and pad vectors from models with smaller dimensions", async () => {
171+
// Mock 768-dimensional vectors
172+
mockEmbedQuery.mockResolvedValueOnce(new Array(768).fill(0.1));
173+
mockEmbedDocuments.mockResolvedValueOnce([new Array(768).fill(0.1)]);
174+
175+
documentStore = new DocumentStore(":memory:");
176+
await documentStore.initialize();
177+
178+
// Should pad to 1536 when inserting
179+
const doc = {
180+
pageContent: "test content",
181+
metadata: { title: "test", url: "http://test.com", path: ["test"] },
182+
};
183+
184+
// This should succeed (vectors are padded internally)
185+
await expect(
186+
documentStore.addDocuments("test-lib", "1.0.0", [doc]),
187+
).resolves.not.toThrow();
188+
});
189+
190+
it("should reject models that produce vectors larger than 1536 dimensions", async () => {
191+
// Mock a 3072-dimensional vector (like text-embedding-3-large)
192+
mockEmbedQuery.mockResolvedValueOnce(new Array(3072).fill(0.1));
193+
documentStore = new DocumentStore(":memory:");
194+
await expect(documentStore.initialize()).rejects.toThrow(/exceeds.*1536/);
195+
});
196+
197+
it("should pad both document and query vectors consistently", async () => {
198+
// Mock 768-dimensional vectors for both init and subsequent operations
199+
const smallVector = new Array(768).fill(0.1);
200+
mockEmbedQuery
201+
.mockResolvedValueOnce(smallVector) // for initialization
202+
.mockResolvedValueOnce(smallVector); // for search query
203+
mockEmbedDocuments.mockResolvedValueOnce([smallVector]); // for document embeddings
204+
205+
documentStore = new DocumentStore(":memory:");
206+
await documentStore.initialize();
207+
208+
const doc = {
209+
pageContent: "test content",
210+
metadata: { title: "test", url: "http://test.com", path: ["test"] },
211+
};
212+
213+
// Add a document (this pads the document vector)
214+
await documentStore.addDocuments("test-lib", "1.0.0", [doc]);
215+
216+
// Search should work (query vector gets padded too)
217+
await expect(
218+
documentStore.findByContent("test-lib", "1.0.0", "test query", 5),
219+
).resolves.not.toThrow();
220+
221+
// Verify both vectors were padded (via the JSON stringification)
222+
const insertCall = mockStatement.run.mock.calls.find(
223+
(call) => call[0]?.toString().startsWith("1"), // Looking for rowid=1
224+
);
225+
const searchCall = mockStatementAll.mock.lastCall;
226+
227+
// Both vectors should be stringified arrays of length 1536
228+
const insertVector = JSON.parse(insertCall?.[3] || "[]");
229+
const searchVector = JSON.parse(searchCall?.[2] || "[]");
230+
expect(insertVector.length).toBe(1536);
231+
expect(searchVector.length).toBe(1536);
232+
});
233+
});
153234
});

src/store/DocumentStore.ts

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { OpenAIEmbeddings } from "@langchain/openai";
33
import Database, { type Database as DatabaseType } from "better-sqlite3";
44
import * as sqliteVec from "sqlite-vec";
55
import type { DocumentMetadata } from "../types";
6-
import { ConnectionError, StoreError } from "./errors";
6+
import { ConnectionError, DimensionError, StoreError } from "./errors";
77
import { createTablesSQL } from "./schema";
88
import { type DbDocument, type DbQueryResult, mapDbDocumentToDocument } from "./types";
99

@@ -27,6 +27,8 @@ interface RankedResult extends RawSearchResult {
2727
export class DocumentStore {
2828
private readonly db: DatabaseType;
2929
private embeddings!: OpenAIEmbeddings;
30+
private readonly dbDimension: number = 1536; // Fixed dimension from schema.ts
31+
private modelDimension!: number;
3032
private statements!: {
3133
getById: Database.Statement;
3234
insertDocument: Database.Statement;
@@ -167,14 +169,53 @@ export class DocumentStore {
167169
}
168170

169171
/**
170-
* Initializes embeddings client
172+
* Pads a vector to the fixed database dimension by appending zeros.
173+
* Throws an error if the input vector is longer than the database dimension.
171174
*/
172-
private initializeEmbeddings(): void {
173-
this.embeddings = new OpenAIEmbeddings({
174-
modelName: "text-embedding-3-small",
175+
private padVector(vector: number[]): number[] {
176+
if (vector.length > this.dbDimension) {
177+
throw new Error(
178+
`Vector dimension ${vector.length} exceeds database dimension ${this.dbDimension}`,
179+
);
180+
}
181+
if (vector.length === this.dbDimension) {
182+
return vector;
183+
}
184+
return [...vector, ...new Array(this.dbDimension - vector.length).fill(0)];
185+
}
186+
187+
/**
188+
* Initializes embeddings client using environment variables for configuration.
189+
*
190+
* Supports:
191+
* - OPENAI_API_KEY (handled automatically by LangChain)
192+
* - OPENAI_ORG_ID (handled automatically by LangChain)
193+
* - DOCS_MCP_EMBEDDING_MODEL (optional, defaults to "text-embedding-3-small")
194+
* - OPENAI_API_BASE (optional)
195+
*/
196+
private async initializeEmbeddings(): Promise<void> {
197+
const modelName = process.env.DOCS_MCP_EMBEDDING_MODEL || "text-embedding-3-small";
198+
const baseURL = process.env.OPENAI_API_BASE;
199+
200+
const config: ConstructorParameters<typeof OpenAIEmbeddings>[0] = {
175201
stripNewLines: true,
176202
batchSize: 512,
177-
});
203+
modelName,
204+
};
205+
206+
if (baseURL) {
207+
config.configuration = { baseURL };
208+
}
209+
210+
this.embeddings = new OpenAIEmbeddings(config);
211+
212+
// Determine the model's actual dimension by embedding a test string
213+
const testVector = await this.embeddings.embedQuery("test");
214+
this.modelDimension = testVector.length;
215+
216+
if (this.modelDimension > this.dbDimension) {
217+
throw new DimensionError(modelName, this.modelDimension, this.dbDimension);
218+
}
178219
}
179220

180221
/**
@@ -202,9 +243,13 @@ export class DocumentStore {
202243
// 3. Initialize prepared statements
203244
this.prepareStatements();
204245

205-
// 4. Initialize embeddings client
206-
this.initializeEmbeddings();
246+
// 4. Initialize embeddings client (await to catch errors)
247+
await this.initializeEmbeddings();
207248
} catch (error) {
249+
// Re-throw StoreError directly, wrap others in ConnectionError
250+
if (error instanceof StoreError) {
251+
throw error;
252+
}
208253
throw new ConnectionError("Failed to initialize database connection", error);
209254
}
210255
}
@@ -281,7 +326,8 @@ export class DocumentStore {
281326
const header = `<title>${doc.metadata.title}</title>\n<url>${doc.metadata.url}</url>\n<path>${doc.metadata.path.join(" / ")}</path>\n`;
282327
return `${header}${doc.pageContent}`;
283328
});
284-
const embeddings = await this.embeddings.embedDocuments(texts);
329+
const rawEmbeddings = await this.embeddings.embedDocuments(texts);
330+
const paddedEmbeddings = rawEmbeddings.map((vector) => this.padVector(vector));
285331

286332
// Insert documents in a transaction
287333
const transaction = this.db.transaction((docs: typeof documents) => {
@@ -308,7 +354,7 @@ export class DocumentStore {
308354
BigInt(rowId),
309355
library.toLowerCase(),
310356
version.toLowerCase(),
311-
JSON.stringify(embeddings[i]),
357+
JSON.stringify(paddedEmbeddings[i]),
312358
);
313359
}
314360
});
@@ -364,7 +410,8 @@ export class DocumentStore {
364410
limit: number,
365411
): Promise<Document[]> {
366412
try {
367-
const embedding = await this.embeddings.embedQuery(query);
413+
const rawEmbedding = await this.embeddings.embedQuery(query);
414+
const embedding = this.padVector(rawEmbedding);
368415
const ftsQuery = this.escapeFtsQuery(query); // Escape the query for FTS
369416

370417
const stmt = this.db.prepare(`

src/store/errors.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ class StoreError extends Error {
1414
}
1515
}
1616

17+
class DimensionError extends StoreError {
18+
constructor(
19+
public readonly modelName: string,
20+
public readonly modelDimension: number,
21+
public readonly dbDimension: number,
22+
) {
23+
super(
24+
`Model "${modelName}" produces ${modelDimension}-dimensional vectors, ` +
25+
`which exceeds the database's fixed dimension of ${dbDimension}. ` +
26+
`Please use a model with dimension ≤ ${dbDimension}.`,
27+
);
28+
}
29+
}
30+
1731
class ConnectionError extends StoreError {}
1832

1933
class DocumentNotFoundError extends StoreError {
@@ -22,4 +36,4 @@ class DocumentNotFoundError extends StoreError {
2236
}
2337
}
2438

25-
export { StoreError, ConnectionError, DocumentNotFoundError };
39+
export { StoreError, ConnectionError, DocumentNotFoundError, DimensionError };

0 commit comments

Comments
 (0)