Module: Search¶
aiforge.modules.search — Semantic search powered by embeddings and ChromaDB. Index any dataset (structured dicts, files, or directories), then query it with natural language. No LLM calls — only embedding calls — making it fast and cheap. Similarity scores are cosine-based, ranked highest first.
Installation¶
The search module is included with the base aiforge install. ChromaDB is a core dependency.
Import¶
from aiforge import SemanticSearch
from aiforge import ( # types
SearchResponse,
SearchHit,
IndexResponse,
SearchDocument,
IndexRequest,
SearchRequest,
)
Quick Start¶
from aiforge import SemanticSearch
ss = SemanticSearch(collection="products")
# Index some documents
ss.index([
{"id": "1", "text": "Wireless noise-cancelling headphones", "metadata": {"price": 299}},
{"id": "2", "text": "Portable Bluetooth speaker with bass boost", "metadata": {"price": 79}},
{"id": "3", "text": "USB-C fast charging cable 6ft", "metadata": {"price": 12}},
])
# Semantic search
results = ss.query("something to listen to music on the go", top_k=3)
for hit in results.hits:
print(f"{hit.score:.3f} | {hit.text}")
# 0.923 | Portable Bluetooth speaker with bass boost
# 0.891 | Wireless noise-cancelling headphones
# 0.342 | USB-C fast charging cable 6ft
Constructor¶
SemanticSearch(
collection: str = "default",
persist_dir: str = "./aiforge_data",
top_k: int = 5,
store: VectorStoreAdapter | None = None,
*,
retrieval_mode: str | None = None, # "vector" | "hybrid" | "keyword"
rerank: bool | None = None,
rerank_top_n: int | None = None,
fetch_k: int | None = None,
reranker: Reranker | None = None,
# Inherited from BaseModule:
quality: str | None = None,
provider: str | None = None,
**kwargs, # AIForgeSettings overrides
)
Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
collection |
str |
"default" |
Vector store collection name. Leading/trailing whitespace is stripped. Must not be empty. |
persist_dir |
str |
"./aiforge_data" |
Data directory for ChromaDB persistence. Ignored by non-ChromaDB backends. |
top_k |
int |
5 |
Default number of results per query. Can be overridden per-call. Must be 1–100. |
store |
VectorStoreAdapter \| None |
None |
Pre-built vector store adapter for dependency injection or testing. When None, a ChromaDB store is built automatically. |
retrieval_mode |
str \| None |
None → AIFORGE_RETRIEVAL_MODE ("vector") |
"vector", "hybrid" or "keyword". See Retrieval Modes. |
rerank |
bool \| None |
None → AIFORGE_RERANK (False) |
Rerank candidates before returning them. Defaults to True when reranker is given. |
rerank_top_n |
int \| None |
None → AIFORGE_RERANK_TOP_N |
Hits returned after reranking when a query passes no top_k. None keeps top_k. |
fetch_k |
int \| None |
None → AIFORGE_FETCH_K (20) |
Candidates fetched per retriever before fusion / reranking. |
reranker |
Reranker \| None |
None |
Custom reranker; defaults to LLMReranker. See Reranking. |
quality |
str \| None |
None |
Quality tier for embedding model routing: "high", "medium", or "low". |
provider |
str \| None |
None |
Embedding provider name ("openai", "anthropic", etc.). Falls back to settings.PROVIDER. |
**kwargs |
Any AIForgeSettings field as a keyword override, e.g. EMBEDDING_MODEL="text-embedding-3-large". |
Validation Rules¶
The constructor raises ValueError for:
- Empty or whitespace-only collection
- top_k outside the range [1, 100]
Examples¶
from aiforge import SemanticSearch
# Defaults — ChromaDB at ./aiforge_data, collection "default"
ss = SemanticSearch()
# Named collection
ss = SemanticSearch(collection="products")
# More results by default
ss = SemanticSearch(collection="faq", top_k=10)
# Custom persistence directory
ss = SemanticSearch(collection="prod", persist_dir="/data/vectordb")
# Use a specific embedding model
ss = SemanticSearch(collection="docs", EMBEDDING_MODEL="text-embedding-3-large")
# Quality-based routing
ss = SemanticSearch(collection="search", quality="high")
Methods¶
index(documents, *, batch_size=100) -> IndexResponse¶
Embed and upsert documents into the collection. Documents with the same id are updated (upsert semantics).
def index(
self,
documents: list[dict | SearchDocument],
*,
batch_size: int = 100,
) -> IndexResponse
| Parameter | Type | Default | Description |
|---|---|---|---|
documents |
list[dict \| SearchDocument] |
(required) | Documents to index. Accepts SearchDocument instances or plain dicts with id, text, and optional metadata keys. |
batch_size |
int |
100 |
Number of documents per embedding API call. Reduce if hitting provider rate limits. |
Returns: IndexResponse
Raises: ValueError if documents is empty.
Behavior:
1. Each document is coerced to a SearchDocument (if a plain dict)
2. Documents are embedded in batches of batch_size
3. Embeddings + metadata are upserted into ChromaDB
4. Costs are calculated from estimated token counts (1 token ≈ 4 chars)
ss = SemanticSearch(collection="products")
# Index from dicts
resp = ss.index([
{"id": "p1", "text": "Wireless headphones", "metadata": {"price": 299, "category": "audio"}},
{"id": "p2", "text": "Bluetooth speaker", "metadata": {"price": 79, "category": "audio"}},
{"id": "p3", "text": "USB-C cable", "metadata": {"price": 12, "category": "accessories"}},
])
print(resp.indexed_count) # 3
print(resp.total_documents) # 3
print(resp.collection) # "products"
print(f"Cost: ${resp.cost:.6f}")
# Upsert — re-indexing with same IDs updates existing documents
resp = ss.index([
{"id": "p1", "text": "Wireless noise-cancelling headphones (updated)", "metadata": {"price": 249}},
])
print(resp.indexed_count) # 1
print(resp.total_documents) # 3 (not 4 — upsert, not append)
aindex(documents, *, batch_size=100) -> IndexResponse¶
Async version of index(). Uses the async embedding provider internally.
async def aindex(
self,
documents: list[dict | SearchDocument],
*,
batch_size: int = 100,
) -> IndexResponse
| Parameter | Type | Default | Description |
|---|---|---|---|
documents |
list[dict \| SearchDocument] |
(required) | Documents to index. |
batch_size |
int |
100 |
Docs per embedding batch. |
Returns: IndexResponse
index_files(source, file_extensions=None) -> IndexResponse¶
Load files from a path or directory and index them. Each file becomes one SearchDocument with a deterministic UUID v5 ID (based on file path + content prefix). Files that cannot be loaded are skipped with a warning.
def index_files(
self,
source: str | list[str],
file_extensions: list[str] | None = None,
) -> IndexResponse
| Parameter | Type | Default | Description |
|---|---|---|---|
source |
str \| list[str] |
(required) | File path, directory path, or list of file paths. |
file_extensions |
list[str] \| None |
None |
Extension whitelist with leading dot (e.g. [".txt", ".md"]). None accepts all supported text types. |
Returns: IndexResponse — returns indexed_count=0 if no documents were loaded.
ss = SemanticSearch(collection="docs")
# Index all supported files in a directory
resp = ss.index_files("./docs/")
print(f"Indexed {resp.indexed_count} files")
# Index only markdown files
resp = ss.index_files("./docs/", file_extensions=[".md"])
# Index specific files
resp = ss.index_files(["README.md", "CHANGELOG.md"])
query(query, *, top_k=None, filter=None, include_embeddings=False) -> SearchResponse¶
Embed the query and retrieve the most similar documents from the collection.
def query(
self,
query: str,
*,
top_k: int | None = None,
filter: dict | None = None,
include_embeddings: bool = False,
) -> SearchResponse
| Parameter | Type | Default | Description |
|---|---|---|---|
query |
str |
(required) | Search query text. Must not be empty or whitespace-only. |
top_k |
int \| None |
None |
Number of results to return. None uses the instance default (self.default_top_k). Must be 1–100. |
filter |
dict \| None |
None |
ChromaDB where clause for metadata filtering, e.g. {"price": {"$lt": 100}}. |
include_embeddings |
bool |
False |
When True, each SearchHit includes its raw embedding vector. |
Returns: SearchResponse
Raises: ValueError if query is empty/whitespace or top_k is out of range.
Scoring: Similarity scores are computed as 1.0 - cosine_distance, clamped to [0.0, 1.0]. Higher is more similar.
ss = SemanticSearch(collection="products")
# Basic search
results = ss.query("audio equipment")
for hit in results.hits:
print(f" {hit.score:.3f} | {hit.id} | {hit.text}")
# 0.923 | p2 | Bluetooth speaker
# 0.891 | p1 | Wireless headphones
# More results
results = ss.query("electronics", top_k=20)
# Metadata filtering
results = ss.query("audio", filter={"price": {"$lt": 100}})
print(results.hits[0].text) # "Bluetooth speaker" (price: 79)
print(results.hits[0].metadata) # {"price": 79, "category": "audio"}
# Include embeddings for downstream processing
results = ss.query("audio", include_embeddings=True)
print(len(results.hits[0].embeddings)) # 1536 (for text-embedding-3-small)
# Metrics
print(results.query) # "audio"
print(results.total_searched) # 3
print(results.cost) # 0.000001
print(results.latency_ms) # 45.2
aquery(query, *, top_k=None, filter=None, include_embeddings=False) -> SearchResponse¶
Async version of query(). Embeds the query asynchronously and runs ChromaDB operations in a thread pool to avoid blocking the event loop.
async def aquery(
self,
query: str,
*,
top_k: int | None = None,
filter: dict | None = None,
include_embeddings: bool = False,
) -> SearchResponse
| Parameter | Type | Default | Description |
|---|---|---|---|
query |
str |
(required) | Search query text. |
top_k |
int \| None |
None |
Number of results. |
filter |
dict \| None |
None |
Metadata filter. |
include_embeddings |
bool |
False |
Include embedding vectors. |
Returns: SearchResponse
list_collections() -> list[str]¶
Return names of all collections in the vector store.
Returns: list[str]
collection_stats(collection=None) -> dict¶
Return statistics for a collection.
| Parameter | Type | Default | Description |
|---|---|---|---|
collection |
str \| None |
None |
Collection name. Defaults to this instance's collection_name. |
Returns: dict with keys like "chunks", "name".
stats = ss.collection_stats()
print(stats) # {"name": "products", "chunks": 3, ...}
# Check a different collection
stats = ss.collection_stats("docs")
delete_collection(collection=None) -> None¶
Delete a collection and all its data.
| Parameter | Type | Default | Description |
|---|---|---|---|
collection |
str \| None |
None |
Collection to delete. Defaults to this instance's collection_name. |
ss.delete_collection() # delete current collection
ss.delete_collection("old-index") # delete a specific collection
run(text, **kwargs) -> SearchResponse¶
BaseModule interface method. Delegates to query(). Used by pipelines.
| Parameter | Type | Description |
|---|---|---|
text |
str |
The search query. |
**kwargs |
Forwarded to query() — accepts top_k, filter, include_embeddings. |
Returns: SearchResponse
arun(text, **kwargs) -> SearchResponse¶
Async version of run(). Delegates to aquery().
run_batch(items, concurrency=10, max_batch=100) -> BatchResult¶
Run multiple searches concurrently. Inherited from BaseModule.
def run_batch(
self,
items: list[str],
concurrency: int = 10,
max_batch: int = 100,
) -> BatchResult
| Parameter | Type | Default | Description |
|---|---|---|---|
items |
list[str] |
(required) | List of search queries. |
concurrency |
int |
10 |
Max concurrent queries. |
max_batch |
int |
100 |
Max items to process. |
Returns: BatchResult with .results, .total_cost, .total_tokens, .failed.
queries = ["headphones", "speakers", "cables", "microphones"]
batch = ss.run_batch(queries, concurrency=4)
for r in batch.results:
top = r.hits[0] if r.hits else None
print(f" Top: {top.text if top else 'no results'} ({top.score:.3f})" if top else " No results")
Response Types¶
IndexResponse¶
Returned by index(), aindex(), and index_files().
| Field | Type | Description |
|---|---|---|
indexed_count |
int |
Number of documents upserted in this call. |
collection |
str |
Name of the vector store collection. |
total_documents |
int |
Total documents now in the collection (including previous contents). |
cost |
float |
Embedding cost in USD. |
input_tokens |
int |
Estimated tokens embedded (1 token ≈ 4 characters). |
output_tokens |
int |
Always 0 (embeddings have no output tokens). |
model |
str |
Embedding model name used. |
trace_id |
str |
Unique trace ID (16-char hex). |
latency_ms |
float |
End-to-end time in milliseconds. |
Methods:
| Method | Returns | Description |
|---|---|---|
to_text() |
str |
"Indexed 3 documents into 'products' (3 total)" |
resp = ss.index([...])
print(resp.to_text()) # "Indexed 3 documents into 'products' (3 total)"
print(resp.indexed_count) # 3
print(resp.total_documents) # 3
print(resp.cost) # 0.000012
print(resp.model) # "text-embedding-3-small"
SearchResponse¶
Returned by query(), aquery(), run(), and arun().
| Field | Type | Description |
|---|---|---|
hits |
list[SearchHit] |
Ranked results, highest score first. Empty list if no matches. |
query |
str |
The query string that produced these results. |
total_searched |
int |
Total documents in the searched collection. |
cost |
float |
Embedding cost for the query in USD. |
input_tokens |
int |
Estimated tokens for the query embedding. |
output_tokens |
int |
Always 0. |
model |
str |
Embedding model name used. |
trace_id |
str |
Unique trace ID (16-char hex). |
latency_ms |
float |
End-to-end search time in milliseconds. |
Methods:
| Method | Returns | Description |
|---|---|---|
to_text() |
str |
Returns the top hit's text for pipeline chaining. Empty string if no hits. |
results = ss.query("audio equipment")
print(results.query) # "audio equipment"
print(results.total_searched) # 100
print(len(results.hits)) # 5
# Pipeline chaining — returns top hit text
print(results.to_text()) # "Portable Bluetooth speaker with bass boost"
# No results → empty string
empty = ss.query("xyz nonexistent")
print(empty.to_text()) # ""
SearchHit¶
Represents a single ranked search result.
| Field | Type | Description |
|---|---|---|
id |
str |
Document identifier. |
text |
str |
Document text content. |
score |
float |
Retrieval score in [0.0, 1.0], higher is better. Cosine similarity (1.0 - cosine_distance) in vector mode; see Retrieval Modes for hybrid / keyword. |
rerank_score |
float \| None |
Reranker relevance score. Only set when reranking is enabled (0–1 for LLMReranker, any scale for custom rerankers). |
metadata |
dict |
Document metadata (whatever was stored during indexing). |
embeddings |
list[float] \| None |
Raw embedding vector. Only populated when include_embeddings=True is passed to the query. |
hit = results.hits[0]
print(hit.id) # "p2"
print(hit.text) # "Portable Bluetooth speaker with bass boost"
print(hit.score) # 0.923
print(hit.metadata) # {"price": 79, "category": "audio"}
print(hit.embeddings) # None (unless include_embeddings=True)
SearchDocument¶
Input schema for documents to be indexed.
| Field | Type | Default | Description |
|---|---|---|---|
id |
str |
(required) | Unique document identifier. Minimum 1 character. |
text |
str |
(required) | Document text content. Minimum 1 character. |
metadata |
dict |
{} |
Arbitrary metadata dictionary. |
# Create explicitly
doc = SearchDocument(id="p1", text="Wireless headphones", metadata={"price": 299})
# Or pass dicts directly to index() — they're coerced automatically
ss.index([{"id": "p1", "text": "Wireless headphones", "metadata": {"price": 299}}])
Request Schemas (API Server)¶
These schemas are used by the API server endpoints, not typically by SDK consumers:
IndexRequest — POST /search/index
| Field | Type | Default | Description |
|---|---|---|---|
documents |
list[SearchDocument] |
(required) | Documents to index. |
collection |
str |
"default" |
Collection name. |
SearchRequest — POST /search/query
| Field | Type | Default | Description |
|---|---|---|---|
query |
str |
(required) | Search query text. |
collection |
str |
"default" |
Collection name. |
top_k |
int |
5 |
Number of results (1–100). |
filter |
dict \| None |
None |
Metadata filter. |
include_embeddings |
bool |
False |
Include embedding vectors. |
Similarity Scoring¶
Scores are computed as 1.0 - cosine_distance, clamped to [0.0, 1.0]:
| Score | Interpretation |
|---|---|
0.9+ |
Very high similarity — near-exact semantic match |
0.7–0.9 |
High similarity — related content |
0.5–0.7 |
Moderate similarity — loosely related |
< 0.5 |
Low similarity — likely not relevant |
Note: This metric assumes cosine distance (ChromaDB default). Other distance metrics (L2, inner product) would need different conversion.
In hybrid and keyword modes the score has a different meaning; see below.
Retrieval Modes¶
| Mode | How it works | Query embedding | SearchHit.score |
|---|---|---|---|
"vector" (default) |
Dense cosine similarity. Unchanged behaviour. | Yes | 1 - cosine_distance |
"keyword" |
Okapi BM25 over document texts (k1=1.5, b=0.75). |
No (zero embedding cost) | BM25 relative to the best hit (best = 1.0) |
"hybrid" |
Both, each fetching fetch_k candidates, fused with Reciprocal Rank Fusion Σ 1 / (RRF_K + rank). |
Yes | RRF relative to the best possible (ranked first by both = 1.0) |
ss = SemanticSearch(collection="kb", retrieval_mode="hybrid")
ss.index(docs)
ss.query("error E4012 quota", top_k=5) # exact code + semantic match
The BM25 index is pure Python and in memory. It is built from the vector
store's own documents (VectorStoreAdapter.list_all_documents()) on the first
keyword or hybrid query and rebuilt after index() / delete_collection() on
this instance, or whenever the store's document count changes. Nothing extra
is persisted; the vector store stays the source of truth. Chroma, pgvector,
Qdrant and Pinecone support it; other backends fall back to vector retrieval
with a retrieval.keyword_unsupported warning.
Metadata filters work in every mode. For keyword hits they are evaluated
locally and support field equality, $eq $ne $gt $gte $lt $lte $in $nin
$contains, $and and $or; a filter with another operator runs that query in
vector mode with a warning. In hybrid mode, include_embeddings=True fills
embeddings only for hits the vector side returned.
Reranking¶
With rerank=True (or AIFORGE_RERANK=true), a query fetches
max(top_k, fetch_k) candidates, reranks them and returns the best top_k
(or rerank_top_n when the call passes no top_k). Each hit carries
rerank_score.
ss = SemanticSearch(collection="kb", retrieval_mode="hybrid", rerank=True, fetch_k=30)
resp = ss.query("how do I rotate API keys?", top_k=5)
print(resp.hits[0].rerank_score, resp.cost) # cost includes the rerank call
The default LLMReranker makes one LLM call per query using the
provider's native structured output ({"scores": [{"index", "score"}]}) and
keeps the original order if the call fails. This is the one case where
SemanticSearch calls an LLM; its tokens and cost are added to the
SearchResponse.
Plug in your own model with any object that has
rerank(query, candidates, top_n), or wrap a scoring function:
from sentence_transformers import CrossEncoder
from aiforge.core.rerank import ScoreFunctionReranker
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
ss = SemanticSearch(
collection="kb",
reranker=ScoreFunctionReranker(lambda q, docs: ce.predict([(q, d) for d in docs]).tolist()),
)
Metadata Filtering¶
ChromaDB where clauses can filter results by metadata fields:
# Exact match
results = ss.query("audio", filter={"category": "audio"})
# Comparison operators
results = ss.query("audio", filter={"price": {"$lt": 100}}) # less than
results = ss.query("audio", filter={"price": {"$gte": 50}}) # greater than or equal
results = ss.query("audio", filter={"price": {"$ne": 0}}) # not equal
# Logical operators
results = ss.query("audio", filter={
"$and": [
{"category": "audio"},
{"price": {"$lt": 200}},
]
})
See the ChromaDB filtering docs for the full where clause syntax.
Upsert Semantics¶
When indexing documents with index(), documents are upserted by their id:
- New ID → inserted
- Existing ID → text and metadata are updated, embedding is re-computed
This makes it safe to re-index the same documents after updates without creating duplicates.
When using index_files(), IDs are generated deterministically from file_path + content[:64] via UUID v5, so re-indexing the same files is idempotent.
No LLM Calls¶
Unlike other AIForge modules, SemanticSearch makes no LLM calls — only embedding calls (unless you enable the LLM reranker). This means:
- Lower cost (embedding is ~10–100x cheaper than LLM completion)
- Lower latency (embedding is ~50ms vs ~500ms+ for LLM)
- No need for a completion model — only an embedding model is required
Instance Properties¶
After constructing a SemanticSearch, the following properties are available:
| Property | Type | Description |
|---|---|---|
ss.collection_name |
str |
Collection name (stripped). |
ss.persist_dir |
str |
ChromaDB data directory. |
ss.default_top_k |
int |
Default results per query. |
ss.settings |
AIForgeSettings |
Resolved configuration. |
ss.cost_tracker |
CostTracker |
Session-level cost tracker. |
Full Examples¶
Product Catalog Search¶
from aiforge import SemanticSearch
ss = SemanticSearch(collection="products", top_k=5)
# Index product catalog
ss.index([
{"id": "h1", "text": "Wireless noise-cancelling headphones with 30hr battery", "metadata": {"price": 299, "category": "audio"}},
{"id": "s1", "text": "Portable Bluetooth speaker with deep bass", "metadata": {"price": 79, "category": "audio"}},
{"id": "c1", "text": "USB-C fast charging cable 6ft braided", "metadata": {"price": 12, "category": "accessories"}},
{"id": "m1", "text": "Condenser microphone for podcast recording", "metadata": {"price": 149, "category": "audio"}},
{"id": "k1", "text": "Mechanical keyboard with RGB lighting", "metadata": {"price": 89, "category": "peripherals"}},
])
# Natural language search
results = ss.query("something to listen to music on the go")
for hit in results.hits:
print(f" {hit.score:.3f} | ${hit.metadata['price']} | {hit.text}")
# Filtered search — only affordable audio
results = ss.query("audio equipment", filter={
"$and": [
{"category": "audio"},
{"price": {"$lt": 100}},
]
})
Document Knowledge Base¶
from aiforge import SemanticSearch
ss = SemanticSearch(collection="knowledge-base")
# Index files from a directory
resp = ss.index_files("./docs/", file_extensions=[".md", ".txt"])
print(f"Indexed {resp.indexed_count} files, cost: ${resp.cost:.6f}")
# Search the knowledge base
results = ss.query("how to configure authentication")
print(results.hits[0].text)
print(results.hits[0].metadata.get("source")) # file path
Pipeline Integration¶
Use SemanticSearch as part of a pipeline via run() / arun():
from aiforge import SemanticSearch
ss = SemanticSearch(collection="faq")
# Pipeline-compatible — delegates to query()
result = ss.run("How do I reset my password?", top_k=3)
print(result.to_text()) # top hit's text — can feed into next pipeline stage
Batch Search¶
ss = SemanticSearch(collection="products")
queries = [
"wireless headphones",
"portable speaker",
"charging cable",
"gaming keyboard",
]
batch = ss.run_batch(queries, concurrency=4)
for i, r in enumerate(batch.results):
top = r.hits[0] if r.hits else None
print(f" '{queries[i]}' → {top.text if top else 'no results'}")
print(f"Total cost: ${batch.total_cost:.6f}")
Collection Management¶
ss = SemanticSearch(collection="temp-index")
# Index some data
ss.index([{"id": "1", "text": "Test document"}])
# List all collections
print(ss.list_collections()) # ["temp-index", "products", ...]
# Check stats
stats = ss.collection_stats()
print(stats) # {"name": "temp-index", "chunks": 1, ...}
# Clean up
ss.delete_collection()
Async Usage¶
import asyncio
from aiforge import SemanticSearch
async def main():
ss = SemanticSearch(collection="async-demo")
# Async index
resp = await ss.aindex([
{"id": "1", "text": "First document"},
{"id": "2", "text": "Second document"},
])
print(f"Indexed {resp.indexed_count}")
# Async search
results = await ss.aquery("first", top_k=1)
print(results.hits[0].text)
asyncio.run(main())
Include Raw Embeddings¶
results = ss.query("audio equipment", include_embeddings=True)
for hit in results.hits:
print(f" {hit.id}: score={hit.score:.3f}, embedding_dim={len(hit.embeddings)}")
# p2: score=0.923, embedding_dim=1536
Configuring via YAML¶
ss = SemanticSearch(collection="docs") # picks up aiforge.yaml automatically
results = ss.query("test")
print(results.model) # "text-embedding-3-small"
Error Handling¶
| Error | When | Example |
|---|---|---|
ValueError |
Empty collection, empty query, top_k out of range, empty documents list |
SemanticSearch(collection="") |
ProviderError |
Embedding provider returns an error | Invalid API key |
RateLimitError |
Provider rate limit exceeded | Large batch indexing |
APITimeoutError |
Provider request timed out | Slow network |
from aiforge.core.types import ProviderError
try:
results = ss.query("test")
except ProviderError as e:
print(f"Embedding failed: {e}")
try:
ss.index([]) # empty list
except ValueError as e:
print(f"Validation error: {e}")
# "documents must not be empty"
File Structure¶
aiforge/modules/search/
├── __init__.py # Exports: SemanticSearch, SearchDocument, SearchHit, SearchResponse, IndexResponse, IndexRequest, SearchRequest
├── module.py # SemanticSearch class (index, query, collection management)
├── schemas.py # Pydantic models (SearchDocument, SearchHit, IndexResponse, SearchResponse, IndexRequest, SearchRequest)
└── widget.py # Streamlit playground widget (Search + Index modes)
SemanticSearch vs DocQA¶
Both modules use vector stores and embeddings, but they serve different purposes:
| SemanticSearch | DocQA | |
|---|---|---|
| Purpose | Find the most similar documents | Answer a question in natural language |
| LLM calls | None — embedding-only | Yes — agentic LangGraph pipeline with iterative refinement |
| Ingestion | Pre-structured documents with explicit id, text, metadata |
Raw files, directories, or git URLs (auto-loads, language-aware chunking) |
| Output | Ranked hits with similarity score, id, text |
Synthesized answer (text), sources, confidence, optional trace |
| Cost | Embedding tokens only | Embedding tokens (ingest) + LLM tokens (ask) |
| Use when | You need a ranked list of matching passages | You need a written answer derived from documents |
Use SemanticSearch when you want to find documents and handle the results yourself; use DocQA when you want a fully synthesized answer.
See Also¶
- Quick Start — Basic search example
- DocQA — Full RAG pipeline with agentic refinement (uses search internally)
- Pipelines — Chain with other modules
- API Server —
POST /search/indexandPOST /search/queryendpoints - API Server > Webhooks — Async indexing with webhook notifications
- Cost Tracking — Embedding cost tracking
- Configuration — Full settings reference