Add support for a vector search index in SAI

- Adds jbellis/jvector (1.0.2) library for DiskANN based indexes on floating point vectors
- Adds ORDER BY ANN OF capability to do ANN search and order the results by score

 patch by Mike Adamson; reviewed by Andrés de la Peña, Jonathon Ellis for CASSANDRA-18715

Co-authored-by Jonathon Ellis jbellis@gmail.com
Co-authored-by Zhao Yang zhaoyangsingapore@gmail.com
This commit is contained in:
Mike Adamson 2023-08-30 11:51:04 +01:00 committed by Mick Semb Wever
parent b59b832eba
commit 949b760f55
No known key found for this signature in database
GPG Key ID: E91335D77E3E87CB
163 changed files with 13029 additions and 417 deletions

View File

@ -372,5 +372,9 @@
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analysis-common</artifactId>
</dependency>
<dependency>
<groupId>io.github.jbellis</groupId>
<artifactId>jvector</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -1201,6 +1201,11 @@
<artifactId>lucene-analysis-common</artifactId>
<version>9.7.0</version>
</dependency>
<dependency>
<groupId>io.github.jbellis</groupId>
<artifactId>jvector</artifactId>
<version>1.0.2</version>
</dependency>
<dependency>
<groupId>com.carrotsearch.randomizedtesting</groupId>
<artifactId>randomizedtesting-runner</artifactId>

View File

@ -1,4 +1,5 @@
5.0-alpha2
* Add support for vector search in SAI (CASSANDRA-18715)
* Remove crc_check_chance from CompressionParams (CASSANDRA-18872)
* Fix schema loading of UDTs inside vectors inside UDTs (CASSANDRA-18964)
* Add cqlsh autocompletion for the vector data type (CASSANDRA-18946)

View File

@ -86,6 +86,7 @@ New features
src/java/org/apache/cassandra/db/compaction/UnifiedCompactionStrategy.md
- New `VectorType` (cql `vector<element_type, dimension>`) which adds new fixed-length element arrays. See CASSANDRA-18504
- Added new vector similarity functions `similarity_cosine`, `similarity_euclidean` and `similarity_dot_product`.
- Added ANN vector similarity search via ORDER BY ANN OF syntax on SAI indexes (using jvector library).
- Removed UDT type migration logic for 3.6+ clusters upgrading to 4.0. If migration has been disabled, it must be
enabled before upgrading to 5.0 if the cluster used UDTs. See CASSANDRA-18504
- Entended max expiration time from 2038-01-19T03:14:06+00:00 to 2106-02-07T06:28:13+00:00

View File

@ -224,7 +224,8 @@ K_MASKED: M A S K E D;
K_UNMASK: U N M A S K;
K_SELECT_MASKED: S E L E C T '_' M A S K E D;
K_VECTOR: V E C T O R;
K_VECTOR: V E C T O R;
K_ANN: A N N;
// Case-insensitive alpha characters
fragment A: ('a'|'A');

View File

@ -268,7 +268,7 @@ selectStatement returns [SelectStatement.RawStatement expr]
@init {
Term.Raw limit = null;
Term.Raw perPartitionLimit = null;
Map<ColumnIdentifier, Boolean> orderings = new LinkedHashMap<>();
List<Ordering.Raw> orderings = new ArrayList<>();
List<Selectable.Raw> groups = new ArrayList<>();
boolean allowFiltering = false;
boolean isJson = false;
@ -459,11 +459,17 @@ customIndexExpression [WhereClause.Builder clause]
: 'expr(' idxName[name] ',' t=term ')' { clause.add(new CustomIndexExpression(name, t));}
;
orderByClause[Map<ColumnIdentifier, Boolean> orderings]
orderByClause[List<Ordering.Raw> orderings]
@init{
boolean reversed = false;
Ordering.Direction direction = Ordering.Direction.ASC;
}
: c=cident (K_ANN K_OF t=term)? (K_ASC | K_DESC { direction = Ordering.Direction.DESC; })?
{
Ordering.Raw.Expression expr = (t == null)
? new Ordering.Raw.SingleColumn(c)
: new Ordering.Raw.Ann(c, t);
orderings.add(new Ordering.Raw(expr, direction));
}
: c=cident (K_ASC | K_DESC { reversed = true; })? { orderings.put(c, reversed); }
;
groupByClause[List<Selectable.Raw> groups]
@ -2014,5 +2020,6 @@ basic_unreserved_keyword returns [String str]
| K_UNMASK
| K_SELECT_MASKED
| K_VECTOR
| K_ANN
) { $str = $k.text; }
;

View File

@ -429,8 +429,9 @@ public enum CassandraRelevantProperties
SAI_INTERSECTION_CLAUSE_LIMIT("cassandra.sai.intersection_clause_limit", "2"),
/** Latest version to be used for SAI index writing */
SAI_LATEST_VERSION("cassandra.sai.latest_version", "aa"),
SAI_MAX_FROZEN_TERM_SIZE("cassandra.sai.max_frozen_term_size_kb", "5"),
SAI_MAX_STRING_TERM_SIZE("cassandra.sai.max_string_term_size_kb", "1"),
SAI_MAX_FROZEN_TERM_SIZE("cassandra.sai.max_frozen_term_size", "5KiB"),
SAI_MAX_STRING_TERM_SIZE("cassandra.sai.max_string_term_size", "1KiB"),
SAI_MAX_VECTOR_TERM_SIZE("cassandra.sai.max_vector_term_size", "32KiB"),
/** Minimum number of reachable leaves for a given node to be eligible for an auxiliary posting list */
SAI_MINIMUM_POSTINGS_LEAVES("cassandra.sai.minimum_postings_leaves", "64"),
@ -455,6 +456,18 @@ public enum CassandraRelevantProperties
SAI_TEST_BALANCED_TREE_DEBUG_ENABLED("cassandra.sai.test.balanced_tree_debug_enabled", "false"),
SAI_TEST_DISABLE_TIMEOUT("cassandra.sai.test.disable.timeout", "false"),
/** Whether to allow the user to specify custom options to the hnsw index */
SAI_VECTOR_ALLOW_CUSTOM_PARAMETERS("cassandra.sai.vector.allow_custom_parameters", "false"),
/** Controls the maximum top-k limit for vector search */
SAI_VECTOR_SEARCH_MAX_TOP_K("cassandra.sai.vector_search.max_top_k", "1000"),
/**
* Controls the maximum number of PrimaryKeys that will be read into memory at one time when ordering/limiting
* the results of an ANN query constrained by non-ANN predicates.
*/
SAI_VECTOR_SEARCH_ORDER_CHUNK_SIZE("cassandra.sai.vector_search.order_chunk_size", "100000"),
SCHEMA_PULL_INTERVAL_MS("cassandra.schema_pull_interval_ms", "60000"),
SCHEMA_UPDATE_HANDLER_FACTORY_CLASS("cassandra.schema.update_handler_factory.class"),
SEARCH_CONCURRENCY_FACTOR("cassandra.search_concurrency_factor", "1"),

View File

@ -258,6 +258,23 @@ public enum Operator
{
throw new UnsupportedOperationException();
}
},
ANN(15)
{
@Override
public String toString()
{
return "ANN";
}
@Override
public boolean isSatisfiedBy(AbstractType<?> type, ByteBuffer leftOperand, ByteBuffer rightOperand)
{
// The ANN operator is only supported by the vector index so, normally, should never be called directly.
// In networked queries (non-local) the coordinator will end up calling the row filter directly. So, this
// needs to return true so that the returned values are allowed through to the VectorTopKProcessor
return true;
}
};
/**

View File

@ -0,0 +1,180 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.cql3;
import org.apache.cassandra.cql3.restrictions.SingleColumnRestriction;
import org.apache.cassandra.cql3.restrictions.SingleRestriction;
import org.apache.cassandra.schema.ColumnMetadata;
import org.apache.cassandra.schema.TableMetadata;
/**
* A single element of an ORDER BY clause.
* <code>ORDER BY ordering1 [, ordering2 [, ...]] </code>
* <p>
* An ordering comprises an expression that produces the values to compare against each other
* and a sorting direction (ASC, DESC).
*/
public class Ordering
{
public final Expression expression;
public final Direction direction;
public Ordering(Expression expression, Direction direction)
{
this.expression = expression;
this.direction = direction;
}
public static abstract class Expression
{
protected final ColumnMetadata columnMetadata;
public Expression(ColumnMetadata columnMetadata)
{
this.columnMetadata = columnMetadata;
}
public boolean hasNonClusteredOrdering()
{
return false;
}
public SingleRestriction toRestriction()
{
throw new UnsupportedOperationException();
}
public ColumnMetadata getColumn()
{
return columnMetadata;
}
}
/**
* Represents a single column in <code>ORDER BY column</code>
*/
public static class SingleColumn extends Expression
{
public SingleColumn(ColumnMetadata columnMetadata)
{
super(columnMetadata);
}
}
/**
* An expression used in Approximate Nearest Neighbor ordering. <code>ORDER BY column ANN OF value</code>
*/
public static class Ann extends Expression
{
final Term vectorValue;
public Ann(ColumnMetadata columnMetadata, Term vectorValue)
{
super(columnMetadata);
this.vectorValue = vectorValue;
}
@Override
public boolean hasNonClusteredOrdering()
{
return true;
}
@Override
public SingleRestriction toRestriction()
{
return new SingleColumnRestriction.AnnRestriction(columnMetadata, vectorValue);
}
}
public enum Direction
{ASC, DESC}
/**
* Represents ANTLR's abstract syntax tree of a single element in the {@code ORDER BY} clause.
* This comes directly out of CQL parser.
*/
public static class Raw
{
final Expression expression;
final Direction direction;
public Raw(Expression expression, Direction direction)
{
this.expression = expression;
this.direction = direction;
}
/**
* Resolves column identifiers against the table schema.
* Binds markers (?) to columns.
*/
public Ordering bind(TableMetadata table, VariableSpecifications boundNames)
{
return new Ordering(expression.bind(table, boundNames), direction);
}
public interface Expression
{
Ordering.Expression bind(TableMetadata table, VariableSpecifications boundNames);
}
public static class SingleColumn implements Expression
{
final ColumnIdentifier column;
SingleColumn(ColumnIdentifier column)
{
this.column = column;
}
@Override
public Ordering.Expression bind(TableMetadata table, VariableSpecifications boundNames)
{
return new Ordering.SingleColumn(table.getExistingColumn(column));
}
}
public static class Ann implements Expression
{
final ColumnIdentifier columnId;
final Term.Raw vectorValue;
Ann(ColumnIdentifier column, Term.Raw vectorValue)
{
this.columnId = column;
this.vectorValue = vectorValue;
}
@Override
public Ordering.Expression bind(TableMetadata table, VariableSpecifications boundNames)
{
ColumnMetadata column = table.getExistingColumn(columnId);
Term value = vectorValue.prepare(table.keyspace, column);
value.collectMarkerSpecification(boundNames);
return new Ordering.Ann(column, value);
}
}
}
}

View File

@ -114,6 +114,16 @@ public abstract class QueryOptions
return new OptionsWithColumnSpecifications(options, columnSpecs);
}
public static QueryOptions withConsistencyLevel(QueryOptions options, ConsistencyLevel consistencyLevel)
{
return new OptionsWithConsistencyLevel(options, consistencyLevel);
}
public static QueryOptions withPageSize(QueryOptions options, int pageSize)
{
return new OptionsWithPageSize(options, pageSize);
}
public abstract ConsistencyLevel getConsistency();
public abstract List<ByteBuffer> getValues();
public abstract boolean skipMetadata();
@ -424,6 +434,40 @@ public abstract class QueryOptions
}
}
static class OptionsWithConsistencyLevel extends QueryOptionsWrapper
{
private final ConsistencyLevel consistencyLevel;
OptionsWithConsistencyLevel(QueryOptions wrapped, ConsistencyLevel consistencyLevel)
{
super(wrapped);
this.consistencyLevel = consistencyLevel;
}
@Override
public ConsistencyLevel getConsistency()
{
return consistencyLevel;
}
}
static class OptionsWithPageSize extends QueryOptionsWrapper
{
private final int pageSize;
OptionsWithPageSize(QueryOptions wrapped, int pageSize)
{
super(wrapped);
this.pageSize = pageSize;
}
@Override
public int getPageSize()
{
return pageSize;
}
}
/**
* <code>QueryOptions</code> decorator that provides access to the column specifications.
*/

View File

@ -155,6 +155,8 @@ public abstract class Relation
case LIKE_MATCHES:
case LIKE:
return newLikeRestriction(table, boundNames, relationType);
case ANN:
throw invalidRequest("ANN is only supported in ORDER BY");
default: throw invalidRequest("Unsupported \"!=\" relation: %s", this);
}
}

View File

@ -28,6 +28,8 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import com.google.common.annotations.VisibleForTesting;
@ -89,6 +91,11 @@ public abstract class UntypedResultSet implements Iterable<UntypedResultSet.Row>
return size() == 0;
}
public Stream<Row> stream()
{
return StreamSupport.stream(spliterator(), false);
}
public abstract int size();
public abstract Row one();

View File

@ -27,7 +27,7 @@ import org.apache.cassandra.db.marshal.FloatType;
import org.apache.cassandra.db.marshal.VectorType;
import org.apache.cassandra.exceptions.InvalidRequestException;
import org.apache.cassandra.transport.ProtocolVersion;
import org.apache.lucene.index.VectorSimilarityFunction;
import io.github.jbellis.jvector.vector.VectorSimilarityFunction;
public class VectorFcts
{
@ -39,7 +39,7 @@ public class VectorFcts
}
private static FunctionFactory createSimilarityFunctionFactory(String name,
VectorSimilarityFunction luceneFunction,
VectorSimilarityFunction vectorSimilarityFunction,
boolean supportsZeroVectors)
{
return new FunctionFactory(name,
@ -55,7 +55,7 @@ public class VectorFcts
int dimensions = firstArgType.dimension;
if (!argTypes.stream().allMatch(t -> ((VectorType<?>) t).dimension == dimensions))
throw new InvalidRequestException("All arguments must have the same vector dimensions");
return createSimilarityFunction(name.name, firstArgType, luceneFunction, supportsZeroVectors);
return createSimilarityFunction(name.name, firstArgType, vectorSimilarityFunction, supportsZeroVectors);
}
};
}

View File

@ -132,6 +132,24 @@ public abstract class MultiColumnRestriction implements SingleRestriction
return false;
}
@Override
public final Index findSupportingIndex(IndexRegistry indexRegistry)
{
for (Index index : indexRegistry.listIndexes())
if (isSupportedBy(index))
return index;
return null;
}
@Override
public Index findSupportingIndexFromQueryPlan(Index.QueryPlan indexQueryPlan)
{
for (Index index : indexQueryPlan.getIndexes())
if (isSupportedBy(index))
return index;
return null;
}
@Override
public boolean needsFiltering(Index.Group indexGroup)
{

View File

@ -69,6 +69,21 @@ public interface Restriction
*/
boolean hasSupportingIndex(IndexRegistry indexRegistry);
/**
* Find first supporting index for current restriction
*
* @param indexRegistry the index registry
* @return <code>index</code> if the restriction is on indexed columns, <code>null</code>
*/
Index findSupportingIndex(IndexRegistry indexRegistry);
/**
* Find the first supporting index for the current restriction from an {@link Index.QueryPlan}.
* @param indexQueryPlan the index query plan
* @return <code>index</code> if the restriction is on indexed columns, <code>null</code>
*/
Index findSupportingIndexFromQueryPlan(Index.QueryPlan indexQueryPlan);
/**
* Returns whether this restriction would need filtering if the specified index group were used.
*

View File

@ -66,6 +66,7 @@ final class RestrictionSet implements Restrictions, Iterable<SingleRestriction>
private final boolean hasIn;
private final boolean hasContains;
private final boolean hasSlice;
private final boolean hasAnn;
private final boolean hasOnlyEqualityRestrictions;
public RestrictionSet()
@ -74,6 +75,7 @@ final class RestrictionSet implements Restrictions, Iterable<SingleRestriction>
false,
false,
false,
false,
true);
}
@ -82,6 +84,7 @@ final class RestrictionSet implements Restrictions, Iterable<SingleRestriction>
boolean hasIn,
boolean hasContains,
boolean hasSlice,
boolean hasAnn,
boolean hasOnlyEqualityRestrictions)
{
this.restrictions = restrictions;
@ -89,6 +92,7 @@ final class RestrictionSet implements Restrictions, Iterable<SingleRestriction>
this.hasIn = hasIn;
this.hasContains = hasContains;
this.hasSlice = hasSlice;
this.hasAnn = hasAnn;
this.hasOnlyEqualityRestrictions = hasOnlyEqualityRestrictions;
}
@ -168,18 +172,20 @@ final class RestrictionSet implements Restrictions, Iterable<SingleRestriction>
public RestrictionSet addRestriction(SingleRestriction restriction)
{
// RestrictionSet is immutable so we need to clone the restrictions map.
TreeMap<ColumnMetadata, SingleRestriction> newRestricitons = new TreeMap<>(this.restrictions);
TreeMap<ColumnMetadata, SingleRestriction> newRestrictions = new TreeMap<>(this.restrictions);
boolean newHasIn = hasIn || restriction.isIN();
boolean newHasContains = hasContains || restriction.isContains();
boolean newHasSlice = hasSlice || restriction.isSlice();
boolean newHasAnn = hasAnn || restriction.isANN();
boolean newHasOnlyEqualityRestrictions = hasOnlyEqualityRestrictions && (restriction.isEQ() || restriction.isIN());
return new RestrictionSet(mergeRestrictions(newRestricitons, restriction),
return new RestrictionSet(mergeRestrictions(newRestrictions, restriction),
hasMultiColumnRestrictions || restriction.isMultiColumn(),
newHasIn,
newHasContains,
newHasSlice,
newHasAnn,
newHasOnlyEqualityRestrictions);
}
@ -244,6 +250,30 @@ final class RestrictionSet implements Restrictions, Iterable<SingleRestriction>
return false;
}
@Override
public Index findSupportingIndex(IndexRegistry indexRegistry)
{
for (SingleRestriction restriction : restrictions.values())
{
Index index = restriction.findSupportingIndex(indexRegistry);
if (index != null)
return index;
}
return null;
}
@Override
public Index findSupportingIndexFromQueryPlan(Index.QueryPlan indexQueryPlan)
{
for (SingleRestriction restriction : restrictions.values())
{
Index index = restriction.findSupportingIndexFromQueryPlan(indexQueryPlan);
if (index != null)
return index;
}
return null;
}
/**
* Returns the column after the specified one.
*
@ -318,6 +348,11 @@ final class RestrictionSet implements Restrictions, Iterable<SingleRestriction>
return hasSlice;
}
public boolean hasAnn()
{
return hasAnn;
}
/**
* Checks if all of the underlying restrictions are EQ or IN restrictions.
*

View File

@ -90,6 +90,18 @@ class RestrictionSetWrapper implements Restrictions
return restrictions.hasSupportingIndex(indexRegistry);
}
@Override
public Index findSupportingIndex(IndexRegistry indexRegistry)
{
return restrictions.findSupportingIndex(indexRegistry);
}
@Override
public Index findSupportingIndexFromQueryPlan(Index.QueryPlan indexQueryPlan)
{
return restrictions.findSupportingIndexFromQueryPlan(indexQueryPlan);
}
@Override
public boolean needsFiltering(Index.Group indexGroup)
{

View File

@ -81,6 +81,26 @@ public abstract class SingleColumnRestriction implements SingleRestriction
return false;
}
@Override
public Index findSupportingIndex(IndexRegistry indexRegistry)
{
for (Index index : indexRegistry.listIndexes())
if (isSupportedBy(index))
return index;
return null;
}
@Override
public Index findSupportingIndexFromQueryPlan(Index.QueryPlan indexQueryPlan)
{
for (Index index : indexQueryPlan.getIndexes())
if (isSupportedBy(index))
return index;
return null;
}
@Override
public boolean needsFiltering(Index.Group indexGroup)
{
@ -804,4 +824,69 @@ public abstract class SingleColumnRestriction implements SingleRestriction
return Pair.create(operator, newValue);
}
}
public static final class AnnRestriction extends SingleColumnRestriction
{
private final Term value;
public AnnRestriction(ColumnMetadata columnDef, Term value)
{
super(columnDef);
this.value = value;
}
public ByteBuffer value(QueryOptions options)
{
return value.bindAndGet(options);
}
@Override
public boolean isANN()
{
return true;
}
@Override
public void addFunctionsTo(List<Function> functions)
{
value.addFunctionsTo(functions);
}
@Override
MultiColumnRestriction toMultiColumnRestriction()
{
throw new UnsupportedOperationException();
}
@Override
public void addToRowFilter(RowFilter filter,
IndexRegistry indexRegistry,
QueryOptions options)
{
filter.add(columnDef, Operator.ANN, value.bindAndGet(options));
}
@Override
public MultiCBuilder appendTo(MultiCBuilder builder, QueryOptions options)
{
throw new UnsupportedOperationException();
}
@Override
public String toString()
{
return String.format("ANN(%s)", value);
}
@Override
public SingleRestriction doMergeWith(SingleRestriction otherRestriction)
{
throw invalidRequest("%s cannot be restricted by more than one relation in an ANN ordering", columnDef.name);
}
@Override
protected boolean isSupportedBy(Index index)
{
return index.supportsExpression(columnDef, Operator.ANN);
}
}
}

View File

@ -51,6 +51,11 @@ public interface SingleRestriction extends Restriction
return false;
}
public default boolean isANN()
{
return false;
}
/**
* @return <code>true</code> if this restriction is based on equality comparison rather than a range or negation
*/

View File

@ -19,8 +19,12 @@ package org.apache.cassandra.cql3.restrictions;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.google.common.base.Joiner;
import com.google.common.collect.Iterables;
import com.google.common.collect.Streams;
import org.apache.cassandra.cql3.*;
import org.apache.cassandra.cql3.functions.Function;
@ -30,6 +34,8 @@ import org.apache.cassandra.db.*;
import org.apache.cassandra.db.filter.RowFilter;
import org.apache.cassandra.db.guardrails.Guardrails;
import org.apache.cassandra.db.marshal.AbstractType;
import org.apache.cassandra.db.marshal.FloatType;
import org.apache.cassandra.db.marshal.VectorType;
import org.apache.cassandra.db.virtual.VirtualKeyspaceRegistry;
import org.apache.cassandra.db.virtual.VirtualTable;
import org.apache.cassandra.dht.*;
@ -63,6 +69,14 @@ public final class StatementRestrictions
"Executing this query despite the performance unpredictability with ALLOW FILTERING has been disabled " +
"by the allow_filtering_enabled property in cassandra.yaml";
public static final String ANN_REQUIRES_INDEX_MESSAGE = "ANN ordering by vector requires the column to be indexed";
public static final String VECTOR_INDEXES_ANN_ONLY_MESSAGE = "Vector indexes only support ANN queries";
public static final String ANN_ONLY_SUPPORTED_ON_VECTOR_MESSAGE = "ANN ordering is only supported on float vector indexes";
public static final String ANN_REQUIRES_INDEXED_FILTERING_MESSAGE = "ANN ordering by vector requires all restricted column(s) to be indexed";
/**
* The type of statement
*/
@ -138,11 +152,12 @@ public final class StatementRestrictions
TableMetadata table,
WhereClause whereClause,
VariableSpecifications boundNames,
List<Ordering> orderings,
boolean selectsOnlyStaticColumns,
boolean allowFiltering,
boolean forView)
{
this(state, type, table, whereClause, boundNames, selectsOnlyStaticColumns, type.allowUseOfSecondaryIndices(), allowFiltering, forView);
this(state, type, table, whereClause, boundNames, orderings, selectsOnlyStaticColumns, type.allowUseOfSecondaryIndices(), allowFiltering, forView);
}
/*
@ -154,6 +169,7 @@ public final class StatementRestrictions
TableMetadata table,
WhereClause whereClause,
VariableSpecifications boundNames,
List<Ordering> orderings,
boolean selectsOnlyStaticColumns,
boolean allowUseOfSecondaryIndices,
boolean allowFiltering,
@ -161,9 +177,7 @@ public final class StatementRestrictions
{
this(type, table, allowFiltering);
IndexRegistry indexRegistry = null;
if (type.allowUseOfSecondaryIndices())
indexRegistry = IndexRegistry.obtain(table);
final IndexRegistry indexRegistry = type.allowUseOfSecondaryIndices() ? IndexRegistry.obtain(table) : null;
/*
* WHERE clause. For a given entity, rules are:
@ -206,6 +220,10 @@ public final class StatementRestrictions
}
}
// ORDER BY clause.
// Some indexes can be used for ordering.
nonPrimaryKeyRestrictions = addOrderingRestrictions(orderings, nonPrimaryKeyRestrictions);
hasRegularColumnsRestrictions = nonPrimaryKeyRestrictions.hasRestrictionFor(ColumnMetadata.Kind.REGULAR);
boolean hasQueriableClusteringColumnIndex = false;
@ -274,10 +292,55 @@ public final class StatementRestrictions
throw invalidRequest("Non PRIMARY KEY columns found in where clause: %s ",
Joiner.on(", ").join(nonPrimaryKeyColumns));
}
var annRestriction = Streams.stream(nonPrimaryKeyRestrictions).filter(SingleRestriction::isANN).findFirst();
if (annRestriction.isPresent())
{
// If there is an ANN restriction then it must be for a vector<float, n> column, and it must have an index
var annColumn = annRestriction.get().getFirstColumn();
if (!annColumn.type.isVector() || !(((VectorType<?>)annColumn.type).elementType instanceof FloatType))
throw invalidRequest(StatementRestrictions.ANN_ONLY_SUPPORTED_ON_VECTOR_MESSAGE);
if (indexRegistry == null || indexRegistry.listIndexes().stream().noneMatch(i -> i.dependsOn(annColumn)))
throw invalidRequest(StatementRestrictions.ANN_REQUIRES_INDEX_MESSAGE);
// We do not allow ANN queries using partition key restrictions that need filtering
if (partitionKeyRestrictions.needFiltering(table))
throw invalidRequest(StatementRestrictions.ANN_REQUIRES_INDEXED_FILTERING_MESSAGE);
// We do not allow ANN query filtering using non-indexed columns
var nonAnnColumns = Streams.stream(nonPrimaryKeyRestrictions)
.filter(r -> !r.isANN())
.map(Restriction::getFirstColumn)
.collect(Collectors.toList());
var clusteringColumns = clusteringColumnsRestrictions.getColumnDefinitions();
if (!nonAnnColumns.isEmpty() || !clusteringColumns.isEmpty())
{
var nonIndexedColumns = Stream.concat(nonAnnColumns.stream(), clusteringColumns.stream())
.filter(c -> indexRegistry.listIndexes().stream().noneMatch(i -> i.dependsOn(c)))
.collect(Collectors.toList());
if (!nonIndexedColumns.isEmpty())
throw invalidRequest(StatementRestrictions.ANN_REQUIRES_INDEXED_FILTERING_MESSAGE);
}
}
else
{
// We do not support indexed vector restrictions that are not part of an ANN ordering
var vectorColumn = nonPrimaryKeyRestrictions.getColumnDefs()
.stream()
.filter(c -> c.type.isVector())
.findFirst();
if (vectorColumn.isPresent() && indexRegistry.listIndexes().stream().anyMatch(i -> i.dependsOn(vectorColumn.get())))
throw invalidRequest(StatementRestrictions.VECTOR_INDEXES_ANN_ONLY_MESSAGE);
}
if (hasQueriableIndex)
{
usesSecondaryIndexing = true;
else if (!allowFiltering && requiresAllowFilteringIfNotSpecified())
throw invalidRequest(allowFilteringMessage(state));
}
else
{
if (!allowFiltering && requiresAllowFilteringIfNotSpecified())
throw invalidRequest(allowFilteringMessage(state));
}
filterRestrictions.add(nonPrimaryKeyRestrictions);
}
@ -450,6 +513,10 @@ public final class StatementRestrictions
return false;
}
public boolean isTopK()
{
return nonPrimaryKeyRestrictions.hasAnn();
}
/**
* Returns the <code>Restrictions</code> for the specified type of columns.
*
@ -476,6 +543,38 @@ public final class StatementRestrictions
return this.usesSecondaryIndexing;
}
/**
* This is a hack to push ordering down to indexes.
* Indexes are selected based on RowFilter only, so we need to turn orderings into restrictions
* so they end up in the row filter.
*
* @param orderings orderings from the select statement
* @return the {@link RestrictionSet} with the added orderings
*/
private RestrictionSet addOrderingRestrictions(List<Ordering> orderings, RestrictionSet restrictionSet)
{
List<Ordering> annOrderings = orderings.stream().filter(o -> o.expression.hasNonClusteredOrdering()).collect(Collectors.toList());
if (annOrderings.size() > 1)
throw new InvalidRequestException("Cannot specify more than one ANN ordering");
else if (annOrderings.size() == 1)
{
if (orderings.size() > 1)
throw new InvalidRequestException("ANN ordering does not support any other ordering");
Ordering annOrdering = annOrderings.get(0);
if (annOrdering.direction != Ordering.Direction.ASC)
throw new InvalidRequestException("Descending ANN ordering is not supported");
SingleRestriction restriction = annOrdering.expression.toRestriction();
return restrictionSet.addRestriction(restriction);
}
return restrictionSet;
}
private static Iterable<Restriction> allColumnRestrictions(ClusteringColumnRestrictions clusteringColumnsRestrictions, RestrictionSet nonPrimaryKeyRestrictions)
{
return Iterables.concat(clusteringColumnsRestrictions.getRestrictionSet(), nonPrimaryKeyRestrictions);
}
private void processPartitionKeyRestrictions(ClientState state, boolean hasQueriableIndex, boolean allowFiltering, boolean forView)
{
if (!type.allowPartitionKeyRanges())

View File

@ -286,6 +286,18 @@ final class TokenFilter implements PartitionKeyRestrictions
restrictions.addToRowFilter(filter, indexRegistry, options);
}
@Override
public Index findSupportingIndex(IndexRegistry indexRegistry)
{
return restrictions.findSupportingIndex(indexRegistry);
}
@Override
public Index findSupportingIndexFromQueryPlan(Index.QueryPlan indexQueryPlan)
{
return restrictions.findSupportingIndexFromQueryPlan(indexQueryPlan);
}
@Override
public boolean needsFiltering(Index.Group indexGroup)
{

View File

@ -129,6 +129,18 @@ public abstract class TokenRestriction implements PartitionKeyRestrictions
throw new UnsupportedOperationException("Index expression cannot be created for token restriction");
}
@Override
public Index findSupportingIndex(IndexRegistry indexRegistry)
{
return null;
}
@Override
public Index findSupportingIndexFromQueryPlan(Index.QueryPlan indexQueryPlan)
{
return null;
}
@Override
public boolean needsFiltering(Index.Group indexGroup)
{

View File

@ -17,6 +17,7 @@
*/
package org.apache.cassandra.cql3.statements;
import java.util.Collections;
import java.util.List;
import org.apache.cassandra.audit.AuditLogContext;
@ -167,7 +168,8 @@ public class DeleteStatement extends ModificationStatement
bindVariables,
operations,
whereClause,
conditions);
conditions,
Collections.emptyList());
DeleteStatement stmt = new DeleteStatement(bindVariables,
metadata,

View File

@ -1037,13 +1037,14 @@ public abstract class ModificationStatement implements CQLStatement.SingleKeyspa
VariableSpecifications boundNames,
Operations operations,
WhereClause where,
Conditions conditions)
Conditions conditions,
List<Ordering> orderings)
{
if (where.containsCustomExpressions())
throw new InvalidRequestException(CUSTOM_EXPRESSIONS_NOT_ALLOWED);
boolean applyOnlyToStaticColumns = appliesOnlyToStaticColumns(operations, conditions);
return new StatementRestrictions(state, type, metadata, where, boundNames, applyOnlyToStaticColumns, false, false);
return new StatementRestrictions(state, type, metadata, where, boundNames, orderings, applyOnlyToStaticColumns, false, false);
}
public List<Pair<ColumnIdentifier, ColumnCondition.Raw>> getConditions()

