add query expansion module with lex/vec/hyde parsing
Browse filesImplements expandQuery() which runs the expansion model and
parseExpansionOutput() which extracts structured lex, vec, and hyde
variants from the model's text output, with fallback to raw text.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- src/pipeline/expansion.test.ts +94 -0
- src/pipeline/expansion.ts +68 -0
src/pipeline/expansion.test.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from "vitest";
|
| 2 |
+
import { parseExpansionOutput } from "./expansion";
|
| 3 |
+
|
| 4 |
+
// ---------------------------------------------------------------------------
|
| 5 |
+
// parseExpansionOutput
|
| 6 |
+
// ---------------------------------------------------------------------------
|
| 7 |
+
describe("parseExpansionOutput", () => {
|
| 8 |
+
it("parses all three variants from well-formed output", () => {
|
| 9 |
+
const text = [
|
| 10 |
+
"lex: sqlite, fts5, full-text search",
|
| 11 |
+
"vec: How to perform full-text search using SQLite FTS5",
|
| 12 |
+
"hyde: This document explains the SQLite FTS5 extension for full-text search indexing.",
|
| 13 |
+
].join("\n");
|
| 14 |
+
|
| 15 |
+
const result = parseExpansionOutput(text);
|
| 16 |
+
expect(result.lex).toBe("sqlite, fts5, full-text search");
|
| 17 |
+
expect(result.vec).toEqual([
|
| 18 |
+
"How to perform full-text search using SQLite FTS5",
|
| 19 |
+
]);
|
| 20 |
+
expect(result.hyde).toBe(
|
| 21 |
+
"This document explains the SQLite FTS5 extension for full-text search indexing.",
|
| 22 |
+
);
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
it("handles multiple vec lines", () => {
|
| 26 |
+
const text = [
|
| 27 |
+
"lex: embeddings, vector search",
|
| 28 |
+
"vec: Generating vector embeddings for semantic search",
|
| 29 |
+
"vec: Using cosine similarity to find related documents",
|
| 30 |
+
"hyde: This guide covers the creation of vector embeddings.",
|
| 31 |
+
].join("\n");
|
| 32 |
+
|
| 33 |
+
const result = parseExpansionOutput(text);
|
| 34 |
+
expect(result.vec).toEqual([
|
| 35 |
+
"Generating vector embeddings for semantic search",
|
| 36 |
+
"Using cosine similarity to find related documents",
|
| 37 |
+
]);
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
it("is case-insensitive for prefixes", () => {
|
| 41 |
+
const text = [
|
| 42 |
+
"LEX: uppercase keywords",
|
| 43 |
+
"Vec: Mixed case sentence",
|
| 44 |
+
"HYDE: All caps hypothetical document",
|
| 45 |
+
].join("\n");
|
| 46 |
+
|
| 47 |
+
const result = parseExpansionOutput(text);
|
| 48 |
+
expect(result.lex).toBe("uppercase keywords");
|
| 49 |
+
expect(result.vec).toEqual(["Mixed case sentence"]);
|
| 50 |
+
expect(result.hyde).toBe("All caps hypothetical document");
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it("falls back to raw text when no prefixes are found", () => {
|
| 54 |
+
const raw = "some unstructured model output with no prefixes";
|
| 55 |
+
const result = parseExpansionOutput(raw);
|
| 56 |
+
expect(result.lex).toBe(raw);
|
| 57 |
+
expect(result.vec).toEqual([raw]);
|
| 58 |
+
expect(result.hyde).toBe(raw);
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
it("uses raw text for missing variants", () => {
|
| 62 |
+
const text = "lex: only keywords here";
|
| 63 |
+
const result = parseExpansionOutput(text);
|
| 64 |
+
expect(result.lex).toBe("only keywords here");
|
| 65 |
+
// vec and hyde fall back to original text
|
| 66 |
+
expect(result.vec).toEqual([text]);
|
| 67 |
+
expect(result.hyde).toBe(text);
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
it("handles extra whitespace and blank lines", () => {
|
| 71 |
+
const text = [
|
| 72 |
+
"",
|
| 73 |
+
" lex: spaced keywords ",
|
| 74 |
+
"",
|
| 75 |
+
" vec: spaced sentence ",
|
| 76 |
+
" hyde: spaced hypothetical ",
|
| 77 |
+
"",
|
| 78 |
+
].join("\n");
|
| 79 |
+
|
| 80 |
+
const result = parseExpansionOutput(text);
|
| 81 |
+
expect(result.lex).toBe("spaced keywords");
|
| 82 |
+
expect(result.vec).toEqual(["spaced sentence"]);
|
| 83 |
+
expect(result.hyde).toBe("spaced hypothetical");
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
it("handles only vec present", () => {
|
| 87 |
+
const text = "vec: just a vector sentence";
|
| 88 |
+
const result = parseExpansionOutput(text);
|
| 89 |
+
expect(result.vec).toEqual(["just a vector sentence"]);
|
| 90 |
+
// lex and hyde fall back to original text
|
| 91 |
+
expect(result.lex).toBe(text);
|
| 92 |
+
expect(result.hyde).toBe(text);
|
| 93 |
+
});
|
| 94 |
+
});
|
src/pipeline/expansion.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getExpansionPipeline } from "./models";
|
| 2 |
+
import type { ExpandedQuery } from "../types";
|
| 3 |
+
|
| 4 |
+
// The expansion model prompt format (from QMD)
|
| 5 |
+
const EXPANSION_PROMPT = (query: string) =>
|
| 6 |
+
`/no_think Expand this search query: ${query}`;
|
| 7 |
+
|
| 8 |
+
// Parse the model's output text into structured ExpandedQuery
|
| 9 |
+
function parseExpansionOutput(text: string): ExpandedQuery {
|
| 10 |
+
// The model outputs lines like:
|
| 11 |
+
// lex: keyword1, keyword2, keyword3
|
| 12 |
+
// vec: A semantically rich sentence about the topic
|
| 13 |
+
// hyde: This document discusses the implementation of...
|
| 14 |
+
//
|
| 15 |
+
// Parse each line by prefix. Handle multiple vec lines.
|
| 16 |
+
// Fallback: if parsing fails, use the raw text as all three variants.
|
| 17 |
+
|
| 18 |
+
const lines = text.trim().split("\n");
|
| 19 |
+
let lex = "";
|
| 20 |
+
const vec: string[] = [];
|
| 21 |
+
let hyde = "";
|
| 22 |
+
|
| 23 |
+
for (const line of lines) {
|
| 24 |
+
const trimmed = line.trim();
|
| 25 |
+
if (trimmed.toLowerCase().startsWith("lex:")) {
|
| 26 |
+
lex = trimmed.slice(4).trim();
|
| 27 |
+
} else if (trimmed.toLowerCase().startsWith("vec:")) {
|
| 28 |
+
vec.push(trimmed.slice(4).trim());
|
| 29 |
+
} else if (trimmed.toLowerCase().startsWith("hyde:")) {
|
| 30 |
+
hyde = trimmed.slice(5).trim();
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Fallback: if we couldn't parse any variant, use raw text
|
| 35 |
+
if (!lex && vec.length === 0 && !hyde) {
|
| 36 |
+
return { lex: text, vec: [text], hyde: text };
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
return {
|
| 40 |
+
lex: lex || text,
|
| 41 |
+
vec: vec.length > 0 ? vec : [text],
|
| 42 |
+
hyde: hyde || text,
|
| 43 |
+
};
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Run query expansion using the loaded model
|
| 47 |
+
export async function expandQuery(query: string): Promise<ExpandedQuery> {
|
| 48 |
+
const pipe = getExpansionPipeline();
|
| 49 |
+
if (!pipe) throw new Error("Expansion model not loaded");
|
| 50 |
+
|
| 51 |
+
const prompt = EXPANSION_PROMPT(query);
|
| 52 |
+
const output = await pipe(prompt, {
|
| 53 |
+
max_new_tokens: 256,
|
| 54 |
+
do_sample: false,
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
// pipeline("text-generation") returns array of { generated_text: string }
|
| 58 |
+
const generated = (output as Array<{ generated_text: string }>)[0]
|
| 59 |
+
.generated_text;
|
| 60 |
+
|
| 61 |
+
// The generated text includes the prompt - strip it
|
| 62 |
+
const responseText =
|
| 63 |
+
typeof generated === "string" ? generated.slice(prompt.length) : generated;
|
| 64 |
+
|
| 65 |
+
return parseExpansionOutput(String(responseText));
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export { parseExpansionOutput }; // export for testing
|