htknow/src/search/advanced.rs
Defeng fe8f0a875a
Some checks failed
CI / Frontend (Vite) (push) Successful in 24s
CI / Docker Release Tar (push) Has been skipped
CI / Backend (Rust) (push) Failing after 10m15s
Initial project import
2026-07-24 10:28:31 +08:00

428 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::{collections::HashSet, time::Duration};
use anyhow::{Context, Result, anyhow};
use log::{info, warn};
use once_cell::sync::Lazy;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use super::SearchResultItem;
use crate::config;
const MAX_NEIGHBOR_SLICES: i64 = 30;
/// 复用的 LLM HTTP client连接池。LLM 调用超时较长,固定 600s。
static LLM_HTTP_CLIENT: Lazy<Client> =
Lazy::new(|| Client::builder().timeout(Duration::from_secs(600)).build().expect("build reqwest client"));
#[derive(Clone)]
pub struct LlmClient {
client: Client,
api_url: Option<String>,
api_key: Option<String>,
model: String,
}
impl Default for LlmClient {
fn default() -> Self {
Self::new()
}
}
impl LlmClient {
pub fn new() -> Self {
let cfg = config::get();
let llm = cfg.llm.clone();
// 复用全局 client避免每次构造都新建连接池
Self { client: LLM_HTTP_CLIENT.clone(), api_url: llm.api_url, api_key: llm.api_key, model: llm.model }
}
pub fn is_enabled(&self) -> bool {
self.api_url.is_some()
}
pub async fn chat_json<T: serde::de::DeserializeOwned>(
&self, prompt: &str, max_tokens: usize, temperature: f32,
) -> Result<T> {
let content = self.chat(prompt, max_tokens, temperature).await?;
let clean = clean_json_like(&content);
let parsed = serde_json::from_str::<T>(&clean)
.map_err(|e| anyhow!("Failed to parse LLM JSON response: {}. content={}", e, clean))?;
Ok(parsed)
}
pub async fn chat(&self, prompt: &str, max_tokens: usize, temperature: f32) -> Result<String> {
let url = self.api_url.as_ref().ok_or_else(|| anyhow!("LLM not configured"))?;
let request = ChatRequest {
model: self.model.clone(),
messages: vec![
Message {
role: "system".to_string(),
content: "Do not output chain-of-thought. Only output final answer.".to_string(),
},
Message { role: "user".to_string(), content: prompt.to_string() },
],
max_tokens: Some(max_tokens),
temperature: Some(temperature),
};
let mut builder = self.client.post(url).json(&request);
if let Some(api_key) = &self.api_key {
builder = builder.header("Authorization", format!("Bearer {}", api_key));
}
let response = builder.send().await.context("LLM HTTP request failed")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!("LLM HTTP error {}: {}", status, body));
}
let body = response.text().await.context("Failed to read LLM body")?;
let parsed: ChatResponse =
serde_json::from_str(&body).map_err(|e| anyhow!("Failed to decode LLM response: {} body={}", e, body))?;
let choice =
parsed.choices.first().ok_or_else(|| anyhow!("LLM response missing choices"))?.message.content.clone();
Ok(choice)
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "snake_case")]
pub enum PlanAction {
RecentDocuments,
DocumentStructure,
PageContent,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlanStep {
pub action: PlanAction,
#[serde(default)]
pub comment: String,
}
pub struct QueryPlanner {
_llm: LlmClient,
}
impl QueryPlanner {
pub fn new(llm: LlmClient) -> Self {
Self { _llm: llm }
}
pub async fn plan(&self, _query: &str, _max_steps: usize) -> Vec<PlanStep> {
default_plan(2)
}
}
fn default_plan(max_steps: usize) -> Vec<PlanStep> {
let mut steps = vec![
PlanStep {
action: PlanAction::RecentDocuments, comment: "搜索相关文档并选出候选切片".to_string()
},
PlanStep {
action: PlanAction::PageContent,
comment: "按候选顺序补充上下文并由 LLM 判断可回答性,命中即停止".to_string(),
},
];
steps.truncate(max_steps.min(steps.len()));
steps
}
pub struct RelevanceJudge {
llm: LlmClient,
}
#[derive(Debug, Clone, Serialize)]
pub struct JudgeOutcome {
pub is_relevant: bool,
pub score: f32,
pub reason: String,
}
impl RelevanceJudge {
pub fn new(llm: LlmClient) -> Self {
Self { llm }
}
pub async fn judge(&self, question: &str, context: &str) -> JudgeOutcome {
if question.trim().is_empty() || context.trim().is_empty() {
return JudgeOutcome { is_relevant: false, score: 0.0, reason: "输入为空".to_string() };
}
if !self.llm.is_enabled() {
return JudgeOutcome { is_relevant: true, score: 1.0, reason: "LLM 未启用,默认通过".to_string() };
}
let prompt = format!(
r#"
你是一名检索质量评估助手。判断候选内容是否可以帮助回答问题,只输出 JSON
{{"relevant":true/false, "score":0-1之间的小数, "reason":"简要说明"}}
问题:
{}
候选内容:
{}
"#,
question, context
);
match self.llm.chat_json::<JudgeResponse>(&prompt, 50000, 0.2).await {
Ok(resp) => {
let mut score = resp.score.unwrap_or(0.5);
if !(0.0..=1.0).contains(&score) {
score = score.clamp(0.0, 1.0);
}
info!("result: {}\nscore: {}", context, score);
JudgeOutcome {
is_relevant: resp.relevant,
score,
reason: resp.reason.unwrap_or_else(|| "LLM 无理由".to_string()),
}
}
Err(err) => {
warn!("LLM judge failed: {}", err);
JudgeOutcome { is_relevant: true, score: 0.6, reason: "LLM 判定失败,默认通过".to_string() }
}
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ChunkSegment {
pub slice_id: i64,
pub text: String,
}
#[derive(Debug, Serialize)]
pub struct ContextChunk {
pub slice_ids: Vec<i64>,
pub content: String,
pub segments: Vec<ChunkSegment>,
}
#[derive(Clone)]
pub struct ChunkRefiner {
llm: LlmClient,
}
impl ChunkRefiner {
pub fn new(llm: LlmClient) -> Self {
Self { llm }
}
pub async fn refine(&self, question: &str, segments: &[ChunkSegment]) -> Result<RefineOutcome> {
if segments.is_empty() {
return Ok(RefineOutcome { segments: Vec::new(), reason: None });
}
if !self.llm.is_enabled() {
return Ok(RefineOutcome { segments: segments.to_vec(), reason: None });
}
let limited_segments: Vec<RefineSegmentInput> = segments
.iter()
.map(|seg| RefineSegmentInput { slice_id: seg.slice_id, text: truncate_text(&seg.text, 400) })
.collect();
let prompt = format!(
r#"
你会得到一个问题和若干切片。请保留对问题最有帮助的切片,其余丢弃。
输出 JSON{{"keep_slice_ids":[1,2], "reason":"简短说明"}}。至少保留一个切片。
问题:
{}
切片:
{}
"#,
question,
serde_json::to_string(&limited_segments)?
);
match self.llm.chat_json::<RefineResponse>(&prompt, 50000, 0.2).await {
Ok(resp) => {
let keep_ids: HashSet<i64> = if resp.keep_slice_ids.is_empty() {
segments.iter().map(|seg| seg.slice_id).collect()
} else {
resp.keep_slice_ids.into_iter().collect()
};
let filtered: Vec<ChunkSegment> =
segments.iter().filter(|seg| keep_ids.contains(&seg.slice_id)).cloned().collect();
let final_segments = if filtered.is_empty() { segments.to_vec() } else { filtered };
Ok(RefineOutcome { segments: final_segments, reason: resp.reason })
}
Err(err) => {
warn!("LLM chunk refine failed: {}", err);
Ok(RefineOutcome { segments: segments.to_vec(), reason: None })
}
}
}
}
pub struct RefineOutcome {
pub segments: Vec<ChunkSegment>,
pub reason: Option<String>,
}
pub async fn assemble_context_chunk(
pool: &SqlitePool, center: &SearchResultItem, per_side_chars: usize,
) -> Result<ContextChunk> {
// 前后切片两条查询相互独立,并发执行
let (before_rows, after_rows) = tokio::try_join!(
fetch_before_slices(pool, center.file_id, center.id),
fetch_after_slices(pool, center.file_id, center.id),
)?;
let before = take_with_limit(before_rows, per_side_chars, true);
let after = take_with_limit(after_rows, per_side_chars, false);
let mut segments: Vec<ChunkSegment> = Vec::new();
for row in &before {
segments.push(ChunkSegment { slice_id: row.id, text: row.content.clone() });
}
segments.push(ChunkSegment { slice_id: center.id, text: center.content.clone() });
for row in &after {
segments.push(ChunkSegment { slice_id: row.id, text: row.content.clone() });
}
let slice_ids = segments.iter().map(|seg| seg.slice_id).collect();
let combined = segments.iter().map(|seg| seg.text.as_str()).collect::<Vec<_>>().join("\n");
Ok(ContextChunk { slice_ids, content: combined, segments })
}
#[derive(sqlx::FromRow, Clone)]
struct SliceRow {
id: i64,
content: String,
}
#[derive(Debug, Serialize)]
struct RefineSegmentInput {
slice_id: i64,
text: String,
}
fn truncate_text(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let mut result = String::new();
for (idx, ch) in text.chars().enumerate() {
if idx >= max_chars {
result.push_str("...");
break;
}
result.push(ch);
}
result
}
fn take_with_limit(items: Vec<SliceRow>, char_limit: usize, reverse_after: bool) -> Vec<SliceRow> {
if reverse_after {
// 当前 items 为倒序
let mut selected = Vec::new();
let mut total = 0;
for row in items.into_iter() {
total += row.content.chars().count();
selected.push(row);
if total >= char_limit {
break;
}
}
selected.reverse();
selected
} else {
let mut selected = Vec::new();
let mut total = 0;
for row in items.into_iter() {
total += row.content.chars().count();
selected.push(row);
if total >= char_limit {
break;
}
}
selected
}
}
async fn fetch_before_slices(pool: &SqlitePool, file_id: i64, center_id: i64) -> Result<Vec<SliceRow>> {
let sql =
format!("SELECT id FROM slices WHERE file_id = ? AND id < ? ORDER BY id DESC LIMIT {}", MAX_NEIGHBOR_SLICES);
let ids: Vec<i64> = sqlx::query_scalar(&sql).bind(file_id).bind(center_id).fetch_all(pool).await?;
let contents = crate::slice_content::read_all(file_id).await?;
Ok(ids.into_iter().map(|id| SliceRow { id, content: contents.get(&id).cloned().unwrap_or_default() }).collect())
}
async fn fetch_after_slices(pool: &SqlitePool, file_id: i64, center_id: i64) -> Result<Vec<SliceRow>> {
let sql =
format!("SELECT id FROM slices WHERE file_id = ? AND id > ? ORDER BY id ASC LIMIT {}", MAX_NEIGHBOR_SLICES);
let ids: Vec<i64> = sqlx::query_scalar(&sql).bind(file_id).bind(center_id).fetch_all(pool).await?;
let contents = crate::slice_content::read_all(file_id).await?;
Ok(ids.into_iter().map(|id| SliceRow { id, content: contents.get(&id).cloned().unwrap_or_default() }).collect())
}
#[derive(Debug, Deserialize)]
struct RefineResponse {
#[serde(default)]
keep_slice_ids: Vec<i64>,
#[serde(default)]
reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct JudgeResponse {
relevant: bool,
#[serde(default)]
score: Option<f32>,
#[serde(default)]
reason: Option<String>,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
}
#[derive(Debug, Serialize)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
#[derive(Debug, Deserialize)]
struct ChatChoice {
message: ChatMessage,
}
#[derive(Debug, Deserialize)]
struct ChatMessage {
content: String,
}
fn clean_json_like(input: &str) -> String {
let mut text = input.trim();
if let Some(rest) = text.split_once("</think>") {
text = rest.1;
}
if let Some(stripped) = text.strip_prefix("```json") {
text = stripped.trim_start();
} else if let Some(stripped) = text.strip_prefix("```") {
text = stripped.trim_start();
}
if let Some(stripped) = text.strip_suffix("```") {
text = stripped.trim_end();
}
text.trim().to_string()
}