rerank.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. import { streamChat } from "@/lib/llm-client"
  2. import { useWikiStore, type LlmConfig, type RerankConfig } from "@/stores/wiki-store"
  3. import { isDirectRerankEndpoint, requestDirectRerank } from "@/lib/rerank-api"
  4. import { resolveDefaultModel } from "@/lib/novel/model-resolver"
  5. export interface RerankCandidate {
  6. id: string
  7. title: string
  8. snippet: string
  9. source?: string
  10. path?: string
  11. }
  12. interface RerankResponseItem {
  13. id: string
  14. score?: number
  15. }
  16. interface RerankResponse {
  17. order?: RerankResponseItem[]
  18. }
  19. export interface RerankOptions {
  20. topK?: number
  21. purpose?: string
  22. }
  23. function extractJsonObject(raw: string): RerankResponse {
  24. const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]
  25. const candidate = fenced?.match(/\{[\s\S]*\}/)?.[0] ?? raw.match(/\{[\s\S]*\}/)?.[0]
  26. if (!candidate) {
  27. throw new Error("Rerank response did not contain JSON")
  28. }
  29. return JSON.parse(candidate) as RerankResponse
  30. }
  31. function resolveRerankModel(
  32. baseConfig: LlmConfig,
  33. rerankConfig: RerankConfig,
  34. ): LlmConfig | null {
  35. if (!rerankConfig.enabled) return null
  36. if (rerankConfig.useMainLlm) {
  37. return { ...baseConfig, reasoning: { mode: "off" } }
  38. }
  39. if (!rerankConfig.model.trim()) return null
  40. return {
  41. provider: rerankConfig.provider,
  42. apiKey: rerankConfig.apiKey,
  43. model: rerankConfig.model,
  44. ollamaUrl: rerankConfig.ollamaUrl,
  45. customEndpoint: rerankConfig.customEndpoint,
  46. apiMode: rerankConfig.provider === "custom" ? rerankConfig.apiMode : undefined,
  47. maxContextSize: Math.min(baseConfig.maxContextSize ?? 65536, 65536),
  48. reasoning: { mode: "off" },
  49. }
  50. }
  51. function buildPrompt(
  52. query: string,
  53. candidates: RerankCandidate[],
  54. purpose?: string,
  55. ): string {
  56. const serialized = candidates.map((candidate, index) => ({
  57. id: candidate.id,
  58. rank: index + 1,
  59. title: candidate.title,
  60. source: candidate.source ?? "",
  61. path: candidate.path ?? "",
  62. snippet: candidate.snippet.slice(0, 500),
  63. }))
  64. return [
  65. "你是一个检索结果重排助手。",
  66. "你的任务是根据查询意图,把候选结果按最相关到最不相关重新排序。",
  67. "不要生成新条目,不要修改 id,不要解释过程。",
  68. "优先考虑:与查询目标的直接相关性、对当前任务的可执行价值、事实约束和记忆一致性。",
  69. purpose ? `当前用途:${purpose}` : "",
  70. "",
  71. `查询:${query}`,
  72. "",
  73. "候选结果 JSON:",
  74. JSON.stringify(serialized, null, 2),
  75. "",
  76. "只返回 JSON,对象格式必须是:",
  77. '{"order":[{"id":"候选id","score":0.0}]}',
  78. ].filter(Boolean).join("\n")
  79. }
  80. export function isRerankEnabled(rerankConfig: RerankConfig): boolean {
  81. return rerankConfig.enabled
  82. }
  83. export async function rerankCandidates<T extends RerankCandidate>(
  84. query: string,
  85. candidates: T[],
  86. options: RerankOptions = {},
  87. ): Promise<T[]> {
  88. if (candidates.length <= 1) return candidates.slice(0, options.topK ?? candidates.length)
  89. const { llmConfig: rawLlmConfig, rerankConfig } = useWikiStore.getState()
  90. const llmConfig = resolveDefaultModel(rawLlmConfig)
  91. const modelConfig = resolveRerankModel(llmConfig, rerankConfig)
  92. if (!modelConfig) {
  93. return candidates.slice(0, options.topK ?? candidates.length)
  94. }
  95. const candidateLimit = Math.min(
  96. candidates.length,
  97. Math.max(options.topK ?? 0, rerankConfig.maxCandidates),
  98. )
  99. const candidateSlice = candidates.slice(0, candidateLimit)
  100. const prompt = buildPrompt(query, candidateSlice, options.purpose)
  101. if (isDirectRerankEndpoint(modelConfig)) {
  102. try {
  103. const directResults = await requestDirectRerank(
  104. modelConfig,
  105. query,
  106. candidateSlice.map((candidate) => [candidate.title, candidate.snippet, candidate.source, candidate.path].filter(Boolean).join("\n")),
  107. AbortSignal.timeout(45000),
  108. )
  109. const ordered: T[] = []
  110. const used = new Set<number>()
  111. for (const item of directResults) {
  112. if (!Number.isInteger(item.index) || item.index < 0 || item.index >= candidateSlice.length || used.has(item.index)) continue
  113. used.add(item.index)
  114. ordered.push(candidateSlice[item.index] as T)
  115. }
  116. for (let index = 0; index < candidateSlice.length; index += 1) {
  117. if (used.has(index)) continue
  118. ordered.push(candidateSlice[index] as T)
  119. }
  120. const result = [...ordered, ...candidates.slice(candidateLimit)]
  121. return result.slice(0, options.topK ?? result.length)
  122. } catch (error) {
  123. console.warn("[rerank] direct rerank endpoint failed, using original order:", error)
  124. return candidates.slice(0, options.topK ?? candidates.length)
  125. }
  126. }
  127. let content = ""
  128. let streamError: Error | null = null
  129. await streamChat(modelConfig, [{ role: "user", content: prompt }], {
  130. onToken: (token) => {
  131. content += token
  132. },
  133. onDone: () => {},
  134. onError: (error) => {
  135. streamError = error
  136. },
  137. }, AbortSignal.timeout(45000), {
  138. temperature: 0,
  139. max_tokens: 1200,
  140. })
  141. if (streamError) {
  142. console.warn("[rerank] falling back to original order:", streamError)
  143. return candidates.slice(0, options.topK ?? candidates.length)
  144. }
  145. let parsed: RerankResponse
  146. try {
  147. parsed = extractJsonObject(content)
  148. } catch (error) {
  149. console.warn("[rerank] could not parse response, using original order:", error)
  150. return candidates.slice(0, options.topK ?? candidates.length)
  151. }
  152. const byId = new Map(candidateSlice.map((candidate) => [candidate.id, candidate]))
  153. const ordered: T[] = []
  154. const used = new Set<string>()
  155. for (const item of parsed.order ?? []) {
  156. if (!item?.id || used.has(item.id)) continue
  157. const candidate = byId.get(item.id)
  158. if (!candidate) continue
  159. used.add(item.id)
  160. ordered.push(candidate)
  161. }
  162. for (const candidate of candidateSlice) {
  163. if (used.has(candidate.id)) continue
  164. ordered.push(candidate)
  165. }
  166. const result = [...ordered, ...candidates.slice(candidateLimit)]
  167. return result.slice(0, options.topK ?? result.length)
  168. }