/** * @file hnsw_retrieval_diagnostic.cpp * @brief Diagnostic test to verify HNSW vector retrieval is working correctly * * This test creates a small corpus, generates embeddings, and verifies that * vector search returns expected results with correct similarity scores. * * Run with: * YAMS_TEST_SAFE_SINGLE_INSTANCE=1 ./builddir/tests/integration/daemon/hnsw_retrieval_diagnostic */ #include #include #include #include "tests/integration/daemon/test_async_helpers.h" #include "tests/integration/daemon/test_daemon_harness.h" #include #include #include #include #include #include #include #include #include #include #include #include namespace fs = std::filesystem; using namespace std::chrono_literals; using namespace yams; namespace { // Test documents with known semantic similarity struct TestDoc { std::string filename; std::string content; std::string category; // For grouping similar docs }; const std::vector TEST_CORPUS = { // Cluster 1: Machine Learning {"ml_intro.txt", "Machine learning is a subset of artificial intelligence that enables computers to learn from " "data. " "Neural networks are a key component of deep learning systems.", "ml"}, {"deep_learning.txt", "Deep learning uses multiple layers of neural networks to process complex patterns. " "Convolutional neural networks are used for image recognition tasks.", "ml"}, {"ai_basics.txt", "Artificial intelligence encompasses machine learning, natural language processing, and " "robotics. " "AI systems can learn from experience and improve over time.", "ml"}, // Cluster 2: Cooking {"pasta_recipe.txt", "To make pasta, boil water with salt, add the pasta and cook for 8-10 minutes. " "Drain and serve with your favorite sauce like marinara or alfredo.", "cooking"}, {"baking_bread.txt", "Bread baking requires flour, water, yeast, and salt. Knead the dough for 10 minutes, " "let it rise for an hour, then bake at 400F for 30 minutes.", "cooking"}, // Cluster 3: Programming {"python_intro.txt", "Python is a high-level programming language known for its readability. " "It supports object-oriented, functional, and procedural programming paradigms.", "programming"}, {"cpp_guide.txt", "C++ is a powerful systems programming language. It provides low-level memory control " "and high performance for applications like games and operating systems.", "programming"}, }; // Test queries with expected results struct TestQuery { std::string query; std::vector expectedTopFiles; // Files that should be in top results std::string expectedCategory; }; const std::vector TEST_QUERIES = { {"neural networks and deep learning", {"ml_intro.txt", "deep_learning.txt", "ai_basics.txt"}, "ml"}, {"how to cook italian food", {"pasta_recipe.txt", "baking_bread.txt"}, "cooking"}, {"software development languages", {"python_intro.txt", "cpp_guide.txt"}, "programming"}, }; // Helper to create a client daemon::DaemonClient createClient(const fs::path& socketPath) { daemon::ClientConfig config; config.socketPath = socketPath; config.connectTimeout = 5s; config.requestTimeout = 30s; return daemon::DaemonClient(config); } class HNSWDiagnosticFixture { public: void setup() { spdlog::set_level(spdlog::level::info); spdlog::info("=== HNSW Retrieval Diagnostic ==="); // Create temp directory for test corpus tempDir_ = fs::temp_directory_path() / ("hnsw_diag_" + std::to_string(std::time(nullptr))); fs::create_directories(tempDir_); // Write test documents for (const auto& doc : TEST_CORPUS) { fs::path filePath = tempDir_ / doc.filename; std::ofstream out(filePath); out << doc.content; out.close(); spdlog::info("Created test doc: {}", filePath.string()); } // Start daemon with real embeddings via ONNX plugin yams::test::DaemonHarnessOptions opts; opts.isolateState = true; opts.enableModelProvider = true; opts.useMockModelProvider = false; // Use real ONNX embeddings opts.autoLoadPlugins = true; opts.configureModelPool = true; opts.modelPoolLazyLoading = false; // Resolve plugin directory relative to either the repo root or an existing builddir cwd. opts.pluginDir = fs::current_path() / "builddir" / "plugins"; if (!fs::exists(*opts.pluginDir)) { fs::path directBuildPlugins = fs::current_path() / "plugins"; if (fs::exists(directBuildPlugins)) { opts.pluginDir = std::move(directBuildPlugins); } } opts.preloadModels = {"all-MiniLM-L6-v2"}; // Configure ONNX plugin nlohmann::json onnxConfig; onnxConfig["preferred_model"] = "all-MiniLM-L6-v2"; onnxConfig["preload"] = "all-MiniLM-L6-v2"; onnxConfig["keep_model_hot"] = true; // Prefer env-configured models root; otherwise try common defaults. // If none exist, omit models_root and let the plugin use its own defaults. { fs::path modelsRoot; if (const char* envModelsRoot = std::getenv("YAMS_MODELS_ROOT"); envModelsRoot && *envModelsRoot) { modelsRoot = envModelsRoot; } else { fs::path macExternal = "/Volumes/picaso/hak/storage/models"; if (fs::exists(macExternal)) { modelsRoot = macExternal; } else if (const char* home = std::getenv("HOME"); home && *home) { fs::path alt = fs::path(home) / ".yams" / "models"; if (fs::exists(alt)) { modelsRoot = alt; } } } if (!modelsRoot.empty()) { onnxConfig["models_root"] = modelsRoot.string(); } else { SKIP("No ONNX models_root found; skipping semantic retrieval diagnostic"); } } opts.pluginConfigs["onnx_plugin"] = onnxConfig.dump(); spdlog::info("Plugin directory: {}", opts.pluginDir->string()); bool started = false; for (int attempt = 0; attempt < 3 && !started; ++attempt) { harness_ = std::make_unique(opts); started = harness_->start(60s); if (!started) { spdlog::warn("Daemon start attempt {} failed in HNSW diagnostic; retrying", attempt + 1); harness_->stop(); harness_.reset(); std::this_thread::sleep_for(500ms); } } if (!started) { SKIP("Daemon failed to start for HNSW diagnostic after retries (likely TSAN/CI " "contention)"); } spdlog::info("Daemon started"); // Ingest test corpus ingestCorpus(); // Wait for embeddings waitForEmbeddings(); } void teardown() { if (harness_) { harness_->stop(); } if (!tempDir_.empty() && fs::exists(tempDir_)) { fs::remove_all(tempDir_); } } void ingestCorpus() { // Use the ServiceManager directly for simpler ingestion auto* daemon = harness_->daemon(); REQUIRE(daemon != nullptr); auto* sm = daemon->getServiceManager(); REQUIRE(sm != nullptr); auto contentStore = sm->getContentStore(); auto metaRepo = sm->getMetadataRepo(); REQUIRE(contentStore != nullptr); REQUIRE(metaRepo != nullptr); spdlog::info("Ingesting corpus from: {}", tempDir_.string()); for (const auto& doc : TEST_CORPUS) { fs::path filePath = tempDir_ / doc.filename; // Read file content std::ifstream in(filePath, std::ios::binary); std::vector bytes; char c; while (in.get(c)) { bytes.push_back(static_cast(c)); } // Store in content store auto storeResult = contentStore->storeBytes(bytes); REQUIRE(storeResult.has_value()); std::string hash = storeResult.value().contentHash; // Create document metadata yams::metadata::DocumentInfo docInfo; docInfo.fileName = doc.filename; docInfo.filePath = filePath.string(); docInfo.sha256Hash = hash; docInfo.fileSize = bytes.size(); docInfo.mimeType = "text/plain"; docInfo.fileExtension = ".txt"; docInfo.modifiedTime = std::chrono::time_point_cast( std::chrono::system_clock::now()); auto insertResult = metaRepo->insertDocument(docInfo); REQUIRE(insertResult.has_value()); spdlog::info("Ingested: {} (hash: {})", doc.filename, hash.substr(0, 8)); } spdlog::info("Corpus ingestion complete"); } void waitForEmbeddings() { spdlog::info("Waiting for embedding generation (max 30s)..."); auto* daemon = harness_->daemon(); REQUIRE(daemon != nullptr); auto* sm = daemon->getServiceManager(); REQUIRE(sm != nullptr); // Poll stats until we have embeddings for all docs (or timeout) for (int i = 0; i < 30; ++i) { // Max 30 seconds auto vectorDb = sm->getVectorDatabase(); if (vectorDb) { vectorCount_ = vectorDb->getVectorCount(); if (i % 5 == 0) { spdlog::info("Vector count: {} (waiting for {})", vectorCount_, TEST_CORPUS.size()); } // We expect at least one vector per document if (vectorCount_ >= TEST_CORPUS.size()) { spdlog::info("Embeddings ready: {} vectors for {} docs", vectorCount_, TEST_CORPUS.size()); hasEmbeddings_ = true; return; } } std::this_thread::sleep_for(1000ms); } // Don't fail - we can still test keyword search without embeddings spdlog::warn("Embeddings not generated after 30s - continuing with keyword-only tests"); hasEmbeddings_ = false; } bool hasEmbeddings() const { return hasEmbeddings_; } daemon::SearchResponse runSearch(const std::string& query, const std::string& searchType, int limit = 10) { auto client = createClient(harness_->socketPath()); // Connect first auto connectResult = cli::run_sync(client.connect(), 5s); REQUIRE(connectResult.has_value()); daemon::SearchRequest searchReq; searchReq.query = query; searchReq.searchType = searchType; searchReq.limit = limit; searchReq.timeout = 10s; auto result = cli::run_sync(client.search(searchReq), 15s); REQUIRE(result.has_value()); return result.value(); } void runDiagnostics() { spdlog::info("\n=== Running Search Diagnostics ===\n"); // Check if embeddings are actually available before testing semantic search. if (!hasEmbeddings_ || vectorCount_ == 0) { SKIP("ONNX models not available - skipping semantic search diagnostic"); } for (const auto& tq : TEST_QUERIES) { spdlog::info("Query: \"{}\"", tq.query); spdlog::info("Expected category: {}", tq.expectedCategory); std::string expectedFiles; for (const auto& f : tq.expectedTopFiles) { if (!expectedFiles.empty()) expectedFiles += ", "; expectedFiles += f; } spdlog::info("Expected top files: {}", expectedFiles); // Test semantic/vector search spdlog::info("\n--- Vector Search ---"); auto vectorResults = runSearch(tq.query, "semantic", 5); printResults(vectorResults, "vector"); // Test keyword search spdlog::info("\n--- Keyword Search ---"); auto keywordResults = runSearch(tq.query, "keyword", 5); printResults(keywordResults, "keyword"); // Test hybrid search spdlog::info("\n--- Hybrid Search ---"); auto hybridResults = runSearch(tq.query, "hybrid", 5); printResults(hybridResults, "hybrid"); // Analyze results analyzeResults(tq, vectorResults, keywordResults, hybridResults); spdlog::info("\n{}\n", std::string(60, '=')); } } void printResults(const daemon::SearchResponse& results, const std::string& searchType) { if (results.results.empty()) { spdlog::warn(" {} search: NO RESULTS", searchType); return; } int rank = 1; for (const auto& r : results.results) { fs::path p(r.path); std::string filename = p.filename().string(); spdlog::info(" #{}: {} (score={:.4f})", rank, filename, r.score); rank++; } } void analyzeResults(const TestQuery& tq, const daemon::SearchResponse& vectorRes, const daemon::SearchResponse& /*keywordRes*/, const daemon::SearchResponse& /*hybridRes*/) { spdlog::info("\n--- Analysis ---"); // Check if expected files are in vector results std::set vectorFiles; for (const auto& r : vectorRes.results) { fs::path p(r.path); vectorFiles.insert(p.filename().string()); } int vectorHits = 0; for (const auto& expected : tq.expectedTopFiles) { if (vectorFiles.count(expected)) { vectorHits++; } } float vectorRecall = static_cast(vectorHits) / tq.expectedTopFiles.size(); spdlog::info("Vector recall: {}/{} = {:.1f}%", vectorHits, tq.expectedTopFiles.size(), vectorRecall * 100); // Check vector score distribution if (!vectorRes.results.empty()) { float topScore = static_cast(vectorRes.results[0].score); float bottomScore = static_cast(vectorRes.results.back().score); spdlog::info("Vector score range: [{:.4f}, {:.4f}]", bottomScore, topScore); } // Flag potential issues if (vectorFiles.empty()) { spdlog::error("ISSUE: Vector search returned NO results!"); } else if (vectorRecall < 0.5f) { spdlog::warn("ISSUE: Low vector recall ({:.1f}%)", vectorRecall * 100); } } private: std::unique_ptr harness_; fs::path tempDir_; bool hasEmbeddings_ = false; size_t vectorCount_ = 0; }; } // namespace TEST_CASE("HNSW Retrieval Diagnostic", "[hnsw][diagnostic][integration]") { HNSWDiagnosticFixture fixture; fixture.setup(); fixture.runDiagnostics(); fixture.teardown(); }