shreyask Claude Opus 4.6 commited on
Commit
74744ae
·
verified ·
1 Parent(s): eb46abf

add query expansion module with lex/vec/hyde parsing

Browse files

Implements 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 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