-
Notifications
You must be signed in to change notification settings - Fork 189
[ISSUE-830] Fix inverted embedding value filter that dropped digit-free text #832
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,17 +31,17 @@ public class SearchUtils { | |
| '*', '#', '-', '?', '`', '{', '}', '[', ']', '(', ')', '>', '<', ':', '/', '.' | ||
| )); | ||
|
|
||
| // Set of allowed characters for validation in isAllAllowedChars | ||
| // Includes: digits (0-9), and some common safe symbols | ||
| private static final Set<Character> IGNORE_CHARS = buildIgnoredChars(); | ||
| // Characters that carry no meaning on their own: digits and a few common symbols. | ||
| // A value made up entirely of these is not worth indexing or embedding. | ||
| private static final Set<Character> IGNORABLE_CHARS = buildIgnorableChars(); | ||
|
|
||
| /** | ||
| * Builds the set of allowed characters for input validation. | ||
| * Includes alphanumeric characters and selected common symbols. | ||
| * Builds the set of characters that carry no meaning on their own. | ||
| * Digits and a few common symbols. | ||
| * | ||
| * @return an unmodifiable set of ignored characters | ||
| * @return an unmodifiable set of ignorable characters | ||
| */ | ||
| private static Set<Character> buildIgnoredChars() { | ||
| private static Set<Character> buildIgnorableChars() { | ||
| Set<Character> ignored = new HashSet<>(32); | ||
| // Add digits | ||
| for (char c = '0'; c <= '9'; c++) { | ||
|
|
@@ -87,18 +87,30 @@ public static String formatQuery(String query) { | |
| } | ||
|
|
||
| /** | ||
| * Checks whether all characters in the given string are within the allowed character set. | ||
| * Useful for validating usernames, identifiers, or safe input formats. | ||
| * Whether the given value consists entirely of characters that carry no meaning on their own, | ||
| * and therefore has nothing worth indexing or embedding. A bare id, a date or a run of | ||
| * punctuation is ignorable; anything containing a letter or a CJK character is not. | ||
| * | ||
| * @param str the string to validate | ||
| * @return true if all characters are allowed; false otherwise | ||
| * <p>Callers use this to skip values, so an empty or absent value is ignorable too: there is | ||
| * nothing in it to index. | ||
| * | ||
| * <p>This replaces {@code isAllAllowedChars}, whose loop returned on the first character | ||
| * <em>inside</em> the set rather than the first one outside it, making it the negation of both | ||
| * its own name and its own documentation. The practical effect was that ordinary prose was | ||
| * discarded while digit-only noise was kept, so an embedding store silently produced nothing | ||
| * for text that happened to contain no digit. The method is renamed rather than corrected in | ||
| * place, so that any caller depending on the previous meaning fails to compile instead of | ||
| * silently flipping behaviour. | ||
| * | ||
| * @param str the value to check | ||
| * @return true if every character is ignorable, or the value is null or empty | ||
| */ | ||
| public static boolean isAllAllowedChars(String str) { | ||
| public static boolean isAllIgnorableChars(String str) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. First of all, thank you for your contribution. However, upon reviewing the repository, I noticed that there are still references to this function in some places. I have the following suggestions:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks. I ran exactly the search you asked for, and there are no remaining call sites. One hit, and it is the Javadoc line in this file explaining what the method replaces. Widening the search to every file type gives two more, both generated build output rather than source: On the deprecated shim, I would rather not add it, for three reasons. The delegation is not behaviour preserving, which is the part that worries me most. It also does not compile here. There is no downstream contract to protect either. It arrived with #716 in January, after the rc, so no published artifact has ever exposed this method and nothing outside the repo can be calling it. A deprecation window over two or three release cycles would keep a method whose semantics are the defect this PR fixes, and it would protect no one. If you would still like a transition period, the only shim I would be comfortable adding is one that preserves the original contract rather than approximating it,
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, thank you very much for checking again. I've looked it over carefully, and indeed most of the references are documentation comments. You can delete the old names.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for taking another look. Leaving the rename as it stands then, with the old name gone. |
||
| if (str == null || str.isEmpty()) { | ||
| return false; // Consider empty/null invalid; adjust based on use case | ||
| return true; | ||
| } | ||
| for (char c : str.toCharArray()) { | ||
| if (IGNORE_CHARS.contains(c)) { | ||
| if (!IGNORABLE_CHARS.contains(c)) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| /* | ||
| * 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.geaflow.ai.index; | ||
|
|
||
| import com.google.gson.Gson; | ||
| import com.sun.net.httpserver.HttpExchange; | ||
| import com.sun.net.httpserver.HttpServer; | ||
| import java.io.IOException; | ||
| import java.io.OutputStream; | ||
| import java.net.InetSocketAddress; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.util.ArrayList; | ||
| import java.util.Arrays; | ||
| import java.util.Collections; | ||
| import java.util.HashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.concurrent.CopyOnWriteArrayList; | ||
| import org.apache.geaflow.ai.common.model.EmbeddingResponse; | ||
| import org.apache.geaflow.ai.common.model.ModelConfig; | ||
| import org.apache.geaflow.ai.graph.GraphEntity; | ||
| import org.apache.geaflow.ai.graph.LocalMemoryGraphAccessor; | ||
| import org.apache.geaflow.ai.graph.io.Edge; | ||
| import org.apache.geaflow.ai.graph.io.EdgeGroup; | ||
| import org.apache.geaflow.ai.graph.io.EdgeSchema; | ||
| import org.apache.geaflow.ai.graph.io.EntityGroup; | ||
| import org.apache.geaflow.ai.graph.io.GraphSchema; | ||
| import org.apache.geaflow.ai.graph.io.MemoryGraph; | ||
| import org.apache.geaflow.ai.graph.io.Vertex; | ||
| import org.apache.geaflow.ai.graph.io.VertexGroup; | ||
| import org.apache.geaflow.ai.graph.io.VertexSchema; | ||
| import org.apache.geaflow.ai.verbalization.SubgraphSemanticPromptFunction; | ||
| import org.junit.jupiter.api.AfterEach; | ||
| import org.junit.jupiter.api.Assertions; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.io.TempDir; | ||
|
|
||
| /** | ||
| * The index over successive runs, against a local embeddings endpoint so the whole path is | ||
| * exercised without a model service: request and response serialisation, the on disk index file, | ||
| * and what a later run decides to embed. | ||
| * | ||
| * <p>Chiefly this pins down what happens to an entity that yields no embeddable text. Such an | ||
| * entity is registered with an empty vector list, which is also what makes it skippable within a | ||
| * run, since {@code scanEdge} returns an edge from both of its endpoints. Nothing is written to the | ||
| * index file for it, and {@code initStore} rebuilds its map from that file alone, so a later run | ||
| * treats it as unseen and embeds it once its value carries meaning. That last point is the one worth | ||
| * a test: the alternative, never revisiting it, would make the omission permanent. | ||
| */ | ||
| public class EmbeddingIndexLifecycleTest { | ||
|
|
||
| private static final String LABEL = "chunk"; | ||
| private static final String EDGE_LABEL = "rel"; | ||
| private static final String MEANINGFUL = "learning without thought is labour lost"; | ||
| private static final String IGNORABLE = "2024-01-01"; | ||
| private static final String EDGE_TEXT = "the master teaches the disciple"; | ||
| private static final int DIMS = 4; | ||
|
|
||
| private HttpServer server; | ||
| private final List<String> requestBodies = new CopyOnWriteArrayList<>(); | ||
|
|
||
| @BeforeEach | ||
| void startEndpoint() throws IOException { | ||
| server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); | ||
| server.createContext("/v1/embeddings", this::handle); | ||
| server.setExecutor(null); | ||
| server.start(); | ||
| } | ||
|
|
||
| @AfterEach | ||
| void stopEndpoint() { | ||
| if (server != null) { | ||
| server.stop(0); | ||
| } | ||
| } | ||
|
|
||
| private void handle(HttpExchange exchange) throws IOException { | ||
| byte[] requestBytes = readAll(exchange); | ||
| String body = new String(requestBytes, StandardCharsets.UTF_8); | ||
| requestBodies.add(body); | ||
|
|
||
| String[] inputs = new Gson().fromJson(body, Request.class).input; | ||
| EmbeddingResponse response = new EmbeddingResponse(); | ||
| response.object = "list"; | ||
| response.model = "test-local"; | ||
| response.data = new ArrayList<>(); | ||
| for (int i = 0; i < inputs.length; i++) { | ||
| EmbeddingResponse.EmbeddingVector vector = new EmbeddingResponse.EmbeddingVector(); | ||
| vector.index = i; | ||
| vector.embedding = deterministicVector(inputs[i]); | ||
| response.data.add(vector); | ||
| } | ||
|
|
||
| byte[] out = new Gson().toJson(response).getBytes(StandardCharsets.UTF_8); | ||
| exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8"); | ||
| exchange.sendResponseHeaders(200, out.length); | ||
| try (OutputStream os = exchange.getResponseBody()) { | ||
| os.write(out); | ||
| } | ||
| } | ||
|
|
||
| private static byte[] readAll(HttpExchange exchange) throws IOException { | ||
| java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); | ||
| byte[] chunk = new byte[4096]; | ||
| int read; | ||
| while ((read = exchange.getRequestBody().read(chunk)) > 0) { | ||
| buffer.write(chunk, 0, read); | ||
| } | ||
| return buffer.toByteArray(); | ||
| } | ||
|
|
||
| /** Stable and non-zero, so a stored vector can be told apart from an absent one. */ | ||
| private static double[] deterministicVector(String text) { | ||
| double[] vector = new double[DIMS]; | ||
| for (int i = 0; i < DIMS; i++) { | ||
| vector[i] = ((text.hashCode() >> i) & 0xFF) / 255.0 + 0.1; | ||
| } | ||
| return vector; | ||
| } | ||
|
|
||
| private ModelConfig config() { | ||
| return new ModelConfig("test-local", | ||
| "http://127.0.0.1:" + server.getAddress().getPort(), "/v1/embeddings", "test-token"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testIndexLifecycleAcrossRuns(@TempDir Path tempDir) throws Exception { | ||
| String indexPath = tempDir.resolve("index.jsonl").toString(); | ||
|
|
||
| // First run. One vertex and the edge carry meaning, the other vertex is only a date. | ||
| LocalMemoryGraphAccessor first = buildGraph(MEANINGFUL, IGNORABLE); | ||
| EmbeddingIndexStore store = new EmbeddingIndexStore(); | ||
| store.initStore(first, new SubgraphSemanticPromptFunction(first), indexPath, config()); | ||
|
|
||
| Assertions.assertEquals(1, requestBodies.size(), | ||
| "the two values that carry meaning belong to one batch, so one request"); | ||
| Assertions.assertFalse(store.getEntityIndex(first.getVertex(LABEL, "v1")).isEmpty(), | ||
| "a value carrying meaning must hold a vector"); | ||
| Assertions.assertTrue(store.getEntityIndex(first.getVertex(LABEL, "v2")).isEmpty(), | ||
| "a value that is only a date has nothing to embed"); | ||
| Assertions.assertEquals(2, indexLines(indexPath), | ||
| "only the vertex and the edge that carry meaning are persisted"); | ||
|
|
||
| // Second run over an unchanged graph. Everything with vectors comes back from the file, so | ||
| // the model is not consulted again. | ||
| requestBodies.clear(); | ||
| LocalMemoryGraphAccessor second = buildGraph(MEANINGFUL, IGNORABLE); | ||
| EmbeddingIndexStore reloaded = new EmbeddingIndexStore(); | ||
| reloaded.initStore(second, new SubgraphSemanticPromptFunction(second), indexPath, config()); | ||
|
|
||
| Assertions.assertEquals(0, requestBodies.size(), | ||
| "an unchanged graph must not be re-embedded"); | ||
| Assertions.assertFalse(reloaded.getEntityIndex(second.getVertex(LABEL, "v1")).isEmpty(), | ||
| "vectors must survive a rebuild from the index file"); | ||
| Assertions.assertEquals(2, indexLines(indexPath), "no duplicate records appended"); | ||
|
|
||
| // Third run, with the date replaced by text that carries meaning. Nothing was persisted for | ||
| // that entity, so this run must embed it, and must leave the rest alone. | ||
| requestBodies.clear(); | ||
| String nowMeaningful = "the master replied in the temple"; | ||
| LocalMemoryGraphAccessor third = buildGraph(MEANINGFUL, nowMeaningful); | ||
| EmbeddingIndexStore afterChange = new EmbeddingIndexStore(); | ||
| afterChange.initStore(third, new SubgraphSemanticPromptFunction(third), indexPath, config()); | ||
|
|
||
| Assertions.assertEquals(1, requestBodies.size(), | ||
| "exactly the entity that gained meaning is embedded"); | ||
| Assertions.assertTrue(requestBodies.get(0).contains(nowMeaningful), | ||
| "the request must carry the newly meaningful text"); | ||
| Assertions.assertFalse(requestBodies.get(0).contains(MEANINGFUL), | ||
| "an entity already in the index file must not be embedded again"); | ||
| Assertions.assertFalse(afterChange.getEntityIndex(third.getVertex(LABEL, "v2")).isEmpty(), | ||
| "an entity that yielded nothing before must not be excluded for good"); | ||
| Assertions.assertEquals(3, indexLines(indexPath), "the new record is appended"); | ||
| } | ||
|
|
||
| private long indexLines(String indexPath) throws IOException { | ||
| return Files.readAllLines(java.nio.file.Paths.get(indexPath), StandardCharsets.UTF_8) | ||
| .stream().filter(line -> !line.trim().isEmpty()).count(); | ||
| } | ||
|
|
||
| private LocalMemoryGraphAccessor buildGraph(String v1Text, String v2Text) { | ||
| GraphSchema schema = new GraphSchema(); | ||
| schema.setName("lifecycle"); | ||
| VertexSchema vs = new VertexSchema(LABEL, "id", Collections.singletonList("text")); | ||
| EdgeSchema es = new EdgeSchema(EDGE_LABEL, "srcId", "dstId", | ||
| Collections.singletonList("rel")); | ||
| schema.addVertex(vs); | ||
| schema.addEdge(es); | ||
|
|
||
| List<Vertex> vertices = new ArrayList<>(Arrays.asList( | ||
| new Vertex(LABEL, "v1", Collections.singletonList(v1Text)), | ||
| new Vertex(LABEL, "v2", Collections.singletonList(v2Text)))); | ||
| List<Edge> edges = new ArrayList<>(Collections.singletonList( | ||
| new Edge(EDGE_LABEL, "v1", "v2", Collections.singletonList(EDGE_TEXT)))); | ||
| Map<String, EntityGroup> entities = new HashMap<>(); | ||
| entities.put(LABEL, new VertexGroup(vs, vertices)); | ||
| entities.put(EDGE_LABEL, new EdgeGroup(es, edges)); | ||
| return new LocalMemoryGraphAccessor(new MemoryGraph(schema, entities)); | ||
| } | ||
|
|
||
| /** Mirrors the request the client sends, enough of it to read the inputs back. */ | ||
| private static class Request { | ||
| private String[] input; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Insufficient Exception Handling in EmbeddingIndexStore
Risk Description: In the
indexBatchmethod, when an entity lacks embeddable text, a warning log is generated, but the entity is still registered inindexStoreMap(with an empty list of warnings). This can lead to the following issues:During a second initialization, the
containsKeycheck skips these entities (assuming they are already indexed).Queries return empty results instead of a clear signal indicating "not indexed."
Problem scenario: Assume the graph contains two entities:
1st run: Entity A has text (embedding successful); Entity B has no text (empty list registered).
2nd run: Entity B's value is updated to meaningful text.
containsKey(B)returnstrue→ skipped.Entity B is never re-embedded.
Recommendation:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good thing to check, and I went looking for it rather than reasoning about it. The skip you describe does not happen, and I have added a test that would fail if it did:
EmbeddingIndexLifecycleTest, inb007b1a4.The reason is in
initStorerather than inindexBatch. Every call begins withand then repopulates the map from the index file only. Nothing is ever written to that file for an entity with no embeddable text, because
indexBatchreturns records for embedded texts alone. So on the next run the empty entry from the previous run does not exist,containsKeyis false, and the entity is queued again. Your scenario ends the other way round: entity B does get re-embedded as soon as its value carries meaning.The test runs three
initStorecalls against a local/v1/embeddingsendpoint, so the request and response serialisation, the file and the decision about what to embed are all real, with no model service and no key. Run 1: a meaningful vertex and a meaningful edge are embedded and persisted, a date-only vertex is not, 2 lines in the file. Run 2, unchanged graph: 0 requests, vectors restored from the file, still 2 lines. Run 3, the date replaced bythe master replied in the temple: exactly 1 request, its body contains the new text and does not contain the already-indexed text, the file grows to 3 lines, andgetEntityIndexfor that entity is no longer empty. The log from run 2 shows the mechanism directly,1 of 1 entities have no embeddable text, the 1 being that entity queued again rather than skipped.On not registering the empty list at all: I looked at that and it would cost something.
MemoryGraph.scanEdge(vertex)returns both out-edges and in-edges, soinitStorereaches the same edge from each of its endpoints. For an edge with no embeddable text, the empty entry inindexStoreMapis what makes the second visit a no-op. Dropping it without a replacement means verbalizing that edge twice per run, and thecheckedButEmptyset restores the skip but also adds a second piece of state that has to be kept in step with the map and reset on everyinitStore, for a saving of one localverbalizecall. Given that the cross-run recovery above depends on those entities not being remembered, I would rather keep one map whose meaning is "these are the vectors I have" than two structures that have to agree.Your second suggestion I have taken, in a smaller form.
getEntityIndexreturned an empty list for two different situations, an entity that was checked and held nothing, and an entity nobody has looked at. It still returns the same value, since a caller wants vectors either way and has nothing useful to do with the distinction, but the first case now logs at debug level instead of being silent.One nearby gap that is real and that I have deliberately not touched: an entity that is already in the index file and whose value then changes is not re-embedded, because the index key is
ModelUtils.getGraphEntityKey, which is id and label with no content in it, so the stale vector keeps matching. That is independent of this fix, it predates it, and fixing it means a content hash in the record and an invalidation path. Happy to open a separate issue for it if you agree it is worth one.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is indeed a vulnerability nearby that I deliberately avoided: entities already existing in the index file using
ModelUtils.getGraphEntityKeywill not be re-embedded after their values change because the index key is an emptyidandtag, so outdated vectors still match. This is unrelated to this fix; it predates it, and fixing it would require adding a content hash and an invalidation path to the record. If you think it's worth creating a separate issue for this, I'd be happy to do so.Regarding this, which could potentially cause fact-checking issues when old and new facts are switched, you're welcome to open a new issue so we can discuss this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Opened as #844. Before writing it up I reproduced it, and it is worse than a stale vector: the values get swapped in both directions.
One vertex, value changed between two runs against the same index file, on
master(3f73eb55), with a local endpoint that records what it is asked to embed:the master said learning without thought is labour lostthe stable burned down and nobody asked about the horseThe vector the entity holds after run 2 belongs to the value that is no longer there,
cosine(stored, old value) = 1.0000againstcosine(stored, current value) = 0.2357. Recall then goes the wrong way on both queries: a query for the old value, which is not in the graph any more, returns the entity, while a query for the current value does not. And because the subgraph is verbalised from the current graph, the first case hands the model a context containing the new value while having matched on the old one. That is your fact-switching concern, and I think it is the part that matters most: the retrieval reason and the returned content disagree, so nothing downstream can tell that it happened.Small correction to the paraphrase, in case it matters for the issue: the key is not empty, it is
PREFIX_V + id + label, for exampleVv1chunk. The problem is not that it is empty but that nothing in it derives from the embedded text, so a value change leaves the key identical andinitStorereads that as already indexed.One more thing the same run turned up, which I put in #844 as a secondary observation: a record whose entity has been deleted from the graph is never pruned. It is dropped during the rebuild, since
key2EntityMaphas no such key, but the line stays in the file, so the file only grows, and a later entity reusing that id would silently adopt the old vector.Directions are sketched in the issue rather than settled: a hash of the embedded text in each record, compared during the rebuild, with a mismatch counting as not indexed. That changes the file format, so it needs a decision on how to treat existing index files, and pruning wants a rewrite rather than an append, which is probably separate compaction work. Happy to pick it up once you have a view on the record format. Keeping it out of #832, which only changed which values count as embeddable.