use anyhow::{Context, Result};
use arrow_array::{Float32Array, RecordBatch, StringArray};
use lancedb::{query::Query, Table};
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfig {
pub limit: usize,
pub threshold: Option<f32>,
pub rerank: bool,
pub filters: Option<HashMap<String, serde_json::Value>>,
pub mode: SearchMode,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
limit: 10,
threshold: Some(0.7),
rerank: false,
filters: None,
mode: SearchMode::Vector,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchMode {
Vector,
Keyword,
Hybrid,
Multimodal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub id: String,
pub content: String,
pub score: f32,
pub distance: f32,
pub metadata: Option<serde_json::Value>,
pub source: Option<String>,
}
pub struct VectorSearch {
table: Table,
}
impl VectorSearch {
pub fn new(table: Table) -> Self {
Self { table }
}
pub async fn search(
&self,
query_vector: Vec<f32>,
config: SearchConfig,
) -> Result<Vec<SearchResult>> {
info!("Performing vector search with limit: {}", config.limit);
let mut query = self.table
.vector_search(&query_vector)
.limit(config.limit);
if let Some(filters) = &config.filters {
for (key, value) in filters {
query = apply_filter(query, key, value)?;
}
}
let results = query
.execute()
.await
.context("Failed to execute vector search")?;
let mut search_results = Vec::new();
for batch in results.try_collect::<Vec<RecordBatch>>().await? {
search_results.extend(batch_to_results(&batch, &config)?);
}
if let Some(threshold) = config.threshold {
search_results.retain(|r| r.score >= threshold);
}
if config.rerank {
search_results = self.rerank_results(search_results).await?;
}
Ok(search_results)
}
pub async fn keyword_search(
&self,
query: &str,
config: SearchConfig,
) -> Result<Vec<SearchResult>> {
info!("Performing keyword search for: {}", query);
let results = self.table
.query()
.full_text_search("content", query)
.limit(config.limit)
.execute()
.await
.context("Failed to execute keyword search")?;
let mut search_results = Vec::new();
for batch in results.try_collect::<Vec<RecordBatch>>().await? {
search_results.extend(batch_to_results(&batch, &config)?);
}
Ok(search_results)
}
pub async fn hybrid_search(
&self,
query: &str,
query_vector: Vec<f32>,
config: SearchConfig,
) -> Result<Vec<SearchResult>> {
info!("Performing hybrid search");
let mut vector_config = config.clone();
vector_config.limit = config.limit * 2; let vector_results = self.search(query_vector, vector_config).await?;
let mut keyword_config = config.clone();
keyword_config.limit = config.limit * 2;
let keyword_results = self.keyword_search(query, keyword_config).await?;
let combined = self.combine_results(vector_results, keyword_results, &config)?;
Ok(combined)
}
pub async fn knn_search(
&self,
query_vector: Vec<f32>,
k: usize,
) -> Result<Vec<SearchResult>> {
debug!("Performing KNN search with k={}", k);
let results = self.table
.vector_search(&query_vector)
.limit(k)
.execute()
.await
.context("Failed to execute KNN search")?;
let mut search_results = Vec::new();
let config = SearchConfig::default();
for batch in results.try_collect::<Vec<RecordBatch>>().await? {
search_results.extend(batch_to_results(&batch, &config)?);
}
Ok(search_results)
}
pub async fn multi_query_search(
&self,
query_vectors: Vec<Vec<f32>>,
config: SearchConfig,
) -> Result<Vec<SearchResult>> {
info!("Performing multi-query search with {} queries", query_vectors.len());
let mut all_results = Vec::new();
let mut result_map: HashMap<String, (SearchResult, f32)> = HashMap::new();
for (i, vector) in query_vectors.iter().enumerate() {
debug!("Processing query vector {}/{}", i + 1, query_vectors.len());
let results = self.search(vector.clone(), config.clone()).await?;
for result in results {
let entry = result_map
.entry(result.id.clone())
.or_insert((result.clone(), 0.0));
entry.1 += result.score;
}
}
let num_queries = query_vectors.len() as f32;
for (mut result, total_score) in result_map.into_values() {
result.score = total_score / num_queries;
all_results.push(result);
}
all_results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
all_results.truncate(config.limit);
Ok(all_results)
}
pub async fn diverse_search(
&self,
query_vector: Vec<f32>,
config: SearchConfig,
diversity_threshold: f32,
) -> Result<Vec<SearchResult>> {
info!("Performing diverse search with threshold: {}", diversity_threshold);
let mut search_config = config.clone();
search_config.limit = config.limit * 3;
let candidates = self.search(query_vector, search_config).await?;
let mut selected = Vec::new();
let mut selected_vectors: Vec<Vec<f32>> = Vec::new();
for candidate in candidates {
if selected.len() >= config.limit {
break;
}
let mut is_diverse = true;
if !selected_vectors.is_empty() {
for existing in &selected {
if candidate.content.chars().take(50).collect::<String>()
== existing.content.chars().take(50).collect::<String>() {
is_diverse = false;
break;
}
}
}
if is_diverse {
selected.push(candidate);
}
}
Ok(selected)
}
async fn rerank_results(
&self,
mut results: Vec<SearchResult>,
) -> Result<Vec<SearchResult>> {
for result in &mut results {
if let Some(metadata) = &result.metadata {
if let Some(quality) = metadata.get("quality_score") {
if let Some(q) = quality.as_f64() {
result.score *= (1.0 + (q as f32 * 0.1));
}
}
}
}
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
Ok(results)
}
fn combine_results(
&self,
vector_results: Vec<SearchResult>,
keyword_results: Vec<SearchResult>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>> {
let mut combined_map: HashMap<String, SearchResult> = HashMap::new();
let vector_weight = 0.7;
for mut result in vector_results {
result.score *= vector_weight;
combined_map.insert(result.id.clone(), result);
}
let keyword_weight = 0.3;
for mut result in keyword_results {
result.score *= keyword_weight;
combined_map
.entry(result.id.clone())
.and_modify(|r| r.score += result.score)
.or_insert(result);
}
let mut combined: Vec<SearchResult> = combined_map.into_values().collect();
combined.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
combined.truncate(config.limit);
Ok(combined)
}
}
fn apply_filter(
mut query: Query,
key: &str,
value: &serde_json::Value,
) -> Result<Query> {
match value {
serde_json::Value::String(s) => {
query = query.filter(format!("{} = '{}'", key, s));
}
serde_json::Value::Number(n) => {
query = query.filter(format!("{} = {}", key, n));
}
serde_json::Value::Bool(b) => {
query = query.filter(format!("{} = {}", key, b));
}
_ => {
return Err(anyhow::anyhow!("Unsupported filter value type"));
}
}
Ok(query)
}
fn batch_to_results(batch: &RecordBatch, config: &SearchConfig) -> Result<Vec<SearchResult>> {
let mut results = Vec::new();
let id_array = batch
.column_by_name("id")
.context("Missing id column")?
.as_any()
.downcast_ref::<StringArray>()
.context("Invalid id column type")?;
let content_array = batch
.column_by_name("content")
.context("Missing content column")?
.as_any()
.downcast_ref::<StringArray>()
.context("Invalid content column type")?;
let distance_array = batch
.column_by_name("_distance")
.map(|col| {
col.as_any()
.downcast_ref::<Float32Array>()
})
.unwrap_or(None);
for i in 0..batch.num_rows() {
let id = id_array.value(i).to_string();
let content = content_array.value(i).to_string();
let distance = distance_array
.and_then(|arr| Some(arr.value(i)))
.unwrap_or(0.0);
let score = 1.0 / (1.0 + distance);
results.push(SearchResult {
id,
content,
score,
distance,
metadata: None,
source: None,
});
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_config_default() {
let config = SearchConfig::default();
assert_eq!(config.limit, 10);
assert_eq!(config.threshold, Some(0.7));
assert!(!config.rerank);
assert!(matches!(config.mode, SearchMode::Vector));
}
#[test]
fn test_search_mode_serialization() {
let mode = SearchMode::Hybrid;
let json = serde_json::to_string(&mode).unwrap();
assert_eq!(json, r#""hybrid""#);
let deserialized: SearchMode = serde_json::from_str(&json).unwrap();
assert!(matches!(deserialized, SearchMode::Hybrid));
}
}