文件
mynode/packages/lib/services/ai/SearchService.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

297 行
9.2 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

import Logger from '@joplin/utils/Logger';
import NoteEmbedding from '../../models/NoteEmbedding';
import Note from '../../models/Note';
import Tag from '../../models/Tag';
import AiService from './AiService';
import { EmbeddingProvider } from './types';
import { EmbeddingsPage, GetEmbeddingsOptions, SearchOptions, SearchQuery, SearchRelevance, SearchResult, SearchScope } from '../plugins/api/types';
import { splitByMarkdownFormattingApproximate } from '../../string-utils';
export type { EmbeddingsPage, GetEmbeddingsOptions, SearchOptions, SearchQuery, SearchRelevance, SearchResult, SearchScope };
const logger = Logger.create('SearchService');
// Semantic search over the local embedding index.
// The "relevance" preset is the plugin-facing contract; we own the mapping
// to model-specific (k, minScore) so plugins survive model changes.
interface RelevanceTuning {
k: number;
minScore: number;
}
// Tuned for multilingual-e5-small. Becomes a per-model map when we add more.
// multilingual-e5-small usually returns results in the range [0.7,1]
const RELEVANCE_DEFAULTS: Record<SearchRelevance, RelevanceTuning> = {
strict: { k: 5, minScore: 0.86 },
normal: { k: 10, minScore: 0.83 },
loose: { k: 20, minScore: 0.74 },
};
const DEFAULT_EMBEDDINGS_PAGE_SIZE = 500;
const MAX_EMBEDDINGS_PAGE_SIZE = 5000;
// Opaque to plugins: the cursor format is an implementation detail and may
// change. The `v1:` prefix lets a future format coexist via a version bump.
const encodeEmbeddingsCursor = (rowid: number) => `v1:${rowid}`;
const decodeEmbeddingsCursor = (cursor: string | undefined) => {
if (!cursor) return undefined;
const match = /^v1:(\d+)$/.exec(cursor);
if (!match) throw new Error(`Invalid embeddings cursor: ${JSON.stringify(cursor)}`);
return Number(match[1]);
};
const resolveEmbeddingsLimit = (raw: number | undefined) => {
if (raw === undefined) return DEFAULT_EMBEDDINGS_PAGE_SIZE;
if (!Number.isInteger(raw) || raw < 1) {
throw new Error(`Invalid embeddings limit: ${raw}`);
}
return Math.min(raw, MAX_EMBEDDINGS_PAGE_SIZE);
};
// vec0 returns L2 distance. Our vectors are L2-normalised, so cosine
// similarity = 1 − d²/2 exactly. Clamp to handle float drift on self-matches
// and opposite-vector edges.
const cosineFromDistance = (distance: number) => {
const score = 1 - (distance * distance) / 2;
if (score < 0) return 0;
if (score > 1) return 1;
return score;
};
const cosineSimilarity = (a: number[], b: number[]) => {
const dot = (a: number[], b: number[]) => {
if (a.length !== b.length) throw new Error(`Length mismatch: ${a.length} != ${b.length}`);
let sum = 0;
for (let i = 0; i < a.length; i++) {
sum += a[i] * b[i];
}
return sum;
};
const norm = (v: number[]) => Math.sqrt(dot(v, v));
return dot(a, b) / norm(a) / norm(b);
};
class MissingProviderError extends Error {
public constructor() {
super('No embedding provider is active. Enable AI features in Settings → AI.');
}
}
export default class SearchService {
private static instance_: SearchService;
public static instance(): SearchService {
if (!this.instance_) this.instance_ = new SearchService();
return this.instance_;
}
public async getEmbeddings(options: GetEmbeddingsOptions = {}): Promise<EmbeddingsPage> {
const provider = AiService.instance().getActiveEmbeddingProvider();
if (!provider) {
throw new MissingProviderError();
}
const limit = resolveEmbeddingsLimit(options.limit);
const afterRowid = decodeEmbeddingsCursor(options.cursor);
// Fetch one extra row to detect end-of-stream without an extra round-
// trip: if the DB returns limit+1, there's at least one more page; if
// it returns ≤limit, we know this is the last page.
const fetched = await NoteEmbedding.chunksPage({
noteIds: options.noteIds,
afterRowid,
limit: limit + 1,
});
const hasMore = fetched.length > limit;
const rows = hasMore ? fetched.slice(0, limit) : fetched;
// Take the page's modelId from the rows themselves rather than the
// live provider: a model swap during the await could leave rows from
// the old model in flight while provider.modelId already reports the
// new one. Empty pages have no rows to read from, so fall back to the
// provider (no rows = no mismatch to expose).
let modelId: string;
let dimension: number;
if (rows.length === 0) {
modelId = provider.modelId;
dimension = provider.dimension;
} else {
modelId = rows[0].modelId;
dimension = rows[0].vector.length;
for (const r of rows) {
if (r.modelId !== modelId) {
throw new Error(`Embeddings page spans multiple models (${modelId} and ${r.modelId}). The index is being rebuilt — retry shortly.`);
}
}
}
const chunks = rows.map(r => ({
noteId: r.noteId,
chunkIndex: r.chunkIndex,
chunkText: r.chunkText,
vector: r.vector,
}));
const nextCursor = hasMore
? encodeEmbeddingsCursor(rows[rows.length - 1].rowid)
: undefined;
return {
modelId,
dimension,
chunks,
nextCursor,
};
}
public async search(options: SearchOptions): Promise<SearchResult[]> {
const provider = AiService.instance().getActiveEmbeddingProvider();
if (!provider) {
throw new Error('No embedding provider is active. Enable AI features in Settings → AI.');
}
const relevance = options.relevance ?? 'normal';
const tuning = RELEVANCE_DEFAULTS[relevance];
const queryVectors = await this.resolveQueryVectors(options.query, provider);
if (!queryVectors.length) return [];
const noteIds = await this.resolveScope(options.scope);
// Empty scope = search nothing (e.g. tag with no notes).
if (noteIds && noteIds.length === 0) return [];
// noteId queries produce one vector per chunk; merge by (note, chunk),
// keeping the highest score seen.
const best = new Map<string, SearchResult>();
for (const queryVector of queryVectors) {
const hits = await NoteEmbedding.similaritySearch(queryVector, {
k: tuning.k,
noteIds: noteIds ?? undefined,
});
for (const hit of hits) {
const score = cosineFromDistance(hit.distance);
if (score < tuning.minScore) continue;
const key = `${hit.noteId}:${hit.chunkIndex}`;
const existing = best.get(key);
if (!existing || score > existing.score) {
best.set(key, {
noteId: hit.noteId,
chunkIndex: hit.chunkIndex,
chunkText: hit.chunkText,
score,
});
}
}
}
return Array.from(best.values())
.sort((a, b) => b.score - a.score)
.slice(0, tuning.k);
}
public async bestMatchInResult(query: string, result: SearchResult) {
return this.bestSubMatchInText_(query, result.chunkText);
}
private async bestSubMatchInText_(query: string, text: string) {
const provider = AiService.instance().getActiveEmbeddingProvider();
if (!provider) {
throw new MissingProviderError();
}
const queryVectors = await this.resolveQueryVectors({ text: query }, provider);
const scoreChunk = (embedding: number[]) => {
let bestScore = 0;
for (const query of queryVectors) {
const score = cosineSimilarity(query, embedding);
if (score >= bestScore) {
bestScore = score;
}
}
return bestScore;
};
const findBestIndex = (scores: number[]) => {
let bestIndex = 0;
let bestScore = -Infinity;
for (let i = 0; i < scores.length; i++) {
if (scores[i] > bestScore) {
bestScore = scores[i];
bestIndex = i;
}
}
return bestIndex;
};
const getBestSubtext = async (chunks: string[]) => {
const embeddings = await provider.embed(chunks);
const scores = embeddings.map(scoreChunk);
return chunks[findBestIndex(scores)];
};
const lines = text.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (lines.length) {
text = await getBestSubtext(lines);
}
// Try to get a sub-section that doesn't include Markdown formatting. Results including
// Markdown are more difficult to highlight after rendering:
const segments = splitByMarkdownFormattingApproximate(text)
.map(segment => segment.trim())
// Marking short or single-character matches usually isn't helpful
.filter(segment => segment.length > 1);
if (segments.length > 1) {
text = await getBestSubtext(segments);
} else if (segments.length > 0) {
text = segments[0];
}
return text;
}
private async resolveQueryVectors(
query: SearchQuery,
provider: EmbeddingProvider,
): Promise<number[][]> {
if ('text' in query) {
if (!query.text.trim()) return [];
// Asymmetric providers (e5) get better retrieval with embedQuery;
// symmetric ones fall back to embed.
const embedQuery = provider.embedQuery?.bind(provider) ?? provider.embed.bind(provider);
return embedQuery([query.text]);
}
// Reuse stored vectors so the math stays symmetric and we avoid a
// re-embed pass.
const vectors = await NoteEmbedding.vectorsByNoteId(query.noteId);
if (!vectors.length) {
logger.info(`No embeddings indexed for note ${query.noteId} — returning empty result`);
}
return vectors;
}
private async resolveScope(scope: SearchScope | undefined): Promise<string[] | null> {
if (!scope || scope.type === 'all') return null;
switch (scope.type) {
case 'note':
return [scope.noteId];
case 'folder': {
const notes = await Note.previews(scope.folderId, { fields: ['id'] });
return notes.map(n => n.id);
}
case 'tag':
return Tag.noteIds(scope.tagId);
}
}
}