View File

@ -26,6 +26,7 @@ import javax.annotation.concurrent.ThreadSafe;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
@ -35,7 +36,9 @@ import org.slf4j.LoggerFactory;
import org.apache.cassandra.audit.AuditLogContext;
import org.apache.cassandra.audit.AuditLogEntryType;
import org.apache.cassandra.auth.Permission;
import org.apache.cassandra.cql3.restrictions.SingleRestriction;
import org.apache.cassandra.db.guardrails.Guardrails;
import org.apache.cassandra.index.Index;
import org.apache.cassandra.schema.ColumnMetadata;
import org.apache.cassandra.schema.Schema;
import org.apache.cassandra.schema.SchemaConstants;
@ -109,6 +112,15 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
private static final NoSpamLogger noSpamLogger = NoSpamLogger.getLogger(SelectStatement.logger, 1, TimeUnit.MINUTES);
public static final int DEFAULT_PAGE_SIZE = 10000;
public static final String TOPK_CONSISTENCY_LEVEL_ERROR = "Top-K queries can only be run with consistency level ONE/LOCAL_ONE. Consistency level %s was used.";
public static final String TOPK_LIMIT_ERROR = "Top-K queries must have a limit specified and the limit must be less than the query page size";
public static final String TOPK_PARTITION_LIMIT_ERROR = "Top-K queries do not support per-partition limits";
public static final String TOPK_AGGREGATION_ERROR = "Top-K queries can not be run with aggregation";
public static final String TOPK_CONSISTENCY_LEVEL_WARNING = "Top-K queries can only be run with consistency level ONE " +
"/ LOCAL_ONE / NODE_LOCAL. Consistency level %s was requested. " +
"Downgrading the consistency level to %s.";
public static final String TOPK_PAGE_SIZE_WARNING = "Top-K queries do not support paging and the page size is set to %d, " +
"which is less than LIMIT %d. The page size has been set to %<d to match the LIMIT.";
public final VariableSpecifications bindVariables;
public final TableMetadata table;
@ -129,10 +141,10 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
/**
* The comparator used to orders results when multiple keys are selected (using IN).
*/
private final Comparator<List<ByteBuffer>> orderingComparator;
private final ColumnComparator<List<ByteBuffer>> orderingComparator;
// Used by forSelection below
private static final Parameters defaultParameters = new Parameters(Collections.emptyMap(),
private static final Parameters defaultParameters = new Parameters(Collections.emptyList(),
Collections.emptyList(),
false,
false,
@ -145,7 +157,7 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
StatementRestrictions restrictions,
boolean isReversed,
AggregationSpecification.Factory aggregationSpecFactory,
Comparator<List<ByteBuffer>> orderingComparator,
ColumnComparator<List<ByteBuffer>> orderingComparator,
Term limit,
Term perPartitionLimit)
{
@ -281,14 +293,51 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
Selectors selectors = selection.newSelectors(options);
AggregationSpecification aggregationSpec = getAggregationSpec(options);
ReadQuery query = getQuery(options, state.getClientState(), selectors.getColumnFilter(),
nowInSec, userLimit, userPerPartitionLimit, pageSize, aggregationSpec);
DataLimits limit = getDataLimits(userLimit, userPerPartitionLimit, pageSize, aggregationSpec);
// Handle additional validation for topK queries
if (restrictions.isTopK())
{
checkFalse(aggregationSpec != null, TOPK_AGGREGATION_ERROR);
// We aren't going to allow SERIAL at all, so we can error out on those.
checkFalse(options.getConsistency() == ConsistencyLevel.LOCAL_SERIAL ||
options.getConsistency() == ConsistencyLevel.SERIAL,
String.format(TOPK_CONSISTENCY_LEVEL_ERROR, options.getConsistency()));
if (options.getConsistency() != ConsistencyLevel.ONE &&
options.getConsistency() != ConsistencyLevel.LOCAL_ONE &&
options.getConsistency() != ConsistencyLevel.NODE_LOCAL)
{
ConsistencyLevel supplied = options.getConsistency();
ConsistencyLevel downgrade = supplied.isDatacenterLocal() ? ConsistencyLevel.LOCAL_ONE : ConsistencyLevel.ONE;
options = QueryOptions.withConsistencyLevel(options, downgrade);
ClientWarn.instance.warn(String.format(TOPK_CONSISTENCY_LEVEL_WARNING, supplied, downgrade));
}
checkFalse(limit.isUnlimited(), TOPK_LIMIT_ERROR);
checkFalse(limit.perPartitionCount() != DataLimits.NO_LIMIT, TOPK_PARTITION_LIMIT_ERROR);
if (pageSize > 0 && pageSize < limit.count())
{
int oldPageSize = pageSize;
pageSize = limit.count();
limit = getDataLimits(userLimit, userPerPartitionLimit, pageSize, aggregationSpec);
options = QueryOptions.withPageSize(options, pageSize);
ClientWarn.instance.warn(String.format(TOPK_PAGE_SIZE_WARNING, oldPageSize, limit.count()));
}
}
ReadQuery query = getQuery(options, state.getClientState(), selectors.getColumnFilter(), nowInSec, limit);
if (options.isReadThresholdsEnabled())
query.trackWarnings();
ResultMessage.Rows rows;
if (aggregationSpec == null && (pageSize <= 0 || (query.limits().count() <= pageSize)))
if (aggregationSpec == null && (pageSize <= 0 || (query.limits().count() <= pageSize) || query.isTopK()))
{
rows = execute(query, options, state.getClientState(), selectors, nowInSec, userLimit, null, queryStartNanoTime, unmask);
}
@ -341,10 +390,19 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
int pageSize,
AggregationSpecification aggregationSpec)
{
boolean isPartitionRangeQuery = restrictions.isKeyRange() || restrictions.usesSecondaryIndexing();
DataLimits limit = getDataLimits(userLimit, perPartitionLimit, pageSize, aggregationSpec);
return getQuery(options, state, columnFilter, nowInSec, limit);
}
public ReadQuery getQuery(QueryOptions options,
ClientState state,
ColumnFilter columnFilter,
long nowInSec,
DataLimits limit)
{
boolean isPartitionRangeQuery = restrictions.isKeyRange() || restrictions.usesSecondaryIndexing();
if (isPartitionRangeQuery)
return getRangeCommand(options, state, columnFilter, limit, nowInSec);
@ -483,7 +541,7 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
// Please note that the isExhausted state of the pager only gets updated when we've closed the page, so this
// shouldn't be moved inside the 'try' above.
if (!pager.isExhausted())
if (!pager.isExhausted() && !pager.pager.isTopK())
msg.result.metadata.setHasMorePages(pager.state());
return msg;
@ -535,7 +593,7 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
try (ReadExecutionController executionController = query.executionController())
{
if (aggregationSpec == null && (pageSize <= 0 || (query.limits().count() <= pageSize)))
if (aggregationSpec == null && (pageSize <= 0 || (query.limits().count() <= pageSize) || query.isTopK()))
{
try (PartitionIterator data = query.executeInternal(executionController))
{
@ -822,7 +880,9 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
// If we do post ordering we need to get all the results sorted before we can trim them.
if (aggregationSpec != AggregationSpecification.AGGREGATE_EVERYTHING)
{
if (!needsPostQueryOrdering())
// If we aren't need post-query ordering but we are doing index ordering (currently ANN only) then
// we do need to use the user limit.
if (!needsPostQueryOrdering() || needIndexOrdering())
cqlRowLimit = userLimit;
cqlPerPartitionLimit = perPartitionLimit;
}
@ -939,7 +999,7 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
ResultSet cqlRows = result.build();
maybeWarn(result, options);
orderResults(cqlRows);
orderResults(cqlRows, options);
cqlRows.trim(userLimit);
@ -1080,19 +1140,28 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
private boolean needsPostQueryOrdering()
{
// We need post-query ordering only for queries with IN on the partition key and an ORDER BY.
return restrictions.keyIsInRelation() && !parameters.orderings.isEmpty();
// We need post-query ordering only for queries with IN on the partition key and an ORDER BY or index restriction reordering
return restrictions.keyIsInRelation() && !parameters.orderings.isEmpty() || needIndexOrdering();
}
private boolean needIndexOrdering()
{
return orderingComparator != null && orderingComparator.indexOrdering();
}
/**
* Orders results when multiple keys are selected (using IN)
* Orders results when multiple keys are selected (using IN).
* <p>
* In the case of ANN ordering the rows are first ordered in index column order and then by primary key.
*/
private void orderResults(ResultSet cqlRows)
private void orderResults(ResultSet cqlRows, QueryOptions options)
{
if (cqlRows.size() == 0 || !needsPostQueryOrdering())
return;
Collections.sort(cqlRows.rows, orderingComparator);
Comparator<List<ByteBuffer>> comparator = orderingComparator.prepareFor(table, getRowFilter(options), options);
if (comparator != null)
cqlRows.rows.sort(comparator);
}
public static class RawStatement extends QualifiedStatement
@ -1133,13 +1202,13 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
List<Selectable> selectables = RawSelector.toSelectables(selectClause, table);
boolean containsOnlyStaticColumns = selectOnlyStaticColumns(table, selectables);
StatementRestrictions restrictions = prepareRestrictions(state, table, bindVariables, containsOnlyStaticColumns, forView);
List<Ordering> orderings = getOrderings(table);
StatementRestrictions restrictions = prepareRestrictions(state, table, bindVariables, orderings, containsOnlyStaticColumns, forView);
// If we order post-query, the sorted column needs to be in the ResultSet for sorting,
// even if we don't ultimately ship them to the client (CASSANDRA-4911).
Map<ColumnMetadata, Boolean> orderingColumns = getOrderingColumns(table);
Set<ColumnMetadata> resultSetOrderingColumns = restrictions.keyIsInRelation() ? orderingColumns.keySet()
: Collections.emptySet();
Map<ColumnMetadata, Ordering> orderingColumns = getOrderingColumns(orderings);
Set<ColumnMetadata> resultSetOrderingColumns = getResultSetOrdering(restrictions, orderingColumns);
Selection selection = prepareSelection(table,
selectables,
@ -1163,18 +1232,18 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
&& perPartitionLimit != null,
"PER PARTITION LIMIT is not allowed with aggregate queries.");
Comparator<List<ByteBuffer>> orderingComparator = null;
ColumnComparator<List<ByteBuffer>> orderingComparator = null;
boolean isReversed = false;
if (!orderingColumns.isEmpty())
{
assert !forView;
verifyOrderingIsAllowed(restrictions);
verifyOrderingIsAllowed(restrictions, orderingColumns);
orderingComparator = getOrderingComparator(selection, restrictions, orderingColumns);
isReversed = isReversed(table, orderingColumns, restrictions);
if (isReversed)
orderingComparator = Collections.reverseOrder(orderingComparator);
}
if (isReversed && orderingComparator != null)
orderingComparator = orderingComparator.reverse();
}
checkNeedsFiltering(table, restrictions);
@ -1190,6 +1259,13 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
prepareLimit(bindVariables, perPartitionLimit, keyspace(), perPartitionLimitReceiver()));
}
private Set<ColumnMetadata> getResultSetOrdering(StatementRestrictions restrictions, Map<ColumnMetadata, Ordering> orderingColumns)
{
if (restrictions.keyIsInRelation() || orderingColumns.values().stream().anyMatch(o -> o.expression.hasNonClusteredOrdering()))
return orderingColumns.keySet();
return Collections.emptySet();
}
private Selection prepareSelection(TableMetadata table,
List<Selectable> selectables,
VariableSpecifications boundNames,
@ -1242,22 +1318,30 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
}
/**
* Returns the columns used to order the data.
* @return the columns used to order the data.
* Returns the columns in the same order as given list used to order the data.
* @return the columns in the same order as given list used to order the data.
*/
private Map<ColumnMetadata, Boolean> getOrderingColumns(TableMetadata table)
private Map<ColumnMetadata, Ordering> getOrderingColumns(List<Ordering> orderings)
{
if (parameters.orderings.isEmpty())
if (orderings.isEmpty())
return Collections.emptyMap();
Map<ColumnMetadata, Boolean> orderingColumns = new LinkedHashMap<>();
for (Map.Entry<ColumnIdentifier, Boolean> entry : parameters.orderings.entrySet())
Map<ColumnMetadata, Ordering> orderingColumns = new LinkedHashMap<>();
for (Ordering ordering : orderings)
{
orderingColumns.put(table.getExistingColumn(entry.getKey()), entry.getValue());
ColumnMetadata column = ordering.expression.getColumn();
orderingColumns.put(column, ordering);
}
return orderingColumns;
}
private List<Ordering> getOrderings(TableMetadata table)
{
return parameters.orderings.stream()
.map(o -> o.bind(table, bindVariables))
.collect(Collectors.toList());
}
/**
* Prepares the restrictions.
*
@ -1270,6 +1354,7 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
private StatementRestrictions prepareRestrictions(ClientState state,
TableMetadata metadata,
VariableSpecifications boundNames,
List<Ordering> orderings,
boolean selectsOnlyStaticColumns,
boolean forView) throws InvalidRequestException
{
@ -1278,6 +1363,7 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
metadata,
whereClause,
boundNames,
orderings,
selectsOnlyStaticColumns,
parameters.allowFiltering,
forView);
@ -1295,9 +1381,11 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
return prepLimit;
}
private static void verifyOrderingIsAllowed(StatementRestrictions restrictions) throws InvalidRequestException
private static void verifyOrderingIsAllowed(StatementRestrictions restrictions, Map<ColumnMetadata, Ordering> orderingColumns) throws InvalidRequestException
{
checkFalse(restrictions.usesSecondaryIndexing(), "ORDER BY with 2ndary indexes is not supported.");
if (orderingColumns.values().stream().anyMatch(o -> o.expression.hasNonClusteredOrdering()))
return;
checkFalse(restrictions.usesSecondaryIndexing(), "ORDER BY with 2ndary indexes is not supported, except for ANN queries.");
checkFalse(restrictions.isKeyRange(), "ORDER BY is only supported when the partition key is restricted by an EQ or an IN.");
}
@ -1424,11 +1512,19 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
checkFalse(f.isAggregate(), "Aggregate functions are not supported within the GROUP BY clause, got: %s", f.name());
}
private Comparator<List<ByteBuffer>> getOrderingComparator(Selection selection,
StatementRestrictions restrictions,
Map<ColumnMetadata, Boolean> orderingColumns)
throws InvalidRequestException
private ColumnComparator<List<ByteBuffer>> getOrderingComparator(Selection selection,
StatementRestrictions restrictions,
Map<ColumnMetadata, Ordering> orderingColumns) throws InvalidRequestException
{
for (Map.Entry<ColumnMetadata, Ordering> e : orderingColumns.entrySet())
{
if (e.getValue().expression.hasNonClusteredOrdering())
{
Preconditions.checkState(orderingColumns.size() == 1);
return new IndexColumnComparator(e.getValue().expression.toRestriction(), selection.getOrderingIndex(e.getKey()));
}
}
if (!restrictions.keyIsInRelation())
return null;
@ -1444,14 +1540,17 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
: new CompositeComparator(sorters, idToSort);
}
private boolean isReversed(TableMetadata table, Map<ColumnMetadata, Boolean> orderingColumns, StatementRestrictions restrictions) throws InvalidRequestException
private boolean isReversed(TableMetadata table, Map<ColumnMetadata, Ordering> orderingColumns, StatementRestrictions restrictions) throws InvalidRequestException
{
if (orderingColumns.values().stream().anyMatch(o -> o.expression.hasNonClusteredOrdering()))
return false;
Boolean[] reversedMap = new Boolean[table.clusteringColumns().size()];
int i = 0;
for (Map.Entry<ColumnMetadata, Boolean> entry : orderingColumns.entrySet())
for (var entry : orderingColumns.entrySet())
{
ColumnMetadata def = entry.getKey();
boolean reversed = entry.getValue();
Ordering ordering = entry.getValue();
boolean reversed = ordering.direction == Ordering.Direction.DESC;
checkTrue(def.isClusteringColumn(),
"Order by is currently only supported on the clustered columns of the PRIMARY KEY, got %s", def.name);
@ -1522,13 +1621,13 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
public static class Parameters
{
// Public because CASSANDRA-9858
public final Map<ColumnIdentifier, Boolean> orderings;
public final List<Ordering.Raw> orderings;
public final List<Selectable.Raw> groups;
public final boolean isDistinct;
public final boolean allowFiltering;
public final boolean isJson;
public Parameters(Map<ColumnIdentifier, Boolean> orderings,
public Parameters(List<Ordering.Raw> orderings,
List<Selectable.Raw> groups,
boolean isDistinct,
boolean allowFiltering,
@ -1551,6 +1650,43 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
return bValue == null ? 1 : comparator.compare(aValue, bValue);
}
public ColumnComparator<T> reverse()
{
return new ReversedColumnComparator<>(this);
}
/**
* @return true if ordering is performed by index
*/
public boolean indexOrdering()
{
return false;
}
/**
* Produces a prepared {@link ColumnComparator} for current table and query-options
*/
public Comparator<T> prepareFor(TableMetadata table, RowFilter rowFilter, QueryOptions options)
{
return this;
}
}
private static class ReversedColumnComparator<T> extends ColumnComparator<T>
{
private final ColumnComparator<T> wrapped;
public ReversedColumnComparator(ColumnComparator<T> wrapped)
{
this.wrapped = wrapped;
}
@Override
public int compare(T o1, T o2)
{
return wrapped.compare(o2, o1);
}
}
/**
@ -1573,6 +1709,44 @@ public class SelectStatement implements CQLStatement.SingleKeyspaceCqlStatement
}
}
private static class IndexColumnComparator extends ColumnComparator<List<ByteBuffer>>
{
private final SingleRestriction restriction;
private final int columnIndex;
public IndexColumnComparator(SingleRestriction restriction, int columnIndex)
{
this.restriction = restriction;
this.columnIndex = columnIndex;
}
@Override
public boolean indexOrdering()
{
return true;
}
@Override
public Comparator<List<ByteBuffer>> prepareFor(TableMetadata table, RowFilter rowFilter, QueryOptions options)
{
if (table.indexes.isEmpty() || rowFilter.isEmpty())
return this;
Index.QueryPlan indexQueryPlan = Keyspace.openAndGetStore(table).indexManager.getBestIndexQueryPlanFor(rowFilter);
Index index = restriction.findSupportingIndexFromQueryPlan(indexQueryPlan);
assert index != null;
Comparator<ByteBuffer> comparator = index.getPostQueryOrdering(restriction, options);
return (a, b) -> compare(comparator, a.get(columnIndex), b.get(columnIndex));
}
@Override
public int compare(List<ByteBuffer> o1, List<ByteBuffer> o2)
{
throw new UnsupportedOperationException();
}
}
/**
* Used in orderResults(...) method when multiple 'ORDER BY' conditions where given
*/

View File

@ -183,6 +183,7 @@ public class UpdateStatement extends ModificationStatement
metadata,
whereClause.build(),
bindVariables,
Collections.emptyList(),
applyOnlyToStaticColumns,
false,
false);
@ -253,6 +254,7 @@ public class UpdateStatement extends ModificationStatement
metadata,
whereClause.build(),
bindVariables,
Collections.emptyList(),
applyOnlyToStaticColumns,
false,
false);
@ -320,7 +322,8 @@ public class UpdateStatement extends ModificationStatement
bindVariables,
operations,
whereClause,
conditions);
conditions,
Collections.emptyList());
return new UpdateStatement(type,
bindVariables,

View File

@ -279,6 +279,7 @@ public final class CreateViewStatement extends AlterSchemaStatement
table,
whereClause,
VariableSpecifications.empty(),
Collections.emptyList(),
false,
false,
true,

View File

@ -111,6 +111,7 @@ import org.apache.cassandra.dht.Range;
import org.apache.cassandra.dht.Splitter;
import org.apache.cassandra.dht.Token;
import org.apache.cassandra.exceptions.ConfigurationException;
import org.apache.cassandra.exceptions.InvalidRequestException;
import org.apache.cassandra.exceptions.StartupException;
import org.apache.cassandra.index.SecondaryIndexManager;
import org.apache.cassandra.index.internal.CassandraIndex;
@ -1459,9 +1460,12 @@ public class ColumnFamilyStore implements ColumnFamilyStoreMBean, Memtable.Owner
}
catch (RuntimeException e)
{
throw new RuntimeException(e.getMessage()
+ " for ks: "
+ getKeyspaceName() + ", table: " + name, e);
String message = e.getMessage() + " for ks: " + keyspace.getName() + ", table: " + name;
if (e instanceof InvalidRequestException)
throw new InvalidRequestException(message, e);
throw new RuntimeException(message, e);
}
}

View File

@ -472,7 +472,7 @@ public class PartitionRangeReadCommand extends ReadCommand implements PartitionR
public PartitionIterator postReconciliationProcessing(PartitionIterator result)
{
Index.QueryPlan queryPlan = indexQueryPlan();
return queryPlan == null ? result : queryPlan.postProcessor().apply(result);
return queryPlan == null ? result : queryPlan.postProcessor(this).apply(result);
}
@Override

View File

@ -254,6 +254,12 @@ public abstract class ReadCommand extends AbstractReadQuery
return indexQueryPlan;
}
@Override
public boolean isTopK()
{
return indexQueryPlan != null && indexQueryPlan.isTopK();
}
@VisibleForTesting
public Index.Searcher indexSearcher()
{

View File

@ -121,6 +121,9 @@ public class ReadCommandVerbHandler implements IVerbHandler<ReadCommand>
if (replica == null)
{
if (command.isTopK())
return;
logger.warn("Received a read request from {} for a range that is not owned by the current replica {}.",
message.from(),
command);

View File

@ -81,6 +81,11 @@ public class ReadExecutionController implements AutoCloseable
}
}
public boolean isRangeCommand()
{
return command != null && command.isRangeRequest();
}
public ReadExecutionController indexReadController()
{
return indexController;

View File

@ -256,4 +256,15 @@ public interface ReadQuery
default void trackWarnings()
{
}
/**
* The query is a top-k query if the query has an {@link org.apache.cassandra.index.Index.QueryPlan} that
* supports top-k ordering.
*
* @return {@code true} if this is a top-k query
*/
default boolean isTopK()
{
return false;
}
}

View File

