Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 69 additions & 3 deletions Sources/DigUpKit/IndexStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,25 @@ public final class IndexStore {
file = directory.appendingPathComponent(name)
db = try Database(path: file.path)
try migrate()
try ensureGraphTables()
}

/// Ensures the GraphRAG `edges` table exists without bumping `PRAGMA user_version`, so prebuilt `v0.7.0` helpers
/// and newer GraphRAG-enabled helpers can share `index.sqlite` and `code.sqlite` seamlessly.
public func ensureGraphTables() throws {
try db.execute("""
CREATE TABLE IF NOT EXISTS edges(
id INTEGER PRIMARY KEY,
source_file INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
source_segment INTEGER NOT NULL REFERENCES segments(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
entity_key TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 1.0
);
CREATE INDEX IF NOT EXISTS edges_key ON edges(entity_key);
CREATE INDEX IF NOT EXISTS edges_file ON edges(source_file);
CREATE INDEX IF NOT EXISTS edges_segment ON edges(source_segment);
""")
}

/// Bytes on disk: the database and its write-ahead log.
Expand Down Expand Up @@ -434,19 +453,61 @@ public final class IndexStore {

// MARK: Segments

/// Adds a segment; `text` goes into keyword search (normalized, see `SearchText`), `vector` into meaning search.
/// Adds a segment; `text` goes into keyword search (normalized, see `SearchText`), `vector` into meaning search,
/// and deterministic entities/identifiers go into `edges` for lightweight SQLite GraphRAG multi-hop traversal.
public func addSegment(file: Int64, kind: SegmentKind, modality: Modality, loc: Double? = nil, locEnd: Double? = nil,
excerpt: String? = nil, text: String? = nil, vector: [Float]? = nil) throws {
try db.transaction { // the segment and its keyword text together
try db.run("INSERT INTO segments(file, kind, modality, loc, loc_end, excerpt, vec) VALUES(?, ?, ?, ?, ?, ?, ?)",
[.int(file), .text(kind.rawValue), .text(vector == nil ? Modality.none.rawValue : modality.rawValue),
.optional(loc), .optional(locEnd), .optional(excerpt),
vector.map { .blob(Vectors.half($0)) } ?? .null])
let segmentID = db.lastInsertID
if let text, !text.isEmpty {
try db.run("INSERT INTO fts(rowid, name, body, original) VALUES(?, '', ?, ?)",
[.int(db.lastInsertID)] + Self.keywordText(text))
[.int(segmentID)] + Self.keywordText(text))
for entity in Self.extractGraphEntities(from: text) {
try db.run("INSERT INTO edges(source_file, source_segment, entity_type, entity_key, weight) VALUES(?, ?, ?, ?, ?)",
[.int(file), .int(segmentID), .text(entity.type), .text(entity.key), .double(entity.weight)])
}
}
}
}

/// Zero-LLM-overhead deterministic entity extractor for SQLite GraphRAG (`symbol`, `id_code`, `file_ref`).
public static func extractGraphEntities(from text: String) -> [(type: String, key: String, weight: Double)] {
var results: [(type: String, key: String, weight: Double)] = []
var seen = Set<String>()
let tokens = text.components(separatedBy: CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_-./")).inverted)
for raw in tokens where raw.count >= 4 && raw.count <= 80 {
let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: ".-_/"))
guard trimmed.count >= 4 else { continue }
let lower = trimmed.lowercased()
// 1. File references (e.g., auth.ts, settings.yaml, report.pdf)
if trimmed.contains("."), let ext = trimmed.split(separator: ".").last,
["py", "ts", "tsx", "js", "rs", "swift", "md", "pdf", "json", "yaml", "yml", "toml", "sql", "sh"].contains(ext.lowercased()) {
let key = (trimmed as NSString).lastPathComponent.lowercased()
if seen.insert("file_ref:\(key)").inserted {
results.append(("file_ref", key, 2.2))
}
}
// 2. CamelCase or snake_case symbols (e.g., refreshAccessToken, quantize_row_q8_0)
let hasUpper = trimmed.rangeOfCharacter(from: .uppercaseLetters) != nil
let hasLower = trimmed.rangeOfCharacter(from: .lowercaseLetters) != nil
let hasDigit = trimmed.rangeOfCharacter(from: .decimalDigits) != nil
if (hasUpper && hasLower && trimmed.count >= 6) || (trimmed.contains("_") && hasLower) {
if seen.insert("symbol:\(lower)").inserted {
results.append(("symbol", lower, 2.0))
}
} else if hasDigit && (hasUpper || hasLower) && (trimmed.contains("-") || trimmed.contains("_") || hasUpper) {
// 3. Exact ID codes (invoice numbers, ticket IDs, error codes, CVEs)
if seen.insert("id_code:\(lower)").inserted {
results.append(("id_code", lower, 2.5))
}
}
if results.count >= 32 { break }
}
return results
}

