Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions src/main/services/KnowledgeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
} from './blocks/documentBlocks'
import { resolveChunkProvenance, type ChunkProvenance } from './chunkProvenance'
import { DenseRetriever } from './retrieval'
import type { EvidenceLocator, Retriever } from './retrieval'
import type { EvidenceLocator, RetrievalRequest, RetrievalResult, Retriever } from './retrieval'
import { WebFetchService } from './WebFetchService'
import { vectorStoreManager } from '../vectorstore'
import Logger from '../../shared/utils/logger'
Expand Down Expand Up @@ -751,34 +751,48 @@ export class KnowledgeService {
}

/**
* 语义搜索。
* 检索:embedding space 前置校验 + 委托给当前 `Retriever`,并带回本次检索的
* trace(#157 会把它快照到回答上)。
*
* 保持原有签名与返回形状,内部委托给默认的 `Retriever`(当前是 `DenseRetriever`)。
* embedding space 校验留在这里:它是前置条件,不是检索策略的一部分。
* embedding space 校验留在这里:它是前置条件,不是检索策略的一部分。旧的
* `search()` 形状保持不变,只是改为经这里委托。
*/
async search(
notebookId: string,
query: string,
options: SearchOptions = {}
): Promise<SearchResult[]> {
const { includeContent = true } = options
async retrieve(request: RetrievalRequest): Promise<RetrievalResult> {
const db = getDatabase()

// 0. 索引身份校验:当前模型与建索引时不一致,向量不可比,明确要求重新索引
const space = await this.embeddingService.getSpace()
const storedSpace = db
.select()
.from(notebookEmbeddingSpaces)
.where(eq(notebookEmbeddingSpaces.notebookId, notebookId))
.where(eq(notebookEmbeddingSpaces.notebookId, request.notebookId))
.get()
if (storedSpace && storedSpace.spaceId !== space.id) {
throw new Error(
'The search model has changed since this notebook was indexed. Re-index the notebook before searching.'
)
}

return this.retriever.search(request)
}

/**
* 语义搜索。
*
* 保持原有签名与返回形状,内部委托给 `retrieve()`(默认策略是
* `DenseRetriever`),再映射成兼容的 `SearchResult` 形状。
*/
async search(
notebookId: string,
query: string,
options: SearchOptions = {}
): Promise<SearchResult[]> {
const { includeContent = true } = options

// 1. 检索(向量 → 批量补齐来源/定位信息)
const evidence = await this.retriever.search(notebookId, query, {
const { evidence } = await this.retrieve({
notebookId,
query,
topK: options.topK,
threshold: options.threshold
})
Expand Down
5 changes: 5 additions & 0 deletions src/main/services/ingestion/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Ingestion 模块入口
*/

export * from './types'
86 changes: 86 additions & 0 deletions src/main/services/ingestion/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Ingestion contract
*
* 索引管道需要一套自己的状态,而不是复用 `documents.status`
* (`pending | processing | indexed | failed`)。原因有三:
*
* 1. 它表达不了 `queued → parsing → chunking → embedding → ready` 这样的阶段,
* 用户在导入大文件时看不到进行到哪一步;
* 2. 它把**来源身份**(source)和**派生索引**(index)混在一个字段里 —— 文件被删
* 应该是 source 缺失、index 过期,而不是 source 消失;
* 3. 一次导入/重建索引是**一次尝试**,可能失败、可以重试,而 `documents` 一行只
* 能记一个终态。重试需要历史,因此单独的 `ingestion_runs` 记录。
*
* 这里只定义形状与状态机;表、写入与生命周期由 #95 实现。`source state` 与
* `index state` 也是同理:#98(批量导入)与 #158(监听文件夹)都必须转成这里的
* 一次 run 入队,而不是各自重写 parse/chunk/embed。
*/

/** 一次索引尝试的种类:首次导入,或对已有 source 重建索引。 */
export type IngestionRunKind = 'import' | 'reindex'

/**
* 一次 ingestion run 的阶段。
*
* 前六个是进行中的阶段,后三个是终态;终态之后不再变化。`queued` 在 run 开始
* 之前,`completed` / `failed` / `cancelled` 之后不再有下一个阶段。
*/
export type IngestionStage =
| 'queued'
| 'copying'
| 'parsing'
| 'chunking'
| 'embedding'
| 'finalizing'
| 'completed'
| 'failed'
| 'cancelled'

/** 全部阶段,按进行顺序排列(终态排在最后)。 */
export const INGESTION_STAGES: readonly IngestionStage[] = [
'queued',
'copying',
'parsing',
'chunking',
'embedding',
'finalizing',
'completed',
'failed',
'cancelled'
]

/** 终态:run 到此结束,不再变化。 */
export const TERMINAL_INGESTION_STAGES = ['completed', 'failed', 'cancelled'] as const

export type TerminalIngestionStage = (typeof TERMINAL_INGESTION_STAGES)[number]

/** 一个阶段是否是终态。用于判断 run 是否还能被重试/推进。 */
export function isTerminalStage(stage: IngestionStage): stage is TerminalIngestionStage {
return (TERMINAL_INGESTION_STAGES as readonly string[]).includes(stage)
}

/**
* 来源文件本身的状态。与索引状态分开:文件被删是 `missing`,索引才是 `stale`,
* 因此 citation / 摘录仍然存在,只是指向一个暂时取不到的源文件。
*/
export type SourceState = 'available' | 'missing' | 'changed'

/** 派生索引的状态。 */
export type IndexState = 'queued' | 'processing' | 'ready' | 'failed' | 'stale'

/**
* 一次索引尝试的记录(#95 的 `ingestion_runs` 行)。
*
* 一个 document 可以有多次 run:失败的、重试后成功的。`documents` 只保留来源身份,
* run 保留每次尝试的历史。
*/
export interface IngestionRun {
id: string
documentId: string
kind: IngestionRunKind
stage: IngestionStage
progress: number
errorMessage?: string
startedAt: number
finishedAt?: number
}
37 changes: 26 additions & 11 deletions src/main/services/retrieval/DenseRetriever.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,41 @@ import { getDatabase } from '../../db'
import { vectorStoreManager } from '../../vectorstore'
import type { EmbeddingService } from '../EmbeddingService'
import { hydrateEvidence } from './evidence'
import type { RetrieveOptions, RetrievedEvidence, Retriever } from './types'
import { assertFilterSupported } from './filter'
import { buildRetrievalTrace } from './trace'
import type { RetrievalRequest, RetrievalResult, Retriever } from './types'

const STRATEGY = 'dense'

export class DenseRetriever implements Retriever {
constructor(private readonly embeddingService: EmbeddingService) {}

async search(
notebookId: string,
query: string,
options: RetrieveOptions = {}
): Promise<RetrievedEvidence[]> {
const { topK = 5, threshold = 0.5 } = options
async search(request: RetrievalRequest): Promise<RetrievalResult> {
// 先拒绝还不支持的过滤:静默忽略会返回"看起来受限、实际未受限"的结果。
assertFilterSupported(request.filter)

const topK = request.topK ?? 5
const threshold = request.threshold ?? 0.5
const startedAt = performance.now()

// E5 要求 query 前缀,与索引时的 document 前缀区分
await this.embeddingService.ensureReady()
const queryEmbedding = await this.embeddingService.embed(query, 'query')
const queryEmbedding = await this.embeddingService.embed(request.query, 'query')

const vectorStore = await vectorStoreManager.getStore(notebookId)
const vectorStore = await vectorStoreManager.getStore(request.notebookId)
const hits = await vectorStore.query(queryEmbedding.embedding, { topK, threshold })
if (hits.length === 0) return []

return hydrateEvidence(getDatabase(), hits)
const evidence = hits.length === 0 ? [] : hydrateEvidence(getDatabase(), hits)

return {
evidence,
trace: buildRetrievalTrace({
strategy: STRATEGY,
filter: request.filter,
topK,
threshold,
durationMs: performance.now() - startedAt
})
}
}
}
21 changes: 21 additions & 0 deletions src/main/services/retrieval/filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { RetrievalFilter } from './types'

/**
* 目前只有 dense 检索,还没有任何后端支持按来源预过滤。
*
* `RetrievalRequest.filter` 是 #94 需要的 seam,但预过滤的实现(向量表要能按来源
* 过滤,且必须在 KNN 之前生效)属于 #94。这里如果接受 filter 却不使用,就会返回
* 「看起来受限、实际是整个 notebook」的结果 —— 那正是 #160 要删掉的那种「声明了
* 却不生效」的接口。所以宁可在它被真正使用前明确拒绝,也不要静默忽略。
*
* 空数组与缺省一样表示「不过滤」,因此不触发拒绝。
*/
export function assertFilterSupported(filter?: RetrievalFilter): void {
const documentIds = filter?.documentIds
if (documentIds && documentIds.length > 0) {
throw new Error(
'Source filtering is not implemented yet (#94). ' +
'Refusing to ignore the filter and return unscoped results.'
)
}
}
2 changes: 2 additions & 0 deletions src/main/services/retrieval/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
export * from './types'
export { DenseRetriever } from './DenseRetriever'
export { hydrateEvidence, assembleEvidence, type EvidenceHit } from './evidence'
export { buildRetrievalTrace, type RetrievalTraceInput } from './trace'
export { assertFilterSupported } from './filter'
34 changes: 34 additions & 0 deletions src/main/services/retrieval/trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { RetrievalFilter, RetrievalTrace } from './types'

export interface RetrievalTraceInput {
strategy: string
filter?: RetrievalFilter
topK: number
threshold?: number
durationMs: number
}

/**
* 组装一次检索的 trace。
*
* 纯函数,便于单测:trace 会被 #157 原样快照到 message 上,所以它的形状必须稳
* 定,并且与具体策略实现解耦 —— 策略只说「我用了什么参数」,不决定 trace 的结构。
*
* `scope` 只在真的有过滤时才带 `documentIds`;没有过滤时是 `{}`,序列化后也不会
* 留下一个 `undefined` 字段。
*/
export function buildRetrievalTrace(input: RetrievalTraceInput): RetrievalTrace {
const scope: { documentIds?: string[] } = {}
if (input.filter?.documentIds) scope.documentIds = input.filter.documentIds

const trace: RetrievalTrace = {
strategy: input.strategy,
scope,
topK: input.topK,
durationMs: input.durationMs
}

if (input.threshold !== undefined) trace.threshold = input.threshold

return trace
}
45 changes: 41 additions & 4 deletions src/main/services/retrieval/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,55 @@ export interface RetrievedEvidence {
}

/**
* 检索选项。`includeContent` 不在这里:截断内容由 legacy `SearchResult` 映射决定,
* 不是检索策略的事(策略应当总是返回真实内容)。
* 限制检索范围的过滤条件。
*
* `documentIds` 为空或缺省都表示「整个 notebook」。当前还没有后端实现预过滤,
* 所以这个 seam 由 `assertFilterSupported()` 守着:被真正使用前拒绝,而不是静默
* 忽略(实现落在 #94)。
*/
export interface RetrieveOptions {
export interface RetrievalFilter {
documentIds?: string[]
}

/**
* 一次检索请求。
*
* 取代旧的 `(notebookId, query, options)` 位置参数:#94 的 scope、#77 的策略参数
* 与 #157 要快照的 trace 都要挂在这一个对象上,而不是散落在调用点。
*/
export interface RetrievalRequest {
notebookId: string
query: string
topK?: number
threshold?: number
filter?: RetrievalFilter
}

/**
* 一次检索实际生效的参数。
*
* 它会随回答一起被快照(#157),因此必须由检索层产出、而不是调用方猜:
* `strategy` 说明用的是哪条检索路径,`scope` 说明结果被限制在哪些来源。
* `threshold` 缺省表示该策略没有阈值,不是「阈值等于 0」。
*/
export interface RetrievalTrace {
strategy: string
scope: { documentIds?: string[] }
topK: number
threshold?: number
durationMs: number
}

/** 检索结果:证据 + 本次检索的可解释参数。 */
export interface RetrievalResult {
evidence: RetrievedEvidence[]
trace: RetrievalTrace
}

/**
* 检索策略的稳定契约。当前只有 `DenseRetriever`;#77 的 BM25 / hybrid / reranker
* 只要实现这个接口就能被 eval harness 直接度量与替换。
*/
export interface Retriever {
search(notebookId: string, query: string, options?: RetrieveOptions): Promise<RetrievedEvidence[]>
search(request: RetrievalRequest): Promise<RetrievalResult>
}
16 changes: 11 additions & 5 deletions src/main/smokeTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,12 +656,18 @@ async function runChecks(): Promise<string[]> {

// Retrieval delivers provenance, not a bare chunk (#74). The re-index above
// just rebuilt chunks, embeddings, vectors and the chunk↔block mapping, so
// this drives the real DenseRetriever against them.
const evidence = await new DenseRetriever(fakeEmbeddingService()).search(
reindexNotebook,
'sample query'
)
// this drives the real DenseRetriever against them. The trace (#160) rides
// alongside, so the packaged app proves the whole result shape, not just the
// evidence array.
const reindexResult = await new DenseRetriever(fakeEmbeddingService()).search({
notebookId: reindexNotebook,
query: 'sample query'
})
const evidence = reindexResult.evidence
assert(evidence.length > 0, 'DenseRetriever returned no evidence for an indexed notebook')
assert(reindexResult.trace.strategy === 'dense', 'retrieval trace lost its strategy')
assert(reindexResult.trace.topK === 5, `retrieval trace topK was ${reindexResult.trace.topK}`)
assert(Number.isFinite(reindexResult.trace.durationMs), 'retrieval trace has no duration')
const first = evidence[0]
assert(first.documentId === reindexDocId, `evidence came from ${first.documentId}`)
assert(first.source.title.length > 0, 'evidence lost its source title')
Expand Down
8 changes: 5 additions & 3 deletions src/main/vectorstore/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ export interface QueryResult {
export interface QueryOptions {
topK?: number // 返回前 K 个结果,默认 5
threshold?: number // 相似度阈值(0-1),低于此值的结果不返回
filter?: {
chunkIds?: string[] // 限制在指定 chunk 中搜索
}
}

// 这里曾经声明过 `filter?: { chunkIds?: string[] }`,但 `SQLiteVectorStore.query()`
// 从未读取它,`knnQuerySql()` 也没有对应的 WHERE —— 一个声明了却不生效的能力,比
// 没有这个能力更糟。真正需要按来源预过滤的是 #94,而它要求过滤发生在 KNN 之前,
// 且向量表要能按 document id 过滤;那属于 #94 的实现,不属于这个接口。

/**
* VectorStore 配置
*/
Expand Down
Loading
Loading