@ -674,7 +674,7 @@ public abstract class RowFilter implements Iterable<RowFilter.Expression>
case GTE:
case GT:
{
assert !column.isComplex() : "Only CONTAINS and CONTAINS_KEY are supported for 'complex' types";
assert !column.isComplex() : "Only CONTAINS and CONTAINS_KEY are supported for collection types";
// In order to support operators on Counter types, their value has to be extracted from internal
// representation. See CASSANDRA-11629
@ -699,8 +699,9 @@ public abstract class RowFilter implements Iterable<RowFilter.Expression>
case LIKE_SUFFIX:
case LIKE_CONTAINS:
case LIKE_MATCHES:
case ANN:
{
assert !column.isComplex() : "Only CONTAINS and CONTAINS_KEY are supported for 'complex' types";
assert !column.isComplex() : "Only CONTAINS and CONTAINS_KEY are supported for collection types";
ByteBuffer foundValue = getValue(metadata, partitionKey, row);
// Note that CQL expression are always of the form 'x < 4', i.e. the tested value is on the left.
return foundValue != null && operator.isSatisfiedBy(column.type, foundValue, value);

View File

@ -236,6 +236,19 @@ public class ByteArrayAccessor implements ValueAccessor<byte[]>
return ByteArrayUtil.getFloat(value, 0);
}
@Override
public float[] toFloatArray(byte[] value, int dimension)
{
float[] array = new float[dimension];
int offset = 0;
for (int i = 0; i < dimension; i++)
{
array[i] = getFloat(value, offset);
offset += Float.BYTES;
}
return array;
}
@Override
public double toDouble(byte[] value)
{

View File

@ -20,6 +20,7 @@ package org.apache.cassandra.db.marshal;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.util.UUID;
@ -243,6 +244,18 @@ public class ByteBufferAccessor implements ValueAccessor<ByteBuffer>
return ByteBufferUtil.toFloat(value);
}
@Override
public float[] toFloatArray(ByteBuffer value, int dimension)
{
FloatBuffer floatBuffer = value.asFloatBuffer();
if (floatBuffer.remaining() != dimension)
throw new IllegalArgumentException(String.format("Could not convert to a float[] with different dimension. " +
"Was expecting %d but got %d", dimension, floatBuffer.remaining()));
float[] floatArray = new float[floatBuffer.remaining()];
floatBuffer.get(floatArray);
return floatArray;
}
@Override
public double toDouble(ByteBuffer value)
{

View File

@ -361,6 +361,9 @@ public interface ValueAccessor<V>
/** returns a TimeUUID from offset 0 */
Ballot toBallot(V value);
/** returns a float[] from offset 0 */
float[] toFloatArray(V value, int dimension);
/**
* writes the byte value {@param value} to {@param dst} at offset {@param offset}
* @return the number of bytes written to {@param value}

View File

@ -158,14 +158,8 @@ public final class VectorType<T> extends AbstractType<List<T>>
if (isNull(input, accessor))
return null;
float[] array = new float[dimension];
int offset = 0;
for (int i = 0; i < dimension; i++)
{
array[i] = accessor.getFloat(input, offset);
offset += Float.BYTES;
}
return array;
return accessor.toFloatArray(input, dimension);
}
public ByteBuffer decompose(T... values)

View File

@ -162,7 +162,7 @@ public class View
if (null == select)
{
SelectStatement.Parameters parameters =
new SelectStatement.Parameters(Collections.emptyMap(),
new SelectStatement.Parameters(Collections.emptyList(),
Collections.emptyList(),
false,
true,

View File

@ -21,8 +21,10 @@
package org.apache.cassandra.index;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@ -34,6 +36,8 @@ import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.apache.cassandra.cql3.Operator;
import org.apache.cassandra.cql3.QueryOptions;
import org.apache.cassandra.cql3.restrictions.Restriction;
import org.apache.cassandra.db.ColumnFamilyStore;
import org.apache.cassandra.db.DecoratedKey;
import org.apache.cassandra.db.DeletionTime;
@ -452,6 +456,18 @@ public interface Index
*/
public RowFilter getPostIndexQueryFilter(RowFilter filter);
/**
* Return a comparator that reorders query result before sending to client
*
* @param restriction restriction that requires current index
* @param options query options
* @return a comparator for post-query ordering; or null if not supported
*/
default Comparator<ByteBuffer> getPostQueryOrdering(Restriction restriction, QueryOptions options)
{
return null;
}
/**
* Return an estimate of the number of results this index is expected to return for any given
* query that it can be used to answer. Used in conjunction with indexes() and supportsExpression()
@ -960,8 +976,10 @@ public interface Index
* The function takes a PartitionIterator of the results from the replicas which has already been collated
* and reconciled, along with the command being executed. It returns another PartitionIterator containing the results
* of the transformation (which may be the same as the input if the transformation is a no-op).
*
* @param command the read command being executed
*/
default Function<PartitionIterator, PartitionIterator> postProcessor()
default Function<PartitionIterator, PartitionIterator> postProcessor(ReadCommand command)
{
return partitions -> partitions;
}
@ -995,6 +1013,14 @@ public interface Index
{
return true;
}
/**
* @return true if given index query plan is a top-k request
*/
default boolean isTopK()
{
return false;
}
}
/*

View File

@ -40,14 +40,17 @@ import org.apache.cassandra.db.marshal.AbstractType;
import org.apache.cassandra.db.marshal.AsciiType;
import org.apache.cassandra.db.marshal.BooleanType;
import org.apache.cassandra.db.marshal.CompositeType;
import org.apache.cassandra.db.marshal.FloatType;
import org.apache.cassandra.db.marshal.UTF8Type;
import org.apache.cassandra.db.marshal.UUIDType;
import org.apache.cassandra.db.marshal.VectorType;
import org.apache.cassandra.db.rows.Cell;
import org.apache.cassandra.db.rows.Row;
import org.apache.cassandra.dht.IPartitioner;
import org.apache.cassandra.index.sai.analyzer.AbstractAnalyzer;
import org.apache.cassandra.index.sai.disk.SSTableIndex;
import org.apache.cassandra.index.sai.disk.format.Version;
import org.apache.cassandra.index.sai.disk.v1.IndexWriterConfig;
import org.apache.cassandra.index.sai.memory.MemtableIndexManager;
import org.apache.cassandra.index.sai.metrics.ColumnQueryMetrics;
import org.apache.cassandra.index.sai.metrics.IndexMetrics;
@ -91,6 +94,7 @@ public class IndexContext
private final IndexViewManager viewManager;
private final IndexMetrics indexMetrics;
private final ColumnQueryMetrics columnQueryMetrics;
private final IndexWriterConfig indexWriterConfig;
private final AbstractAnalyzer.AnalyzerFactory analyzerFactory;
private final PrimaryKey.Factory primaryKeyFactory;
@ -115,6 +119,8 @@ public class IndexContext
this.indexMetadata = indexMetadata;
this.memtableIndexManager = indexMetadata == null ? null : new MemtableIndexManager(this);
this.indexWriterConfig = indexMetadata == null ? IndexWriterConfig.emptyConfig()
: IndexWriterConfig.fromOptions(indexMetadata.name, validator, indexMetadata.options);
this.indexMetrics = indexMetadata == null ? null : new IndexMetrics(this);
this.viewManager = new IndexViewManager(this);
this.columnQueryMetrics = isLiteral() ? new ColumnQueryMetrics.TrieIndexMetrics(this)
@ -144,6 +150,11 @@ public class IndexContext
return keyspace;
}
public IndexWriterConfig getIndexWriterConfig()
{
return indexWriterConfig;
}
public IndexMetrics getIndexMetrics()
{
return indexMetrics;
@ -267,6 +278,12 @@ public class IndexContext
op == Operator.LIKE_MATCHES ||
op == Operator.LIKE_SUFFIX) return false;
// ANN is only supported against vectors, and vector indexes only support ANN
if (isVector())
return op == Operator.ANN;
if (op == Operator.ANN)
return false;
Expression.IndexOperator operator = Expression.IndexOperator.valueOf(op);
if (isNonFrozenCollection())
@ -354,6 +371,11 @@ public class IndexContext
return TypeUtil.isLiteral(getValidator());
}
public boolean isVector()
{
return getValidator().isVector() && ((VectorType<?>)getValidator()).elementType instanceof FloatType;
}
@Override
public boolean equals(Object obj)
{

View File

@ -58,6 +58,8 @@ public class QueryContext
public boolean queryTimedOut = false;
private VectorQueryContext vectorContext;
public QueryContext(ReadCommand readCommand, long executionQuotaMs)
{
this.readCommand = readCommand;
@ -78,4 +80,11 @@ public class QueryContext
throw new QueryCancelledException(readCommand);
}
}
public VectorQueryContext vectorContext()
{
if (vectorContext == null)
vectorContext = new VectorQueryContext(readCommand);
return vectorContext;
}
}

View File

@ -18,6 +18,7 @@
package org.apache.cassandra.index.sai;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@ -40,16 +41,21 @@ import java.util.function.BooleanSupplier;
import java.util.stream.Collectors;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.Futures; // checkstyle: permit this import
import com.google.common.util.concurrent.ListenableFuture; // checkstyle: permit this import
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.github.jbellis.jvector.vector.VectorSimilarityFunction;
import org.apache.cassandra.config.DatabaseDescriptor;
import org.apache.cassandra.cql3.CQL3Type;
import org.apache.cassandra.cql3.CqlBuilder;
import org.apache.cassandra.cql3.Operator;
import org.apache.cassandra.cql3.QueryOptions;
import org.apache.cassandra.cql3.restrictions.Restriction;
import org.apache.cassandra.cql3.restrictions.SingleColumnRestriction;
import org.apache.cassandra.cql3.statements.schema.IndexTarget;
import org.apache.cassandra.db.CassandraWriteContext;
import org.apache.cassandra.db.ColumnFamilyStore;
@ -61,6 +67,8 @@ import org.apache.cassandra.db.compaction.CompactionManager;
import org.apache.cassandra.db.filter.RowFilter;
import org.apache.cassandra.db.lifecycle.LifecycleNewTracker;
import org.apache.cassandra.db.marshal.AbstractType;
import org.apache.cassandra.db.marshal.FloatType;
import org.apache.cassandra.db.marshal.VectorType;
import org.apache.cassandra.db.memtable.Memtable;
import org.apache.cassandra.db.partitions.PartitionUpdate;
import org.apache.cassandra.db.rows.Row;
@ -79,6 +87,7 @@ import org.apache.cassandra.index.sai.analyzer.NonTokenizingOptions;
import org.apache.cassandra.index.sai.disk.SSTableIndex;
import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
import org.apache.cassandra.index.sai.disk.format.Version;
import org.apache.cassandra.index.sai.disk.v1.IndexWriterConfig;
import org.apache.cassandra.index.sai.utils.TypeUtil;
import org.apache.cassandra.index.sai.view.View;
import org.apache.cassandra.index.transactions.IndexTransaction;
@ -90,17 +99,36 @@ import org.apache.cassandra.io.sstable.format.SSTableReader;
import org.apache.cassandra.schema.ColumnMetadata;
import org.apache.cassandra.schema.IndexMetadata;
import org.apache.cassandra.schema.TableMetadata;
import org.apache.cassandra.service.ClientWarn;
import org.apache.cassandra.service.StorageService;
import org.apache.cassandra.utils.Pair;
import org.apache.cassandra.utils.concurrent.OpOrder;
import static org.apache.cassandra.index.sai.disk.v1.IndexWriterConfig.MAX_TOP_K;
public class StorageAttachedIndex implements Index
{
public static final String NAME = "sai";
public static final String VECTOR_USAGE_WARNING = "SAI ANN indexes on vector columns are experimental and are not recommended for production use.\n" +
"They don't yet support SELECT queries with:\n" +
" * Consistency level higher than ONE/LOCAL_ONE.\n" +
" * Paging.\n" +
" * No LIMIT clauses.\n" +
" * PER PARTITION LIMIT clauses.\n" +
" * GROUP BY clauses.\n" +
" * Aggregation functions.\n" +
" * Filters on columns without a SAI index.";
public static final String VECTOR_NON_FLOAT_ERROR = "SAI ANN indexes are only allowed on vector columns with float elements";
public static final String VECTOR_1_DIMENSION_COSINE_ERROR = "Cosine similarity is not supported for single-dimension vectors";
public static final String VECTOR_MULTIPLE_DATA_DIRECTORY_ERROR = "SAI ANN indexes are not allowed on multiple data directories";
@VisibleForTesting
public static final String ANALYSIS_ON_KEY_COLUMNS_MESSAGE = "Analysis options are not supported on primary key columns, but found ";
public static final String ANN_LIMIT_ERROR = "Use of ANN OF in an ORDER BY clause requires a LIMIT that is not greater than %s. LIMIT was %s";
private static final Logger logger = LoggerFactory.getLogger(StorageAttachedIndex.class);
private static class StorageAttachedIndexBuildingSupport implements IndexBuildingSupport
@ -147,6 +175,10 @@ public class StorageAttachedIndex implements Index
private static final Set<String> VALID_OPTIONS = ImmutableSet.of(IndexTarget.TARGET_OPTION_NAME,
IndexTarget.CUSTOM_INDEX_OPTION_NAME,
IndexWriterConfig.MAXIMUM_NODE_CONNECTIONS,
IndexWriterConfig.CONSTRUCTION_BEAM_WIDTH,
IndexWriterConfig.SIMILARITY_FUNCTION,
IndexWriterConfig.OPTIMIZE_FOR,
NonTokenizingOptions.CASE_SENSITIVE,
NonTokenizingOptions.NORMALIZE,
NonTokenizingOptions.ASCII);
@ -242,7 +274,15 @@ public class StorageAttachedIndex implements Index
throw new InvalidRequestException("Cannot create more than one storage-attached index on the same column: " + target.left);
}
Map<String, String> analysisOptions = AbstractAnalyzer.getAnalyzerOptions(options);
if (target.left.isPrimaryKeyColumn() && !analysisOptions.isEmpty())
{
throw new InvalidRequestException(ANALYSIS_ON_KEY_COLUMNS_MESSAGE + new CqlBuilder().append(analysisOptions));
}
AbstractType<?> type = TypeUtil.cellValueType(target.left, target.right);
AbstractAnalyzer.fromOptions(type, analysisOptions);
IndexWriterConfig config = IndexWriterConfig.fromOptions(null, type, options);
// If we are indexing map entries we need to validate the subtypes
if (TypeUtil.isComposite(type))
@ -257,13 +297,21 @@ public class StorageAttachedIndex implements Index
{
throw new InvalidRequestException("Unsupported type: " + type.asCQL3Type());
}
Map<String, String> analysisOptions = AbstractAnalyzer.getAnalyzerOptions(options);
if (target.left.isPrimaryKeyColumn() && !analysisOptions.isEmpty())
else if (type.isVector())
{
throw new InvalidRequestException(ANALYSIS_ON_KEY_COLUMNS_MESSAGE + new CqlBuilder().append(analysisOptions));
VectorType<?> vectorType = (VectorType<?>) type;
if (!(vectorType.elementType instanceof FloatType))
throw new InvalidRequestException(VECTOR_NON_FLOAT_ERROR);
if (vectorType.dimension == 1 && config.getSimilarityFunction() == VectorSimilarityFunction.COSINE)
throw new InvalidRequestException(VECTOR_1_DIMENSION_COSINE_ERROR);
if (DatabaseDescriptor.getRawConfig().data_file_directories.length > 1)
throw new InvalidRequestException(VECTOR_MULTIPLE_DATA_DIRECTORY_ERROR);
ClientWarn.instance.warn(VECTOR_USAGE_WARNING);
}
AbstractAnalyzer.fromOptions(type, analysisOptions);
return Collections.emptyMap();
}
@ -528,6 +576,40 @@ public class StorageAttachedIndex implements Index
throw new UnsupportedOperationException();
}
@Override
public Comparator<ByteBuffer> getPostQueryOrdering(Restriction restriction, QueryOptions options)
{
// For now, only support ANN
assert restriction instanceof SingleColumnRestriction.AnnRestriction;
Preconditions.checkState(indexContext.isVector());
SingleColumnRestriction.AnnRestriction annRestriction = (SingleColumnRestriction.AnnRestriction) restriction;
VectorSimilarityFunction function = indexContext.getIndexWriterConfig().getSimilarityFunction();
float[] target = TypeUtil.decomposeVector(indexContext, annRestriction.value(options).duplicate());
return (leftBuf, rightBuf) -> {
float[] left = TypeUtil.decomposeVector(indexContext, leftBuf.duplicate());
double scoreLeft = function.compare(left, target);
float[] right = TypeUtil.decomposeVector(indexContext, rightBuf.duplicate());
double scoreRight = function.compare(right, target);
return Double.compare(scoreRight, scoreLeft); // descending order
};
}
@Override
public void validate(ReadCommand command) throws InvalidRequestException
{
if (!getIndexContext().isVector())
return;
// to avoid overflow of the vector graph internal data structure and avoid OOM when filtering top-k
if (command.limits().count() > MAX_TOP_K)
throw new InvalidRequestException(String.format(ANN_LIMIT_ERROR, MAX_TOP_K, command.limits().count()));
}
@Override
public long getEstimatedResultRows()
{
@ -670,12 +752,15 @@ public class StorageAttachedIndex implements Index
@Override
public void updateRow(Row oldRow, Row newRow)
{
insertRow(newRow);
adjustMemtableSize(indexContext.getMemtableIndexManager().update(key, oldRow, newRow, memtable),
CassandraWriteContext.fromContext(writeContext).getGroup());
}
void adjustMemtableSize(long additionalSpace, OpOrder.Group opGroup)
{
memtable.markExtraOnHeapUsed(additionalSpace, opGroup);
// The memtable will assert if we try and reduce its memory usage so, for now, just don't tell it.
if (additionalSpace >= 0)
memtable.markExtraOnHeapUsed(additionalSpace, opGroup);
}
}
}

View File

@ -0,0 +1,194 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai;
import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.NavigableSet;
import java.util.Set;
import java.util.TreeSet;
import io.github.jbellis.jvector.util.Bits;
import org.apache.cassandra.db.ReadCommand;
import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
import org.apache.cassandra.index.sai.disk.v1.vector.DiskAnn;
import org.apache.cassandra.index.sai.disk.v1.vector.OnHeapGraph;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
/**
* This represents the state of a vector query. It is repsonsible for maintaining a list of any {@link PrimaryKey}s
* that have been updated or deleted during a search of the indexes.
* <p>
* The number of {@link #shadowedPrimaryKeys} is compared before and after a search is performed. If it changes, it
* means that a {@link PrimaryKey} was found to have been changed. In this case the whole search is repeated until the
* counts match.
* <p>
* When this process has completed, a {@link Bits} array is generated. This is used by the vector graph search to
* identify which nodes in the graph to include in the results.
*/
public class VectorQueryContext
{
private final int limit;
// Holds primary keys that are shadowed by expired TTL or row tombstone or range tombstone.
// They are populated by the StorageAttachedIndexSearcher during filtering. They are used to generate
// a bitset for the graph search to indicate graph nodes to ignore.
private TreeSet<PrimaryKey> shadowedPrimaryKeys;
public VectorQueryContext(ReadCommand readCommand)
{
this.limit = readCommand.limits().count();
}
public int limit()
{
return limit;
}
public void recordShadowedPrimaryKey(PrimaryKey primaryKey)
{
if (shadowedPrimaryKeys == null)
shadowedPrimaryKeys = new TreeSet<>();
shadowedPrimaryKeys.add(primaryKey);
}
// Returns true if the row ID will be included or false if the row ID will be shadowed
public boolean shouldInclude(long sstableRowId, PrimaryKeyMap primaryKeyMap)
{
return shadowedPrimaryKeys == null || !shadowedPrimaryKeys.contains(primaryKeyMap.primaryKeyFromRowId(sstableRowId));
}
public boolean shouldInclude(PrimaryKey pk)
{
return shadowedPrimaryKeys == null || !shadowedPrimaryKeys.contains(pk);
}
public boolean containsShadowedPrimaryKey(PrimaryKey primaryKey)
{
return shadowedPrimaryKeys != null && shadowedPrimaryKeys.contains(primaryKey);
}
/**
* @return shadowed primary keys, in ascending order
*/
public NavigableSet<PrimaryKey> getShadowedPrimaryKeys()
{
if (shadowedPrimaryKeys == null)
return Collections.emptyNavigableSet();
return shadowedPrimaryKeys;
}
public Bits bitsetForShadowedPrimaryKeys(OnHeapGraph<PrimaryKey> graph)
{
if (shadowedPrimaryKeys == null)
return null;
return new IgnoredKeysBits(graph, shadowedPrimaryKeys);
}
public Bits bitsetForShadowedPrimaryKeys(SegmentMetadata metadata, PrimaryKeyMap primaryKeyMap, DiskAnn graph) throws IOException
{
Set<Integer> ignoredOrdinals = null;
try (var ordinalsView = graph.getOrdinalsView())
{
for (PrimaryKey primaryKey : getShadowedPrimaryKeys())
{
// not in current segment
if (primaryKey.compareTo(metadata.minKey) < 0 || primaryKey.compareTo(metadata.maxKey) > 0)
continue;
long sstableRowId = primaryKeyMap.rowIdFromPrimaryKey(primaryKey);
if (sstableRowId == Long.MAX_VALUE) // not found
continue;
int segmentRowId = Math.toIntExact(sstableRowId - metadata.rowIdOffset);
// not in segment yet
if (segmentRowId < 0)
continue;
// end of segment
if (segmentRowId > metadata.maxSSTableRowId)
break;
int ordinal = ordinalsView.getOrdinalForRowId(segmentRowId);
if (ordinal >= 0)
{
if (ignoredOrdinals == null)
ignoredOrdinals = new HashSet<>();
ignoredOrdinals.add(ordinal);
}
}
}
if (ignoredOrdinals == null)
return null;
return new IgnoringBits(ignoredOrdinals, metadata);
}
private static class IgnoringBits implements Bits
{
private final Set<Integer> ignoredOrdinals;
private final int length;
public IgnoringBits(Set<Integer> ignoredOrdinals, SegmentMetadata metadata)
{
this.ignoredOrdinals = ignoredOrdinals;
this.length = 1 + Math.toIntExact(metadata.maxSSTableRowId - metadata.rowIdOffset);
}
@Override
public boolean get(int index)
{
return !ignoredOrdinals.contains(index);
}
@Override
public int length()
{
return length;
}
}
private static class IgnoredKeysBits implements Bits
{
private final OnHeapGraph<PrimaryKey> graph;
private final NavigableSet<PrimaryKey> ignored;
public IgnoredKeysBits(OnHeapGraph<PrimaryKey> graph, NavigableSet<PrimaryKey> ignored)
{
this.graph = graph;
this.ignored = ignored;
}
@Override
public boolean get(int ordinal)
{
var keys = graph.keysFromOrdinal(ordinal);
return keys.stream().anyMatch(k -> !ignored.contains(k));
}
@Override
public int length()
{
return graph.size();
}
}
}

View File

@ -20,6 +20,8 @@ package org.apache.cassandra.index.sai.disk;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -64,7 +66,7 @@ public class IndexSearchResultIterator extends KeyRangeIterator
{
List<KeyRangeIterator> subIterators = new ArrayList<>(1 + sstableIndexes.size());
KeyRangeIterator memtableIterator = expression.context.getMemtableIndexManager().searchMemtableIndexes(expression, keyRange);
KeyRangeIterator memtableIterator = expression.context.getMemtableIndexManager().searchMemtableIndexes(queryContext, expression, keyRange);
if (memtableIterator != null)
subIterators.add(memtableIterator);
@ -78,10 +80,10 @@ public class IndexSearchResultIterator extends KeyRangeIterator
if (sstableIndex.isReleased())
throw new IllegalStateException(sstableIndex.getIndexContext().logMessage("Index was released from the view during the query"));
List<KeyRangeIterator> segmentIterators = sstableIndex.search(expression, keyRange, queryContext);
List<KeyRangeIterator> indexIterators = sstableIndex.search(expression, keyRange, queryContext);
if (!segmentIterators.isEmpty())
subIterators.addAll(segmentIterators);
if (!indexIterators.isEmpty())
subIterators.addAll(indexIterators);
}
catch (Throwable e)
{
@ -96,6 +98,22 @@ public class IndexSearchResultIterator extends KeyRangeIterator
return new IndexSearchResultIterator(union, sstableIndexes, queryContext);
}
public static IndexSearchResultIterator build(List<KeyRangeIterator> sstableIntersections,
KeyRangeIterator memtableResults,
Set<SSTableIndex> referencedIndexes,
QueryContext queryContext)
{
queryContext.sstablesHit += referencedIndexes
.stream()
.map(SSTableIndex::getSSTable).collect(Collectors.toSet()).size();
queryContext.checkpoint();
KeyRangeIterator union = KeyRangeUnionIterator.builder(sstableIntersections.size() + 1)
.add(sstableIntersections)
.add(memtableResults)
.build();
return new IndexSearchResultIterator(union, referencedIndexes, queryContext);
}
protected PrimaryKey computeNext()
{
try

View File

@ -24,6 +24,7 @@ import java.io.IOException;
import javax.annotation.concurrent.NotThreadSafe;
import javax.annotation.concurrent.ThreadSafe;
import org.apache.cassandra.dht.Token;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
/**
@ -70,6 +71,24 @@ public interface PrimaryKeyMap extends Closeable
*/
long rowIdFromPrimaryKey(PrimaryKey key);
/**
* Returns the first row ID of the nearest {@link Token} greater than or equal to the given {@link Token},
* or a negative value if not found
*
* @param token the {@link Token} to lookup
* @return the ceiling row ID associated with the {@link Token} or a negative value
*/
long ceiling(Token token);
/**
* Returns the last row ID of the nearest {@link Token} less than or equal to the given {@link Token},
* or a negative value if the {@link Token} is at its minimum value
*
* @param token the {@link Token} to lookup
* @return the floor row ID associated with the {@link Token}
*/
long floor(Token token);
@Override
default void close()
{

View File

@ -64,6 +64,18 @@ public class RowMapping
@Override
public void add(PrimaryKey key, long sstableRowId) {}
@Override
public int get(PrimaryKey key)
{
return -1;
}
@Override
public int size()
{
return 0;
}
};
private final InMemoryTrie<Long> rowMapping = new InMemoryTrie<>(BufferType.OFF_HEAP);
@ -75,6 +87,8 @@ public class RowMapping
public long maxSSTableRowId = -1;
public int count;
private RowMapping()
{}
@ -163,6 +177,25 @@ public class RowMapping
if (minKey == null)
minKey = key;
maxKey = key;
count++;
}
/**
* Returns the SSTable row Id for a {@link PrimaryKey}
*
* @param key the {@link PrimaryKey}
* @return a valid SSTable row Id for the {@link PrimaryKey} or -1 if the {@link PrimaryKey} doesn't exist
* in the {@link RowMapping}
*/
public int get(PrimaryKey key)
{
Long sstableRowId = rowMapping.get(key);
return sstableRowId == null ? -1 : Math.toIntExact(sstableRowId);
}
public int size()
{
return count;
}
public boolean hasRows()

View File

@ -37,6 +37,7 @@ import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.SSTableContext;
import org.apache.cassandra.index.sai.disk.format.Version;
import org.apache.cassandra.index.sai.disk.v1.segment.SegmentOrdering;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.io.sstable.SSTableIdFactory;
@ -50,7 +51,7 @@ import org.apache.cassandra.io.sstable.format.SSTableReader;
* <li>Exposes the index metadata for the column index</li>
* </ul>
*/
public abstract class SSTableIndex
public abstract class SSTableIndex implements SegmentOrdering
{
private static final Logger logger = LoggerFactory.getLogger(SSTableIndex.class);

View File

@ -0,0 +1,160 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import javax.annotation.concurrent.NotThreadSafe;
import com.google.common.base.Stopwatch;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.cassandra.exceptions.QueryCancelledException;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.postings.PostingList;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.io.util.FileUtils;
import org.apache.cassandra.utils.Throwables;
/**
* From sstable row id range iterator to primary key range iterator
*/
@NotThreadSafe
public class SSTableRowIdKeyRangeIterator extends KeyRangeIterator
{
private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private final Stopwatch timeToExhaust = Stopwatch.createStarted();
private final QueryContext queryContext;
private final PrimaryKeyMap primaryKeyMap;
private final PostingList postingList;
private boolean needsSkipping = false;
private PrimaryKey skipToToken = null;
/**
* Create a direct PostingListRangeIterator where the underlying PostingList is materialised
* immediately so the posting list size can be used.
*/
private SSTableRowIdKeyRangeIterator(PrimaryKey min,
PrimaryKey max,
long count,
PrimaryKeyMap primaryKeyMap,
QueryContext queryContext,
PostingList postingList)
{
super(min, max, count);
this.primaryKeyMap = primaryKeyMap;
this.queryContext = queryContext;
this.postingList = postingList;
}
public static KeyRangeIterator create(PrimaryKeyMap primaryKeyMap, QueryContext queryContext, PostingList postingList)
{
if (postingList.size() <= 0)
return KeyRangeIterator.empty();
PrimaryKey min = primaryKeyMap.primaryKeyFromRowId(postingList.minimum());
PrimaryKey max = primaryKeyMap.primaryKeyFromRowId(postingList.maximum());
return new SSTableRowIdKeyRangeIterator(min, max, postingList.size(), primaryKeyMap, queryContext, postingList);
}
@Override
protected void performSkipTo(PrimaryKey nextKey)
{
if (skipToToken != null && skipToToken.compareTo(nextKey) >= 0)
return;
skipToToken = nextKey;
needsSkipping = true;
}
@Override
protected PrimaryKey computeNext()
{
try
{
queryContext.checkpoint();
// just end the iterator if we don't have a postingList or current segment is skipped
if (exhausted())
return endOfData();
long rowId = getNextRowId();
if (rowId == PostingList.END_OF_STREAM)
return endOfData();
return primaryKeyMap.primaryKeyFromRowId(rowId);
}
catch (Throwable t)
{
//VSTODO We aren't tidying up resources here
if (!(t instanceof QueryCancelledException))
logger.error("Unable to provide next token!", t);
throw Throwables.cleaned(t);
}
}
@Override
public void close()
{
if (logger.isTraceEnabled())
{
final long exhaustedInMills = timeToExhaust.stop().elapsed(TimeUnit.MILLISECONDS);
logger.trace("PostinListRangeIterator exhausted after {} ms", exhaustedInMills);
}
FileUtils.closeQuietly(Arrays.asList(postingList, primaryKeyMap));
}
private boolean exhausted()
{
return needsSkipping && skipToToken.compareTo(getMaximum()) > 0;
}
/**
* reads the next sstable row ID from the underlying range iterator, potentially skipping to get there.
*/
private long getNextRowId() throws IOException
{
long sstableRowId;
if (needsSkipping)
{
long targetRowID = primaryKeyMap.rowIdFromPrimaryKey(skipToToken);
// skipToToken is larger than max token in token file
if (targetRowID < 0)
return PostingList.END_OF_STREAM;
sstableRowId = postingList.advance(targetRowID);
needsSkipping = false;
}
else
{
sstableRowId = postingList.nextPosting();
}
return sstableRowId;
}
}

View File

@ -50,6 +50,11 @@ public enum IndexComponent
*/
TERMS_DATA("TermsData"),
/**
* Product Quantization store used to store compressed vectors for the vector index
*/
COMPRESSED_VECTORS("CompressedVectors"),
/**
* Stores postings written by {@link PostingsWriter}
*/

View File

@ -44,7 +44,7 @@ public class Version implements Comparable<Version>
// These should be added in reverse order so that the latest version is used first. Version matching tests
// are more likely to match the latest version, so we want to test that one first.
public static final SortedSet<Version> ALL = new TreeSet<Version>(Comparator.reverseOrder()) {{
public static final SortedSet<Version> ALL = new TreeSet<>(Comparator.reverseOrder()) {{
add(AA);
}};

View File

@ -65,6 +65,17 @@ public class IndexFileUtils
return new IndexOutputWriter(new ChecksummingWriter(file, writerOption));
}
public IndexOutputWriter openOutput(File file, boolean append) throws IOException
{
assert writerOption.finishOnClose() : "IndexOutputWriter relies on close() to sync with disk.";
IndexOutputWriter indexOutputWriter = new IndexOutputWriter(new ChecksummingWriter(file, writerOption));
if (append)
indexOutputWriter.skipBytes(file.length());
return indexOutputWriter;
}
public IndexInput openInput(FileHandle handle)
{
return IndexInputReader.create(handle);

View File

@ -0,0 +1,196 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;
import io.github.jbellis.jvector.vector.VectorSimilarityFunction;
import org.apache.cassandra.config.CassandraRelevantProperties;
import org.apache.cassandra.db.marshal.AbstractType;
import org.apache.cassandra.exceptions.InvalidRequestException;
import org.apache.cassandra.index.sai.disk.v1.vector.OptimizeFor;
import static org.apache.cassandra.config.CassandraRelevantProperties.SAI_VECTOR_SEARCH_MAX_TOP_K;
/**
* Per-index config for storage-attached index writers.
*/
public class IndexWriterConfig
{
public static final String MAXIMUM_NODE_CONNECTIONS = "maximum_node_connections";
public static final int MAXIMUM_MAXIMUM_NODE_CONNECTIONS = 512;
public static final int DEFAULT_MAXIMUM_NODE_CONNECTIONS = 16;
public static final String CONSTRUCTION_BEAM_WIDTH = "construction_beam_width";
public static final int MAXIMUM_CONSTRUCTION_BEAM_WIDTH = 3200;
public static final int DEFAULT_CONSTRUCTION_BEAM_WIDTH = 100;
public static final String SIMILARITY_FUNCTION = "similarity_function";
public static final VectorSimilarityFunction DEFAULT_SIMILARITY_FUNCTION = VectorSimilarityFunction.COSINE;
public static final String validSimilarityFunctions = Arrays.stream(VectorSimilarityFunction.values())
.map(Enum::name)
.collect(Collectors.joining(", "));
public static final String OPTIMIZE_FOR = "optimize_for";
private static final OptimizeFor DEFAULT_OPTIMIZE_FOR = OptimizeFor.LATENCY;
private static final String validOptimizeFor = Arrays.stream(OptimizeFor.values())
.map(Enum::name)
.collect(Collectors.joining(", "));
public static final int MAX_TOP_K = SAI_VECTOR_SEARCH_MAX_TOP_K.getInt();
private static final IndexWriterConfig EMPTY_CONFIG = new IndexWriterConfig(-1, -1, null, null);
// The maximum number of outgoing connections a node can have in a graph.
private final int maximumNodeConnections;
// The size of the beam search used when finding nearest neighbours.
private final int constructionBeamWidth;
// Used to determine the search to determine the topK results. The score returned is used to order the topK results.
private final VectorSimilarityFunction similarityFunction;
private final OptimizeFor optimizeFor;
public IndexWriterConfig(int maximumNodeConnections,
int constructionBeamWidth,
VectorSimilarityFunction similarityFunction,
OptimizeFor optimizerFor)
{
this.maximumNodeConnections = maximumNodeConnections;
this.constructionBeamWidth = constructionBeamWidth;
this.similarityFunction = similarityFunction;
this.optimizeFor = optimizerFor;
}
public int getMaximumNodeConnections()
{
return maximumNodeConnections;
}
public int getConstructionBeamWidth()
{
return constructionBeamWidth;
}
public VectorSimilarityFunction getSimilarityFunction()
{
return similarityFunction;
}
public OptimizeFor getOptimizeFor()
{
return optimizeFor;
}
public static IndexWriterConfig fromOptions(String indexName, AbstractType<?> type, Map<String, String> options)
{
int maximumNodeConnections = DEFAULT_MAXIMUM_NODE_CONNECTIONS;
int queueSize = DEFAULT_CONSTRUCTION_BEAM_WIDTH;
VectorSimilarityFunction similarityFunction = DEFAULT_SIMILARITY_FUNCTION;
OptimizeFor optimizeFor = DEFAULT_OPTIMIZE_FOR;
if (options.get(MAXIMUM_NODE_CONNECTIONS) != null ||
options.get(CONSTRUCTION_BEAM_WIDTH) != null ||
options.get(SIMILARITY_FUNCTION) != null ||
options.get(OPTIMIZE_FOR) != null)
{
if (!type.isVector())
throw new InvalidRequestException(String.format("CQL type %s cannot have vector options", type.asCQL3Type()));
if (options.containsKey(MAXIMUM_NODE_CONNECTIONS))
{
if (!CassandraRelevantProperties.SAI_VECTOR_ALLOW_CUSTOM_PARAMETERS.getBoolean())
throw new InvalidRequestException(String.format("Maximum node connections cannot be set without enabling %s", CassandraRelevantProperties.SAI_VECTOR_ALLOW_CUSTOM_PARAMETERS.name()));
try
{
maximumNodeConnections = Integer.parseInt(options.get(MAXIMUM_NODE_CONNECTIONS));
}
catch (NumberFormatException e)
{
throw new InvalidRequestException(String.format("Maximum number of connections %s is not a valid integer for index %s",
options.get(MAXIMUM_NODE_CONNECTIONS), indexName));
}
if (maximumNodeConnections <= 0 || maximumNodeConnections > MAXIMUM_MAXIMUM_NODE_CONNECTIONS)
throw new InvalidRequestException(String.format("Maximum number of connections for index %s cannot be <= 0 or > %s, was %s", indexName, MAXIMUM_MAXIMUM_NODE_CONNECTIONS, maximumNodeConnections));
}
if (options.containsKey(CONSTRUCTION_BEAM_WIDTH))
{
if (!CassandraRelevantProperties.SAI_VECTOR_ALLOW_CUSTOM_PARAMETERS.getBoolean())
throw new InvalidRequestException(String.format("Construction beam width cannot be set without enabling %s", CassandraRelevantProperties.SAI_VECTOR_ALLOW_CUSTOM_PARAMETERS.name()));
try
{
queueSize = Integer.parseInt(options.get(CONSTRUCTION_BEAM_WIDTH));
}
catch (NumberFormatException e)
{
throw new InvalidRequestException(String.format("Construction beam width %s is not a valid integer for index %s",
options.get(CONSTRUCTION_BEAM_WIDTH), indexName));
}
if (queueSize <= 0 || queueSize > MAXIMUM_CONSTRUCTION_BEAM_WIDTH)
throw new InvalidRequestException(String.format("Construction beam width for index %s cannot be <= 0 or > %s, was %s", indexName, MAXIMUM_CONSTRUCTION_BEAM_WIDTH, queueSize));
}
if (options.containsKey(SIMILARITY_FUNCTION))
{
String option = options.get(SIMILARITY_FUNCTION).toUpperCase();
try
{
similarityFunction = VectorSimilarityFunction.valueOf(option);
}
catch (IllegalArgumentException e)
{
throw new InvalidRequestException(String.format("Similarity function %s was not recognized for index %s. Valid values are: %s",
option, indexName, validSimilarityFunctions));
}
}
if (options.containsKey(OPTIMIZE_FOR))
{
String option = options.get(OPTIMIZE_FOR).toUpperCase();
try
{
optimizeFor = OptimizeFor.valueOf(option);
}
catch (IllegalArgumentException e)
{
throw new InvalidRequestException(String.format("optimize_for '%s' was not recognized for index %s. Valid values are: %s",
option, indexName, validOptimizeFor));
}
}
}
return new IndexWriterConfig(maximumNodeConnections, queueSize, similarityFunction, optimizeFor);
}
public static IndexWriterConfig emptyConfig()
{
return EMPTY_CONFIG;
}
@Override
public String toString()
{
return String.format("IndexWriterConfig{%s=%d, %s=%d, %s=%s, %s=%s}",
MAXIMUM_NODE_CONNECTIONS, maximumNodeConnections,
CONSTRUCTION_BEAM_WIDTH, constructionBeamWidth,
SIMILARITY_FUNCTION, similarityFunction,
OPTIMIZE_FOR, optimizeFor);
}
}

View File

@ -42,6 +42,7 @@ import org.apache.cassandra.index.sai.memory.MemtableIndex;
import org.apache.cassandra.index.sai.memory.MemtableTermsIterator;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.index.sai.utils.TypeUtil;
import org.apache.cassandra.utils.ByteBufferUtil;
import org.apache.cassandra.utils.Pair;
import org.apache.cassandra.utils.bytecomparable.ByteComparable;
@ -104,22 +105,20 @@ public class MemtableIndexWriter implements PerColumnIndexWriter
return;
}
final Iterator<Pair<ByteComparable, LongArrayList>> iterator = rowMapping.merge(memtable);
try (MemtableTermsIterator terms = new MemtableTermsIterator(memtable.getMinTerm(), memtable.getMaxTerm(), iterator))
if (indexContext.isVector())
{
long cellCount = flush(rowMapping.minKey, rowMapping.maxKey, indexContext.getValidator(), terms, rowMapping.maxSSTableRowId);
flushVectorIndex(start, stopwatch);
}
else
{
final Iterator<Pair<ByteComparable, LongArrayList>> iterator = rowMapping.merge(memtable);
indexDescriptor.createComponentOnDisk(IndexComponent.COLUMN_COMPLETION_MARKER, indexContext);
try (MemtableTermsIterator terms = new MemtableTermsIterator(memtable.getMinTerm(), memtable.getMaxTerm(), iterator))
{
long cellCount = flush(indexContext.getValidator(), terms, rowMapping.maxSSTableRowId);
indexContext.getIndexMetrics().memtableIndexFlushCount.inc();
long elapsed = stopwatch.elapsed(TimeUnit.MILLISECONDS);
logger.debug(indexContext.logMessage("Completed flushing {} memtable index cells to SSTable {}. Duration: {} ms. Total elapsed: {} ms"),
cellCount, indexDescriptor.sstableDescriptor, elapsed - start, elapsed);
indexContext.getIndexMetrics().memtableFlushCellsPerSecond.update((long) (cellCount * 1000.0 / Math.max(1, elapsed - start)));
completeIndexFlush(cellCount, start, stopwatch);
}
}
}
catch (Throwable t)
@ -131,11 +130,7 @@ public class MemtableIndexWriter implements PerColumnIndexWriter
}
}
private long flush(PrimaryKey minKey,
PrimaryKey maxKey,
AbstractType<?> termComparator,
MemtableTermsIterator terms,
long maxSSTableRowId) throws IOException
private long flush(AbstractType<?> termComparator, MemtableTermsIterator terms, long maxSSTableRowId) throws IOException
{
long numRows;
SegmentMetadata.ComponentMetadataMap indexMetas;
@ -171,8 +166,8 @@ public class MemtableIndexWriter implements PerColumnIndexWriter
numRows,
terms.getMinSSTableRowId(),
terms.getMaxSSTableRowId(),
minKey,
maxKey,
rowMapping.minKey,
rowMapping.maxKey,
terms.getMinTerm(),
terms.getMaxTerm(),
indexMetas);
@ -184,4 +179,43 @@ public class MemtableIndexWriter implements PerColumnIndexWriter
return numRows;
}
private void flushVectorIndex(long startTime, Stopwatch stopwatch) throws IOException
{
SegmentMetadata.ComponentMetadataMap metadataMap = memtable.writeDirect(indexDescriptor, indexContext, rowMapping::get);
completeIndexFlush(rowMapping.size(), startTime, stopwatch);
SegmentMetadata metadata = new SegmentMetadata(0,
rowMapping.size(),
0,
rowMapping.maxSSTableRowId,
rowMapping.minKey,
rowMapping.maxKey,
ByteBufferUtil.bytes(0),
ByteBufferUtil.bytes(0),
metadataMap);
try (MetadataWriter writer = new MetadataWriter(indexDescriptor.openPerIndexOutput(IndexComponent.META, indexContext)))
{
SegmentMetadata.write(writer, Collections.singletonList(metadata));
}
}
private void completeIndexFlush(long cellCount, long startTime, Stopwatch stopwatch) throws IOException
{
indexDescriptor.createComponentOnDisk(IndexComponent.COLUMN_COMPLETION_MARKER, indexContext);
indexContext.getIndexMetrics().memtableIndexFlushCount.inc();
long elapsedTime = stopwatch.elapsed(TimeUnit.MILLISECONDS);
logger.debug(indexContext.logMessage("Completed flushing {} memtable index cells to SSTable {}. Duration: {} ms. Total elapsed: {} ms"),
cellCount,
indexDescriptor.sstableDescriptor,
elapsedTime - startTime,
elapsedTime);
indexContext.getIndexMetrics().memtableFlushCellsPerSecond.update((long) (cellCount * 1000.0 / Math.max(1, elapsedTime - startTime)));
}
}