/// `body` and `original` for keyword search: the normalized text, and the text as written if that differs.
Expand All @@ -457,13 +518,18 @@ public final class IndexStore {

private func addNameRow(file: Int64, path: String) throws {
try db.run("INSERT INTO segments(file, kind, modality) VALUES(?, 'name', 'none')", [.int(file)])
let segID = db.lastInsertID
try db.run("INSERT INTO fts(rowid, name, body) VALUES(?, ?, ?)",
[.int(db.lastInsertID), .text(SearchText.normalized(Self.nameText(path))),
[.int(segID), .text(SearchText.normalized(Self.nameText(path))),
.text(SearchText.normalized(Self.folderText(path)))])
let fileName = Self.nameText(path).lowercased()
try db.run("INSERT INTO edges(source_file, source_segment, entity_type, entity_key, weight) VALUES(?, ?, 'file_ref', ?, 2.5)",
[.int(file), .int(segID), .text(fileName)])
}

private func deleteSegments(of file: Int64, keepingName: Bool) throws {
let filter = keepingName ? "AND kind != 'name'" : ""
try db.run("DELETE FROM edges WHERE source_segment IN (SELECT id FROM segments WHERE file = ? \(filter))", [.int(file)])
try db.run("DELETE FROM fts WHERE rowid IN (SELECT id FROM segments WHERE file = ? \(filter))", [.int(file)])
try db.run("DELETE FROM segments WHERE file = ? \(filter)", [.int(file)])
}
Expand Down
34 changes: 33 additions & 1 deletion Sources/DigUpKit/ModelFiles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,22 @@ public enum ModelFiles {
public let name: String
public let bytes: Int64
public let sha256: String
public let repository: String
public let revision: String

public init(name: String, bytes: Int64, sha256: String,
repository: String = ModelFiles.repository,
revision: String = ModelFiles.revision) {
self.name = name
self.bytes = bytes
self.sha256 = sha256
self.repository = repository
self.revision = revision
}

/// Hugging Face's address for this file at the pinned revision (it redirects to a CDN that takes HTTP ranges).
public var url: URL {
URL(string: "https://huggingface.co/\(ModelFiles.repository)/resolve/\(ModelFiles.revision)/\(name)")!
URL(string: "https://huggingface.co/\(repository)/resolve/\(revision)/\(name)")!
}
}

Expand All @@ -30,8 +42,28 @@ public enum ModelFiles {
/// The image and audio encoders (555 MB).
public static let media = File(name: "mmproj-embeddinggemma-2-Q8_0.gguf", bytes: 554_821_024,
sha256: "c4a8a52691ecef40618438928bdf9e68379b854e24166f292592353db0aab64f")

/// Primary local generative SLM for 8-core / 12GB RAM PC: Qwen2.5-3B-Instruct Q4_K_M (~1.93 GB).
public static let chatDefault = File(
name: "qwen2.5-3b-instruct-q4_k_m.gguf",
bytes: 2_104_932_128,
sha256: "6cf6efbb4dc67880f9b8fb2f00ca2bf0209fa0d9ed99ab3a241ff3814820b270",
repository: "Qwen/Qwen2.5-3B-Instruct-GGUF",
revision: "main"
)

/// Ultra-fast fallback SLM for CPU-constrained multitasking: Qwen2.5-1.5B-Instruct Q4_K_M (~1.12 GB).
public static let chatTurbo = File(
name: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
bytes: 1_117_320_576,
sha256: "6ae5c20f9b4d05f46166fa4ca6838d16e54c57eb26ae3b4ca4b208f7da3bc60b",
repository: "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
revision: "main"
)

/// In download order.
public static let all = [text, media]
public static let chatModels = [chatDefault, chatTurbo]
public static let totalBytes = all.reduce(0) { $0 + $1.bytes }

/// Where the app keeps them (`AppPaths`).
Expand Down
88 changes: 88 additions & 0 deletions Sources/DigUpKit/Searcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -435,4 +435,92 @@ public final class Searcher {
}?.value
})
}

