Code accompanying:
Arun Kavishwar and William Lotter. FDA AI Search: Making FDA-Authorized AI Devices Searchable.
Machine Learning for Health (ML4H) Symposium, 2025.
FDA AI Search is a website that enables semantic querying of FDA-authorized AI-enabled devices. The backend includes an embedding-based retrieval system, where LLM-extracted features from authorization summaries are compared to user queries to find relevant matches. The website is hosted at fda-ai-search.com.
The project follows these main steps:
- Data Scraping and Preparation: Collects initial device data from the FDA and merges it with an external taxonomy.
- Content Processing with Gemini: Extracts text from linked PDF documents and uses the Gemini API to generate summaries, keywords, and relevant questions.
- Embedding Creation and Search: Generates vector embeddings for the processed text data (summaries, keywords, questions) to enable semantic search capabilities.
Run update_data_and_embeddings.sh, with a GEMINI_API_KEY in the environment.
.
├── fda_scraper.py # Script for scraping FDA data and merging with taxonomy
├── view_fda_data.py # Script to view and inspect the scraped CSV data
├── process_pdf_summary.py # Script for processing PDF content with Gemini API
├── create_embeddings.py # Script for creating and searching text embeddings
├── perform_search.py # Script dedicated to performing hybrid search (used by frontend and optimization)
├── test_search_performance.py # Script for running a basic test suite against perform_search.py
├── optimize_search_weights.py # Script for optimizing search weights using Optuna
├── fda_ai_records.csv # Main CSV file storing all processed data
├── data/
│ ├── AI_devices_taxonomy.xlsx # Excel file with device taxonomy information
│ └── ai-ml-enabled-devices-csv.csv # (Potentially another source, or older version)
├── embedding_data/ # Directory for storing FAISS indexes and pickled text data
│ ├── summary_index.faiss
│ ├── summary_texts.pkl
│ ├── keywords_index.faiss
│ ├── keywords_texts.pkl
│ ├── questions_index.faiss
│ ├── questions_texts.pkl
│ ├── concepts_index.faiss
│ ├── concepts_texts.pkl
│ ├── thesis_index.faiss
│ ├── thesis_texts.pkl
│ ├── search_boost_index.faiss
│ ├── search_boost_texts.pkl
│ ├── query_match_1_index.faiss
│ ├── query_match_1_texts.pkl
│ ├── query_match_2_index.faiss
│ ├── query_match_2_texts.pkl
│ ├── query_match_3_index.faiss
│ ├── query_match_3_texts.pkl
│ └── submission_numbers.pkl
├── fda-search-app/ # Next.js frontend application
│ ├── src/
│ │ ├── app/
│ │ │ ├── api/search/route.ts # Backend API route for search
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx # Root layout for the Next.js app
│ │ │ └── page.tsx # Main page component for search UI
│ ├── next.config.mjs
│ ├── package.json
│ ├── tailwind.config.ts
│ └── tsconfig.json
└── README.md # This documentation file
This script is responsible for the initial data collection and preparation.
Algorithm:
- Scrape FDA Main List:
- Fetches the list of AI/ML-enabled medical devices from the FDA's official webpage (
https://www.fda.gov/medical-devices/software-medical-device-samd/artificial-intelligence-and-machine-learning-aiml-enabled-medical-devices). - Parses the HTML table to extract device details like 'date_of_final_decision', 'submission_number', 'device_model', 'company', 'panel_lead', and 'primary_product_code'.
- Fetches the list of AI/ML-enabled medical devices from the FDA's official webpage (
- Scrape PDF Links:
- For each device, constructs a URL to the FDA device page using its 'submission_number'.
- Visits this page and attempts to find a link to a summary PDF document.
- Stores the found PDF link (or "Not Found") in the 'summary_pdf_link' column.
- Load Taxonomy Data:
- Reads taxonomy information from an Excel file (
data/AI_devices_taxonomy.xlsx). - Maps Excel column names to standardized names (e.g., 'lead_panel', 'data_type', 'clinical_function', 'ai_function', 'ai_function_subclass').
- Reads taxonomy information from an Excel file (
- Merge Data:
- Merges the scraped FDA data with the taxonomy data based on the 'submission_number'.
- Output:
- Saves the combined and processed data into
fda_ai_records.csv. - If
fda_ai_records.csvalready exists and seems valid, it can load this existing data to avoid re-scraping the main FDA list, but will still attempt to get PDF links if missing.
- Saves the combined and processed data into
Usage:
python fda_scraper.pyThis utility script helps in inspecting the contents of fda_ai_records.csv.
Functionality:
- Loads the
fda_ai_records.csvinto a pandas DataFrame. - Provides functions to:
- Display the first N rows (
display_head). - Show DataFrame information, including data types and non-null counts (
display_info). - Provide descriptive statistics (
display_description). - Retrieve and display a specific record by its 'submission_number' (
get_record_by_submission_id). - List all column names (
list_columns). - Count total records and unique submission numbers (using 'submission_number').
- Display the first N rows (
Usage:
python view_fda_data.pyOr for interactive use:
python -i view_fda_data.pyThis script processes the PDF documents linked in fda_ai_records.csv using the Google Gemini API.
Algorithm:
- Load Data: Reads
fda_ai_records.csv. - PDF Text Extraction:
- For each record with a valid 'summary_pdf_link':
- Downloads the PDF content from the URL.
- Uses
PyPDF2to extract text from the PDF. Handles potential encryption and empty PDFs.
- For each record with a valid 'summary_pdf_link':
- Gemini API Interaction:
- If text is successfully extracted:
- Configures the Gemini API using an environment variable
GEMINI_API_KEY. - Uses the
gemini-2.5-pro-preview-05-06model. - Sends the extracted PDF text to the Gemini API with a prompt requesting:
- A thorough two-paragraph summary.
- Exactly 10 salient keywords.
- Exactly 5 insightful questions a clinician or scientist might ask about the document.
- A list of 5 concepts.
- A 2-sentence thesis statement.
- The API is configured to return a JSON response structured according to a Pydantic model (
DocumentAnalysis). - Includes retry logic and uses
dirtyjsonfor robust parsing of potentially malformed JSON responses.
- Configures the Gemini API using an environment variable
- If text is successfully extracted:
- Update CSV:
- Updates the DataFrame (and subsequently
fda_ai_records.csv) with:summary: The generated summary.summary_keywords: Comma-separated list of keywords.generated_questions: Semicolon-separated list of questions.concepts: Comma-separated list of concepts.thesis: The generated thesis statement.gemini_cost_summary: The calculated cost for the API call.
- Handles errors during PDF extraction or API calls by logging them into the respective columns.
- Updates the DataFrame (and subsequently
Setup:
- Ensure the
GEMINI_API_KEYenvironment variable is set. - Install necessary Python packages:
google-generativeai,requests,PyPDF2,pydantic,pandas,dirtyjson.
Usage:
The script accepts a command-line argument -n or --num_records to specify how many records to process.
python process_pdf_summary.py -n 5 # Process up to 5 recordsThis script uses the Google Gemini API to generate three distinct clinician search queries for each record in fda_ai_records.csv. These queries are intended to represent how a clinician might search for a specific device.
Algorithm:
- Load Data: Reads
fda_ai_records.csv. - Check for Existing Queries: Iterates through the DataFrame, processing only records where the
query_match_1column is empty, NaN, or explicitly marked "Error". - Gemini API Interaction:
- Configures the Gemini API using the
GEMINI_API_KEYenvironment variable. - Uses the
gemini-2.5-flashmodel. - Sends a prompt to Gemini with the record's data (excluding the 'summary' column) requesting three distinct clinician search queries.
- The API is configured to return a JSON response structured according to a Pydantic model (
QueryMatch) with keysquery_match_1,query_match_2, andquery_match_3. - Includes retry logic and uses
dirtyjsonfor robust parsing.
- Configures the Gemini API using the
- Update CSV:
- Updates the DataFrame (and subsequently
fda_ai_records.csv) with the generated queries in thequery_match_1,query_match_2, andquery_match_3columns. - Updates the
gemini_cost_summarywith the cost of the API call. - If query generation fails, the columns are marked "Error".
- Updates the DataFrame (and subsequently
Setup:
- Ensure the
GEMINI_API_KEYenvironment variable is set. - Install necessary Python packages:
google-generativeai,pandas,pydantic,dirtyjson.
Usage:
The script accepts a command-line argument -n or --num_records to specify how many records to process.
python generate_query_match_column.py -n 10 # Process up to 10 recordsThis script generates vector embeddings for textual data in fda_ai_records.csv and allows for semantic search.
Algorithm:
-
Load Data: Reads
fda_ai_records.csv. -
Load Model: Initializes a Sentence Transformer model (
abhinand/MedEmbed-base-v0.1, from Abhinand Balachandran MedEmbed: Medical-Focused Embedding Models). -
Embedding Generation (if
--generateflag is used):- Extracts text from:
summarycolumn.summary_keywordscolumn.generated_questionscolumn.conceptscolumn.thesiscolumn.search_boost_textcolumn.query_match_1,query_match_2, andquery_match_3columns.
- For each set of texts:
- Encodes the texts into numerical embeddings using the loaded model.
- Normalizes the embeddings (L2 normalization).
- Creates a FAISS index (
IndexFlatIPfor cosine similarity). - Adds the embeddings to the index.
- Saves the FAISS index to a
.faissfile (e.g.,embedding_data/summary_index.faiss). - Saves the corresponding original texts and submission numbers to
.pklfiles (e.g.,embedding_data/summary_texts.pkl,embedding_data/submission_numbers.pkl) usingpickle.
- Extracts text from:
-
Hybrid Search (if
--queryargument is provided):- Loads the pre-generated FAISS indexes and pickled text data for summaries, keywords, questions, concepts, thesis, search_boost_text, and query_match columns.
- Encodes the user's query string using the same Sentence Transformer model.
- Searches each index for the query embedding.
- Calculates a weighted hybrid similarity score for each document based on scores from the different fields (summary, keywords, questions, concepts, thesis, search_boost_text, query_match_1, query_match_2, query_match_3).
- Returns the top K results, sorted by the hybrid similarity score, along with the submission number and the text of the summary.
Setup:
- Install necessary Python packages:
sentence-transformers,faiss-cpu(orfaiss-gpuif you have a compatible GPU),pandas,numpy,pickle.
Usage:
- To generate embeddings:
python create_embeddings.py --generate
- To perform a hybrid search (after generating embeddings):
This script can also accept a
python create_embeddings.py --query "your search query here" --top_k 5--weights_jsonargument to use a custom set of weights for thesearch_hybridfunction, overriding its internal defaults. Example:python create_embeddings.py --query "angiography" --weights_json "{\"summary\": 0.1, \"keywords\": 0.2, \"questions\": 0.1, \"concepts\": 0.3, \"thesis\": 0.3, \"search_boost\": 0.0}"
This script is dedicated to performing the hybrid semantic search. It's used by the Next.js frontend and the weight optimization script. It loads all necessary models, FAISS indexes, and text data to conduct searches.
Functionality:
- Loads the SentenceTransformer model (
abhinand/MedEmbed-base-v0.1). - Loads all pre-generated FAISS indexes and pickled text data from the
embedding_data/directory. - Accepts a
--query(required) and--top_k(optional, default 10) command-line argument. - Accepts an optional
--weights_jsonargument. If provided (as a JSON string), these weights will be used for the hybrid search scoring. Otherwise, it uses its internal default weights. - Performs the hybrid search by:
- Embedding the query.
- Searching each FAISS index.
- Calculating a weighted hybrid score for documents found across different text fields.
- Retrieves additional details (Device Name, Applicant, Decision Date, PDF Link, Thesis, Keywords, Concepts) for the top K results from
fda_ai_records.csv. - Outputs the search results as a JSON string to standard output. If an error occurs, it prints a JSON error object to standard error.
Usage:
python perform_search.py --query "cardiac mri" --top_k 5To use custom weights:
python perform_search.py --query "cardiac mri" --weights_json "{\"summary\": 0.1, \"keywords\": 0.2, \"questions\": 0.1, \"concepts\": 0.3, \"thesis\": 0.3, \"search_boost\": 0.0}"This script provides a basic test suite to evaluate the performance of the perform_search.py script.
Functionality:
Usage:
python test_search_performance.pyYou can modify num_records_to_test within the script's if __name__ == "__main__": block to change the number of test cases.
This script employs the Optuna hyperparameter optimization framework to systematically find the most effective weights for the different text fields (summary, keywords, questions, concepts, thesis, search_boost) used in the hybrid search performed by perform_search.py. The goal is to maximize the relevance of search results.
Algorithm & Rigorous Rate Evaluation Process:
-
Test Case Generation:
- The script begins by selecting a configurable number of records (default:
NUM_TEST_CASES = 35) fromfda_ai_records.csv. - For each selected record, it generates a clinically relevant search query. This is done by prompting a local Ollama instance running the
gemma3:4bmodel (user-specified, previouslyllama3.2:latest). The prompt combines the 'thesis' and 'key concepts' from the FDA record to ask the model for a concise search term a clinician might use. - If the Ollama CLI call fails (e.g., command not found, model unavailable, timeout), the script falls back to a simpler heuristic query generation method (based on snippets of thesis, concepts, or keywords).
- The
submission_numberof the record is designated as the expected top result for the generated query.
- The script begins by selecting a configurable number of records (default:
-
Optuna Study Setup:
- An Optuna study is created or loaded if it already exists. The study progress and results are saved in an SQLite database (e.g.,
optimization_results/fda-search-weight-opt-TIMESTAMP.db). This allows for resumption of long optimization runs. - The optimization direction is set to 'maximize' the success rate.
- An Optuna study is created or loaded if it already exists. The study progress and results are saved in an SQLite database (e.g.,
-
Objective Function (Executed for Each Optuna Trial):
- Weight Suggestion: Optuna suggests a new set of weights for each of the six text fields (
summary,keywords,questions,concepts,thesis,search_boost). - Normalization: These raw weights are normalized so that they sum to 1.0, ensuring a consistent basis for comparison.
- Performance Evaluation:
- The normalized weights are passed (as a JSON string via the
--weights_jsonargument) to theperform_search.pyscript for each of the generated test cases. perform_search.pyexecutes a search using the test query and the provided weights.- A test case is considered "passed" if its
expected_submission_numberappears within the topTOP_N_RESULTS_FOR_PASS(default: 3) results returned byperform_search.py.
- The normalized weights are passed (as a JSON string via the
- Success Rate Calculation: The objective function returns the overall success rate for the current set of weights, calculated as (number of passed test cases) / (total number of test cases).
- Weight Suggestion: Optuna suggests a new set of weights for each of the six text fields (
-
Optimization Loop:
- Optuna runs a configurable number of trials (
N_OPTUNA_TRIALS, default: 120), iteratively adjusting weights based on past results to find combinations that yield higher success rates.
- Optuna runs a configurable number of trials (
-
Logging and Results:
- Comprehensive Logging: All significant events, including initial setup, test case generation details (query source: Ollama or fallback), each trial's weights, detailed results for every test case (query, expected submission, returned submissions with similarity scores, pass/fail status), and final outcomes, are logged to both the console and a timestamped
.logfile (e.g.,optimization_results/optimization_run_YYYYMMDD-HHMMSS.log). - Final Output: After all trials are complete, the script reports:
- The best trial number and its achieved success rate.
- The raw and normalized weights from the best trial.
- A Python dictionary representation of the recommended normalized weights, which can be manually used to update the default weights in
create_embeddings.pyorperform_search.py.
- The script does not automatically modify any other project files with the optimized weights.
- Comprehensive Logging: All significant events, including initial setup, test case generation details (query source: Ollama or fallback), each trial's weights, detailed results for every test case (query, expected submission, returned submissions with similarity scores, pass/fail status), and final outcomes, are logged to both the console and a timestamped
Prerequisites:
- Embeddings Must Be Generated: Ensure
python create_embeddings.py --generatehas been run to create all necessary FAISS indexes and.pklfiles inembedding_data/. - Install Optuna:
pip install optuna - Ollama and Model: Ollama CLI must be installed and in the system PATH. The
gemma3:4bmodel should be pulled (ollama pull gemma3:4b).
Usage:
python optimize_search_weights.pyThe script can be time-consuming due to the large number of search executions. Configuration parameters like NUM_TEST_CASES, N_OPTUNA_TRIALS, and TOP_N_RESULTS_FOR_PASS can be adjusted at the top of the script. The optimization_results/ directory will contain the Optuna database and the detailed log file for the run.
This project includes a Next.js web application that provides a user interface to search the FDA AI/ML device data processed by the Python scripts.
The frontend allows users to type search queries, view results in a table, and access PDF summaries where available. It aims for a clean, responsive interface.
- Interactive Search: Users can type queries into a search bar.
- Debounced Queries: API calls are debounced to avoid excessive requests while typing.
- Results Table: Displays:
- Device Name
- Applicant
- Submission Number (links to PDF if available)
- Decision Date
- Similarity Score
- Thesis Statement (full text, wraps)
- Keywords & Concepts (full text, wraps, separated by a line)
- Loading Indicator: Shows a loading animation while search results are being fetched.
- Styling: Uses Tailwind CSS for styling and the Merriweather serif font for a professional look.
- Navigate to the app directory:
cd fda-search-app - Install dependencies:
npm install
- Run the development server:
The application will typically be available at
npm run dev
http://localhost:3000.
- The main UI is in
fda-search-app/src/app/page.tsx. When a user types a query, it triggers afetchrequest to the backend API route. - The API route
fda-search-app/src/app/api/search/route.tsreceives the search query. - This API route uses Node.js
child_process.spawnto execute theperform_search.pyscript (located in the project root:../perform_search.py). The query is passed as a command-line argument to the Python script. perform_search.pyloads the SentenceTransformer model, the pre-generated FAISS indexes, and the pickled text data from theembedding_data/directory. It performs the hybrid semantic search based on the query.- The Python script prints its results as a JSON string to its standard output.
- The Next.js API route captures this JSON output and sends it back to the frontend component.
- The frontend component (
page.tsx) then updates its state with the received results, re-rendering the table.
fda-search-app/src/app/page.tsx: The main React component for the search page, handling user input, API calls, and rendering results.fda-search-app/src/app/layout.tsx: The root layout for the Next.js application. It sets up global styles and imports the Merriweather font.fda-search-app/src/app/globals.css: Contains global CSS styles, including Tailwind CSS base imports and custom styles.fda-search-app/src/app/api/search/route.ts: The backend API endpoint that bridges the Next.js app and the Python search script.fda-search-app/tailwind.config.ts: Configuration file for Tailwind CSS.
The create_embeddings.py script filters the data from fda_ai_records.csv before creating embeddings. Specifically, it excludes records where the 'thesis' column is empty, "ERROR", or "none". This means the search indexes only contain data from records with valid thesis statements.
The current architecture where the Next.js API route (fda-search-app/src/app/api/search/route.ts) calls perform_search.py via child_process.spawn for each query is functional but has performance limitations. The primary bottleneck is that the Python script needs to:
- Initialize the Python interpreter.
- Load the SentenceTransformer model (which can be several hundred MB).
- Load FAISS indexes and associated pickled text data from disk. This entire setup process occurs on every search request, leading to noticeable loading times (e.g., 4-8 seconds or more depending on the system).
Here are strategies to significantly improve search speed:
- Concept: Run the Python search logic as a persistent web server (e.g., using Flask or FastAPI) instead of a script that starts up on each request.
- Pros:
- The ML model, FAISS indexes, and data are loaded into memory once when the Python server starts.
- Subsequent search API calls to this server are very fast (typically milliseconds), as they only involve query embedding and the FAISS search itself.
- Cons:
- Requires managing a separate Python web server process alongside the Next.js application.
- Deployment (e.g., with Vercel for Next.js):
- The Next.js app is deployed to Vercel.
- The Python Flask/FastAPI application is deployed to a platform suitable for persistent applications (e.g., Google Cloud Run ($30/mo?), AWS App Runner ($15/mo), Render ($7/mo)).
- The Next.js API route (
/api/search) would then make an HTTP request to the deployed Python API endpoint.
- Concept: Provide instant feedback to the user while the full semantic search runs in the background.
- Method:
- Client-Side Keyword Filter (Instant): On user input, immediately filter a pre-loaded subset of data (e.g., device names, keywords from
fda_ai_records.csv) directly in the browser using simple string matching. - Asynchronous Semantic Search: Simultaneously, send the query to the backend for the full (potentially slower) semantic search.
- UI Update: When semantic results arrive, update/re-rank the displayed list.
- Client-Side Keyword Filter (Instant): On user input, immediately filter a pre-loaded subset of data (e.g., device names, keywords from
- Pros: Excellent perceived speed for initial results.
- Cons: Initial results are not semantically ranked. The "true" semantic results still take time to load if the backend isn't optimized (see option 1).
- Smaller Model: Using a smaller, faster SentenceTransformer model could reduce load times, but may trade off search accuracy.
- Caching: OS-level file caching might offer some minor speedups on repeated local script runs, but the primary cost is model and library initialization within the Python process for each
spawncall.
- Model Execution: Efficiently running large NLP models (like SentenceTransformers) in a Node.js serverless environment is challenging due to cold starts and resource limits. The model still needs to be loaded on each (cold) invocation.
- FAISS Availability: A mature, performant JavaScript equivalent to FAISS (for fast vector similarity search on pre-built indexes) is not readily available. This would require a different, likely slower, vector search strategy.
- Overall: This approach is unlikely to be faster for the current semantic search setup and would involve significant reimplementation with potentially less performant tools.
For significant improvements in actual search speed, the Persistent Python Backend (Option 1) is the most robust approach. Combining it with Frontend Strategies (Option 2) can offer the best overall user experience.
This project relies on several Python libraries. You can typically install them using pip:
pip install pandas requests beautifulsoup4 openpyxl google-generativeai PyPDF2 pydantic dirtyjson sentence-transformers faiss-cpu numpy optuna(Note: faiss-gpu can be used instead of faiss-cpu if you have a CUDA-enabled GPU and the necessary drivers.)
Ensure you also have a GEMINI_API_KEY environment variable set for the process_pdf_summary.py script.
https://fda-ai-search-224066961651.us-east1.run.app
This project includes a robust evaluation suite located in the py_src/test directory to assess the performance of the semantic search engine.
test_search_evaluation.py: The main evaluation script that orchestrates the testing process.evaluation_dataset.json: A JSON file containing a list of queries to be used for the evaluation.relevance_judge.py: A module that uses the Gemini 1.5 Flash model to act as an AI relevance judge, scoring the relevance of documents to a given query.bm25_baseline.py: A module that implements the BM25 algorithm, a classic keyword-based search method, to serve as a baseline for comparison.
- AI-Powered Relevance Judgment: For each query in the dataset, the AI judge assesses the relevance of each document's summary, assigning a score of 0 (non-relevant), 1 (partially relevant), or 2 (highly relevant).
- Comparative Evaluation: The evaluation script runs each query against both the embedding-based semantic search and the BM25 baseline.
- Metrics Calculation: The script calculates a comprehensive set of industry-standard Information Retrieval (IR) metrics for both systems:
- Precision@K: The fraction of relevant documents in the top K results.
- Recall@K: The fraction of all relevant documents that are retrieved in the top K results.
- Mean Average Precision (MAP): A single-figure measure of quality across all recall levels.
- Normalized Discounted Cumulative Gain (nDCG): A measure of ranking quality that gives more weight to highly-ranked and more relevant documents.
- Average LLM Score: The average relevance score assigned by the LLM judge to the retrieved documents.
- Hit@K: A binary metric (1 or 0) indicating if at least one relevant document is found within the top K results (used when not using LLM judge).
- Reporting: The script outputs a detailed report comparing the performance of both systems, including an overall score based on the average nDCG and average LLM score.
To run the evaluation, ensure your GEMINI_API_KEY is set as an environment variable and execute the following command from the project root:
cd py_src && python -m pip install -r requirements.txt && python -m test.test_search_evaluation --output_dir ../embedding_data --use_llm_judgeThe --use_llm_judge flag enables the AI relevance judgment. If omitted, the script will use the gold_standard field from evaluation_dataset.json for a simpler, binary relevance assessment (relevant/not relevant).