View File

@ -44,15 +44,11 @@ public class PerColumnIndexFiles implements Closeable
{
this.indexDescriptor = indexDescriptor;
this.indexContext = indexContext;
if (indexContext.isLiteral())
for (IndexComponent component : indexDescriptor.version.onDiskFormat().perColumnIndexComponents(indexContext))
{
files.put(IndexComponent.POSTING_LISTS, indexDescriptor.createPerIndexFileHandle(IndexComponent.POSTING_LISTS, indexContext, this::close));
files.put(IndexComponent.TERMS_DATA, indexDescriptor.createPerIndexFileHandle(IndexComponent.TERMS_DATA, indexContext, this::close));
}
else
{
files.put(IndexComponent.BALANCED_TREE, indexDescriptor.createPerIndexFileHandle(IndexComponent.BALANCED_TREE, indexContext, this::close));
files.put(IndexComponent.POSTING_LISTS, indexDescriptor.createPerIndexFileHandle(IndexComponent.POSTING_LISTS, indexContext, this::close));
if (component == IndexComponent.META || component == IndexComponent.COLUMN_COMPLETION_MARKER)
continue;
files.put(component, indexDescriptor.createPerIndexFileHandle(component, indexContext, this::close));
}
}
@ -71,6 +67,11 @@ public class PerColumnIndexFiles implements Closeable
return getFile(IndexComponent.BALANCED_TREE);
}
public FileHandle compressedVectors()
{
return getFile(IndexComponent.COMPRESSED_VECTORS);
}
@SuppressWarnings({"resource", "RedundantSuppression"})
private FileHandle getFile(IndexComponent indexComponent)
{

View File

@ -48,6 +48,7 @@ import org.apache.cassandra.utils.NoSpamLogger;
import static org.apache.cassandra.config.CassandraRelevantProperties.SAI_MAX_FROZEN_TERM_SIZE;
import static org.apache.cassandra.config.CassandraRelevantProperties.SAI_MAX_STRING_TERM_SIZE;
import static org.apache.cassandra.config.CassandraRelevantProperties.SAI_MAX_VECTOR_TERM_SIZE;
/**
* Column index writer that accumulates (on-heap) indexed data from a compacted SSTable as it's being flushed to disk.
@ -58,8 +59,9 @@ public class SSTableIndexWriter implements PerColumnIndexWriter
private static final Logger logger = LoggerFactory.getLogger(SSTableIndexWriter.class);
private static final NoSpamLogger noSpamLogger = NoSpamLogger.getLogger(logger, 1, TimeUnit.MINUTES);
public static final int MAX_STRING_TERM_SIZE = SAI_MAX_STRING_TERM_SIZE.getInt() * 1024;
public static final int MAX_FROZEN_TERM_SIZE = SAI_MAX_FROZEN_TERM_SIZE.getInt() * 1024;
public static final long MAX_STRING_TERM_SIZE = SAI_MAX_STRING_TERM_SIZE.getSizeInBytes();
public static final long MAX_FROZEN_TERM_SIZE = SAI_MAX_FROZEN_TERM_SIZE.getSizeInBytes();
public static final long MAX_VECTOR_TERM_SIZE = SAI_MAX_VECTOR_TERM_SIZE.getSizeInBytes();
public static final String TERM_OVERSIZE_MESSAGE = "Can't add term of column {} to index for key: {}, term size {} " +
"max allowed size {}, use analyzed = true (if not yet set) for that column.";
@ -68,7 +70,7 @@ public class SSTableIndexWriter implements PerColumnIndexWriter
private final long nowInSec = FBUtilities.nowInSeconds();
private final AbstractAnalyzer analyzer;
private final NamedMemoryLimiter limiter;
private final int maxTermSize;
private final long maxTermSize;
private final BooleanSupplier isIndexValid;
private final List<SegmentMetadata> segments = new ArrayList<>();
@ -82,7 +84,7 @@ public class SSTableIndexWriter implements PerColumnIndexWriter
this.analyzer = indexContext.getAnalyzerFactory().create();
this.limiter = limiter;
this.isIndexValid = isIndexValid;
this.maxTermSize = indexContext.isFrozen() ? MAX_FROZEN_TERM_SIZE : MAX_STRING_TERM_SIZE;
this.maxTermSize = indexContext.isVector() ? MAX_VECTOR_TERM_SIZE : (indexContext.isFrozen() ? MAX_FROZEN_TERM_SIZE : MAX_STRING_TERM_SIZE);
}
@Override
@ -327,9 +329,14 @@ public class SSTableIndexWriter implements PerColumnIndexWriter
private SegmentBuilder newSegmentBuilder()
{
SegmentBuilder builder = TypeUtil.isLiteral(indexContext.getValidator())
? new SegmentBuilder.RAMStringSegmentBuilder(indexContext.getValidator(), limiter)
: new SegmentBuilder.BlockBalancedTreeSegmentBuilder(indexContext.getValidator(), limiter);
SegmentBuilder builder;
if (indexContext.isVector())
builder = new SegmentBuilder.VectorSegmentBuilder(indexContext.getValidator(), limiter, indexContext.getIndexWriterConfig());
else if (indexContext.isLiteral())
builder = new SegmentBuilder.RAMStringSegmentBuilder(indexContext.getValidator(), limiter);
else
builder = new SegmentBuilder.BlockBalancedTreeSegmentBuilder(indexContext.getValidator(), limiter);
long globalBytesUsed = limiter.increment(builder.totalBytesAllocated());
logger.debug(indexContext.logMessage("Created new segment builder while flushing SSTable {}. Global segment memory usage now at {}."),

View File

@ -24,7 +24,7 @@ import javax.annotation.concurrent.NotThreadSafe;
import javax.annotation.concurrent.ThreadSafe;
import org.apache.cassandra.db.DecoratedKey;
import org.apache.cassandra.dht.IPartitioner;
import org.apache.cassandra.dht.Token;
import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
import org.apache.cassandra.index.sai.disk.format.IndexComponent;
import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
@ -66,7 +66,6 @@ public class SkinnyPrimaryKeyMap implements PrimaryKeyMap
protected final LongArray.Factory tokenReaderFactory;
protected final LongArray.Factory partitionReaderFactory;
protected final KeyLookup partitionKeyReader;
protected final IPartitioner partitioner;
protected final PrimaryKey.Factory primaryKeyFactory;
private final FileHandle tokensFile;
@ -90,7 +89,6 @@ public class SkinnyPrimaryKeyMap implements PrimaryKeyMap
NumericValuesMeta partitionKeyBlockOffsetsMeta = new NumericValuesMeta(metadataSource.get(indexDescriptor.componentName(IndexComponent.PARTITION_KEY_BLOCK_OFFSETS)));
KeyLookupMeta partitionKeysMeta = new KeyLookupMeta(metadataSource.get(indexDescriptor.componentName(IndexComponent.PARTITION_KEY_BLOCKS)));
this.partitionKeyReader = new KeyLookup(partitionKeyBlocksFile, partitionKeyBlockOffsetsFile, partitionKeysMeta, partitionKeyBlockOffsetsMeta);
this.partitioner = sstable.metadata().partitioner;
this.primaryKeyFactory = indexDescriptor.primaryKeyFactory;
}
catch (Throwable t)
@ -108,7 +106,6 @@ public class SkinnyPrimaryKeyMap implements PrimaryKeyMap
return new SkinnyPrimaryKeyMap(rowIdToToken,
rowIdToPartitionId,
partitionKeyReader.openCursor(),
partitioner,
primaryKeyFactory);
}
@ -122,19 +119,16 @@ public class SkinnyPrimaryKeyMap implements PrimaryKeyMap
protected final LongArray tokenArray;
protected final LongArray partitionArray;
protected final KeyLookup.Cursor partitionKeyCursor;
protected final IPartitioner partitioner;
protected final PrimaryKey.Factory primaryKeyFactory;
protected SkinnyPrimaryKeyMap(LongArray tokenArray,
LongArray partitionArray,
KeyLookup.Cursor partitionKeyCursor,
IPartitioner partitioner,
PrimaryKey.Factory primaryKeyFactory)
{
this.tokenArray = tokenArray;
this.partitionArray = partitionArray;
this.partitionKeyCursor = partitionKeyCursor;
this.partitioner = partitioner;
this.primaryKeyFactory = primaryKeyFactory;
}
@ -158,6 +152,22 @@ public class SkinnyPrimaryKeyMap implements PrimaryKeyMap
return tokenCollisionDetection(primaryKey, rowId);
}
@Override
public long ceiling(Token token)
{
return tokenArray.indexOf(token.getLongValue());
}
@Override
public long floor(Token token)
{
if (token.isMinimum())
return Long.MIN_VALUE;
long rowId = tokenArray.indexOf(token.getLongValue());
return rowId < 0 ? rowId : rowId;
}
@Override
public void close()
{

View File

@ -86,6 +86,13 @@ public class V1OnDiskFormat implements OnDiskFormat
IndexComponent.BALANCED_TREE,
IndexComponent.POSTING_LISTS);
@VisibleForTesting
public static final Set<IndexComponent> VECTOR_COMPONENTS = EnumSet.of(IndexComponent.COLUMN_COMPLETION_MARKER,
IndexComponent.META,
IndexComponent.COMPRESSED_VECTORS,
IndexComponent.TERMS_DATA,
IndexComponent.POSTING_LISTS);
/**
* Global limit on heap consumed by all index segment building that occurs outside the context of Memtable flush.
* <p>
@ -244,7 +251,7 @@ public class V1OnDiskFormat implements OnDiskFormat
@Override
public Set<IndexComponent> perColumnIndexComponents(IndexContext indexContext)
{
return indexContext.isLiteral() ? LITERAL_COMPONENTS : NUMERIC_COMPONENTS;
return indexContext.isVector() ? VECTOR_COMPONENTS : indexContext.isLiteral() ? LITERAL_COMPONENTS : NUMERIC_COMPONENTS;
}
@Override

View File

@ -37,7 +37,9 @@ import org.apache.cassandra.index.sai.disk.SSTableIndex;
import org.apache.cassandra.index.sai.disk.v1.segment.Segment;
import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.iterators.KeyRangeUnionIterator;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.index.sai.utils.TypeUtil;
import org.apache.cassandra.io.sstable.format.SSTableReader;
import org.apache.cassandra.io.util.FileUtils;
@ -166,13 +168,23 @@ public class V1SSTableIndex extends SSTableIndex
{
if (segment.intersects(keyRange))
{
segmentIterators.add(segment.search(expression, context));
segmentIterators.add(segment.search(expression, keyRange, context));
}
}
return segmentIterators;
}
@Override
public KeyRangeIterator limitToTopKResults(QueryContext context, List<PrimaryKey> primaryKeys, Expression expression) throws IOException
{
KeyRangeUnionIterator.Builder unionIteratorBuilder = KeyRangeUnionIterator.builder(segments.size());
for (Segment segment : segments)
unionIteratorBuilder.add(segment.limitToTopKResults(context, primaryKeys, expression));
return unionIteratorBuilder.build();
}
@Override
public void populateSegmentView(SimpleDataSet dataset)
{

View File

@ -25,7 +25,7 @@ import javax.annotation.concurrent.ThreadSafe;
import org.apache.cassandra.db.Clustering;
import org.apache.cassandra.db.ClusteringComparator;
import org.apache.cassandra.dht.IPartitioner;
import org.apache.cassandra.dht.Token;
import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
import org.apache.cassandra.index.sai.disk.format.IndexComponent;
import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
@ -92,7 +92,6 @@ public class WidePrimaryKeyMap extends SkinnyPrimaryKeyMap
partitionIdToToken,
partitionKeyReader.openCursor(),
clusteringKeyReader.openCursor(),
partitioner,
primaryKeyFactory,
clusteringComparator);
}
@ -112,11 +111,10 @@ public class WidePrimaryKeyMap extends SkinnyPrimaryKeyMap
LongArray partitionArray,
KeyLookup.Cursor partitionKeyCursor,
KeyLookup.Cursor clusteringKeyCursor,
IPartitioner partitioner,
PrimaryKey.Factory primaryKeyFactory,
ClusteringComparator clusteringComparator)
{
super(tokenArray, partitionArray, partitionKeyCursor, partitioner, primaryKeyFactory);
super(tokenArray, partitionArray, partitionKeyCursor, primaryKeyFactory);
this.clusteringComparator = clusteringComparator;
this.clusteringKeyCursor = clusteringKeyCursor;
@ -144,6 +142,15 @@ public class WidePrimaryKeyMap extends SkinnyPrimaryKeyMap
return clusteringKeyCursor.clusteredSeekToKey(clusteringComparator.asByteComparable(primaryKey.clustering()), rowId, startOfNextPartition(rowId));
}
@Override
public long floor(Token token)
{
if (token.isMinimum())
return Long.MIN_VALUE;
long rowId = tokenArray.indexOf(token.getLongValue());
return rowId < 0 ? rowId : startOfNextPartition(rowId) - 1;
}
@Override
public void close()
{

View File

@ -73,14 +73,16 @@ public abstract class AbstractBlockPackedReader implements LongArray
@Override
public long indexOf(long value)
{
// If we are searching backwards, we need to reset the lastIndex. This is not normal since we normally move
// forwards when searching for tokens. We only (may) search backwards in vector searchs where we need the
// primary key ranges presented as row IDs.
if (value < previousValue)
lastIndex = 0;
// already out of range
if (lastIndex >= valueCount)
return -1;
// We keep track previous returned value in lastIndex, so searching backward will not return correct result.
// Also it's logically wrong to search backward during token iteration in PostingListRangeIterator.
if (value < previousValue)
throw new IllegalArgumentException(String.format("%d is smaller than prev token value %d", value, previousValue));
previousValue = value;
int blockIndex = binarySearchBlockMinValues(value);

View File

@ -20,6 +20,7 @@ package org.apache.cassandra.index.sai.disk.v1.postings;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;
import javax.annotation.concurrent.NotThreadSafe;
@ -41,6 +42,8 @@ public class MergePostingList implements PostingList
private final PriorityQueue<PeekablePostingList> postingLists;
private final List<PeekablePostingList> temp;
private final Closeable onClose;
private final long minimum;
private final long maximum;
private final long size;
private long lastRowId = -1;
@ -49,11 +52,17 @@ public class MergePostingList implements PostingList
this.temp = new ArrayList<>(postingLists.size());
this.onClose = onClose;
this.postingLists = postingLists;
long minimum = 0;
long maximum = 0;
long totalPostings = 0;
for (PostingList postingList : postingLists)
{
minimum = Math.min(minimum, postingList.minimum());
maximum = Math.max(maximum, postingList.maximum());
totalPostings += postingList.size();
}
this.minimum = minimum;
this.maximum = maximum;
this.size = totalPostings;
}
@ -68,7 +77,26 @@ public class MergePostingList implements PostingList
return merge(postings, () -> FileUtils.close(postings));
}
@SuppressWarnings({"resource", "RedundantSuppression"})
public static PostingList merge(List<PostingList> postings)
{
PriorityQueue<PeekablePostingList> postingsQueue = new PriorityQueue<>(postings.size(), Comparator.comparingLong(PeekablePostingList::peek));
postings.stream().map(PeekablePostingList::makePeekable).forEach(postingsQueue::add);
return merge(postingsQueue);
}
@Override
public long minimum()
{
return minimum;
}
@Override
public long maximum()
{
return maximum;
}
@SuppressWarnings({ "resource", "RedundantSuppression"})
@Override
public long nextPosting() throws IOException
{

View File

@ -64,12 +64,11 @@ public class PostingListRangeIterator extends KeyRangeIterator
private final PostingList postingList;
private final IndexContext indexContext;
private final PrimaryKeyMap primaryKeyMap;
private final IndexSegmentSearcherContext searcherContext;
private final long rowIdOffset;
private boolean needsSkipping = false;
private PrimaryKey skipToToken = null;
/**
* Create a direct PostingListRangeIterator where the underlying PostingList is materialised
* immediately so the posting list size can be used.
@ -83,10 +82,25 @@ public class PostingListRangeIterator extends KeyRangeIterator
this.indexContext = indexContext;
this.primaryKeyMap = primaryKeyMap;
this.postingList = searcherContext.postingList;
this.searcherContext = searcherContext;
this.rowIdOffset = searcherContext.segmentRowIdOffset;
this.queryContext = searcherContext.context;
}
public PostingListRangeIterator(IndexContext indexContext,
PrimaryKeyMap primaryKeyMap,
PostingList postingList,
QueryContext queryContext)
{
super(primaryKeyMap.primaryKeyFromRowId(postingList.minimum()),
primaryKeyMap.primaryKeyFromRowId(postingList.maximum()),
postingList.size());
this.indexContext = indexContext;
this.primaryKeyMap = primaryKeyMap;
this.postingList = postingList;
this.rowIdOffset = 0;
this.queryContext = queryContext;
}
@Override
protected void performSkipTo(PrimaryKey nextKey)
{
@ -155,7 +169,7 @@ public class PostingListRangeIterator extends KeyRangeIterator
return PostingList.END_OF_STREAM;
}
segmentRowId = postingList.advance(targetRowID - searcherContext.segmentRowIdOffset);
segmentRowId = postingList.advance(targetRowID - rowIdOffset);
needsSkipping = false;
}
@ -165,7 +179,7 @@ public class PostingListRangeIterator extends KeyRangeIterator
}
return segmentRowId != PostingList.END_OF_STREAM
? segmentRowId + searcherContext.segmentRowIdOffset
? segmentRowId + rowIdOffset
: PostingList.END_OF_STREAM;
}
}

View File

@ -23,15 +23,12 @@ import javax.annotation.concurrent.NotThreadSafe;
import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.cassandra.index.sai.disk.io.SeekingRandomAccessInput;
import org.apache.cassandra.index.sai.disk.v1.DirectReaders;
import org.apache.cassandra.index.sai.postings.OrdinalPostingList;
import org.apache.cassandra.index.sai.postings.PostingList;
import org.apache.cassandra.index.sai.disk.v1.LongArray;
import org.apache.cassandra.index.sai.metrics.QueryEventListener;
import org.apache.cassandra.index.sai.disk.io.SeekingRandomAccessInput;
import org.apache.cassandra.index.sai.postings.OrdinalPostingList;
import org.apache.cassandra.index.sai.postings.PostingList;
import org.apache.cassandra.io.util.FileUtils;
import org.apache.lucene.index.CorruptIndexException;
import org.apache.lucene.store.IndexInput;
@ -41,15 +38,13 @@ import org.apache.lucene.util.packed.DirectReader;
/**
* Reads, decompresses and decodes postings lists written by {@link PostingsWriter}.
*
* <p>
* Holds exactly one posting block in memory at a time. Does binary search over skip table to find a postings block to
* load.
*/
@NotThreadSafe
public class PostingsReader implements OrdinalPostingList
{
private static final Logger logger = LoggerFactory.getLogger(PostingsReader.class);
private final IndexInput input;
private final SeekingRandomAccessInput seekingInput;
private final QueryEventListener.PostingListEventListener listener;
@ -62,7 +57,6 @@ public class PostingsReader implements OrdinalPostingList
private long totalPostingsRead;
private long actualPosting;
private long currentPosition;
private LongValues currentFoRValues;
private long postingsDecoded = 0;
@ -175,9 +169,9 @@ public class PostingsReader implements OrdinalPostingList
* Advances to the first row ID beyond the current that is greater than or equal to the
* target, and returns that row ID. Exhausts the iterator and returns {@link #END_OF_STREAM} if
* the target is greater than the highest row ID.
*
* <p>
* Does binary search over the skip table to find the next block to load into memory.
*
* <p>
* Note: Callers must use the return value of this method before calling {@link #nextPosting()}, as calling
* that method will return the next posting, not the one to which we have just advanced.
*
@ -349,7 +343,7 @@ public class PostingsReader implements OrdinalPostingList
byte bitsPerValue = in.readByte();
currentPosition = in.getFilePointer();
long currentPosition = in.getFilePointer();
if (bitsPerValue == 0)
{

View File

@ -23,8 +23,6 @@ import java.io.IOException;
import javax.annotation.concurrent.NotThreadSafe;
import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.agrona.collections.LongArrayList;
import org.apache.cassandra.index.sai.IndexContext;
@ -87,8 +85,6 @@ import static java.lang.Math.max;
@NotThreadSafe
public class PostingsWriter implements Closeable
{
protected static final Logger logger = LoggerFactory.getLogger(PostingsWriter.class);
// import static org.apache.lucene.codecs.lucene50.Lucene50PostingsFormat.BLOCK_SIZE;
private final static int BLOCK_SIZE = 128;

View File

@ -0,0 +1,75 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.postings;
import java.io.IOException;
import java.util.PrimitiveIterator;
import org.apache.cassandra.index.sai.postings.PostingList;
import org.apache.lucene.util.LongHeap;
/**
* A {@link PostingList} for ANN search results. Transforms result from similarity order to row ID order.
*/
public class VectorPostingList implements PostingList
{
private final LongHeap segmentRowIds;
private final int size;
private final int visitedCount;
public VectorPostingList(PrimitiveIterator.OfInt source, int limit, int visitedCount)
{
this.visitedCount = visitedCount;
segmentRowIds = new LongHeap(Math.max(limit, 1));
int n = 0;
while (source.hasNext() && n++ < limit)
segmentRowIds.push(source.nextInt());
this.size = n;
}
@Override
public long nextPosting()
{
if (segmentRowIds.size() == 0)
return PostingList.END_OF_STREAM;
return segmentRowIds.pop();
}
@Override
public long size()
{
return size;
}
@Override
public long advance(long targetRowID) throws IOException
{
long rowId;
do
{
rowId = nextPosting();
} while (rowId < targetRowID);
return rowId;
}
public int getVisitedCount()
{
return visitedCount;
}
}

View File

@ -20,6 +20,8 @@ package org.apache.cassandra.index.sai.disk.v1.segment;
import java.io.Closeable;
import java.io.IOException;
import org.apache.cassandra.db.PartitionPosition;
import org.apache.cassandra.dht.AbstractBounds;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
@ -32,11 +34,11 @@ import org.apache.cassandra.index.sai.postings.PostingList;
/**
* Abstract reader for individual segments of an on-disk index.
*
* <p>
* Accepts shared resources (token/offset file readers), and uses them to perform lookups against on-disk data
* structures.
*/
public abstract class IndexSegmentSearcher implements Closeable
public abstract class IndexSegmentSearcher implements SegmentOrdering, Closeable
{
final PrimaryKeyMap.Factory primaryKeyMapFactory;
final PerColumnIndexFiles indexFiles;
@ -60,9 +62,12 @@ public abstract class IndexSegmentSearcher implements Closeable
SegmentMetadata segmentMetadata,
IndexContext indexContext) throws IOException
{
return indexContext.isLiteral()
? new LiteralIndexSegmentSearcher(primaryKeyMapFactory, indexFiles, segmentMetadata, indexContext)
: new NumericIndexSegmentSearcher(primaryKeyMapFactory, indexFiles, segmentMetadata, indexContext);
if (indexContext.isVector())
return new VectorIndexSegmentSearcher(primaryKeyMapFactory, indexFiles, segmentMetadata, indexContext);
else if (indexContext.isLiteral())
return new LiteralIndexSegmentSearcher(primaryKeyMapFactory, indexFiles, segmentMetadata, indexContext);
else
return new NumericIndexSegmentSearcher(primaryKeyMapFactory, indexFiles, segmentMetadata, indexContext);
}
/**
@ -78,11 +83,11 @@ public abstract class IndexSegmentSearcher implements Closeable
*
* @return {@link KeyRangeIterator} with matches for the given expression
*/
public abstract KeyRangeIterator search(Expression expression, QueryContext queryContext) throws IOException;
public abstract KeyRangeIterator search(Expression expression, AbstractBounds<PartitionPosition> keyRange, QueryContext queryContext) throws IOException;
KeyRangeIterator toIterator(PostingList postingList, QueryContext queryContext) throws IOException
KeyRangeIterator toPrimaryKeyIterator(PostingList postingList, QueryContext queryContext) throws IOException
{
if (postingList == null)
if (postingList == null || postingList.size() == 0)
return KeyRangeIterator.empty();
IndexSegmentSearcherContext searcherContext = new IndexSegmentSearcherContext(metadata.minKey,

View File

@ -43,8 +43,6 @@ public class IndexSegmentSearcherContext
this.segmentRowIdOffset = segmentRowIdOffset;
this.minimumKey = minimumKey;
// use segment's metadata for the range iterator, may not be accurate, but should not matter to performance.
this.maximumKey = maximumKey;
}

View File

@ -25,17 +25,18 @@ import com.google.common.base.MoreObjects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.cassandra.db.PartitionPosition;
import org.apache.cassandra.dht.AbstractBounds;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
import org.apache.cassandra.index.sai.disk.format.IndexComponent;
import org.apache.cassandra.index.sai.disk.v1.PerColumnIndexFiles;
import org.apache.cassandra.index.sai.disk.v1.SAICodecUtils;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.metrics.MulticastQueryEventListeners;
import org.apache.cassandra.index.sai.metrics.QueryEventListener;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.postings.PostingList;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.utils.bytecomparable.ByteComparable;
/**
@ -79,7 +80,7 @@ public class LiteralIndexSegmentSearcher extends IndexSegmentSearcher
@Override
@SuppressWarnings({"resource", "RedundantSuppression"})
public KeyRangeIterator search(Expression expression, QueryContext queryContext) throws IOException
public KeyRangeIterator search(Expression expression, AbstractBounds<PartitionPosition> keyRange, QueryContext queryContext) throws IOException
{
if (logger.isTraceEnabled())
logger.trace(indexContext.logMessage("Searching on expression '{}'..."), expression);
@ -89,8 +90,7 @@ public class LiteralIndexSegmentSearcher extends IndexSegmentSearcher
final ByteComparable term = ByteComparable.fixedLength(expression.lower.value.encoded);
QueryEventListener.TrieIndexEventListener listener = MulticastQueryEventListeners.of(queryContext, perColumnEventListener);
PostingList postingList = reader.exactMatch(term, listener, queryContext);
return toIterator(postingList, queryContext);
return toPrimaryKeyIterator(reader.exactMatch(term, listener, queryContext), queryContext);
}
@Override

View File

@ -24,6 +24,8 @@ import com.google.common.base.MoreObjects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.cassandra.db.PartitionPosition;
import org.apache.cassandra.dht.AbstractBounds;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
@ -34,7 +36,6 @@ import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.metrics.MulticastQueryEventListeners;
import org.apache.cassandra.index.sai.metrics.QueryEventListener;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.postings.PostingList;
import org.apache.lucene.index.CorruptIndexException;
import static org.apache.cassandra.index.sai.disk.v1.bbtree.BlockBalancedTreeQueries.balancedTreeQueryFrom;
@ -79,7 +80,7 @@ public class NumericIndexSegmentSearcher extends IndexSegmentSearcher
@Override
@SuppressWarnings({"resource", "RedundantSuppression"})
public KeyRangeIterator search(Expression exp, QueryContext context) throws IOException
public KeyRangeIterator search(Expression exp, AbstractBounds<PartitionPosition> keyRange, QueryContext context) throws IOException
{
if (logger.isTraceEnabled())
logger.trace(indexContext.logMessage("Searching on expression '{}'..."), exp);
@ -88,8 +89,7 @@ public class NumericIndexSegmentSearcher extends IndexSegmentSearcher
{
final BlockBalancedTreeReader.IntersectVisitor query = balancedTreeQueryFrom(exp, treeReader.getBytesPerValue());
QueryEventListener.BalancedTreeEventListener listener = MulticastQueryEventListeners.of(context, perColumnEventListener);
PostingList postingList = treeReader.intersect(query, listener, context);
return toIterator(postingList, context);
return toPrimaryKeyIterator(treeReader.intersect(query, listener, context), context);
}
else
{

View File

@ -19,6 +19,7 @@ package org.apache.cassandra.index.sai.disk.v1.segment;
import java.io.Closeable;
import java.io.IOException;
import java.util.List;
import com.google.common.annotations.VisibleForTesting;
@ -33,6 +34,7 @@ import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
import org.apache.cassandra.index.sai.disk.v1.PerColumnIndexFiles;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.io.util.FileUtils;
/**
@ -40,7 +42,7 @@ import org.apache.cassandra.io.util.FileUtils;
* It also helps to reduce resource consumption for read requests as only segments that intersect with read request data
* range need to be loaded.
*/
public class Segment implements Closeable
public class Segment implements SegmentOrdering, Closeable
{
private final Token.KeyBound minKeyBound;
private final Token.KeyBound maxKeyBound;
@ -105,9 +107,15 @@ public class Segment implements Closeable
* @return range iterator that matches given expression
*/
public KeyRangeIterator search(Expression expression, QueryContext context) throws IOException
public KeyRangeIterator search(Expression expression, AbstractBounds<PartitionPosition> keyRange, QueryContext context) throws IOException
{
return index.search(expression, context);
return index.search(expression, keyRange, context);
}
@Override
public KeyRangeIterator limitToTopKResults(QueryContext context, List<PrimaryKey> primaryKeys, Expression expression) throws IOException
{
return index.limitToTopKResults(context, primaryKeys, expression);
}
@Override

View File

@ -29,9 +29,11 @@ import org.slf4j.LoggerFactory;
import org.apache.cassandra.db.marshal.AbstractType;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
import org.apache.cassandra.index.sai.disk.v1.IndexWriterConfig;
import org.apache.cassandra.index.sai.disk.v1.bbtree.BlockBalancedTreeRamBuffer;
import org.apache.cassandra.index.sai.disk.v1.bbtree.NumericIndexWriter;
import org.apache.cassandra.index.sai.disk.v1.trie.LiteralIndexWriter;
import org.apache.cassandra.index.sai.disk.v1.vector.OnHeapGraph;
import org.apache.cassandra.index.sai.memory.RAMStringIndexer;
import org.apache.cassandra.index.sai.utils.NamedMemoryLimiter;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
@ -161,6 +163,35 @@ public abstract class SegmentBuilder
}
}
public static class VectorSegmentBuilder extends SegmentBuilder
{
private final OnHeapGraph<Integer> graphIndex;
public VectorSegmentBuilder(AbstractType<?> termComparator, NamedMemoryLimiter limiter, IndexWriterConfig indexWriterConfig)
{
super(termComparator, limiter);
graphIndex = new OnHeapGraph<>(termComparator, indexWriterConfig, false);
}
@Override
public boolean isEmpty()
{
return graphIndex.isEmpty();
}
@Override
protected long addInternal(ByteBuffer term, int segmentRowId)
{
return graphIndex.add(term, segmentRowId, OnHeapGraph.InvalidVectorBehavior.IGNORE);
}
@Override
protected SegmentMetadata.ComponentMetadataMap flushInternal(IndexDescriptor indexDescriptor, IndexContext indexContext) throws IOException
{
return graphIndex.writeData(indexDescriptor, indexContext, p -> p);
}
}
public static int getActiveBuilderCount()
{
return ACTIVE_BUILDER_COUNT.get();

View File

@ -128,6 +128,11 @@ public class SegmentMetadata
this.componentMetadatas = new ComponentMetadataMap(input);
}
public int toSegmentRowId(long sstableRowId)
{
return Math.toIntExact(sstableRowId - rowIdOffset);
}
@SuppressWarnings({"resource", "RedundantSuppression"})
public static List<SegmentMetadata> load(MetadataSource source, PrimaryKey.Factory primaryKeyFactory) throws IOException
{

View File

@ -0,0 +1,58 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.segment;
import java.io.IOException;
import java.util.List;
import org.apache.cassandra.dht.AbstractBounds;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
/**
* A {@link SegmentOrdering} orders and limits a list of {@link PrimaryKey}s.
* <p>
* When using {@link SegmentOrdering} there are several steps to
* build the list of Primary Keys to be ordered and limited:
* <p>
* 1. Find all primary keys that match each non-ordering query predicate.
* 2. Union and intersect the results of step 1 to build a single {@link KeyRangeIterator}
* ordered by {@link PrimaryKey}.
* 3. Filter out any shadowed primary keys.
* 4. Fan the primary keys from step 3 out to each sstable segment to order and limit each
* list of primary keys.
* <p>
* SegmentOrdering handles the fourth step.
* <p>
* Note: a segment ordering is only used when a query has both ordering and non-ordering predicates.
* Where a query has only ordering predicates, the ordering is handled by
* {@link org.apache.cassandra.index.sai.disk.SSTableIndex#search(Expression, AbstractBounds, QueryContext)}.
*/
public interface SegmentOrdering
{
/**
* Reorder, limit, and put back into original order the results from a single sstable
*/
default KeyRangeIterator limitToTopKResults(QueryContext queryContext, List<PrimaryKey> primaryKeys, Expression expression) throws IOException
{
throw new UnsupportedOperationException();
}
}

View File

@ -0,0 +1,363 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.segment;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.github.jbellis.jvector.util.Bits;
import io.github.jbellis.jvector.util.SparseFixedBitSet;
import org.agrona.collections.IntArrayList;
import org.apache.cassandra.db.PartitionPosition;
import org.apache.cassandra.dht.AbstractBounds;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.VectorQueryContext;
import org.apache.cassandra.index.sai.disk.PrimaryKeyMap;
import org.apache.cassandra.index.sai.disk.v1.PerColumnIndexFiles;
import org.apache.cassandra.index.sai.disk.v1.vector.DiskAnn;
import org.apache.cassandra.index.sai.disk.v1.vector.OptimizeFor;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.iterators.KeyRangeListIterator;
import org.apache.cassandra.index.sai.memory.VectorMemoryIndex;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.postings.IntArrayPostingList;
import org.apache.cassandra.index.sai.postings.PostingList;
import org.apache.cassandra.index.sai.utils.AtomicRatio;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.index.sai.utils.RangeUtil;
import org.apache.cassandra.index.sai.utils.TypeUtil;
import org.apache.cassandra.tracing.Tracing;
import static java.lang.Math.max;
import static java.lang.Math.min;
/**
* Executes ANN search against a vector graph for an individual index segment.
*/
public class VectorIndexSegmentSearcher extends IndexSegmentSearcher
{
private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private final DiskAnn graph;
private final int globalBruteForceRows;
private final AtomicRatio actualExpectedRatio = new AtomicRatio();
private final ThreadLocal<SparseFixedBitSet> cachedBitSets;
private final OptimizeFor optimizeFor;
VectorIndexSegmentSearcher(PrimaryKeyMap.Factory primaryKeyMapFactory,
PerColumnIndexFiles perIndexFiles,
SegmentMetadata segmentMetadata,
IndexContext indexContext) throws IOException
{
super(primaryKeyMapFactory, perIndexFiles, segmentMetadata, indexContext);
graph = new DiskAnn(segmentMetadata.componentMetadatas, perIndexFiles, indexContext);
cachedBitSets = ThreadLocal.withInitial(() -> new SparseFixedBitSet(graph.size()));
globalBruteForceRows = Integer.MAX_VALUE;
optimizeFor = indexContext.getIndexWriterConfig().getOptimizeFor();
}
@Override
public long indexFileCacheSize()
{
return graph.ramBytesUsed();
}
@Override
public KeyRangeIterator search(Expression exp, AbstractBounds<PartitionPosition> keyRange, QueryContext context) throws IOException
{
int limit = context.vectorContext().limit();
if (logger.isTraceEnabled())
logger.trace(indexContext.logMessage("Searching on expression '{}'..."), exp);
if (exp.getOp() != Expression.IndexOperator.ANN)
throw new IllegalArgumentException(indexContext.logMessage("Unsupported expression during ANN index query: " + exp));
int topK = optimizeFor.topKFor(limit);
BitsOrPostingList bitsOrPostingList = bitsOrPostingListForKeyRange(context.vectorContext(), keyRange, topK);
if (bitsOrPostingList.skipANN())
return toPrimaryKeyIterator(bitsOrPostingList.postingList(), context);
float[] queryVector = TypeUtil.decomposeVector(indexContext, exp.lower.value.raw.duplicate());
var vectorPostings = graph.search(queryVector, topK, limit, bitsOrPostingList.getBits());
if (bitsOrPostingList.expectedNodesVisited >= 0)
updateExpectedNodes(vectorPostings.getVisitedCount(), bitsOrPostingList.expectedNodesVisited);
return toPrimaryKeyIterator(vectorPostings, context);
}
/**
* Return bit set we need to search the graph; otherwise return posting list to bypass the graph
*/
private BitsOrPostingList bitsOrPostingListForKeyRange(VectorQueryContext context, AbstractBounds<PartitionPosition> keyRange, int limit) throws IOException
{
try (PrimaryKeyMap primaryKeyMap = primaryKeyMapFactory.newPerSSTablePrimaryKeyMap())
{
// not restricted
if (RangeUtil.coversFullRing(keyRange))
return new BitsOrPostingList(context.bitsetForShadowedPrimaryKeys(metadata, primaryKeyMap, graph));
// it will return the next row id if given key is not found.
long minSSTableRowId = primaryKeyMap.ceiling(keyRange.left.getToken());
// If we didn't find the first key, we won't find the last primary key either
if (minSSTableRowId < 0)
return new BitsOrPostingList(PostingList.EMPTY);
long maxSSTableRowId = getMaxSSTableRowId(primaryKeyMap, keyRange.right);
if (minSSTableRowId > maxSSTableRowId)
return new BitsOrPostingList(PostingList.EMPTY);
// if it covers entire segment, skip bit set
if (minSSTableRowId <= metadata.minSSTableRowId && maxSSTableRowId >= metadata.maxSSTableRowId)
return new BitsOrPostingList(context.bitsetForShadowedPrimaryKeys(metadata, primaryKeyMap, graph));
minSSTableRowId = Math.max(minSSTableRowId, metadata.minSSTableRowId);
maxSSTableRowId = min(maxSSTableRowId, metadata.maxSSTableRowId);
// If num of matches are not bigger than limit, skip ANN.
// (nRows should not include shadowed rows, but context doesn't break those out by segment,
// so we will live with the inaccuracy.)
var nRows = Math.toIntExact(maxSSTableRowId - minSSTableRowId + 1);
int maxBruteForceRows = min(globalBruteForceRows, maxBruteForceRows(limit, nRows, graph.size()));
logger.trace("Search range covers {} rows; max brute force rows is {} for sstable index with {} nodes, LIMIT {}",
nRows, maxBruteForceRows, graph.size(), limit);
Tracing.trace("Search range covers {} rows; max brute force rows is {} for sstable index with {} nodes, LIMIT {}",
nRows, maxBruteForceRows, graph.size(), limit);
if (nRows <= maxBruteForceRows)
{
IntArrayList postings = new IntArrayList(Math.toIntExact(nRows), -1);
for (long sstableRowId = minSSTableRowId; sstableRowId <= maxSSTableRowId; sstableRowId++)
{
if (context.shouldInclude(sstableRowId, primaryKeyMap))
postings.addInt(metadata.toSegmentRowId(sstableRowId));
}
return new BitsOrPostingList(new IntArrayPostingList(postings.toIntArray()));
}
// create a bitset of ordinals corresponding to the rows in the given key range
SparseFixedBitSet bits = bitSetForSearch();
boolean hasMatches = false;
try (var ordinalsView = graph.getOrdinalsView())
{
for (long sstableRowId = minSSTableRowId; sstableRowId <= maxSSTableRowId; sstableRowId++)
{
if (context.shouldInclude(sstableRowId, primaryKeyMap))
{
int segmentRowId = metadata.toSegmentRowId(sstableRowId);
int ordinal = ordinalsView.getOrdinalForRowId(segmentRowId);
if (ordinal >= 0)
{
bits.set(ordinal);
hasMatches = true;
}
}
}
}
catch (IOException e)
{
throw new RuntimeException(e);
}
if (!hasMatches)
return new BitsOrPostingList(PostingList.EMPTY);
return new BitsOrPostingList(bits, VectorMemoryIndex.expectedNodesVisited(limit, nRows, graph.size()));
}
}
private long getMaxSSTableRowId(PrimaryKeyMap primaryKeyMap, PartitionPosition right)
{
// if the right token is the minimum token, there is no upper bound on the keyRange and
// we can save a lookup by using the maxSSTableRowId
if (right.isMinimum())
return metadata.maxSSTableRowId;
long max = primaryKeyMap.floor(right.getToken());
if (max < 0)
return metadata.maxSSTableRowId;
return max;
}
private SparseFixedBitSet bitSetForSearch()
{
var bits = cachedBitSets.get();
bits.clear();
return bits;
}
@Override
public KeyRangeIterator limitToTopKResults(QueryContext context, List<PrimaryKey> primaryKeys, Expression expression) throws IOException
{
int limit = context.vectorContext().limit();
// VSTODO would it be better to do a binary search to find the boundaries?
List<PrimaryKey> keysInRange = primaryKeys.stream()
.dropWhile(k -> k.compareTo(metadata.minKey) < 0)
.takeWhile(k -> k.compareTo(metadata.maxKey) <= 0)
.collect(Collectors.toList());
if (keysInRange.isEmpty())
return KeyRangeIterator.empty();
int topK = optimizeFor.topKFor(limit);
if (shouldUseBruteForce(topK, limit, keysInRange.size()))
return new KeyRangeListIterator(metadata.minKey, metadata.maxKey, keysInRange);
try (PrimaryKeyMap primaryKeyMap = primaryKeyMapFactory.newPerSSTablePrimaryKeyMap())
{
// the iterator represents keys from the whole table -- we'll only pull of those that
// are from our own token range, so we can use row ids to order the results by vector similarity.
var maxSegmentRowId = metadata.toSegmentRowId(metadata.maxSSTableRowId);
SparseFixedBitSet bits = bitSetForSearch();
var rowIds = new IntArrayList();
try (var ordinalsView = graph.getOrdinalsView())
{
for (PrimaryKey primaryKey : keysInRange)
{
long sstableRowId = primaryKeyMap.rowIdFromPrimaryKey(primaryKey);
// skip rows that are not in our segment (or more preciesely, have no vectors that were indexed)
// or are not in this segment (exactRowIdForPrimaryKey returns a negative value for not found)
if (sstableRowId < metadata.minSSTableRowId)
continue;
// if sstable row id has exceeded current ANN segment, stop
if (sstableRowId > metadata.maxSSTableRowId)
break;
int segmentRowId = metadata.toSegmentRowId(sstableRowId);
rowIds.add(segmentRowId);
// VSTODO now that we know the size of keys evaluated, is it worth doing the brute
// force check eagerly to potentially skip the PK to sstable row id to ordinal lookup?
int ordinal = ordinalsView.getOrdinalForRowId(segmentRowId);
if (ordinal >= 0)
bits.set(ordinal);
}
}
if (shouldUseBruteForce(topK, limit, rowIds.size()))
return toPrimaryKeyIterator(new IntArrayPostingList(rowIds.toIntArray()), context);
// else ask the index to perform a search limited to the bits we created
float[] queryVector = TypeUtil.decomposeVector(indexContext, expression.lower.value.raw.duplicate());
var results = graph.search(queryVector, topK, limit, bits);
updateExpectedNodes(results.getVisitedCount(), expectedNodesVisited(topK, maxSegmentRowId, graph.size()));
return toPrimaryKeyIterator(results, context);
}
}
private boolean shouldUseBruteForce(int topK, int limit, int numRows)
{
// if we have a small number of results then let TopK processor do exact NN computation
var maxBruteForceRows = min(globalBruteForceRows, maxBruteForceRows(topK, numRows, graph.size()));
logger.trace("SAI materialized {} rows; max brute force rows is {} for sstable index with {} nodes, LIMIT {}",
numRows, maxBruteForceRows, graph.size(), limit);
Tracing.trace("SAI materialized {} rows; max brute force rows is {} for sstable index with {} nodes, LIMIT {}",
numRows, maxBruteForceRows, graph.size(), limit);
return numRows <= maxBruteForceRows;
}
private int maxBruteForceRows(int limit, int nPermittedOrdinals, int graphSize)
{
int expectedNodes = expectedNodesVisited(limit, nPermittedOrdinals, graphSize);
// ANN index will do a bunch of extra work besides the full comparisons (performing PQ similarity for each edge);
// brute force from sstable will also do a bunch of extra work (going through trie index to look up row).
// VSTODO I'm not sure which one is more expensive (and it depends on things like sstable chunk cache hit ratio)
// so I'm leaving it as a 1:1 ratio for now.
return max(limit, expectedNodes);
}
private int expectedNodesVisited(int limit, int nPermittedOrdinals, int graphSize)
{
var observedRatio = actualExpectedRatio.getUpdateCount() >= 10 ? actualExpectedRatio.get() : 1.0;
return (int) (observedRatio * VectorMemoryIndex.expectedNodesVisited(limit, nPermittedOrdinals, graphSize));
}
private void updateExpectedNodes(int actualNodesVisited, int expectedNodesVisited)
{
assert expectedNodesVisited >= 0 : expectedNodesVisited;
assert actualNodesVisited >= 0 : actualNodesVisited;
if (actualNodesVisited >= 1000 && actualNodesVisited > 2 * expectedNodesVisited || expectedNodesVisited > 2 * actualNodesVisited)
logger.warn("Predicted visiting {} nodes, but actually visited {}", expectedNodesVisited, actualNodesVisited);
actualExpectedRatio.update(actualNodesVisited, expectedNodesVisited);
}
@Override
public String toString()
{
return MoreObjects.toStringHelper(this)
.add("indexContext", indexContext)
.toString();
}
@Override
public void close() throws IOException
{
graph.close();
}
private static class BitsOrPostingList
{
private final Bits bits;
private final int expectedNodesVisited;
private final PostingList postingList;
public BitsOrPostingList(@Nullable Bits bits, int expectedNodesVisited)
{
this.bits = bits;
this.expectedNodesVisited = expectedNodesVisited;
this.postingList = null;
}
public BitsOrPostingList(@Nullable Bits bits)
{
this.bits = bits;
this.postingList = null;
this.expectedNodesVisited = -1;
}
public BitsOrPostingList(PostingList postingList)
{
this.bits = null;
this.postingList = Preconditions.checkNotNull(postingList);
this.expectedNodesVisited = -1;
}
@Nullable
public Bits getBits()
{
Preconditions.checkState(!skipANN());
return bits;
}
public PostingList postingList()
{
Preconditions.checkState(skipANN());
return postingList;
}
public boolean skipANN()
{
return postingList != null;
}
}
}

View File

@ -44,7 +44,7 @@ public class TrieTermsDictionaryReader extends Walker<TrieTermsDictionaryReader>
super(rebufferer, root);
}
public static final TrieSerializer<Long, DataOutputPlus> trieSerializer = new TrieSerializer<Long, DataOutputPlus>()
public static final TrieSerializer<Long, DataOutputPlus> trieSerializer = new TrieSerializer<>()
{
@Override
public int sizeofNode(SerializationNode<Long> node, long nodePosition)

View File

@ -0,0 +1,122 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import java.util.Set;
import org.cliffc.high_scale_lib.NonBlockingHashMapLong;
import io.github.jbellis.jvector.util.Bits;
public class BitsUtil
{
public static Bits bitsIgnoringDeleted(Bits toAccept, Set<Integer> deletedOrdinals)
{
return deletedOrdinals.isEmpty()
? toAccept
: toAccept == null ? new NoDeletedBits(deletedOrdinals) : new NoDeletedIntersectingBits(toAccept, deletedOrdinals);
}
public static <T> Bits bitsIgnoringDeleted(Bits toAccept, NonBlockingHashMapLong<VectorPostings<T>> postings)
{
return toAccept == null ? new NoDeletedPostings<>(postings) : new NoDeletedIntersectingPostings<>(toAccept, postings);
}
private static abstract class BitsWithoutLength implements Bits, org.apache.lucene.util.Bits
{
@Override
public int length()
{
// length() is not called on search path
throw new UnsupportedOperationException();
}
}
private static class NoDeletedBits extends BitsWithoutLength
{
private final Set<Integer> deletedOrdinals;
private NoDeletedBits(Set<Integer> deletedOrdinals)
{
this.deletedOrdinals = deletedOrdinals;
}
@Override
public boolean get(int i)
{
return !deletedOrdinals.contains(i);
}
}
private static class NoDeletedIntersectingBits extends BitsWithoutLength
{
private final Bits toAccept;
private final Set<Integer> deletedOrdinals;
private NoDeletedIntersectingBits(Bits toAccept, Set<Integer> deletedOrdinals)
{
this.toAccept = toAccept;
this.deletedOrdinals = deletedOrdinals;
}
@Override
public boolean get(int i)
{
return !deletedOrdinals.contains(i) && toAccept.get(i);
}
}
private static class NoDeletedPostings<T> extends BitsWithoutLength
{
private final NonBlockingHashMapLong<VectorPostings<T>> postings;
public NoDeletedPostings(NonBlockingHashMapLong<VectorPostings<T>> postings)
{
this.postings = postings;
}
@Override
public boolean get(int i)
{
var p = postings.get(i);
assert p != null : "No postings for ordinal " + i;
return !p.isEmpty();
}
}
private static class NoDeletedIntersectingPostings<T> extends BitsWithoutLength
{
private final Bits toAccept;
private final NonBlockingHashMapLong<VectorPostings<T>> postings;
public NoDeletedIntersectingPostings(Bits toAccept, NonBlockingHashMapLong<VectorPostings<T>> postings)
{
this.toAccept = toAccept;
this.postings = postings;
}
@Override
public boolean get(int i)
{
var p = postings.get(i);
assert p != null : "No postings for ordinal " + i;
return !p.isEmpty() && toAccept.get(i);
}
}
}

View File

@ -0,0 +1,101 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import com.google.common.collect.Iterables;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.disk.SSTableIndex;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.io.util.FileUtils;
/**
* A {@link KeyRangeIterator}, used when returning results from a vector search, that checks for query timeouts
* during the iteration process.
*/
public class CheckpointingIterator extends KeyRangeIterator
{
private static final Logger logger = LoggerFactory.getLogger(CheckpointingIterator.class);
private final QueryContext context;
private final KeyRangeIterator union;
private final Iterable<SSTableIndex> referencedIndexes;
public CheckpointingIterator(KeyRangeIterator wrapped,
Iterable<SSTableIndex> referencedIndexes,
Iterable<SSTableIndex> referencedAnnIndexesInHybridSearch,
QueryContext queryContext)
{
super(wrapped.getMinimum(), wrapped.getMaximum(), wrapped.getCount());
this.union = wrapped;
if (referencedAnnIndexesInHybridSearch != null)
this.referencedIndexes = Iterables.concat(referencedIndexes, referencedAnnIndexesInHybridSearch);
else
this.referencedIndexes = referencedIndexes;
this.context = queryContext;
}
@Override
protected PrimaryKey computeNext()
{
try
{
return union.hasNext() ? union.next() : endOfData();
}
finally
{
context.checkpoint();
}
}
@Override
protected void performSkipTo(PrimaryKey nextKey)
{
try
{
union.skipTo(nextKey);
}
finally
{
context.checkpoint();
}
}
@Override
public void close()
{
FileUtils.closeQuietly(union);
referencedIndexes.forEach(CheckpointingIterator::releaseQuietly);
}
private static void releaseQuietly(SSTableIndex index)
{
try
{
index.release();
}
catch (Throwable e)
{
logger.error(index.getIndexContext().logMessage(String.format("Failed to release index on SSTable %s", index.getSSTable())), e);
}
}
}

View File

@ -0,0 +1,101 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import javax.annotation.concurrent.NotThreadSafe;
import io.github.jbellis.jvector.util.RamUsageEstimator;
import org.apache.cassandra.db.marshal.VectorType;
import org.apache.cassandra.io.util.SequentialWriter;
@NotThreadSafe
public class CompactionVectorValues implements RamAwareVectorValues
{
private final int dimension;
private final ArrayList<ByteBuffer> values = new ArrayList<>();
private final VectorType<Float> type;
public CompactionVectorValues(VectorType<Float> type)
{
this.dimension = type.dimension;
this.type = type;
}
@Override
public int size()
{
return values.size();
}
@Override
public int dimension()
{
return dimension;
}
@Override
public float[] vectorValue(int i)
{
return type.composeAsFloat(values.get(i));
}
/** return approximate bytes used by the new vector */
public long add(int ordinal, ByteBuffer value)
{
if (ordinal != values.size())
throw new IllegalArgumentException(String.format("CVV requires vectors to be added in ordinal order (%d given, expected %d)",
ordinal, values.size()));
values.add(value);
return RamEstimation.concurrentHashMapRamUsed(1) + oneVectorBytesUsed();
}
@Override
public CompactionVectorValues copy()
{
return this;
}
public long write(SequentialWriter writer) throws IOException
{
writer.writeInt(size());
writer.writeInt(dimension());
for (var i = 0; i < size(); i++) {
var bb = values.get(i);
assert bb != null : "null vector at index " + i + " of " + size();
writer.write(bb);
}
return writer.position();
}
@Override
public boolean isValueShared()
{
return false;
}
private long oneVectorBytesUsed()
{
return RamUsageEstimator.NUM_BYTES_OBJECT_REF;
}
}

View File

@ -0,0 +1,75 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import org.jctools.maps.NonBlockingHashMapLong;
public class ConcurrentVectorValues implements RamAwareVectorValues
{
private final int dimensions;
private final NonBlockingHashMapLong<float[]> values = new NonBlockingHashMapLong<>();
public ConcurrentVectorValues(int dimensions)
{
this.dimensions = dimensions;
}
@Override
public int size()
{
return values.size();
}
@Override
public int dimension()
{
return dimensions;
}
@Override
public float[] vectorValue(int i)
{
return values.get(i);
}
/** return approximate bytes used by the new vector */
public long add(int ordinal, float[] vector)
{
values.put(ordinal, vector);
return RamEstimation.concurrentHashMapRamUsed(1) + oneVectorBytesUsed();
}
@Override
public boolean isValueShared()
{
return false;
}
@Override
public ConcurrentVectorValues copy()
{
// no actual copy required because we always return distinct float[] for distinct vector ordinals
return this;
}
private long oneVectorBytesUsed()
{
return Integer.BYTES + Integer.BYTES + (long) dimension() * Float.BYTES;
}
}

View File

@ -0,0 +1,182 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import java.io.IOException;
import java.util.Arrays;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.PrimitiveIterator;
import java.util.stream.IntStream;
import io.github.jbellis.jvector.disk.CachingGraphIndex;
import io.github.jbellis.jvector.disk.OnDiskGraphIndex;
import io.github.jbellis.jvector.graph.GraphSearcher;
import io.github.jbellis.jvector.graph.NeighborSimilarity;
import io.github.jbellis.jvector.graph.SearchResult;
import io.github.jbellis.jvector.graph.SearchResult.NodeScore;
import io.github.jbellis.jvector.pq.CompressedVectors;
import io.github.jbellis.jvector.util.Bits;
import io.github.jbellis.jvector.vector.VectorSimilarityFunction;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.disk.format.IndexComponent;
import org.apache.cassandra.index.sai.disk.v1.PerColumnIndexFiles;
import org.apache.cassandra.index.sai.disk.v1.postings.VectorPostingList;
import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
import org.apache.cassandra.io.util.FileHandle;
import org.apache.cassandra.tracing.Tracing;
public class DiskAnn implements AutoCloseable
{
private final FileHandle graphHandle;
private final OnDiskOrdinalsMap ordinalsMap;
private final CachingGraphIndex graph;
private final VectorSimilarityFunction similarityFunction;
// only one of these will be not null
private final CompressedVectors compressedVectors;
public DiskAnn(SegmentMetadata.ComponentMetadataMap componentMetadatas, PerColumnIndexFiles indexFiles, IndexContext context) throws IOException
{
similarityFunction = context.getIndexWriterConfig().getSimilarityFunction();
SegmentMetadata.ComponentMetadata termsMetadata = componentMetadatas.get(IndexComponent.TERMS_DATA);
graphHandle = indexFiles.termsData();
graph = new CachingGraphIndex(new OnDiskGraphIndex<>(RandomAccessReaderAdapter.createSupplier(graphHandle), termsMetadata.offset));
long pqSegmentOffset = componentMetadatas.get(IndexComponent.COMPRESSED_VECTORS).offset;
try (var pqFileHandle = indexFiles.compressedVectors(); var reader = new RandomAccessReaderAdapter(pqFileHandle))
{
reader.seek(pqSegmentOffset);
var containsCompressedVectors = reader.readBoolean();
if (containsCompressedVectors)
compressedVectors = CompressedVectors.load(reader, reader.getFilePointer());
else
compressedVectors = null;
}
SegmentMetadata.ComponentMetadata postingListsMetadata = componentMetadatas.get(IndexComponent.POSTING_LISTS);
ordinalsMap = new OnDiskOrdinalsMap(indexFiles.postingLists(), postingListsMetadata.offset, postingListsMetadata.length);
}
public long ramBytesUsed()
{
return graph.ramBytesUsed();
}
public int size()
{
return graph.size();
}
/**
* @return Row IDs associated with the topK vectors near the query
*/
public VectorPostingList search(float[] queryVector, int topK, int limit, Bits acceptBits)
{
OnHeapGraph.validateIndexable(queryVector, similarityFunction);
var view = graph.getView();
var searcher = new GraphSearcher.Builder<>(view).build();
NeighborSimilarity.ScoreFunction scoreFunction;
NeighborSimilarity.ReRanker<float[]> reRanker;
if (compressedVectors == null)
{
scoreFunction = (NeighborSimilarity.ExactScoreFunction)
i -> similarityFunction.compare(queryVector, view.getVector(i));
reRanker = null;
}
else
{
scoreFunction = compressedVectors.approximateScoreFunctionFor(queryVector, similarityFunction);
reRanker = (i, map) -> similarityFunction.compare(queryVector, map.get(i));
}
var result = searcher.search(scoreFunction,
reRanker,
topK,
ordinalsMap.ignoringDeleted(acceptBits));
Tracing.trace("DiskANN search visited {} nodes to return {} results", result.getVisitedCount(), result.getNodes().length);
return annRowIdsToPostings(result, limit);
}
private class RowIdIterator implements PrimitiveIterator.OfInt, AutoCloseable
{
private final Iterator<NodeScore> it;
private final OnDiskOrdinalsMap.RowIdsView rowIdsView = ordinalsMap.getRowIdsView();
private OfInt segmentRowIdIterator = IntStream.empty().iterator();
public RowIdIterator(NodeScore[] results)
{
this.it = Arrays.stream(results).iterator();
}
@Override
public boolean hasNext()
{
while (!segmentRowIdIterator.hasNext() && it.hasNext())
{
try
{
var ordinal = it.next().node;
segmentRowIdIterator = Arrays.stream(rowIdsView.getSegmentRowIdsMatching(ordinal)).iterator();
}
catch (IOException e)
{
throw new RuntimeException(e);
}
}
return segmentRowIdIterator.hasNext();
}
@Override
public int nextInt() {
if (!hasNext())
throw new NoSuchElementException();
return segmentRowIdIterator.nextInt();
}
@Override
public void close()
{
rowIdsView.close();
}
}
private VectorPostingList annRowIdsToPostings(SearchResult results, int limit)
{
try (var iterator = new RowIdIterator(results.getNodes()))
{
return new VectorPostingList(iterator, limit, results.getVisitedCount());
}
}
@Override
public void close() throws IOException
{
ordinalsMap.close();
graph.close();
graphHandle.close();
}
public OnDiskOrdinalsMap.OrdinalsView getOrdinalsView()
{
return ordinalsMap.getOrdinalsView();
}
}

View File

@ -0,0 +1,55 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import java.util.function.Function;
public class DiskBinarySearch
{
/**
* Search for the target int between positions low and high, using the provided function
* to retrieve the int value at the given ordinal.
*
* Returns the position at which target is found. Raises an exception if it is not found.
*
* This will not call f() after the target is found, so if f is performing disk seeks,
* it will leave the underlying reader at the position right after reading the target.
*
* @return index if target is found; otherwise return -1 if targer is not found
*/
public static long searchInt(long low, long high, int target, Function<Long, Integer> f)
{
assert high < Long.MAX_VALUE >> 2 : "high is too large to avoid potential overflow: " + high;
assert low < high : "low must be less than high: " + low + " >= " + high;
while (low < high)
{
long i = low + (high - low) / 2;
int value = f.apply(i);
if (target == value)
return i;
else if (target > value)
low = i + 1;
else
high = i;
}
return -1;
}
}

View File

@ -0,0 +1,171 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import com.google.common.base.Preconditions;
import io.github.jbellis.jvector.util.Bits;
import org.apache.cassandra.io.util.FileHandle;
import org.apache.cassandra.io.util.RandomAccessReader;
public class OnDiskOrdinalsMap implements AutoCloseable
{
private final FileHandle fh;
private final long ordToRowOffset;
private final long segmentEnd;
private final int size;
// the offset where we switch from recording ordinal -> rows, to row -> ordinal
private final long rowOrdinalOffset;
private final Set<Integer> deletedOrdinals;
public OnDiskOrdinalsMap(FileHandle fh, long segmentOffset, long segmentLength)
{
deletedOrdinals = new HashSet<>();
this.segmentEnd = segmentOffset + segmentLength;
this.fh = fh;
try (var reader = fh.createReader())
{
reader.seek(segmentOffset);
int deletedCount = reader.readInt();
for (var i = 0; i < deletedCount; i++)
{
deletedOrdinals.add(reader.readInt());
}
this.ordToRowOffset = reader.getFilePointer();
this.size = reader.readInt();
reader.seek(segmentEnd - 8);
this.rowOrdinalOffset = reader.readLong();
assert rowOrdinalOffset < segmentEnd : "rowOrdinalOffset " + rowOrdinalOffset + " is not less than segmentEnd " + segmentEnd;
}
catch (Exception e)
{
throw new RuntimeException("Error initializing OnDiskOrdinalsMap at segment " + segmentOffset, e);
}
}
public RowIdsView getRowIdsView()
{
return new RowIdsView();
}
public Bits ignoringDeleted(Bits acceptBits)
{
return BitsUtil.bitsIgnoringDeleted(acceptBits, deletedOrdinals);
}
public class RowIdsView implements AutoCloseable
{
final RandomAccessReader reader = fh.createReader();
public int[] getSegmentRowIdsMatching(int vectorOrdinal) throws IOException
{
Preconditions.checkArgument(vectorOrdinal < size, "vectorOrdinal %s is out of bounds %s", vectorOrdinal, size);
// read index entry
try
{
reader.seek(ordToRowOffset + 4L + vectorOrdinal * 8L);
}
catch (Exception e)
{
throw new RuntimeException(String.format("Error seeking to index offset for ordinal %d with ordToRowOffset %d",
vectorOrdinal, ordToRowOffset), e);
}
var offset = reader.readLong();
// seek to and read rowIds
try
{
reader.seek(offset);
}
catch (Exception e)
{
throw new RuntimeException(String.format("Error seeking to rowIds offset for ordinal %d with ordToRowOffset %d",
vectorOrdinal, ordToRowOffset), e);
}
var postingsSize = reader.readInt();
var rowIds = new int[postingsSize];
for (var i = 0; i < rowIds.length; i++)
{
rowIds[i] = reader.readInt();
}
return rowIds;
}
@Override
public void close()
{
reader.close();
}
}
public OrdinalsView getOrdinalsView()
{
return new OrdinalsView();
}
public class OrdinalsView implements AutoCloseable
{
final RandomAccessReader reader = fh.createReader();
private final long high = (segmentEnd - 8 - rowOrdinalOffset) / 8;
/**
* @return order if given row id is found; otherwise return -1
*/
public int getOrdinalForRowId(int rowId) throws IOException
{
// Compute the offset of the start of the rowId to vectorOrdinal mapping
long index = DiskBinarySearch.searchInt(0, Math.toIntExact(high), rowId, i -> {
try
{
long offset = rowOrdinalOffset + i * 8;
reader.seek(offset);
return reader.readInt();
}
catch (IOException e)
{
throw new RuntimeException(e);
}
});
// not found
if (index < 0)
return -1;
return reader.readInt();
}
@Override
public void close()
{
reader.close();
}
}
@Override
public void close()
{
fh.close();
}
}

View File

@ -0,0 +1,385 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.IntStream;
import org.cliffc.high_scale_lib.NonBlockingHashMapLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.github.jbellis.jvector.disk.OnDiskGraphIndex;
import io.github.jbellis.jvector.graph.GraphIndex;
import io.github.jbellis.jvector.graph.GraphIndexBuilder;
import io.github.jbellis.jvector.graph.GraphSearcher;
import io.github.jbellis.jvector.graph.NeighborSimilarity;
import io.github.jbellis.jvector.graph.RandomAccessVectorValues;
import io.github.jbellis.jvector.pq.CompressedVectors;
import io.github.jbellis.jvector.pq.ProductQuantization;
import io.github.jbellis.jvector.util.Bits;
import io.github.jbellis.jvector.vector.VectorEncoding;
import io.github.jbellis.jvector.vector.VectorSimilarityFunction;
import org.apache.cassandra.db.marshal.AbstractType;
import org.apache.cassandra.db.marshal.VectorType;
import org.apache.cassandra.exceptions.InvalidRequestException;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.disk.format.IndexComponent;
import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
import org.apache.cassandra.index.sai.disk.io.IndexFileUtils;
import org.apache.cassandra.index.sai.disk.v1.IndexWriterConfig;
import org.apache.cassandra.index.sai.disk.v1.SAICodecUtils;
import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
import org.apache.cassandra.io.util.SequentialWriter;
import org.apache.cassandra.tracing.Tracing;
import org.apache.cassandra.utils.ByteBufferUtil;
import org.apache.lucene.util.StringHelper;
public class OnHeapGraph<T>
{
private static final Logger logger = LoggerFactory.getLogger(OnHeapGraph.class);
private final RamAwareVectorValues vectorValues;
private final GraphIndexBuilder<float[]> builder;
private final VectorType<?> vectorType;
private final VectorSimilarityFunction similarityFunction;
private final ConcurrentMap<float[], VectorPostings<T>> postingsMap;
private final NonBlockingHashMapLong<VectorPostings<T>> postingsByOrdinal;
private final AtomicInteger nextOrdinal = new AtomicInteger();
private volatile boolean hasDeletions;
/**
* @param termComparator the vector type
* @param indexWriterConfig
*
* Will create a concurrent object.
*/
public OnHeapGraph(AbstractType<?> termComparator, IndexWriterConfig indexWriterConfig)
{
this(termComparator, indexWriterConfig, true);
}
/**
* @param termComparator the vector type
* @param indexWriterConfig the {@link IndexWriterConfig} for the graph
* @param concurrent should be true for memtables, false for compaction. Concurrent allows us to search
* while building the graph; non-concurrent allows us to avoid synchronization costs.
*/
@SuppressWarnings("unchecked")
public OnHeapGraph(AbstractType<?> termComparator, IndexWriterConfig indexWriterConfig, boolean concurrent)
{
this.vectorType = (VectorType<?>) termComparator;
vectorValues = concurrent
? new ConcurrentVectorValues(((VectorType<?>) termComparator).dimension)
: new CompactionVectorValues(((VectorType<Float>) termComparator));
similarityFunction = indexWriterConfig.getSimilarityFunction();
// We need to be able to inexpensively distinguish different vectors, with a slower path
// that identifies vectors that are equal but not the same reference. A comparison
// based Map (which only needs to look at vector elements until a difference is found)
// is thus a better option than hash-based (which has to look at all elements to compute the hash).
postingsMap = new ConcurrentSkipListMap<>(Arrays::compare);
postingsByOrdinal = new NonBlockingHashMapLong<>();
builder = new GraphIndexBuilder<>(vectorValues,
VectorEncoding.FLOAT32,
similarityFunction,
indexWriterConfig.getMaximumNodeConnections(),
indexWriterConfig.getConstructionBeamWidth(),
1.2f,
1.4f);
}
public int size()
{
return vectorValues.size();
}
public boolean isEmpty()
{
return postingsMap.values().stream().allMatch(VectorPostings::isEmpty);
}
/**
* @return the incremental bytes ysed by adding the given vector to the index
*/
public long add(ByteBuffer term, T key, InvalidVectorBehavior behavior)
{
assert term != null && term.remaining() != 0;
var vector = vectorType.composeAsFloat(term);
if (behavior == InvalidVectorBehavior.IGNORE)
{
try
{
validateIndexable(vector, similarityFunction);
}
catch (InvalidRequestException e)
{
logger.trace("Ignoring invalid vector during index build against existing data: {}", vector, e);
return 0;
}
}
else
{
assert behavior == InvalidVectorBehavior.FAIL;
validateIndexable(vector, similarityFunction);
}
var bytesUsed = 0L;
VectorPostings<T> postings = postingsMap.get(vector);
// if the vector is already in the graph, all that happens is that the postings list is updated
// otherwise, we add the vector in this order:
// 1. to the postingsMap
// 2. to the vectorValues
// 3. to the graph
// This way, concurrent searches of the graph won't see the vector until it's visible
// in the other structures as well.
if (postings == null)
{
postings = new VectorPostings<>(key);
// since we are using ConcurrentSkipListMap, it is NOT correct to use computeIfAbsent here
if (postingsMap.putIfAbsent(vector, postings) == null)
{
// we won the race to add the new entry; assign it an ordinal and add to the other structures
var ordinal = nextOrdinal.getAndIncrement();
postings.setOrdinal(ordinal);
bytesUsed += RamEstimation.concurrentHashMapRamUsed(1); // the new posting Map entry
bytesUsed += (vectorValues instanceof ConcurrentVectorValues)
? ((ConcurrentVectorValues) vectorValues).add(ordinal, vector)
: ((CompactionVectorValues) vectorValues).add(ordinal, term);
bytesUsed += VectorPostings.emptyBytesUsed() + VectorPostings.bytesPerPosting();
postingsByOrdinal.put(ordinal, postings);
bytesUsed += builder.addGraphNode(ordinal, vectorValues);
return bytesUsed;
}
else
{
postings = postingsMap.get(vector);
}
}
// postings list already exists, just add the new key (if it's not already in the list)
if (postings.add(key))
{
bytesUsed += VectorPostings.bytesPerPosting();
}
return bytesUsed;
}
// copied out of a Lucene PR -- hopefully committed soon
public static final float MAX_FLOAT32_COMPONENT = 1E17f;
public static void checkInBounds(float[] v)
{
for (int i = 0; i < v.length; i++)
{
if (!Float.isFinite(v[i]))
{
throw new IllegalArgumentException("non-finite value at vector[" + i + "]=" + v[i]);
}
if (Math.abs(v[i]) > MAX_FLOAT32_COMPONENT)
{
throw new IllegalArgumentException("Out-of-bounds value at vector[" + i + "]=" + v[i]);
}
}
}
public static void validateIndexable(float[] vector, VectorSimilarityFunction similarityFunction)
{
try
{
checkInBounds(vector);
}
catch (IllegalArgumentException e)
{
throw new InvalidRequestException(e.getMessage());
}
if (similarityFunction == VectorSimilarityFunction.COSINE)
{
for (int i = 0; i < vector.length; i++)
{
if (vector[i] != 0)
return;
}
throw new InvalidRequestException("Zero vectors cannot be indexed or queried with cosine similarity");
}
}
public Collection<T> keysFromOrdinal(int node)
{
return postingsByOrdinal.get(node).getPostings();
}
public long remove(ByteBuffer term, T key)
{
assert term != null && term.remaining() != 0;
var vector = vectorType.composeAsFloat(term);
var postings = postingsMap.get(vector);
if (postings == null)
{
// it's possible for this to be called against a different memtable than the one
// the value was originally added to, in which case we do not expect to find
// the key among the postings for this vector
return 0;
}
hasDeletions = true;
return postings.remove(key);
}
/**
* @return keys (PrimaryKey or segment row id) associated with the topK vectors near the query
*/
public PriorityQueue<T> search(float[] queryVector, int limit, Bits toAccept)
{
validateIndexable(queryVector, similarityFunction);
// search() errors out when an empty graph is passed to it
if (vectorValues.size() == 0)
return new PriorityQueue<>();
Bits bits = hasDeletions ? BitsUtil.bitsIgnoringDeleted(toAccept, postingsByOrdinal) : toAccept;
GraphIndex<float[]> graph = builder.getGraph();
var searcher = new GraphSearcher.Builder<>(graph.getView()).withConcurrentUpdates().build();
NeighborSimilarity.ExactScoreFunction scoreFunction = node2 -> vectorCompareFunction(queryVector, node2);
var result = searcher.search(scoreFunction, null, limit, bits);
Tracing.trace("ANN search visited {} in-memory nodes to return {} results", result.getVisitedCount(), result.getNodes().length);
var a = result.getNodes();
PriorityQueue<T> keyQueue = new PriorityQueue<>();
for (int i = 0; i < a.length; i++)
keyQueue.addAll(keysFromOrdinal(a[i].node));
return keyQueue;
}
public SegmentMetadata.ComponentMetadataMap writeData(IndexDescriptor indexDescriptor, IndexContext indexContext, Function<T, Integer> postingTransformer) throws IOException
{
int nInProgress = builder.insertsInProgress();
assert nInProgress == 0 : String.format("Attempting to write graph while %d inserts are in progress", nInProgress);
assert nextOrdinal.get() == builder.getGraph().size() : String.format("nextOrdinal %d != graph size %d -- ordinals should be sequential",
nextOrdinal.get(), builder.getGraph().size());
assert vectorValues.size() == builder.getGraph().size() : String.format("vector count %d != graph size %d",
vectorValues.size(), builder.getGraph().size());
assert postingsMap.keySet().size() == vectorValues.size() : String.format("postings map entry count %d != vector count %d",
postingsMap.keySet().size(), vectorValues.size());
logger.debug("Writing graph with {} rows and {} distinct vectors", postingsMap.values().stream().mapToInt(VectorPostings::size).sum(), vectorValues.size());
try (var pqOutput = IndexFileUtils.instance.openOutput(indexDescriptor.fileFor(IndexComponent.COMPRESSED_VECTORS, indexContext), true);
var postingsOutput = IndexFileUtils.instance.openOutput(indexDescriptor.fileFor(IndexComponent.POSTING_LISTS, indexContext), true);
var indexOutput = IndexFileUtils.instance.openOutput(indexDescriptor.fileFor(IndexComponent.TERMS_DATA, indexContext), true))
{
SAICodecUtils.writeHeader(pqOutput);
SAICodecUtils.writeHeader(postingsOutput);
SAICodecUtils.writeHeader(indexOutput);
// compute and write PQ
long pqOffset = pqOutput.getFilePointer();
long pqPosition = writePQ(pqOutput.asSequentialWriter());
long pqLength = pqPosition - pqOffset;
var deletedOrdinals = new HashSet<Integer>();
postingsMap.values().stream().filter(VectorPostings::isEmpty).forEach(vectorPostings -> deletedOrdinals.add(vectorPostings.getOrdinal()));
// remove ordinals that don't have corresponding row ids due to partition/range deletion
for (VectorPostings<T> vectorPostings : postingsMap.values())
{
vectorPostings.computeRowIds(postingTransformer);
if (vectorPostings.shouldAppendDeletedOrdinal())
deletedOrdinals.add(vectorPostings.getOrdinal());
}
// write postings
long postingsOffset = postingsOutput.getFilePointer();
long postingsPosition = new VectorPostingsWriter<T>().writePostings(postingsOutput.asSequentialWriter(), vectorValues, postingsMap, deletedOrdinals);
long postingsLength = postingsPosition - postingsOffset;
// complete (internal clean up) and write the graph
builder.complete();
long termsOffset = indexOutput.getFilePointer();
OnDiskGraphIndex.write(builder.getGraph(), vectorValues, indexOutput.asSequentialWriter());
long termsLength = indexOutput.getFilePointer() - termsOffset;
// write footers/checksums
SAICodecUtils.writeFooter(pqOutput);
SAICodecUtils.writeFooter(postingsOutput);
SAICodecUtils.writeFooter(indexOutput);
// add components to the metadata map
SegmentMetadata.ComponentMetadataMap metadataMap = new SegmentMetadata.ComponentMetadataMap();
metadataMap.put(IndexComponent.TERMS_DATA, -1, termsOffset, termsLength, Map.of());
metadataMap.put(IndexComponent.POSTING_LISTS, -1, postingsOffset, postingsLength, Map.of());
Map<String, String> vectorConfigs = Map.of("SEGMENT_ID", ByteBufferUtil.bytesToHex(ByteBuffer.wrap(StringHelper.randomId())));
metadataMap.put(IndexComponent.COMPRESSED_VECTORS, -1, pqOffset, pqLength, vectorConfigs);
return metadataMap;
}
}
private float vectorCompareFunction(float[] queryVector, int node)
{
return similarityFunction.compare(queryVector, ((RandomAccessVectorValues<float[]>) vectorValues).vectorValue(node));
}
private long writePQ(SequentialWriter writer) throws IOException
{
// don't bother with PQ if there are fewer than 1K vectors
int M = vectorValues.dimension() / 2;
writer.writeBoolean(vectorValues.size() >= 1024);
if (vectorValues.size() < 1024)
{
logger.debug("Skipping PQ for only {} vectors", vectorValues.size());
return writer.position();
}
logger.debug("Computing PQ for {} vectors", vectorValues.size());
// limit the PQ computation and encoding to one index at a time -- goal during flush is to
// evict from memory ASAP so better to do the PQ build (in parallel) one at a time
ProductQuantization pq;
byte[][] encoded;
synchronized (OnHeapGraph.class)
{
// train PQ and encode
pq = ProductQuantization.compute(vectorValues, M, false);
assert !vectorValues.isValueShared();
encoded = IntStream.range(0, vectorValues.size())
.parallel()
.mapToObj(i -> pq.encode(vectorValues.vectorValue(i)))
.toArray(byte[][]::new);
}
var cv = new CompressedVectors(pq, encoded);
// save
cv.write(writer);
return writer.position();
}
public enum InvalidVectorBehavior
{
IGNORE,
FAIL
}
}

View File

@ -0,0 +1,52 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import java.util.function.Function;
import static java.lang.Math.pow;
/**
* Allows the vector index searches to be optimised for latency or recall. This is used by the
* {@link org.apache.cassandra.index.sai.disk.v1.segment.VectorIndexSegmentSearcher} to determine how many results to ask the graph
* to search for. If we are optimising for {@link #RECALL} we ask for more than the requested limit which
* (since it will search deeper in the graph) will tend to surface slightly better results.
*/
public enum OptimizeFor
{
LATENCY(limit -> 0.979 + 4.021 * pow(limit, -0.761)), // f(1) = 5.0, f(100) = 1.1, f(1000) = 1.0
RECALL(limit -> 0.509 + 9.491 * pow(limit, -0.402)); // f(1) = 10.0, f(100) = 2.0, f(1000) = 1.1
private final Function<Integer, Double> limitMultiplier;
OptimizeFor(Function<Integer, Double> limitMultiplier)
{
this.limitMultiplier = limitMultiplier;
}
public int topKFor(int limit)
{
return (int)(limitMultiplier.apply(limit) * limit);
}
public static OptimizeFor fromString(String value)
{
return valueOf(value.toUpperCase());
}
}

View File

@ -0,0 +1,26 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import io.github.jbellis.jvector.graph.RandomAccessVectorValues;
public interface RamAwareVectorValues extends RandomAccessVectorValues<float[]>
{
float[] vectorValue(int i);
}

View File

@ -0,0 +1,53 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import org.apache.lucene.util.RamUsageEstimator;
public class RamEstimation
{
/**
* @param externalNodeCount the size() of the ConcurrentHashMap
* @return an estimate of the number of bytes used
*/
public static long concurrentHashMapRamUsed(int externalNodeCount) {
long REF_BYTES = RamUsageEstimator.NUM_BYTES_OBJECT_REF;
long AH_BYTES = RamUsageEstimator.NUM_BYTES_ARRAY_HEADER;
long CORES = Runtime.getRuntime().availableProcessors();
long chmNodeBytes =
REF_BYTES // node itself in Node[]
+ 3L * REF_BYTES
+ Integer.BYTES; // node internals
float chmLoadFactor = 0.75f; // this is hardcoded inside ConcurrentHashMap
// CHM has a striped counter Cell implementation, we expect at most one per core
long chmCounters = AH_BYTES + CORES * (REF_BYTES + Long.BYTES);
double nodeCount = externalNodeCount / chmLoadFactor;
return
(long) nodeCount * (chmNodeBytes + REF_BYTES)// nodes
+ AH_BYTES // nodes array
+ Long.BYTES
+ 3 * Integer.BYTES
+ 3 * REF_BYTES // extra internal fields
+ chmCounters
+ REF_BYTES; // the Map reference itself
}
}

View File

@ -0,0 +1,127 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import com.google.common.primitives.Ints;
import io.github.jbellis.jvector.disk.ReaderSupplier;
import org.apache.cassandra.io.util.FileHandle;
import org.apache.cassandra.io.util.RandomAccessReader;
public class RandomAccessReaderAdapter extends RandomAccessReader implements io.github.jbellis.jvector.disk.RandomAccessReader
{
static ReaderSupplier createSupplier(FileHandle fileHandle)
{
return () -> new RandomAccessReaderAdapter(fileHandle);
}
RandomAccessReaderAdapter(FileHandle fileHandle)
{
super(fileHandle.instantiateRebufferer(null));
}
@Override
public void readFully(float[] dest) throws IOException
{
var bh = bufferHolder;
long position = getPosition();
FloatBuffer floatBuffer;
if (bh.offset() == 0 && position % Float.BYTES == 0)
{
// this is a separate code path because buffer() and asFloatBuffer() both allocate
// new and relatively expensive xBuffer objects, so we want to avoid doing that
// twice, where possible
floatBuffer = bh.floatBuffer();
floatBuffer.position(Ints.checkedCast(position / Float.BYTES));
}
else
{
// offset is non-zero, and probably not aligned to Float.BYTES, so
// set the position before converting to FloatBuffer.
var bb = bh.buffer();
bb.position(Ints.checkedCast(position - bh.offset()));
floatBuffer = bb.asFloatBuffer();
}
if (dest.length > floatBuffer.remaining())
{
// slow path -- desired slice is across region boundaries
var bb = ByteBuffer.allocate(Float.BYTES * dest.length);
readFully(bb);
floatBuffer = bb.asFloatBuffer();
}
floatBuffer.get(dest);
seek(position + (long) Float.BYTES * dest.length);
}
/**
* Read ints into an int[], starting at the current position.
*
* @param dest the array to read into
* @param offset the offset in the array at which to start writing ints
* @param count the number of ints to read
*
* Will change the buffer position.
*/
@Override
public void read(int[] dest, int offset, int count) throws IOException
{
if (count == 0)
return;
var bh = bufferHolder;
long position = getPosition();
IntBuffer intBuffer;
if (bh.offset() == 0 && position % Integer.BYTES == 0)
{
// this is a separate code path because buffer() and asIntBuffer() both allocate
// new and relatively expensive xBuffer objects, so we want to avoid doing that
// twice, where possible
intBuffer = bh.intBuffer();
intBuffer.position(Ints.checkedCast(position / Integer.BYTES));
}
else
{
// offset is non-zero, and probably not aligned to Integer.BYTES, so
// set the position before converting to IntBuffer.
var bb = bh.buffer();
bb.position(Ints.checkedCast(position - bh.offset()));
intBuffer = bb.asIntBuffer();
}
if (count > intBuffer.remaining())
{
// slow path -- desired slice is across region boundaries
var bb = ByteBuffer.allocate(Integer.BYTES * count);
readFully(bb);
intBuffer = bb.asIntBuffer();
}
intBuffer.get(dest, offset, count);
seek(position + (long) Integer.BYTES * count);
}
}

View File

@ -0,0 +1,150 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Function;
import com.google.common.base.Preconditions;
import org.agrona.collections.IntArrayList;
import org.apache.lucene.util.RamUsageEstimator;
public class VectorPostings<T>
{
private final List<T> postings;
private volatile int ordinal = -1;
private volatile IntArrayList rowIds;
public VectorPostings(T firstKey)
{
// we expect that the overwhelmingly most common cardinality will be 1, so optimize for reads
postings = new CopyOnWriteArrayList<>(List.of(firstKey));
}
/**
* Split out from constructor only to make dealing with concurrent inserts easier for CassandraOnHeapGraph.
* Should be called at most once per instance.
*/
void setOrdinal(int ordinal)
{
assert this.ordinal == -1 : String.format("ordinal already set to %d; attempted to set to %d", this.ordinal, ordinal);
this.ordinal = ordinal;
}
public boolean add(T key)
{
for (T existing : postings)
if (existing.equals(key))
return false;
postings.add(key);
return true;
}
/**
* @return true if current ordinal is removed by partition/range deletion.
* Must be called after computeRowIds.
*/
public boolean shouldAppendDeletedOrdinal()
{
return !postings.isEmpty() && (rowIds != null && rowIds.isEmpty());
}
/**
* Compute the rowIds corresponding to the {@code <T>} keys in this postings list.
*/
public void computeRowIds(Function<T, Integer> postingTransformer)
{
Preconditions.checkState(rowIds == null);
IntArrayList ids = new IntArrayList(postings.size(), -1);
for (T key : postings)
{
int rowId = postingTransformer.apply(key);
// partition deletion and range deletion won't trigger index update. There is no row id for given key during flush
if (rowId >= 0)
ids.add(rowId);
}
rowIds = ids;
}
/**
* @return rowIds corresponding to the {@code <T>} keys in this postings list.
* Must be called after computeRowIds.
*/
public IntArrayList getRowIds()
{
Preconditions.checkNotNull(rowIds);
return rowIds;
}
public long remove(T key)
{
long bytesUsed = ramBytesUsed();
postings.remove(key);
return bytesUsed - ramBytesUsed();
}
public long ramBytesUsed()
{
return emptyBytesUsed() + postings.size() * bytesPerPosting();
}
public static long emptyBytesUsed()
{
long REF_BYTES = RamUsageEstimator.NUM_BYTES_OBJECT_REF;
long AH_BYTES = RamUsageEstimator.NUM_BYTES_ARRAY_HEADER;
return Integer.BYTES + REF_BYTES + AH_BYTES;
}
// we can't do this exactly without reflection, because keys could be Long or PrimaryKey.
// PK is larger, so we'll take that and return an upper bound.
// we already count the float[] vector in vectorValues, so leave it out here
public static long bytesPerPosting()
{
long REF_BYTES = RamUsageEstimator.NUM_BYTES_OBJECT_REF;
return REF_BYTES
+ 2 * Long.BYTES // hashes in PreHashedDecoratedKey
+ REF_BYTES; // key ByteBuffer, this is used elsewhere, so we don't take the deep size
}
public int size()
{
return postings.size();
}
public List<T> getPostings()
{
return postings;
}
public boolean isEmpty()
{
return postings.isEmpty();
}
public int getOrdinal()
{
assert ordinal >= 0 : "ordinal not set";
return ordinal;
}
}

View File

@ -0,0 +1,111 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.disk.v1.vector;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.cassandra.io.util.SequentialWriter;
import org.apache.cassandra.utils.Pair;
public class VectorPostingsWriter<T>
{
public long writePostings(SequentialWriter writer,
RamAwareVectorValues vectorValues,
Map<float[], VectorPostings<T>> postingsMap,
Set<Integer> deletedOrdinals) throws IOException
{
writeDeletedOrdinals(writer, deletedOrdinals);
writeNodeOrdinalToRowIdMapping(writer, vectorValues, postingsMap);
writeRowIdToNodeOrdinalMapping(writer, vectorValues, postingsMap);
return writer.position();
}
private void writeDeletedOrdinals(SequentialWriter writer, Set<Integer> deletedOrdinals) throws IOException
{
writer.writeInt(deletedOrdinals.size());
for (var ordinal : deletedOrdinals) {
writer.writeInt(ordinal);
}
}
public void writeNodeOrdinalToRowIdMapping(SequentialWriter writer,
RamAwareVectorValues vectorValues,
Map<float[], VectorPostings<T>> postingsMap) throws IOException
{
long ordToRowOffset = writer.getOnDiskFilePointer();
// total number of vectors
writer.writeInt(vectorValues.size());
// Write the offsets of the postings for each ordinal
var offsetsStartAt = ordToRowOffset + 4L + 8L * vectorValues.size();
var nextOffset = offsetsStartAt;
for (var i = 0; i < vectorValues.size(); i++) {
// (ordinal is implied; don't need to write it)
writer.writeLong(nextOffset);
var rowIds = postingsMap.get(vectorValues.vectorValue(i)).getRowIds();
nextOffset += 4 + (rowIds.size() * 4L); // 4 bytes for size and 4 bytes for each integer in the list
}
assert writer.position() == offsetsStartAt : "writer.position()=" + writer.position() + " offsetsStartAt=" + offsetsStartAt;
// Write postings lists
for (var i = 0; i < vectorValues.size(); i++) {
VectorPostings<T> postings = postingsMap.get(vectorValues.vectorValue(i));
var rowIds = postings.getRowIds();
writer.writeInt(rowIds.size());
for (int r = 0; r < rowIds.size(); r++)
writer.writeInt(rowIds.getInt(r));
}
assert writer.position() == nextOffset;
}
public void writeRowIdToNodeOrdinalMapping(SequentialWriter writer,
RamAwareVectorValues vectorValues,
Map<float[], VectorPostings<T>> postingsMap) throws IOException
{
List<Pair<Integer, Integer>> pairs = new ArrayList<>();
// Collect all (rowId, vectorOrdinal) pairs
for (var i = 0; i < vectorValues.size(); i++) {
var rowIds = postingsMap.get(vectorValues.vectorValue(i)).getRowIds();
for (int r = 0; r < rowIds.size(); r++)
pairs.add(Pair.create(rowIds.getInt(r), i));
}
// Sort the pairs by rowId
pairs.sort(Comparator.comparingInt(Pair::left));
// Write the pairs to the file
long startOffset = writer.position();
for (var pair : pairs) {
writer.writeInt(pair.left);
writer.writeInt(pair.right);
}
// write the position of the beginning of rowid -> ordinals mappings to the end
writer.writeLong(startOffset);
}
}

View File

@ -43,6 +43,13 @@ public abstract class KeyRangeIterator extends AbstractGuavaIterator<PrimaryKey>
this(statistics.min, statistics.max, statistics.count);
}
public KeyRangeIterator(KeyRangeIterator range)
{
this(range == null ? null : range.min,
range == null ? null : range.max,
range == null ? -1 : range.count);
}
public KeyRangeIterator(PrimaryKey min, PrimaryKey max, long count)
{
boolean isComplete = min != null && max != null && count != 0;

View File

@ -0,0 +1,67 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.iterators;
import java.util.List;
import com.google.common.collect.Iterators;
import com.google.common.collect.PeekingIterator;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
/**
* A {@link KeyRangeIterator} that iterates over a list of {@link PrimaryKey}s without modifying the underlying list.
*/
public class KeyRangeListIterator extends KeyRangeIterator
{
private final PeekingIterator<PrimaryKey> keyQueue;
/**
* Create a new {@link KeyRangeListIterator} that iterates over the provided list of keys.
*
* @param minimumKey the minimum key for the provided list of keys
* @param maximumKey the maximum key for the provided list of keys
* @param keys the list of keys to iterate over
*/
public KeyRangeListIterator(PrimaryKey minimumKey, PrimaryKey maximumKey, List<PrimaryKey> keys)
{
super(minimumKey, maximumKey, keys.size());
this.keyQueue = Iterators.peekingIterator(keys.iterator());
}
@Override
protected void performSkipTo(PrimaryKey nextKey)
{
while (keyQueue.hasNext())
{
if (keyQueue.peek().compareTo(nextKey) >= 0)
break;
keyQueue.next();
}
}
@Override
public void close() {}
@Override
protected PrimaryKey computeNext()
{
return keyQueue.hasNext() ? keyQueue.next() : endOfData();
}
}

View File

@ -0,0 +1,92 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.iterators;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import javax.annotation.concurrent.NotThreadSafe;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.io.util.FileUtils;
/**
* An iterator that consumes a chunk of {@link PrimaryKey}s from the {@link KeyRangeIterator}, passes them to the
* {@link Function} to filter the chunk of {@link PrimaryKey}s and then pass the results to next consumer.
* The PKs are currently returned in {@link PrimaryKey} order, but that contract may change.
*/
@NotThreadSafe
public class KeyRangeOrderingIterator extends KeyRangeIterator
{
private final KeyRangeIterator input;
private final int chunkSize;
private final Function<List<PrimaryKey>, KeyRangeIterator> nextRangeFunction;
private final ArrayList<PrimaryKey> nextKeys;
private KeyRangeIterator nextIterator;
public KeyRangeOrderingIterator(KeyRangeIterator input, int chunkSize, Function<List<PrimaryKey>, KeyRangeIterator> nextRangeFunction)
{
super(input);
this.input = input;
this.chunkSize = chunkSize;
this.nextRangeFunction = nextRangeFunction;
this.nextKeys = new ArrayList<>(chunkSize);
}
@Override
public PrimaryKey computeNext()
{
if (nextIterator == null || !nextIterator.hasNext())
{
do
{
if (!input.hasNext())
return endOfData();
nextKeys.clear();
do
{
nextKeys.add(input.next());
}
while (nextKeys.size() < chunkSize && input.hasNext());
// Get the next iterator before closing this one to prevent releasing the resource.
var previousIterator = nextIterator;
// If this results in an exception, previousIterator is closed in close() method.
nextIterator = nextRangeFunction.apply(nextKeys);
if (previousIterator != null)
FileUtils.closeQuietly(previousIterator);
// nextIterator might not have any rows due to shadowed primary keys
}
while (!nextIterator.hasNext());
}
return nextIterator.next();
}
@Override
protected void performSkipTo(PrimaryKey nextToken)
{
input.skipTo(nextToken);
nextIterator.skipTo(nextToken);
}
public void close()
{
FileUtils.closeQuietly(input);
FileUtils.closeQuietly(nextIterator);
}
}

View File

@ -0,0 +1,70 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.memory;
import org.apache.cassandra.db.Clustering;
import org.apache.cassandra.db.DecoratedKey;
import org.apache.cassandra.db.PartitionPosition;
import org.apache.cassandra.dht.AbstractBounds;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.index.sai.utils.PrimaryKeys;
import org.apache.cassandra.utils.Pair;
import org.apache.cassandra.utils.bytecomparable.ByteComparable;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Iterator;
import java.util.function.Function;
public abstract class MemoryIndex implements MemtableOrdering
{
protected final IndexContext indexContext;
protected MemoryIndex(IndexContext indexContext)
{
this.indexContext = indexContext;
}
public abstract long add(DecoratedKey key, Clustering<?> clustering, ByteBuffer value);
public abstract long update(DecoratedKey key, Clustering<?> clustering, ByteBuffer oldValue, ByteBuffer newValue);
public abstract KeyRangeIterator search(QueryContext queryContext, Expression expression, AbstractBounds<PartitionPosition> keyRange);
public abstract boolean isEmpty();
public abstract ByteBuffer getMinTerm();
public abstract ByteBuffer getMaxTerm();
/**
* Iterate all Term->PrimaryKeys mappings in sorted order
*/
public abstract Iterator<Pair<ByteComparable, PrimaryKeys>> iterator();
public abstract SegmentMetadata.ComponentMetadataMap writeDirect(IndexDescriptor indexDescriptor,
IndexContext indexContext,
Function<PrimaryKey, Integer> postingTransformer) throws IOException;
}

View File

@ -18,30 +18,37 @@
package org.apache.cassandra.index.sai.memory;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.Function;
import org.apache.cassandra.db.Clustering;
import org.apache.cassandra.db.DecoratedKey;
import org.apache.cassandra.db.PartitionPosition;
import org.apache.cassandra.dht.AbstractBounds;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.index.sai.utils.PrimaryKeys;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.utils.Pair;
import org.apache.cassandra.utils.bytecomparable.ByteComparable;
public class MemtableIndex
public class MemtableIndex implements MemtableOrdering
{
private final TrieMemoryIndex index;
private final MemoryIndex index;
private final LongAdder writeCount = new LongAdder();
private final LongAdder estimatedMemoryUsed = new LongAdder();
public MemtableIndex(IndexContext indexContext)
{
this.index = new TrieMemoryIndex(indexContext);
this.index = indexContext.isVector() ? new VectorMemoryIndex(indexContext) : new TrieMemoryIndex(indexContext);
}
public long writeCount()
@ -56,7 +63,7 @@ public class MemtableIndex
public boolean isEmpty()
{
return getMinTerm() == null;
return index.isEmpty();
}
public ByteBuffer getMinTerm()
@ -80,13 +87,31 @@ public class MemtableIndex
return ram;
}
public KeyRangeIterator search(Expression expression, AbstractBounds<PartitionPosition> keyRange)
public long update(DecoratedKey key, Clustering<?> clustering, ByteBuffer oldValue, ByteBuffer newValue)
{
return index.search(expression, keyRange);
return index.update(key, clustering, oldValue, newValue);
}
public KeyRangeIterator search(QueryContext queryContext, Expression expression, AbstractBounds<PartitionPosition> keyRange)
{
return index.search(queryContext, expression, keyRange);
}
public Iterator<Pair<ByteComparable, PrimaryKeys>> iterator()
{
return index.iterator();
}
public SegmentMetadata.ComponentMetadataMap writeDirect(IndexDescriptor indexDescriptor,
IndexContext indexContext,
Function<PrimaryKey, Integer> postingTransformer) throws IOException
{
return index.writeDirect(indexDescriptor, indexContext, postingTransformer);
}
@Override
public KeyRangeIterator limitToTopResults(List<PrimaryKey> primaryKeys, Expression expression, int limit)
{
return index.limitToTopResults(primaryKeys, expression, limit);
}
}

View File

@ -21,6 +21,7 @@ package org.apache.cassandra.index.sai.memory;
import java.nio.ByteBuffer;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
@ -36,9 +37,11 @@ import org.apache.cassandra.db.memtable.Memtable;
import org.apache.cassandra.db.rows.Row;
import org.apache.cassandra.dht.AbstractBounds;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.iterators.KeyRangeUnionIterator;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.utils.Clock;
import org.apache.cassandra.utils.FBUtilities;
@ -88,6 +91,22 @@ public class MemtableIndexManager
return bytes;
}
public long update(DecoratedKey key, Row oldRow, Row newRow, Memtable memtable)
{
if (!indexContext.isVector())
{
return index(key, newRow, memtable);
}
MemtableIndex target = liveMemtableIndexMap.get(memtable);
if (target == null)
return 0;
ByteBuffer oldValue = indexContext.getValueOf(key, oldRow, FBUtilities.nowInSeconds());
ByteBuffer newValue = indexContext.getValueOf(key, newRow, FBUtilities.nowInSeconds());
return target.update(key, oldRow.clustering(), oldValue, newValue);
}
public void renewMemtable(Memtable renewed)
{
for (Memtable memtable : liveMemtableIndexMap.keySet())
@ -115,7 +134,7 @@ public class MemtableIndexManager
.orElse(null);
}
public KeyRangeIterator searchMemtableIndexes(Expression e, AbstractBounds<PartitionPosition> keyRange)
public KeyRangeIterator searchMemtableIndexes(QueryContext queryContext, Expression e, AbstractBounds<PartitionPosition> keyRange)
{
Collection<MemtableIndex> memtableIndexes = liveMemtableIndexMap.values();
@ -128,7 +147,26 @@ public class MemtableIndexManager
for (MemtableIndex memtableIndex : memtableIndexes)
{
builder.add(memtableIndex.search(e, keyRange));
builder.add(memtableIndex.search(queryContext, e, keyRange));
}
return builder.build();
}
public KeyRangeIterator limitToTopResults(QueryContext context, List<PrimaryKey> source, Expression e)
{
Collection<MemtableIndex> memtables = liveMemtableIndexMap.values();
if (memtables.isEmpty())
{
return KeyRangeIterator.empty();
}
KeyRangeUnionIterator.Builder builder = KeyRangeUnionIterator.builder(memtables.size());
for (MemtableIndex index : memtables)
{
builder.add(index.limitToTopResults(source, e, context.vectorContext().limit()));
}
return builder.build();

View File

@ -0,0 +1,42 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.memory;
import java.util.List;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
/**
* Analogue of {@link org.apache.cassandra.index.sai.disk.v1.segment.SegmentOrdering}, but for memtables.
*/
public interface MemtableOrdering
{
/**
* Filter the given list of {@code PrimaryKey} results to the top `limit` results corresponding to the given expression,
* Returns an iterator over the results that is put back in token order.
* <p>
* Assumes that the given list spans the same rows as the implementing index's segment.
*/
default KeyRangeIterator limitToTopResults(List<PrimaryKey> primaryKeys, Expression expression, int limit)
{
throw new UnsupportedOperationException();
}
}

View File

@ -24,7 +24,9 @@ import java.util.Map;
import java.util.PriorityQueue;
import java.util.SortedSet;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.Function;
import org.apache.cassandra.index.sai.QueryContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -39,6 +41,8 @@ import org.apache.cassandra.db.tries.Trie;
import org.apache.cassandra.dht.AbstractBounds;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.analyzer.AbstractAnalyzer;
import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.index.sai.utils.PrimaryKeys;
@ -53,15 +57,12 @@ import org.apache.cassandra.utils.bytecomparable.ByteSourceInverse;
* This is an in-memory index using the {@link InMemoryTrie} to store a {@link ByteComparable}
* representation of the indexed values. Data is stored on-heap or off-heap and follows the
* settings of the {@link TrieMemtable} to determine where.
*
*
*/
public class TrieMemoryIndex
public class TrieMemoryIndex extends MemoryIndex
{
private static final Logger logger = LoggerFactory.getLogger(TrieMemoryIndex.class);
private static final int MAX_RECURSIVE_KEY_LENGTH = 128;
private final IndexContext indexContext;
private final InMemoryTrie<PrimaryKeys> data;
private final PrimaryKeysReducer primaryKeysReducer;
private final AbstractAnalyzer.AnalyzerFactory analyzerFactory;
@ -73,7 +74,7 @@ public class TrieMemoryIndex
public TrieMemoryIndex(IndexContext indexContext)
{
this.indexContext = indexContext;
super(indexContext);
this.data = new InMemoryTrie<>(TrieMemtable.BUFFER_TYPE);
this.primaryKeysReducer = new PrimaryKeysReducer();
// The use of the analyzer is within a synchronized block so can be considered thread-safe
@ -90,6 +91,7 @@ public class TrieMemoryIndex
* @param value indexed value
* @return amount of heap allocated by the new value
*/
@Override
public synchronized long add(DecoratedKey key, Clustering<?> clustering, ByteBuffer value)
{
AbstractAnalyzer analyzer = analyzerFactory.create();
@ -139,15 +141,21 @@ public class TrieMemoryIndex
}
}
@Override
public long update(DecoratedKey key, Clustering<?> clustering, ByteBuffer oldValue, ByteBuffer newValue)
{
throw new UnsupportedOperationException();
}
/**
* Search for an expression in the in-memory index within the {@link AbstractBounds} defined
* by keyRange. This can either be an exact match or a range match.
* <p>
* @param expression the {@link Expression} to search for
* @param keyRange the {@link AbstractBounds} containing the key range to restrict the search to
* @return a {@link KeyRangeIterator} containing the search results
*/
public KeyRangeIterator search(Expression expression, AbstractBounds<PartitionPosition> keyRange)
public KeyRangeIterator search(QueryContext queryContext, Expression expression, AbstractBounds<PartitionPosition> keyRange)
{
if (logger.isTraceEnabled())
logger.trace("Searching memtable index on expression '{}'...", expression);
@ -171,10 +179,11 @@ public class TrieMemoryIndex
*
* @return the iterator containing the trie data
*/
@Override
public Iterator<Pair<ByteComparable, PrimaryKeys>> iterator()
{
Iterator<Map.Entry<ByteComparable, PrimaryKeys>> iterator = data.entrySet().iterator();
return new Iterator<Pair<ByteComparable, PrimaryKeys>>()
return new Iterator<>()
{
@Override
public boolean hasNext()
@ -191,11 +200,27 @@ public class TrieMemoryIndex
};
}
@Override
public SegmentMetadata.ComponentMetadataMap writeDirect(IndexDescriptor indexDescriptor,
IndexContext indexContext,
Function<PrimaryKey, Integer> postingTransformer)
{
throw new UnsupportedOperationException();
}
@Override
public boolean isEmpty()
{
return minTerm == null;
}
@Override
public ByteBuffer getMinTerm()
{
return minTerm;
}
@Override
public ByteBuffer getMaxTerm()
{
return maxTerm;
@ -259,7 +284,7 @@ public class TrieMemoryIndex
// This allows for receiving a stream of wide range queries where the queue size
// is larger than we would want to default the size to.
// TODO Investigate using a decaying histogram here to avoid the effect of outliers.
private static final FastThreadLocal<Integer> lastQueueSize = new FastThreadLocal<Integer>()
private static final FastThreadLocal<Integer> lastQueueSize = new FastThreadLocal<>()
{
protected Integer initialValue()
{

View File

@ -0,0 +1,376 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.cassandra.index.sai.memory;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Iterator;
import java.util.List;
import java.util.NavigableSet;
import java.util.PriorityQueue;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import javax.annotation.Nullable;
import io.github.jbellis.jvector.util.Bits;
import org.apache.cassandra.db.Clustering;
import org.apache.cassandra.db.DecoratedKey;
import org.apache.cassandra.db.PartitionPosition;
import org.apache.cassandra.dht.AbstractBounds;
import org.apache.cassandra.index.sai.IndexContext;
import org.apache.cassandra.index.sai.QueryContext;
import org.apache.cassandra.index.sai.VectorQueryContext;
import org.apache.cassandra.index.sai.disk.format.IndexDescriptor;
import org.apache.cassandra.index.sai.disk.v1.segment.SegmentMetadata;
import org.apache.cassandra.index.sai.disk.v1.vector.OnHeapGraph;
import org.apache.cassandra.index.sai.iterators.KeyRangeIterator;
import org.apache.cassandra.index.sai.iterators.KeyRangeListIterator;
import org.apache.cassandra.index.sai.plan.Expression;
import org.apache.cassandra.index.sai.utils.PrimaryKey;
import org.apache.cassandra.index.sai.utils.PrimaryKeys;
import org.apache.cassandra.index.sai.utils.RangeUtil;
import org.apache.cassandra.index.sai.utils.TypeUtil;
import org.apache.cassandra.tracing.Tracing;
import org.apache.cassandra.utils.Pair;
import org.apache.cassandra.utils.bytecomparable.ByteComparable;
import static java.lang.Math.log;
import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.lang.Math.pow;
public class VectorMemoryIndex extends MemoryIndex
{
private final OnHeapGraph<PrimaryKey> graph;
private final LongAdder writeCount = new LongAdder();
private PrimaryKey minimumKey;
private PrimaryKey maximumKey;
private final NavigableSet<PrimaryKey> primaryKeys = new ConcurrentSkipListSet<>();
public VectorMemoryIndex(IndexContext indexContext)
{
super(indexContext);
this.graph = new OnHeapGraph<>(indexContext.getValidator(), indexContext.getIndexWriterConfig());
}
@Override
public synchronized long add(DecoratedKey key, Clustering<?> clustering, ByteBuffer value)
{
if (value == null || value.remaining() == 0)
return 0;
var primaryKey = indexContext.hasClustering() ? indexContext.keyFactory().create(key, clustering)
: indexContext.keyFactory().create(key);
return index(primaryKey, value);
}
private long index(PrimaryKey primaryKey, ByteBuffer value)
{
updateKeyBounds(primaryKey);
writeCount.increment();
primaryKeys.add(primaryKey);
return graph.add(value, primaryKey, OnHeapGraph.InvalidVectorBehavior.FAIL);
}
@Override
public long update(DecoratedKey key, Clustering<?> clustering, ByteBuffer oldValue, ByteBuffer newValue)
{
int oldRemaining = oldValue == null ? 0 : oldValue.remaining();
int newRemaining = newValue == null ? 0 : newValue.remaining();
if (oldRemaining == 0 && newRemaining == 0)
return 0;
boolean different;
if (oldRemaining != newRemaining)
{
assert oldRemaining == 0 || newRemaining == 0; // one of them is null
different = true;
}
else
{
different = IntStream.range(0, oldRemaining).anyMatch(i -> oldValue.get(i) != newValue.get(i));
}
long bytesUsed = 0;
if (different)
{
var primaryKey = indexContext.hasClustering() ? indexContext.keyFactory().create(key, clustering)
: indexContext.keyFactory().create(key);
// update bounds because only rows with vectors are included in the key bounds,
// so if the vector was null before, we won't have included it
updateKeyBounds(primaryKey);
// make the changes in this order, so we don't have a window where the row is not in the index at all
if (newRemaining > 0)
bytesUsed += graph.add(newValue, primaryKey, OnHeapGraph.InvalidVectorBehavior.FAIL);
if (oldRemaining > 0)
bytesUsed -= graph.remove(oldValue, primaryKey);
// remove primary key if it's no longer indexed
if (newRemaining <= 0 && oldRemaining > 0)
primaryKeys.remove(primaryKey);
}
return bytesUsed;
}
private void updateKeyBounds(PrimaryKey primaryKey)
{
if (minimumKey == null)
minimumKey = primaryKey;
else if (primaryKey.compareTo(minimumKey) < 0)
minimumKey = primaryKey;
if (maximumKey == null)
maximumKey = primaryKey;
else if (primaryKey.compareTo(maximumKey) > 0)
maximumKey = primaryKey;
}
@Override
public KeyRangeIterator search(QueryContext queryContext, Expression expr, AbstractBounds<PartitionPosition> keyRange)
{
assert expr.getOp() == Expression.IndexOperator.ANN : "Only ANN is supported for vector search, received " + expr.getOp();
VectorQueryContext vectorQueryContext = queryContext.vectorContext();
var buffer = expr.lower.value.raw;
float[] qv = TypeUtil.decomposeVector(indexContext, buffer);
Bits bits;
if (!RangeUtil.coversFullRing(keyRange))
{
// if left bound is MIN_BOUND or KEY_BOUND, we need to include all token-only PrimaryKeys with same token
boolean leftInclusive = keyRange.left.kind() != PartitionPosition.Kind.MAX_BOUND;
// if right bound is MAX_BOUND or KEY_BOUND, we need to include all token-only PrimaryKeys with same token
boolean rightInclusive = keyRange.right.kind() != PartitionPosition.Kind.MIN_BOUND;
// if right token is MAX (Long.MIN_VALUE), there is no upper bound
boolean isMaxToken = keyRange.right.getToken().isMinimum(); // max token
PrimaryKey left = indexContext.keyFactory().create(keyRange.left.getToken()); // lower bound
PrimaryKey right = isMaxToken ? null : indexContext.keyFactory().create(keyRange.right.getToken()); // upper bound
Set<PrimaryKey> resultKeys = isMaxToken ? primaryKeys.tailSet(left, leftInclusive) : primaryKeys.subSet(left, leftInclusive, right, rightInclusive);
if (!vectorQueryContext.getShadowedPrimaryKeys().isEmpty())
resultKeys = resultKeys.stream().filter(pk -> !vectorQueryContext.containsShadowedPrimaryKey(pk)).collect(Collectors.toSet());
if (resultKeys.isEmpty())
return KeyRangeIterator.empty();
int bruteForceRows = maxBruteForceRows(vectorQueryContext.limit(), resultKeys.size(), graph.size());
Tracing.trace("Search range covers {} rows; max brute force rows is {} for memtable index with {} nodes, LIMIT {}",
resultKeys.size(), bruteForceRows, graph.size(), vectorQueryContext.limit());
if (resultKeys.size() < Math.max(vectorQueryContext.limit(), bruteForceRows))
return new ReorderingRangeIterator(new PriorityQueue<>(resultKeys));
else
bits = new KeyRangeFilteringBits(keyRange, vectorQueryContext.bitsetForShadowedPrimaryKeys(graph));
}
else
{
// partition/range deletion won't trigger index update, so we have to filter shadow primary keys in memtable index
bits = queryContext.vectorContext().bitsetForShadowedPrimaryKeys(graph);
}
var keyQueue = graph.search(qv, queryContext.vectorContext().limit(), bits);
if (keyQueue.isEmpty())
return KeyRangeIterator.empty();
return new ReorderingRangeIterator(keyQueue);
}
@Override
public KeyRangeIterator limitToTopResults(List<PrimaryKey> primaryKeys, Expression expression, int limit)
{
if (minimumKey == null)
// This case implies maximumKey is empty too.
return KeyRangeIterator.empty();
List<PrimaryKey> results = primaryKeys.stream()
.dropWhile(k -> k.compareTo(minimumKey) < 0)
.takeWhile(k -> k.compareTo(maximumKey) <= 0)
.collect(Collectors.toList());
int maxBruteForceRows = maxBruteForceRows(limit, results.size(), graph.size());
Tracing.trace("SAI materialized {} rows; max brute force rows is {} for memtable index with {} nodes, LIMIT {}",
results.size(), maxBruteForceRows, graph.size(), limit);
if (results.size() <= maxBruteForceRows)
{
if (results.isEmpty())
return KeyRangeIterator.empty();
return new KeyRangeListIterator(minimumKey, maximumKey, results);
}
ByteBuffer buffer = expression.lower.value.raw;
float[] qv = TypeUtil.decomposeVector(indexContext, buffer);
var bits = new KeyFilteringBits(results);
var keyQueue = graph.search(qv, limit, bits);
if (keyQueue.isEmpty())
return KeyRangeIterator.empty();
return new ReorderingRangeIterator(keyQueue);
}
private int maxBruteForceRows(int limit, int nPermittedOrdinals, int graphSize)
{
int expectedNodesVisited = expectedNodesVisited(limit, nPermittedOrdinals, graphSize);
int expectedComparisons = indexContext.getIndexWriterConfig().getMaximumNodeConnections() * expectedNodesVisited;
// in-memory comparisons are cheaper than pulling a row off disk and then comparing
// VSTODO this is dramatically oversimplified
// larger dimension should increase this, because comparisons are more expensive
// lower chunk cache hit ratio should decrease this, because loading rows is more expensive
double memoryToDiskFactor = 0.25;
return (int) max(limit, memoryToDiskFactor * expectedComparisons);
}
/**
* All parameters must be greater than zero. nPermittedOrdinals may be larger than graphSize.
*/
public static int expectedNodesVisited(int limit, int nPermittedOrdinals, int graphSize)
{
// constants are computed by Code Interpreter based on observed comparison counts in tests
// https://chat.openai.com/share/2b1d7195-b4cf-4a45-8dce-1b9b2f893c75
var sizeRestriction = min(nPermittedOrdinals, graphSize);
var raw = (int) (0.7 * pow(log(graphSize), 2) *
pow(graphSize, 0.33) *
pow(log(limit), 2) *
pow(log((double) graphSize / sizeRestriction), 2) / pow(sizeRestriction, 0.13));
// we will always visit at least min(limit, graphSize) nodes, and we can't visit more nodes than exist in the graph
return min(max(raw, min(limit, graphSize)), graphSize);
}
@Override
public Iterator<Pair<ByteComparable, PrimaryKeys>> iterator()
{
// This method is only used when merging an in-memory index with a RowMapping. This is done a different
// way with the graph using the writeData method below.
throw new UnsupportedOperationException();
}
public SegmentMetadata.ComponentMetadataMap writeDirect(IndexDescriptor indexDescriptor,
IndexContext indexContext,
Function<PrimaryKey, Integer> postingTransformer) throws IOException
{
return graph.writeData(indexDescriptor, indexContext, postingTransformer);
}
@Override
public boolean isEmpty()
{
return graph.isEmpty();
}
@Nullable
@Override
public ByteBuffer getMinTerm()
{
return null;
}
@Nullable
@Override
public ByteBuffer getMaxTerm()
{
return null;
}
private class KeyRangeFilteringBits implements Bits
{
private final AbstractBounds<PartitionPosition> keyRange;
@Nullable
private final Bits bits;
public KeyRangeFilteringBits(AbstractBounds<PartitionPosition> keyRange, @Nullable Bits bits)
{
this.keyRange = keyRange;
this.bits = bits;
}
@Override
public boolean get(int ordinal)
{
if (bits != null && !bits.get(ordinal))
return false;
var keys = graph.keysFromOrdinal(ordinal);
return keys.stream().anyMatch(k -> keyRange.contains(k.partitionKey()));
}
@Override
public int length()
{
return graph.size();
}
}
private class ReorderingRangeIterator extends KeyRangeIterator
{
private final PriorityQueue<PrimaryKey> keyQueue;
ReorderingRangeIterator(PriorityQueue<PrimaryKey> keyQueue)
{
super(minimumKey, maximumKey, keyQueue.size());
this.keyQueue = keyQueue;
}
@Override
// VSTODO maybe we can abuse "current" to avoid having to pop and re-add the last skipped key
protected void performSkipTo(PrimaryKey nextKey)
{
while (!keyQueue.isEmpty() && keyQueue.peek().compareTo(nextKey) < 0)
keyQueue.poll();
}
@Override
public void close() {}
@Override
protected PrimaryKey computeNext()
{
if (keyQueue.isEmpty())
return endOfData();
return keyQueue.poll();
}
}
private class KeyFilteringBits implements Bits
{
private final List<PrimaryKey> results;
public KeyFilteringBits(List<PrimaryKey> results)
{
this.results = results;
}
@Override
public boolean get(int i)
{
var pk = graph.keysFromOrdinal(i);
return results.stream().anyMatch(pk::contains);
}
@Override
public int length()
{
return results.size();
}
}
}

View File

@ -21,7 +21,6 @@ package org.apache.cassandra.index.sai.plan;
import java.nio.ByteBuffer;
import java.util.Objects;
import com.google.common.annotations.VisibleForTesting;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -38,7 +37,7 @@ public class Expression
public enum IndexOperator
{
EQ, RANGE, CONTAINS_KEY, CONTAINS_VALUE;
EQ, RANGE, CONTAINS_KEY, CONTAINS_VALUE, ANN;
public static IndexOperator valueOf(Operator operator)
{
@ -59,6 +58,9 @@ public class Expression
case GTE:
return RANGE;
case ANN:
return ANN;
default:
return null;
}
@ -80,7 +82,6 @@ public class Expression
public final IndexContext context;
public final AbstractType<?> validator;
@VisibleForTesting
protected IndexOperator operator;
public Bound lower, upper;
@ -160,6 +161,11 @@ public class Expression
else
lower = new Bound(value, validator, lowerInclusive);
break;
case ANN:
operator = IndexOperator.ANN;
lower = new Bound(value, validator, true);
upper = lower;
break;
}
assert operator != null;
@ -172,6 +178,11 @@ public class Expression
*/
public boolean isSatisfiedBy(ByteBuffer columnValue)
{
// If the expression represents an ANN ordering then we return true because the actual result
// is approximate and will rarely / never match the expression value
if (validator.isVector())
return true;
if (!TypeUtil.isValid(columnValue, validator))
{
logger.error(context.logMessage("Value is not valid for indexed column {} with {}"), context.getColumnName(), validator);

Some files were not shown because too many files have changed in this diff Show More