// MARK: Lightweight SQLite GraphRAG Multi-Hop Expansion

public struct GraphContextPiece: Sendable {
public let path: String
public let kind: String
public let segmentID: Int64
public let loc: Double?
public let locEnd: Double?
public let hop: Int
public let linkedVia: String?
public let text: String
}

/// Expands initial hybrid search hits (`seeds`) across the SQLite `edges` graph (1 to 2 hops) to retrieve
/// connected code definitions, referenced files, or cross-document identifiers without LLM indexing overhead.
public func retrieveGraphContext(query: String, seeds: [SearchHit], hops: Int = 1,
maxSeeds: Int = 5, maxNeighbors: Int = 5) throws -> [GraphContextPiece] {
var pieces: [GraphContextPiece] = []
var visitedSegments = Set<Int64>()
var seedSegments: [Int64] = []

for hit in seeds.prefix(maxSeeds) {
visitedSegments.insert(hit.segmentID)
seedSegments.append(hit.segmentID)
let body = (try? text(ofSegment: hit.segmentID)) ?? hit.excerpt ?? ""
let clipped = String(body.prefix(1600))
pieces.append(GraphContextPiece(
path: hit.path, kind: hit.kind.rawValue, segmentID: hit.segmentID,
loc: hit.loc, locEnd: hit.locEnd, hop: 0, linkedVia: nil, text: clipped
))
}

guard hops >= 1, !seedSegments.isEmpty else { return pieces }
let idList = seedSegments.map(String.init).joined(separator: ",")
let queryEntities = IndexStore.extractGraphEntities(from: query).map(\.key)
var entityKeys: [String] = queryEntities

try store.db.query("""
SELECT DISTINCT entity_key FROM edges
WHERE source_segment IN (\(idList))
ORDER BY weight DESC LIMIT 16
""") { row in
if let key = row.text(0), !entityKeys.contains(key) {
entityKeys.append(key)
}
}

guard !entityKeys.isEmpty else { return pieces }
let placeholders = Array(repeating: "?", count: entityKeys.count).joined(separator: ",")
let args = entityKeys.map(SQLValue.text)

var neighborRows: [(segID: Int64, path: String, kind: String, loc: Double?, locEnd: Double?, via: String)] = []
try store.db.query("""
SELECT e.source_segment, f.path, f.kind, s.loc, s.loc_end, GROUP_CONCAT(DISTINCT e.entity_key) AS via,
SUM(e.weight) AS total_weight
FROM edges e
JOIN segments s ON s.id = e.source_segment
JOIN files f ON f.id = e.source_file
WHERE e.entity_key IN (\(placeholders))
AND e.source_segment NOT IN (\(idList))
GROUP BY e.source_segment
ORDER BY total_weight DESC
LIMIT \(maxNeighbors * 2)
""", args) { row in
let segID = row.int(0)
guard visitedSegments.insert(segID).inserted else { return }
neighborRows.append((
segID: segID,
path: row.text(1) ?? "",
kind: row.text(2) ?? "doc",
loc: row.isNull(3) ? nil : row.double(3),
locEnd: row.isNull(4) ? nil : row.double(4),
via: row.text(5) ?? ""
))
}

for neighbor in neighborRows.prefix(maxNeighbors) {
if let body = try? text(ofSegment: neighbor.segID), !body.isEmpty {
pieces.append(GraphContextPiece(
path: neighbor.path, kind: neighbor.kind, segmentID: neighbor.segID,
loc: neighbor.loc, locEnd: neighbor.locEnd, hop: 1,
linkedVia: neighbor.via, text: String(body.prefix(1200))
))
}
}
return pieces
}
}
Loading