feat(ai): hybrid RRF retrieval, heading-breadcrumb chunks, merged search tool
Improve agent RAG quality with three changes, plus a roadmap doc for the rest.
- Indexer: prefix each chunk with its heading path ("Page > H1 > H2"), built by
walking the ProseMirror JSON (heading nodes) so a `#` inside a fenced code block
is never mistaken for a heading. Falls back to plain-text chunking on any error.
buildChunkRows: drop indexOf-against-source offsets (breadcrumb prefixes break
verbatim matching) for a cumulative cursor — offsets are provenance-only.
- Hybrid search: new migration adds a generated `fts` tsvector column + GIN index
to page_embeddings (same english+f_unaccent config as pages.tsv). New
PageEmbeddingRepo.hybridSearch fuses cosine + full-text rankings via Reciprocal
Rank Fusion (k=60, equal weights) in one SQL query at chunk granularity.
- Tools: collapse semanticSearch + searchPages into one hybrid `searchPages` tool
with a query-rewrite-oriented description; gracefully falls back to the REST
full-text path when embeddings are unconfigured. Access control (space scope +
page-permission post-filter) preserved. Add a query-rewrite hint to the default
system prompt.
- docs/rag-improvements-plan.md: record what shipped and the deferred backlog
(reranker, attachment indexing, eval harness, tuning).
Note: requires a corpus reindex to populate breadcrumbs on existing pages.
This commit is contained in:
@@ -9,6 +9,8 @@ const DEFAULT_PROMPT = [
|
|||||||
'You help the current user find, read, and reason about pages in their workspace.',
|
'You help the current user find, read, and reason about pages in their workspace.',
|
||||||
'Use the available tools to search and read pages before answering when the answer',
|
'Use the available tools to search and read pages before answering when the answer',
|
||||||
'depends on the workspace content. Cite the pages you used. Be concise and accurate.',
|
'depends on the workspace content. Cite the pages you used. Be concise and accurate.',
|
||||||
|
"When searching, rephrase the user's question into focused keyword queries, and search",
|
||||||
|
'again with different terms if the first results are weak.',
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -77,9 +77,28 @@ export class EmbeddingIndexerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = this.extractText(page);
|
// Prefer heading-breadcrumb chunks: each chunk is prefixed with its heading
|
||||||
if (!text || text.trim().length === 0) {
|
// path ("Page Title > H1 > H2") so the breadcrumb is embedded AND stored in
|
||||||
// Empty page -> remove any prior embeddings so search returns nothing.
|
// `content` (feeding the fts column and the agent's snippet). Walk the
|
||||||
|
// ProseMirror JSON — NOT the markdown text — so a `#` inside a fenced code
|
||||||
|
// block is never mistaken for a heading. Degrades to the plain-text path on
|
||||||
|
// any error / unknown structure (returns null).
|
||||||
|
const breadcrumbChunks = page.content
|
||||||
|
? await this.safeBuildBreadcrumbChunks(page.content, page.title)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Fall back to plain text when breadcrumb chunking is unavailable.
|
||||||
|
const fallbackText =
|
||||||
|
breadcrumbChunks && breadcrumbChunks.length > 0
|
||||||
|
? null
|
||||||
|
: this.extractText(page);
|
||||||
|
|
||||||
|
// Empty page (neither path produced content) -> remove any prior embeddings
|
||||||
|
// so search returns nothing.
|
||||||
|
if (
|
||||||
|
(!breadcrumbChunks || breadcrumbChunks.length === 0) &&
|
||||||
|
(!fallbackText || fallbackText.trim().length === 0)
|
||||||
|
) {
|
||||||
await this.pageEmbeddingRepo.deleteByPage(pageId, workspaceId);
|
await this.pageEmbeddingRepo.deleteByPage(pageId, workspaceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -105,12 +124,17 @@ export class EmbeddingIndexerService {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chunk the plain text.
|
// Use breadcrumb chunks when available; otherwise chunk the plain text.
|
||||||
const splitter = new RecursiveCharacterTextSplitter({
|
let chunks: string[];
|
||||||
chunkSize: CHUNK_SIZE,
|
if (breadcrumbChunks && breadcrumbChunks.length > 0) {
|
||||||
chunkOverlap: CHUNK_OVERLAP,
|
chunks = breadcrumbChunks;
|
||||||
});
|
} else {
|
||||||
const chunks = await splitter.splitText(text);
|
const splitter = new RecursiveCharacterTextSplitter({
|
||||||
|
chunkSize: CHUNK_SIZE,
|
||||||
|
chunkOverlap: CHUNK_OVERLAP,
|
||||||
|
});
|
||||||
|
chunks = await splitter.splitText(fallbackText as string);
|
||||||
|
}
|
||||||
if (chunks.length === 0) {
|
if (chunks.length === 0) {
|
||||||
await this.pageEmbeddingRepo.deleteByPage(pageId, workspaceId);
|
await this.pageEmbeddingRepo.deleteByPage(pageId, workspaceId);
|
||||||
return;
|
return;
|
||||||
@@ -139,7 +163,6 @@ export class EmbeddingIndexerService {
|
|||||||
const rows = this.buildChunkRows(
|
const rows = this.buildChunkRows(
|
||||||
chunks,
|
chunks,
|
||||||
vectors,
|
vectors,
|
||||||
text,
|
|
||||||
{ pageId, workspaceId, spaceId },
|
{ pageId, workspaceId, spaceId },
|
||||||
modelName,
|
modelName,
|
||||||
);
|
);
|
||||||
@@ -255,14 +278,16 @@ export class EmbeddingIndexerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map chunk strings + vectors to insertable rows, computing chunkStart /
|
* Map chunk strings + vectors to insertable rows. Breadcrumb-prefixed chunks
|
||||||
* chunkLength against the source text. A moving cursor handles repeated
|
* are NOT verbatim substrings of any source text, so chunkStart is a running
|
||||||
* substrings and overlap so offsets stay monotonic.
|
* cumulative offset (sum of previous chunk lengths) rather than an indexOf
|
||||||
|
* position. These offsets are informational provenance only — search returns
|
||||||
|
* `content` and never slices by offset. chunkIndex stays a global monotonic
|
||||||
|
* index.
|
||||||
*/
|
*/
|
||||||
private buildChunkRows(
|
private buildChunkRows(
|
||||||
chunks: string[],
|
chunks: string[],
|
||||||
vectors: number[][],
|
vectors: number[][],
|
||||||
sourceText: string,
|
|
||||||
ids: { pageId: string; workspaceId: string; spaceId: string },
|
ids: { pageId: string; workspaceId: string; spaceId: string },
|
||||||
modelName: string,
|
modelName: string,
|
||||||
): PageEmbeddingChunkRow[] {
|
): PageEmbeddingChunkRow[] {
|
||||||
@@ -272,11 +297,8 @@ export class EmbeddingIndexerService {
|
|||||||
const chunk = chunks[i];
|
const chunk = chunks[i];
|
||||||
const embedding = vectors[i];
|
const embedding = vectors[i];
|
||||||
if (!embedding) continue;
|
if (!embedding) continue;
|
||||||
const found = sourceText.indexOf(chunk, cursor);
|
const chunkStart = cursor;
|
||||||
const chunkStart = found >= 0 ? found : cursor;
|
cursor += chunk.length;
|
||||||
// Advance the cursor past the start so later identical chunks resolve to
|
|
||||||
// later occurrences (overlap keeps the next search valid).
|
|
||||||
cursor = chunkStart + 1;
|
|
||||||
rows.push({
|
rows.push({
|
||||||
pageId: ids.pageId,
|
pageId: ids.pageId,
|
||||||
workspaceId: ids.workspaceId,
|
workspaceId: ids.workspaceId,
|
||||||
@@ -295,4 +317,106 @@ export class EmbeddingIndexerService {
|
|||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin try/catch wrapper around buildBreadcrumbChunks. Any failure (malformed
|
||||||
|
* structure, unknown node type, etc.) returns null so the caller degrades
|
||||||
|
* gracefully to the plain-text chunking path.
|
||||||
|
*/
|
||||||
|
private async safeBuildBreadcrumbChunks(
|
||||||
|
contentJson: unknown,
|
||||||
|
pageTitle: string | null,
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
try {
|
||||||
|
return await this.buildBreadcrumbChunks(contentJson, pageTitle);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build heading-breadcrumb chunks by walking the ProseMirror JSON document.
|
||||||
|
*
|
||||||
|
* Each section (the body following a heading) is split with the same 1000/200
|
||||||
|
* RecursiveCharacterTextSplitter, and every resulting piece is prefixed with
|
||||||
|
* its heading path ("Page Title > H1 > H2"). Walking the JSON — not markdown
|
||||||
|
* text — means a `#` inside a fenced code block is never treated as a heading
|
||||||
|
* (ProseMirror heading nodes are explicit).
|
||||||
|
*
|
||||||
|
* Returns null when `contentJson` is not an object with an array `content`, so
|
||||||
|
* the caller falls back to plain-text chunking.
|
||||||
|
*/
|
||||||
|
private async buildBreadcrumbChunks(
|
||||||
|
contentJson: unknown,
|
||||||
|
pageTitle: string | null,
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
const doc = contentJson as { content?: unknown };
|
||||||
|
if (
|
||||||
|
typeof contentJson !== 'object' ||
|
||||||
|
contentJson === null ||
|
||||||
|
!Array.isArray(doc.content)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitter = new RecursiveCharacterTextSplitter({
|
||||||
|
chunkSize: CHUNK_SIZE,
|
||||||
|
chunkOverlap: CHUNK_OVERLAP,
|
||||||
|
});
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
const stack: { level: number; text: string }[] = [];
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
// Flush the accumulated body as one or more chunks under the CURRENT crumb.
|
||||||
|
const flush = async (): Promise<void> => {
|
||||||
|
if (buffer.trim().length === 0) {
|
||||||
|
buffer = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const crumb = [pageTitle, ...stack.map((s) => s.text)]
|
||||||
|
.filter((s) => typeof s === 'string' && s.trim().length > 0)
|
||||||
|
.join(' > ');
|
||||||
|
const pieces = await splitter.splitText(buffer);
|
||||||
|
for (const piece of pieces) {
|
||||||
|
out.push(crumb ? `${crumb}\n\n${piece}` : piece);
|
||||||
|
}
|
||||||
|
buffer = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const block of doc.content as Array<{
|
||||||
|
type?: string;
|
||||||
|
attrs?: { level?: number };
|
||||||
|
}>) {
|
||||||
|
if (block?.type === 'heading') {
|
||||||
|
// Flush the preceding body under the crumb in effect BEFORE this
|
||||||
|
// heading, then update the heading stack.
|
||||||
|
await flush();
|
||||||
|
const level =
|
||||||
|
typeof block.attrs?.level === 'number' ? block.attrs.level : 1;
|
||||||
|
// Pop deeper-or-equal headings: a new H2 replaces a prior H2/H3/...
|
||||||
|
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
const headingText = jsonToText({
|
||||||
|
type: 'doc',
|
||||||
|
content: [block],
|
||||||
|
} as never).trim();
|
||||||
|
if (headingText.length > 0) {
|
||||||
|
stack.push({ level, text: headingText });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const blockText = jsonToText({
|
||||||
|
type: 'doc',
|
||||||
|
content: [block],
|
||||||
|
} as never);
|
||||||
|
buffer = buffer.length > 0 ? `${buffer}\n${blockText}` : blockText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any trailing body after the last heading.
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,37 +87,126 @@ export class AiChatToolsService {
|
|||||||
return {
|
return {
|
||||||
searchPages: tool({
|
searchPages: tool({
|
||||||
description:
|
description:
|
||||||
'Full-text search across the pages the current user can access. ' +
|
'Search the wiki for pages relevant to a query. Combines exact ' +
|
||||||
'Returns a compact list of matching pages with a short snippet.',
|
'keyword/identifier matching with semantic meaning and returns the ' +
|
||||||
|
'most relevant pages with a short snippet, best match first. ' +
|
||||||
|
"Rephrase the user's question into a focused search query (key terms " +
|
||||||
|
'and entities), not a full sentence. If the first results look weak ' +
|
||||||
|
'or incomplete, search again with different wording or synonyms ' +
|
||||||
|
'before answering.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
query: z.string().describe('The search query.'),
|
query: z.string().describe('The search query.'),
|
||||||
limit: z
|
limit: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(50)
|
.max(20)
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Maximum number of results (1-50).'),
|
.describe('Maximum number of results (1-20).'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ query, limit }) => {
|
execute: async ({ query, limit }) => {
|
||||||
// search(query, spaceId?, limit?) -> { items, success }.
|
const trimmed = (query ?? '').trim();
|
||||||
// Items are filterSearchResult(): { id, title, highlight, ... }.
|
if (!trimmed) return [];
|
||||||
const result = await client.search(query, undefined, limit);
|
|
||||||
const items = Array.isArray(result?.items) ? result.items : [];
|
const cap = limit ?? 10;
|
||||||
// Keep the payload token-efficient: id + title + a short snippet only.
|
|
||||||
return items.map((raw) => {
|
// Loopback REST full-text fallback. Used when AI search is not
|
||||||
const item = raw as {
|
// configured, embedding fails, there are no accessible spaces, or the
|
||||||
id?: string;
|
// hybrid query returns nothing — so keyword search always works.
|
||||||
slugId?: string;
|
const fallback = async () => {
|
||||||
title?: string;
|
// search(query, spaceId?, limit?) -> { items, success }.
|
||||||
highlight?: string;
|
// Items are filterSearchResult(): { id, title, highlight, ... }.
|
||||||
};
|
const result = await client.search(trimmed, undefined, cap);
|
||||||
return {
|
const items = Array.isArray(result?.items) ? result.items : [];
|
||||||
id: item.id ?? item.slugId,
|
// Keep the payload token-efficient: id + title + a short snippet.
|
||||||
title: item.title ?? '',
|
return items.map((raw) => {
|
||||||
snippet: snippet(item.highlight),
|
const item = raw as {
|
||||||
};
|
id?: string;
|
||||||
});
|
slugId?: string;
|
||||||
|
title?: string;
|
||||||
|
highlight?: string;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: item.id ?? item.slugId,
|
||||||
|
title: item.title ?? '',
|
||||||
|
snippet: snippet(item.highlight),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// HYBRID path: fuse semantic (vector) + lexical (full-text) rankings
|
||||||
|
// via RRF. Over-fetch candidates so the page-permission post-filter
|
||||||
|
// still leaves enough results.
|
||||||
|
const candidates = Math.min(Math.max(cap * 5, 50), 200);
|
||||||
|
|
||||||
|
// 1) Embed the query. Unconfigured embeddings (or any embedding error)
|
||||||
|
// routes to the REST full-text fallback instead of erroring.
|
||||||
|
let queryVector: number[];
|
||||||
|
try {
|
||||||
|
const [vec] = await this.aiService.embedTexts(workspaceId, [
|
||||||
|
trimmed,
|
||||||
|
]);
|
||||||
|
if (!vec) return await fallback();
|
||||||
|
queryVector = vec;
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof AiEmbeddingNotConfiguredException)) {
|
||||||
|
// Never leak provider/key details; log generically and fall back.
|
||||||
|
this.logger.warn(
|
||||||
|
`searchPages embed failed: ${
|
||||||
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) ACCESS CONTROL: the hybrid query runs IN-PROCESS (a direct
|
||||||
|
// pgvector + full-text query), so unlike the loopback REST tools it
|
||||||
|
// does NOT get CASL for free. Scope to the spaces the user can read
|
||||||
|
// (member spaces + groups), mirroring SearchService.searchPage. No
|
||||||
|
// accessible spaces => fall back to REST (which is CASL-scoped).
|
||||||
|
const accessibleSpaceIds =
|
||||||
|
await this.spaceMemberRepo.getUserSpaceIds(user.id);
|
||||||
|
if (accessibleSpaceIds.length === 0) return await fallback();
|
||||||
|
|
||||||
|
// 3) Hybrid RRF retrieval, scoped to the workspace AND accessible
|
||||||
|
// spaces.
|
||||||
|
const hits = await this.pageEmbeddingRepo.hybridSearch(
|
||||||
|
workspaceId,
|
||||||
|
queryVector,
|
||||||
|
trimmed,
|
||||||
|
accessibleSpaceIds,
|
||||||
|
candidates,
|
||||||
|
);
|
||||||
|
if (hits.length === 0) return await fallback();
|
||||||
|
|
||||||
|
// 4) Page-level permission post-filter: an accessible space does not
|
||||||
|
// imply every page in it is accessible (restricted pages). Mirror
|
||||||
|
// SearchService.searchPage's filterAccessiblePageIds pass.
|
||||||
|
const pageIds = Array.from(new Set(hits.map((h) => h.pageId)));
|
||||||
|
const accessibleIds =
|
||||||
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
|
pageIds,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
const accessibleSet = new Set(accessibleIds);
|
||||||
|
|
||||||
|
// Keep the best (first — hits are ordered by fused score desc) chunk
|
||||||
|
// per page, capped to `cap`.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const results: { id: string; title: string; snippet: string }[] = [];
|
||||||
|
for (const hit of hits) {
|
||||||
|
if (!accessibleSet.has(hit.pageId)) continue;
|
||||||
|
if (seen.has(hit.pageId)) continue;
|
||||||
|
seen.add(hit.pageId);
|
||||||
|
results.push({
|
||||||
|
id: hit.pageId,
|
||||||
|
title: hit.title ?? '',
|
||||||
|
snippet: snippet(hit.content),
|
||||||
|
});
|
||||||
|
if (results.length >= cap) break;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -142,110 +231,6 @@ export class AiChatToolsService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
semanticSearch: tool({
|
|
||||||
description:
|
|
||||||
'Semantic (vector) search across the pages the current user can ' +
|
|
||||||
'access. Finds pages by meaning, not just keywords — use it to ' +
|
|
||||||
'answer conceptual questions. Returns a compact list of relevant ' +
|
|
||||||
'pages with a short snippet. Falls back to searchPages if semantic ' +
|
|
||||||
'search is unavailable.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
query: z.string().describe('The natural-language search query.'),
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(20)
|
|
||||||
.optional()
|
|
||||||
.describe('Maximum number of results (1-20).'),
|
|
||||||
}),
|
|
||||||
execute: async ({ query, limit }) => {
|
|
||||||
// ACCESS CONTROL: this tool runs IN-PROCESS (a direct pgvector query),
|
|
||||||
// so unlike the loopback REST tools it does NOT get CASL for free. We
|
|
||||||
// scope every query to the spaces the user can read, mirroring
|
|
||||||
// SearchService.searchPage (§6.7 / §8). We additionally post-filter by
|
|
||||||
// page-level permissions so restricted pages inside an accessible
|
|
||||||
// space are never returned.
|
|
||||||
const trimmed = (query ?? '').trim();
|
|
||||||
if (trimmed.length === 0) return [];
|
|
||||||
|
|
||||||
// 1) Embed the query (no-op fallback when embeddings are unconfigured
|
|
||||||
// so the agent can fall back to searchPages instead of erroring).
|
|
||||||
let queryVector: number[];
|
|
||||||
try {
|
|
||||||
const [vec] = await this.aiService.embedTexts(workspaceId, [
|
|
||||||
trimmed,
|
|
||||||
]);
|
|
||||||
if (!vec) return [];
|
|
||||||
queryVector = vec;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof AiEmbeddingNotConfiguredException) {
|
|
||||||
return {
|
|
||||||
unavailable: true,
|
|
||||||
reason:
|
|
||||||
'semantic search unavailable (embeddings not configured)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Never leak provider/key details; surface a generic unavailable.
|
|
||||||
this.logger.warn(
|
|
||||||
`semanticSearch embed failed: ${
|
|
||||||
err instanceof Error ? err.message : 'unknown error'
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
unavailable: true,
|
|
||||||
reason: 'semantic search unavailable',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Resolve the spaces this user can read (member spaces + groups),
|
|
||||||
// mirroring SearchService's space scoping. No spaces => no results.
|
|
||||||
const accessibleSpaceIds =
|
|
||||||
await this.spaceMemberRepo.getUserSpaceIds(user.id);
|
|
||||||
if (accessibleSpaceIds.length === 0) return [];
|
|
||||||
|
|
||||||
// 3) Cosine ANN over the embeddings, scoped to the workspace AND the
|
|
||||||
// accessible spaces. Over-fetch a little so the page-permission
|
|
||||||
// post-filter still leaves enough results.
|
|
||||||
const cap = limit ?? 10;
|
|
||||||
const hits = await this.pageEmbeddingRepo.searchByEmbedding(
|
|
||||||
workspaceId,
|
|
||||||
queryVector,
|
|
||||||
accessibleSpaceIds,
|
|
||||||
cap * 3,
|
|
||||||
);
|
|
||||||
if (hits.length === 0) return [];
|
|
||||||
|
|
||||||
// 4) Page-level permission post-filter: a space being accessible does
|
|
||||||
// not imply every page in it is (restricted pages). Mirror
|
|
||||||
// SearchService.searchPage's filterAccessiblePageIds pass.
|
|
||||||
const pageIds = Array.from(new Set(hits.map((h) => h.pageId)));
|
|
||||||
const accessibleIds =
|
|
||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
||||||
pageIds,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
const accessibleSet = new Set(accessibleIds);
|
|
||||||
|
|
||||||
// Keep the best (lowest-distance) hit per page, capped to `limit`.
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const results: { pageId: string; title: string; snippet: string }[] =
|
|
||||||
[];
|
|
||||||
for (const hit of hits) {
|
|
||||||
if (!accessibleSet.has(hit.pageId)) continue;
|
|
||||||
if (seen.has(hit.pageId)) continue;
|
|
||||||
seen.add(hit.pageId);
|
|
||||||
results.push({
|
|
||||||
pageId: hit.pageId,
|
|
||||||
title: hit.title ?? '',
|
|
||||||
snippet: snippet(hit.content),
|
|
||||||
});
|
|
||||||
if (results.length >= cap) break;
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
||||||
|
|
||||||
createPage: tool({
|
createPage: tool({
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunk-level lexical index for HYBRID retrieval (RRF) over `page_embeddings`.
|
||||||
|
*
|
||||||
|
* The agent's retrieval used to be either pure full-text (loopback REST over
|
||||||
|
* `pages.tsv`) OR pure vector (cosine over `page_embeddings.embedding`). Hybrid
|
||||||
|
* retrieval fuses BOTH rankings with Reciprocal Rank Fusion so exact keyword /
|
||||||
|
* identifier matches AND semantic matches both surface. The vector side already
|
||||||
|
* exists; this migration adds the missing LEXICAL side AT CHUNK GRANULARITY so
|
||||||
|
* both CTEs of the fused query rank the SAME chunk rows.
|
||||||
|
*
|
||||||
|
* `fts` is a GENERATED ALWAYS ... STORED `tsvector` built from `content` with
|
||||||
|
* the SAME text-search config as `pages.tsv`: `to_tsvector('english',
|
||||||
|
* f_unaccent(content))`. Using the identical config keeps lexical behaviour
|
||||||
|
* consistent with the existing page search (incl. unaccented Cyrillic content).
|
||||||
|
* `f_unaccent(text)` is declared IMMUTABLE (migration 20250729T213756), which is
|
||||||
|
* exactly what a GENERATED STORED column requires — so this needs NO trigger.
|
||||||
|
* The column is independent of the embedding vector dimension: it indexes text,
|
||||||
|
* not the vector, so it works for any model dimension.
|
||||||
|
*
|
||||||
|
* NOTE: `fts` is deliberately NOT added to the `PageEmbeddings` Kysely type. It
|
||||||
|
* is a generated column accessed ONLY via raw SQL (the hybrid query); adding it
|
||||||
|
* to the Kysely type would force it into the explicit-column insert in
|
||||||
|
* `insertChunks` and break inserts (a GENERATED column cannot be written to).
|
||||||
|
*/
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
// Generated STORED tsvector mirroring pages.tsv's config. f_unaccent is
|
||||||
|
// IMMUTABLE so it is valid inside a GENERATED column expression (no trigger).
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE page_embeddings
|
||||||
|
ADD COLUMN IF NOT EXISTS fts tsvector
|
||||||
|
GENERATED ALWAYS AS (to_tsvector('english', f_unaccent(content))) STORED
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
// GIN index for fast `fts @@ query` lexical matching on the chunk text.
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_page_embeddings_fts
|
||||||
|
ON page_embeddings USING gin(fts)
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`DROP INDEX IF EXISTS idx_page_embeddings_fts`.execute(db);
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE page_embeddings DROP COLUMN IF EXISTS fts
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
@@ -48,6 +48,16 @@ export interface PageEmbeddingSearchHit {
|
|||||||
distance: number;
|
distance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single hybrid (RRF-fused) search hit. Higher `score` is more relevant. */
|
||||||
|
export interface PageEmbeddingHybridHit {
|
||||||
|
pageId: string;
|
||||||
|
spaceId: string;
|
||||||
|
title: string | null;
|
||||||
|
content: string;
|
||||||
|
// Fused Reciprocal Rank Fusion score (sum of 1/(k+rank) across CTEs).
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageEmbeddingRepo {
|
export class PageEmbeddingRepo {
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
@@ -173,6 +183,102 @@ export class PageEmbeddingRepo {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HYBRID retrieval: fuse semantic (cosine) and lexical (full-text) chunk
|
||||||
|
* rankings with Reciprocal Rank Fusion (RRF). Scoped to a workspace AND the
|
||||||
|
* set of spaces the caller may read. Returns [] when `spaceIds` is empty.
|
||||||
|
*
|
||||||
|
* Two CTEs each rank chunks independently, then a FULL OUTER JOIN on the chunk
|
||||||
|
* `id` fuses them. RRF combines RANKS (not raw scores), so the cosine-distance
|
||||||
|
* and ts_rank scales never need normalizing — that is the whole point of RRF.
|
||||||
|
*
|
||||||
|
* score = 1/(k + rank_semantic) + 1/(k + rank_lexical)
|
||||||
|
*
|
||||||
|
* with k = 60 (Cormack et al. 2009; the default in Elasticsearch, OpenSearch
|
||||||
|
* and Weaviate) and equal 1.0/1.0 weights as a starting point. `candidates`
|
||||||
|
* is both the per-CTE over-fetch limit and the final fused LIMIT.
|
||||||
|
*
|
||||||
|
* The `model_dimensions = $dim` filter applies ONLY on the semantic side
|
||||||
|
* (cosine compares same-dimension vectors; pgvector errors otherwise). The
|
||||||
|
* lexical side (`fts`) is dimension-independent. If `websearch_to_tsquery`
|
||||||
|
* yields an EMPTY query (e.g. the text is all stopwords) the `@@` matches
|
||||||
|
* nothing and the lexical CTE is empty, so results degrade to pure-semantic —
|
||||||
|
* which is correct behaviour, not an error.
|
||||||
|
*
|
||||||
|
* `fts` is a generated column accessed only here via raw SQL (deliberately not
|
||||||
|
* in the Kysely `PageEmbeddings` type — see migration 20260618T150000).
|
||||||
|
*/
|
||||||
|
async hybridSearch(
|
||||||
|
workspaceId: string,
|
||||||
|
queryEmbedding: number[],
|
||||||
|
queryText: string,
|
||||||
|
spaceIds: string[],
|
||||||
|
// Per-CTE over-fetch AND the final fused LIMIT.
|
||||||
|
candidates: number,
|
||||||
|
): Promise<PageEmbeddingHybridHit[]> {
|
||||||
|
if (spaceIds.length === 0) return [];
|
||||||
|
|
||||||
|
const queryVector = sql`${pgvector.toSql(queryEmbedding)}::vector`;
|
||||||
|
const queryDim = queryEmbedding.length;
|
||||||
|
const spaceList = sql.join(
|
||||||
|
spaceIds.map((s) => sql`${s}`),
|
||||||
|
sql`, `,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await sql<{
|
||||||
|
pageId: string;
|
||||||
|
spaceId: string;
|
||||||
|
title: string | null;
|
||||||
|
content: string;
|
||||||
|
score: number;
|
||||||
|
}>`
|
||||||
|
WITH semantic AS (
|
||||||
|
SELECT pe.id, pe.page_id, pe.space_id, pe.content, p.title,
|
||||||
|
row_number() OVER (ORDER BY pe.embedding <=> ${queryVector}) AS rank_ix
|
||||||
|
FROM page_embeddings pe
|
||||||
|
JOIN pages p ON p.id = pe.page_id
|
||||||
|
WHERE pe.workspace_id = ${workspaceId}
|
||||||
|
AND pe.space_id IN (${spaceList})
|
||||||
|
AND pe.model_dimensions = ${queryDim}
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
ORDER BY pe.embedding <=> ${queryVector}
|
||||||
|
LIMIT ${candidates}
|
||||||
|
),
|
||||||
|
full_text AS (
|
||||||
|
SELECT pe.id, pe.page_id, pe.space_id, pe.content, p.title,
|
||||||
|
row_number() OVER (ORDER BY ts_rank(pe.fts, q.query) DESC) AS rank_ix
|
||||||
|
FROM page_embeddings pe
|
||||||
|
JOIN pages p ON p.id = pe.page_id,
|
||||||
|
websearch_to_tsquery('english', f_unaccent(${queryText})) AS q(query)
|
||||||
|
WHERE pe.workspace_id = ${workspaceId}
|
||||||
|
AND pe.space_id IN (${spaceList})
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND pe.fts @@ q.query
|
||||||
|
ORDER BY ts_rank(pe.fts, q.query) DESC
|
||||||
|
LIMIT ${candidates}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
coalesce(semantic.page_id, full_text.page_id) AS "pageId",
|
||||||
|
coalesce(semantic.space_id, full_text.space_id) AS "spaceId",
|
||||||
|
coalesce(semantic.title, full_text.title) AS title,
|
||||||
|
coalesce(semantic.content, full_text.content) AS content,
|
||||||
|
coalesce(1.0/(60 + semantic.rank_ix), 0.0) * 1.0
|
||||||
|
+ coalesce(1.0/(60 + full_text.rank_ix), 0.0) * 1.0 AS score
|
||||||
|
FROM semantic
|
||||||
|
FULL OUTER JOIN full_text ON semantic.id = full_text.id
|
||||||
|
ORDER BY score DESC
|
||||||
|
LIMIT ${candidates}
|
||||||
|
`.execute(this.db);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
pageId: row.pageId,
|
||||||
|
spaceId: row.spaceId,
|
||||||
|
title: row.title,
|
||||||
|
content: row.content,
|
||||||
|
score: Number(row.score),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count DISTINCT non-deleted pages that have at least one embedding row in this
|
* Count DISTINCT non-deleted pages that have at least one embedding row in this
|
||||||
* workspace — i.e. how many pages currently have stored embeddings.
|
* workspace — i.e. how many pages currently have stored embeddings.
|
||||||
|
|||||||
145
docs/rag-improvements-plan.md
Normal file
145
docs/rag-improvements-plan.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Улучшение качества RAG-поиска агента — план по итерациям
|
||||||
|
|
||||||
|
> Статус: живой документ. Итерация 1 **реализована** (см. ниже). Остальное —
|
||||||
|
> бэклог на следующие итерации, отсортированный по «качество / усилие».
|
||||||
|
> Контекст: gitmost — форк Docmost. Семантический поиск агента: per-workspace
|
||||||
|
> эмбеддинги в `page_embeddings` (pgvector, dimension-agnostic колонка, seq-scan
|
||||||
|
> с `<=>`), индексация через BullMQ (`reindexPage` / `reindexWorkspace`).
|
||||||
|
> Активная embedding-модель деплоя: OpenAI `text-embedding-3-large` (3072d).
|
||||||
|
|
||||||
|
## Как сверялось с реальным кодом
|
||||||
|
|
||||||
|
Внешнее предложение по улучшению RAG было сверено с кодовой базой. Точные факты
|
||||||
|
на момент итерации 1:
|
||||||
|
|
||||||
|
- Хранилище: [page_embeddings](../apps/server/src/database/migrations/20260617T120000-page-embeddings.ts),
|
||||||
|
колонка `embedding` сделана dimension-agnostic в
|
||||||
|
[20260617T140000](../apps/server/src/database/migrations/20260617T140000-page-embeddings-dimension-agnostic.ts);
|
||||||
|
`model_name` / `model_dimensions` хранятся по строке.
|
||||||
|
- Полнотекстовые индексы **уже существуют** (предложение ошибочно утверждало
|
||||||
|
обратное): `pages_tsv_idx` на `pages.tsv` и `attachments_tsv_idx`. Конфигурация —
|
||||||
|
`to_tsvector('english', f_unaccent(...))` + `setweight`
|
||||||
|
([тут](../apps/server/src/database/migrations/20250729T213756-add-unaccent-pg_trm-update-tsvector..ts)).
|
||||||
|
- Чанкинг: `RecursiveCharacterTextSplitter` 1000/200, без префиксов.
|
||||||
|
- Префиксы `query:` / `passage:` **не нужны**: они требуются для e5/bge/gte/Qwen3,
|
||||||
|
а деплой на OpenAI `text-embedding-3-large` (этот пункт предложения неприменим).
|
||||||
|
- Вложения (`attachment_id` в схеме есть) **не индексируются** — индексатор всегда
|
||||||
|
пишет `attachmentId: null`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 1 — РЕАЛИЗОВАНО
|
||||||
|
|
||||||
|
Три «низковисящих фрукта»:
|
||||||
|
|
||||||
|
### 1. Хлебные крошки заголовков в чанках
|
||||||
|
Файл: [embedding-indexer.service.ts](../apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts).
|
||||||
|
Каждый чанк префиксуется путём заголовков `«Заголовок страницы > H1 > H2»` перед
|
||||||
|
эмбеддингом. Крошки строятся обходом **ProseMirror JSON** (`heading`-ноды с
|
||||||
|
`attrs.level`), а не markdown-текста — поэтому `#` внутри fenced-код-блока (типичный
|
||||||
|
bash-сниппет в WirenBoard-вики) **никогда** не принимается за заголовок. Деградация
|
||||||
|
к старому plain-text чанкингу при отсутствии/сбое `content`. Префикс попадает и в
|
||||||
|
эмбеддинг, и в `content` (а значит — в лексический индекс `fts` и в сниппет агента).
|
||||||
|
|
||||||
|
### 2. Гибридный поиск (RRF), слияние двух инструментов в один
|
||||||
|
- Миграция [20260618T150000-page-embeddings-fts.ts](../apps/server/src/database/migrations/20260618T150000-page-embeddings-fts.ts):
|
||||||
|
генерируемая колонка `fts tsvector GENERATED ALWAYS AS (to_tsvector('english',
|
||||||
|
f_unaccent(content))) STORED` + GIN-индекс. Конфиг совпадает с `pages.tsv` (та же
|
||||||
|
обработка unaccent/Cyrillic); `f_unaccent` IMMUTABLE → триггер не нужен.
|
||||||
|
- Репозиторий: метод `hybridSearch` в
|
||||||
|
[page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) —
|
||||||
|
один SQL-запрос, два CTE (cosine + `websearch_to_tsquery`), слияние Reciprocal Rank
|
||||||
|
Fusion через FULL OUTER JOIN на уровне чанков. `k=60` (дефолт Cormack 2009 /
|
||||||
|
ES / OpenSearch / Weaviate), равные веса 1.0/1.0. RRF сливает **ранги**, поэтому
|
||||||
|
несовместимость шкал BM25 и косинуса не требует нормализации. Dimension-фильтр —
|
||||||
|
только на семантической стороне.
|
||||||
|
- Инструменты: `semanticSearch` удалён, `searchPages` стал единым гибридным
|
||||||
|
инструментом ([ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts)).
|
||||||
|
Контроль доступа сохранён 1-в-1 (scope по доступным спейсам + пост-фильтр прав
|
||||||
|
страниц). Если эмбеддинги не настроены / эмбеддинг упал / нет доступных спейсов /
|
||||||
|
гибрид пуст → graceful fallback на прежний REST-полнотекст (CASL-enforced).
|
||||||
|
|
||||||
|
### 3. Переписывание запроса + описания инструментов
|
||||||
|
- Описание `searchPages` теперь явно просит агента переформулировать вопрос в
|
||||||
|
сфокусированный поисковый запрос и переискивать при слабой выдаче (это переживает
|
||||||
|
кастомный admin-промпт, т.к. лежит в описании инструмента).
|
||||||
|
- Одна строка-подсказка добавлена в `DEFAULT_PROMPT`
|
||||||
|
([ai-chat.prompt.ts](../apps/server/src/core/ai-chat/ai-chat.prompt.ts)).
|
||||||
|
|
||||||
|
> ВАЖНО после деплоя: чтобы крошки и `fts` появились у существующих страниц, нужна
|
||||||
|
> **переиндексация корпуса** (кнопка «Reindex now» / `WORKSPACE_CREATE_EMBEDDINGS`).
|
||||||
|
> Миграция заполнит `fts` у текущих строк автоматически, но крошки добавляются только
|
||||||
|
> при переиндексации (она же перезапишет `content`).
|
||||||
|
|
||||||
|
### Известные нюансы текущей реализации (осознанные компромиссы)
|
||||||
|
- Гибрид покрывает только проиндексированные чанки. Свежесозданная страница
|
||||||
|
становится искомой после отработки её BullMQ-`reindexPage`. Пока эмбеддинги не
|
||||||
|
настроены — работает только REST-fallback (полнотекст уровня страницы по `pages.tsv`).
|
||||||
|
- Если **весь** пул кандидатов гибрида (до 200 чанков) оказался из закрытых для
|
||||||
|
пользователя страниц, инструмент вернёт пусто, а не уйдёт в keyword-fallback.
|
||||||
|
Узкий кейс; возможное улучшение — fallback и при пустом результате пост-фильтра.
|
||||||
|
- `fts` использует конфиг `english` (как и `pages.tsv`) — без русской стеммизации.
|
||||||
|
Для русской вики это консистентно с текущим поиском; переход на `simple`/`russian`
|
||||||
|
конфиг — отдельная задача с переиндексацией.
|
||||||
|
- `candidates` (=clamp(limit×5, 50, 200)) служит и per-CTE лимитом, и финальным
|
||||||
|
лимитом слияния; веса RRF равные. Тюнится после появления оценочного харнесса.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Бэклог следующих итераций (по приоритету «качество / усилие»)
|
||||||
|
|
||||||
|
### A. Реранкер (cross-encoder) — наибольший ROI после гибрида
|
||||||
|
Вставить между over-fetch гибрида и дедупом: брать топ-50–100 кандидатов от
|
||||||
|
`hybridSearch`, реранкать, оставлять топ-5–10. Ожидаемый прирост precision/MRR
|
||||||
|
+10–25 %. Точка вставки уже готова — это шаг между `hybridSearch(... candidates)` и
|
||||||
|
циклом дедупа в `searchPages`.
|
||||||
|
- Хостовый старт (раз уже на OpenAI-инфраструктуре): **Cohere Rerank** или
|
||||||
|
**Voyage `rerank-2.5`** — провайдер по аналогии с текущим pluggable embedding-конфигом.
|
||||||
|
- Self-hosted (под Ollama-этос): **BGE-reranker-v2-m3** через HF Text Embeddings
|
||||||
|
Inference (`/rerank`), либо FlashRank (ONNX/CPU, ~15–30 мс).
|
||||||
|
- Диагностика: если реранк не двигает метрики — узкое место в recall (чанкинг/гибрид),
|
||||||
|
а не в ранжировании.
|
||||||
|
|
||||||
|
### B. Индексация вложений — закрыть пробел покрытия
|
||||||
|
Схема уже готова (`attachment_id`). Добавить в BullMQ-flow шаг извлечения текста из
|
||||||
|
PDF/документов (PyMuPDF для цифровых PDF; OCR для сканов; для таблиц — markdown через
|
||||||
|
LLM-парсер) и вливать его в тот же путь чанк→эмбеддинг→`fts`, помечая `attachment_id`.
|
||||||
|
Структура извлечённых данных важнее голой точности OCR.
|
||||||
|
|
||||||
|
### C. Тюнинг гибрида и оценочный харнесс
|
||||||
|
- Золотой датасет 30–100 примеров (вопрос → нужная страница/чанк) + Ragas/DeepEval
|
||||||
|
(Recall@k, MRR/nDCG, context precision/recall, faithfulness). Прогон до/после
|
||||||
|
каждого изменения. **Прерогатива пропущена в итерации 1 осознанно** — без неё все
|
||||||
|
нижеследующие тюнинги делаются «на глаз».
|
||||||
|
- После харнесса: тюнить веса RRF (старт 1.0/1.0), `k` (старт 60), число `candidates`.
|
||||||
|
- Эксперимент: чанки ~512 симв. против 1000 (предложение указывает на рост precision).
|
||||||
|
|
||||||
|
### D. Contextual Retrieval (Anthropic), если крошек мало
|
||||||
|
Один LLM-вызов на чанк добавляет предложение-контекст. Снижение провалов выдачи
|
||||||
|
на 35–49 %. Ложится в BullMQ-`reindexPage`; на сотнях страниц с prompt caching — копейки.
|
||||||
|
Применять, только если хлебных крошек окажется недостаточно против потери контекста.
|
||||||
|
|
||||||
|
### E. ParadeDB `pg_search` (настоящий BM25), если лексика станет узким местом
|
||||||
|
Нативный `ts_rank` использует только TF и длину документа, без IDF. `pg_search`
|
||||||
|
(Rust/Tantivy) даёт честный BM25-индекс. Не drop-in (свои операторы вместо `@@`) —
|
||||||
|
это изменение кода, а не флаг. На сотнях страниц нативного `tsvector` хватает; брать
|
||||||
|
только если качество лексического ранжирования упрётся в потолок.
|
||||||
|
|
||||||
|
### F. Прочее
|
||||||
|
- **Префиксы query/passage** — НЕ нужны на OpenAI. Внедрять только при переходе на
|
||||||
|
e5/bge/gte/Qwen3 (тогда индексатор ставит `passage:`, запрос — `query:`; BGE-v1.5,
|
||||||
|
наоборот, префиксов НЕ должна получать). Зафиксировано как ловушка на будущее.
|
||||||
|
- **Апгрейд embedding-модели** — уже на `text-embedding-3-large` (топ среди закрытых).
|
||||||
|
Matryoshka (обрезка размерности) — запас на будущее; dimension-agnostic колонка
|
||||||
|
делает миграцию тривиальной (цена — переэмбеддинг корпуса).
|
||||||
|
- **HyDE и широкий multi-query/RAG-Fusion** — НЕ рекомендуются как дефолт: в свежих
|
||||||
|
бенчмарках уступали и добавляют задержку/галлюцинации.
|
||||||
|
|
||||||
|
## Оговорки
|
||||||
|
- Все внешние числа (62→84 % precision, +17 % Recall@5, −35…49 % провалов, +10–25 %
|
||||||
|
от реранка) получены на ДРУГИХ корпусах (SEC-отчёты, финтекст, право, медицина).
|
||||||
|
На этой вики величины будут иными — поэтому пункт C (свой датасет) обязателен перед
|
||||||
|
тонким тюнингом. Внешние числа — направление, не гарантия величины.
|
||||||
|
- Часть источников предложения — вендорский маркетинг (Cohere, Voyage, ParadeDB);
|
||||||
|
направление подтверждается независимыми (T2-RAGBench, оценка Anthropic), но величины
|
||||||
|
у вендоров могут быть завышены.
|
||||||
Reference in New Issue
Block a user